mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-03 20:40:00 -05:00
Merge 881bb1232a into 0263262ef4
This commit is contained in:
commit
f7ddfa23e1
5 changed files with 261 additions and 23 deletions
|
|
@ -0,0 +1,181 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {Modal} from 'react-bootstrap';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import './link_creation_modal.scss';
|
||||
|
||||
// --- Type Definitions ---
|
||||
type Props = {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
onExited: () => void;
|
||||
// Function to call when the user clicks 'Insert', passing the final text and URL
|
||||
onInsert: (text: string, url: string) => void;
|
||||
// Optional initial text (e.g., text selected by the user in the editor)
|
||||
initialText?: string;
|
||||
}
|
||||
|
||||
// --- Component Definition ---
|
||||
|
||||
export default function LinkCreationModal({
|
||||
show,
|
||||
onHide,
|
||||
onExited,
|
||||
onInsert,
|
||||
initialText = '',
|
||||
}: Props) {
|
||||
const {formatMessage} = useIntl();
|
||||
const [displayText, setDisplayText] = useState(initialText);
|
||||
const [url, setUrl] = useState('');
|
||||
|
||||
const [urlError, setUrlError] = useState('');
|
||||
const [textError, setTextError] = useState('');
|
||||
|
||||
// Update internal state when the modal is shown with initial text
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setDisplayText(initialText);
|
||||
setUrl('');
|
||||
setUrlError('');
|
||||
setTextError('');
|
||||
}
|
||||
}, [show, initialText]);
|
||||
|
||||
const handleHide = useCallback(() => {
|
||||
setDisplayText('');
|
||||
setUrl('');
|
||||
setUrlError('');
|
||||
setTextError('');
|
||||
onHide();
|
||||
}, [onHide]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
let valid = true;
|
||||
let newUrlError = '';
|
||||
let newTextError = '';
|
||||
|
||||
if (!url.trim()) {
|
||||
newUrlError = formatMessage({
|
||||
id: 'link_modal.error.url_missing',
|
||||
defaultMessage: 'URL is required.',
|
||||
});
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!displayText.trim()) {
|
||||
newTextError = formatMessage({
|
||||
id: 'link_modal.error.text_missing',
|
||||
defaultMessage: 'Display text is required.',
|
||||
});
|
||||
valid = false;
|
||||
}
|
||||
|
||||
setUrlError(newUrlError);
|
||||
setTextError(newTextError);
|
||||
|
||||
if (valid) {
|
||||
onInsert(displayText.trim(), url.trim());
|
||||
handleHide();
|
||||
}
|
||||
}, [url, displayText, onInsert, handleHide, formatMessage]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}, [handleSubmit]);
|
||||
|
||||
const modalTitle = formatMessage({
|
||||
id: 'link_modal.title',
|
||||
defaultMessage: 'Insert Link',
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
dialogClassName='a11y__modal link-creation-modal'
|
||||
show={show}
|
||||
onHide={handleHide}
|
||||
onExited={onExited}
|
||||
role='dialog'
|
||||
aria-labelledby='linkCreationModalLabel'
|
||||
>
|
||||
<Modal.Header
|
||||
id='linkCreationModalLabel'
|
||||
closeButton={true}
|
||||
>
|
||||
<h5 className='modal-title'>{modalTitle}</h5>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div className='form-group'>
|
||||
<label htmlFor='linkModalText'>
|
||||
<FormattedMessage
|
||||
id='link_modal.text_label'
|
||||
defaultMessage='Text to display'
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
id='linkModalText'
|
||||
className={classNames('form-control', {'has-error': textError})}
|
||||
type='text'
|
||||
value={displayText}
|
||||
onChange={(e) => setDisplayText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={!initialText} // Focus text field if empty
|
||||
maxLength={100} // Arbitrary limit, adjust as needed
|
||||
placeholder={formatMessage({id: 'link_modal.text_placeholder', defaultMessage: 'Display Text'})}
|
||||
/>
|
||||
{textError && <div className='has-error__message'>{textError}</div>}
|
||||
</div>
|
||||
<div className='form-group'>
|
||||
<label htmlFor='linkModalURL'>
|
||||
<FormattedMessage
|
||||
id='link_modal.url_label'
|
||||
defaultMessage='Link URL'
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
id='linkModalURL'
|
||||
className={classNames('form-control', {'has-error': urlError})}
|
||||
type='url'
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={Boolean(initialText)} // Focus URL field if text is pre-filled
|
||||
placeholder='https://example.com'
|
||||
/>
|
||||
{urlError && <div className='has-error__message'>{urlError}</div>}
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<button
|
||||
id='linkModalCancelButton'
|
||||
type='button'
|
||||
className='btn btn-tertiary'
|
||||
onClick={handleHide}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='link_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
id='linkModalInsertButton'
|
||||
type='button'
|
||||
className='btn btn-primary'
|
||||
onClick={handleSubmit}
|
||||
disabled={!url.trim() || !displayText.trim()}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='link_modal.insert'
|
||||
defaultMessage='Insert'
|
||||
/>
|
||||
</button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -88,6 +88,7 @@ import useRewrite from './use_rewrite';
|
|||
import useSubmit from './use_submit';
|
||||
import useTextboxFocus from './use_textbox_focus';
|
||||
import useUploadFiles from './use_upload_files';
|
||||
import LinkCreationModal from './LinkCreationModal';
|
||||
|
||||
import './advanced_text_editor.scss';
|
||||
|
||||
|
|
@ -136,6 +137,7 @@ const AdvancedTextEditor = ({
|
|||
const getChannelSelector = useMemo(makeGetChannel, []);
|
||||
const getDraftSelector = useMemo(makeGetDraft, []);
|
||||
const getDisplayName = useMemo(makeGetDisplayName, []);
|
||||
|
||||
|
||||
let textboxId: string;
|
||||
if (isInEditMode) {
|
||||
|
|
@ -225,6 +227,8 @@ const AdvancedTextEditor = ({
|
|||
const [isMessageLong, setIsMessageLong] = useState(false);
|
||||
const [renderScrollbar, setRenderScrollbar] = useState(false);
|
||||
const [keepEditorInFocus, setKeepEditorInFocus] = useState(false);
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
const [initialLinkText, setInitialLinkText] = useState('');
|
||||
|
||||
const readOnlyChannel = !canPost;
|
||||
const hasDraftMessage = Boolean(draft.message);
|
||||
|
|
@ -298,6 +302,41 @@ const AdvancedTextEditor = ({
|
|||
});
|
||||
}, [showPreview, handleDraftChange, draft]);
|
||||
|
||||
const getCurrentValue = useCallback(() => textboxRef.current?.getInputBox().value, [textboxRef]);
|
||||
|
||||
const getCurrentSelection = useCallback(() => {
|
||||
const input = textboxRef.current?.getInputBox();
|
||||
|
||||
return {
|
||||
start: input?.selectionStart || 0,
|
||||
end: input?.selectionEnd || 0,
|
||||
};
|
||||
}, [textboxRef]);
|
||||
|
||||
|
||||
|
||||
const insertLink = useCallback((text: string, url: string) => {
|
||||
const linkMarkdown = `[${text}](${url})`;
|
||||
|
||||
insertRawMarkdown({markdown: linkMarkdown});
|
||||
|
||||
setShowLinkModal(false);
|
||||
}, [insertRawMarkdown, setShowLinkModal]);
|
||||
|
||||
const openLinkModal = useCallback(() => {
|
||||
if (showPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = getCurrentSelection();
|
||||
const fullMessage = getCurrentValue() || '';
|
||||
const selectedText = fullMessage.substring(selection.start, selection.end) || '';
|
||||
|
||||
setInitialLinkText(selectedText);
|
||||
|
||||
setShowLinkModal(true);
|
||||
}, [showPreview, getCurrentSelection, getCurrentValue]);
|
||||
|
||||
const toggleAdvanceTextEditor = useCallback(() => {
|
||||
dispatch(savePreferences(currentUserId, [{
|
||||
category: Preferences.ADVANCED_TEXT_EDITOR,
|
||||
|
|
@ -450,6 +489,7 @@ const AdvancedTextEditor = ({
|
|||
toggleEmojiPicker,
|
||||
isInEditMode,
|
||||
handleCancel,
|
||||
openLinkModal,
|
||||
);
|
||||
|
||||
const handleSubmitWithEvent = useCallback((e: React.FormEvent) => {
|
||||
|
|
@ -493,16 +533,7 @@ const AdvancedTextEditor = ({
|
|||
* down the current message value that came from the parents state was not optimal,
|
||||
* although still working as expected
|
||||
*/
|
||||
const getCurrentValue = useCallback(() => textboxRef.current?.getInputBox().value, [textboxRef]);
|
||||
|
||||
const getCurrentSelection = useCallback(() => {
|
||||
const input = textboxRef.current?.getInputBox();
|
||||
|
||||
return {
|
||||
start: input.selectionStart,
|
||||
end: input.selectionEnd,
|
||||
};
|
||||
}, [textboxRef]);
|
||||
|
||||
|
||||
const handleWidthChange = useCallback((width: number) => {
|
||||
const input = textboxRef.current?.getInputBox();
|
||||
|
|
@ -702,6 +733,7 @@ const AdvancedTextEditor = ({
|
|||
disableControls={showPreview}
|
||||
additionalControls={additionalControls}
|
||||
location={location}
|
||||
openLinkModal={openLinkModal}
|
||||
/>
|
||||
)}
|
||||
slot2={null}
|
||||
|
|
@ -753,6 +785,15 @@ const AdvancedTextEditor = ({
|
|||
postId={rootId}
|
||||
/>
|
||||
)}
|
||||
<LinkCreationModal
|
||||
show={showLinkModal}
|
||||
onHide={() => setShowLinkModal(false)}
|
||||
onExited={() => {
|
||||
// Placeholder for cleanup if needed
|
||||
}}
|
||||
onInsert={insertLink}
|
||||
initialText={initialLinkText}
|
||||
/>
|
||||
<div
|
||||
className={classNames('AdvancedTextEditor', {
|
||||
'AdvancedTextEditor__attachment-disabled': !canUploadFiles,
|
||||
|
|
|
|||
|
|
@ -130,6 +130,8 @@ interface FormattingBarProps {
|
|||
* e.g: message priority picker
|
||||
*/
|
||||
additionalControls?: React.ReactNodeArray;
|
||||
|
||||
openLinkModal: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_MIN_MODE_X_COORD = 55;
|
||||
|
|
@ -183,6 +185,15 @@ const FormattingBar = (props: FormattingBarProps): JSX.Element => {
|
|||
if (disableControls) {
|
||||
return;
|
||||
}
|
||||
if (mode === 'link') {
|
||||
props.openLinkModal();
|
||||
|
||||
// If hidden controls are open, close them after opening the modal
|
||||
if (showHiddenControls) {
|
||||
setShowHiddenControls(false); // Set to FALSE to close
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// get the current selection values and return early (doing nothing) when we don't get valid values
|
||||
const {start, end} = getCurrentSelection();
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ const useKeyHandler = (
|
|||
toggleEmojiPicker: () => void,
|
||||
isInEditMode?: boolean,
|
||||
onCancel?: () => void,
|
||||
openLinkModal?: () => void,
|
||||
): [
|
||||
(e: React.KeyboardEvent<TextboxElement>) => void,
|
||||
(e: React.KeyboardEvent<TextboxElement>) => void,
|
||||
|
|
@ -238,26 +239,18 @@ const useKeyHandler = (
|
|||
selectionEnd,
|
||||
message: value,
|
||||
});
|
||||
} else if (Utils.isTextSelectedInPostOrReply(e) && Keyboard.isKeyPressed(e, KeyCodes.K)) {
|
||||
} else if (Keyboard.isKeyPressed(e, KeyCodes.K)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
applyMarkdown({
|
||||
markdownMode: 'link',
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
message: value,
|
||||
});
|
||||
|
||||
|
||||
openLinkModal?.();
|
||||
}
|
||||
} else if (ctrlAltCombo && !caretIsWithinCodeBlock) {
|
||||
if (Keyboard.isKeyPressed(e, KeyCodes.K)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
applyMarkdown({
|
||||
markdownMode: 'link',
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
message: value,
|
||||
});
|
||||
openLinkModal?.();
|
||||
} else if (Keyboard.isKeyPressed(e, KeyCodes.C)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
|
@ -366,6 +359,7 @@ const useKeyHandler = (
|
|||
toggleShowPreview,
|
||||
isInEditMode,
|
||||
location,
|
||||
openLinkModal,
|
||||
]);
|
||||
|
||||
// Register paste events
|
||||
|
|
|
|||
|
|
@ -517,3 +517,14 @@ function findWordStart(text: string, start: number) {
|
|||
function isSelectionMultiline(message: string, selectionStart: number, selectionEnd: number) {
|
||||
return message.slice(selectionStart, selectionEnd).includes('\n');
|
||||
}
|
||||
export type ApplyRawMarkdownOptions = {
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
export function insertRawMarkdown({markdown}: ApplyRawMarkdownOptions): ApplyMarkdownReturnValue {
|
||||
return {
|
||||
message: markdown,
|
||||
selectionStart: markdown.length,
|
||||
selectionEnd: markdown.length,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue