This commit is contained in:
shriyaagg 2026-02-04 03:03:32 +02:00 committed by GitHub
commit f7ddfa23e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 261 additions and 23 deletions

View file

@ -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>
);
}

View file

@ -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,

View file

@ -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();

View file

@ -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

View file

@ -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,
};
}