diff --git a/webapp/channels/src/components/advanced_text_editor/LinkCreationModal.tsx b/webapp/channels/src/components/advanced_text_editor/LinkCreationModal.tsx new file mode 100644 index 00000000000..b1c488eca2f --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/LinkCreationModal.tsx @@ -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) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSubmit(); + } + }, [handleSubmit]); + + const modalTitle = formatMessage({ + id: 'link_modal.title', + defaultMessage: 'Insert Link', + }); + + return ( + + +
{modalTitle}
+
+ +
+ + 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 &&
{textError}
} +
+
+ + setUrl(e.target.value)} + onKeyDown={handleKeyDown} + autoFocus={Boolean(initialText)} // Focus URL field if text is pre-filled + placeholder='https://example.com' + /> + {urlError &&
{urlError}
} +
+
+ + + + +
+ ); +} \ No newline at end of file diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx index cfb3d208f36..99345427dd3 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx @@ -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} /> )} + setShowLinkModal(false)} + onExited={() => { + // Placeholder for cleanup if needed + }} + onInsert={insertLink} + initialText={initialLinkText} + />
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(); diff --git a/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx b/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx index 70c68797bc8..dc8d86bb3d1 100644 --- a/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx +++ b/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx @@ -48,6 +48,7 @@ const useKeyHandler = ( toggleEmojiPicker: () => void, isInEditMode?: boolean, onCancel?: () => void, + openLinkModal?: () => void, ): [ (e: React.KeyboardEvent) => void, (e: React.KeyboardEvent) => 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 diff --git a/webapp/channels/src/utils/markdown/apply_markdown.ts b/webapp/channels/src/utils/markdown/apply_markdown.ts index fa09056dd03..cc12d7c0544 100644 --- a/webapp/channels/src/utils/markdown/apply_markdown.ts +++ b/webapp/channels/src/utils/markdown/apply_markdown.ts @@ -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, + }; +} \ No newline at end of file