diff --git a/.changeset/hip-pans-hug.md b/.changeset/hip-pans-hug.md new file mode 100644 index 0000000000000..90adc112ef1ef --- /dev/null +++ b/.changeset/hip-pans-hug.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fix markdown formatting toolbar toggle behavior and marker handling diff --git a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts index 189e5cb8239cd..23b88dfabeedf 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts @@ -1,7 +1,6 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { Accounts } from 'meteor/accounts-base'; -import { Tracker } from 'meteor/tracker'; import type { RefObject } from 'react'; import { limitQuoteChain } from './limitQuoteChain'; @@ -223,48 +222,83 @@ export const createComposerAPI = ( }; const wrapSelection = (pattern: string): void => { - const { selectionEnd = input.value.length, selectionStart = 0 } = input; - const initText = input.value.slice(0, selectionStart); - const selectedText = input.value.slice(selectionStart, selectionEnd); - const finalText = input.value.slice(selectionEnd, input.value.length); + const token = '{{text}}'; + const i = pattern.indexOf(token); + if (i === -1) return; + + const startPattern = pattern.slice(0, i); + const endPattern = pattern.slice(i + token.length); + + const text = input.value; + let { selectionStart: start, selectionEnd: end } = input; focus(); - const startPattern = pattern.slice(0, pattern.indexOf('{{text}}')); - const startPatternFound = input.value.slice(selectionStart - startPattern.length, selectionStart) === startPattern; + const before = text.slice(0, start); + const selected = text.slice(start, end); + const after = text.slice(end); - if (startPatternFound) { - const endPattern = pattern.slice(pattern.indexOf('{{text}}') + '{{text}}'.length); - const endPatternFound = input.value.slice(selectionEnd, selectionEnd + endPattern.length) === endPattern; + const left = before.lastIndexOf(startPattern); + const rightRelative = after.indexOf(endPattern); + const right = rightRelative === -1 ? -1 : end + rightRelative; - if (endPatternFound) { - insertText(selectedText); - input.selectionStart = selectionStart - startPattern.length; - input.selectionEnd = selectionEnd + endPattern.length; + const inside = + left !== -1 && + right !== -1 && + left + startPattern.length <= start && + right >= end; - if (!document.execCommand?.('insertText', false, selectedText)) { - input.value = initText.slice(0, initText.length - startPattern.length) + selectedText + finalText.slice(endPattern.length); - } + if (inside) { + const unwrapStart = left; + const unwrapEnd = right + endPattern.length; - input.selectionStart = selectionStart - startPattern.length; - input.selectionEnd = input.selectionStart + selectedText.length; - triggerEvent(input, 'input'); - triggerEvent(input, 'change'); + const inner = text.slice( + left + startPattern.length, + right + ); - focus(); - return; + input.setSelectionRange(unwrapStart, unwrapEnd); + + if (!document.execCommand?.('insertText', false, inner)) { + input.value = + text.slice(0, unwrapStart) + + inner + + text.slice(unwrapEnd); } + + const pos = unwrapStart; + input.setSelectionRange(pos, pos + inner.length); + + triggerEvent(input, 'input'); + triggerEvent(input, 'change'); + focus(); + return; } - if (!document.execCommand?.('insertText', false, pattern.replace('{{text}}', selectedText))) { - input.value = initText + pattern.replace('{{text}}', selectedText) + finalText; + if (!selected && before.endsWith(startPattern)) { + const newBefore = before.slice(0, before.length - startPattern.length); + input.value = newBefore + after; + const pos = newBefore.length; + input.setSelectionRange(pos, pos); + triggerEvent(input, 'input'); + triggerEvent(input, 'change'); + focus(); + return; } - input.selectionStart = selectionStart + pattern.indexOf('{{text}}'); - input.selectionEnd = input.selectionStart + selectedText.length; + const wrapped = `${startPattern}${selected}${endPattern}`; + + input.setSelectionRange(start, end); + + if (!document.execCommand?.('insertText', false, wrapped)) { + input.value = before + wrapped + after; + } + + const caret = start + startPattern.length; + input.setSelectionRange(caret, caret + selected.length); + triggerEvent(input, 'input'); triggerEvent(input, 'change'); - focus(); }; diff --git a/apps/meteor/app/ui-message/client/messageBox/messageBoxFormatting.ts b/apps/meteor/app/ui-message/client/messageBox/messageBoxFormatting.ts index af58cbe8590b4..bb127508f99a9 100644 --- a/apps/meteor/app/ui-message/client/messageBox/messageBoxFormatting.ts +++ b/apps/meteor/app/ui-message/client/messageBox/messageBoxFormatting.ts @@ -17,7 +17,6 @@ type TextButton = { type PatternButton = { icon: IconName; pattern: string; - // text?: () => string | undefined; command?: string; link?: string; } & FormattingButtonDefault; @@ -35,19 +34,19 @@ export const formattingButtons: ReadonlyArray = [ { label: 'Bold', icon: 'bold', - pattern: '*{{text}}*', + pattern: '**{{text}}**', command: 'b', }, { label: 'Italic', icon: 'italic', - pattern: '_{{text}}_', + pattern: '\u200B_{{text}}_\u200B', command: 'i', }, { label: 'Strikethrough', icon: 'strike', - pattern: '~{{text}}~', + pattern: '~~{{text}}~~', }, { label: 'Inline_code', @@ -57,14 +56,13 @@ export const formattingButtons: ReadonlyArray = [ { label: 'Multi_line_code', icon: 'multiline', - pattern: '```\n{{text}}\n``` ', + pattern: '```\n{{text}}\n```', }, { label: 'Link', icon: 'link', prompt: (composerApi: ComposerAPI) => { const { selection } = composerApi; - const selectedText = composerApi.substring(selection.start, selection.end); const onClose = () => { @@ -73,10 +71,7 @@ export const formattingButtons: ReadonlyArray = [ }; const onConfirm = (url: string, text: string) => { - // Composer API can't handle the selection of the text while the modal is open - flushSync(() => { - onClose(); - }); + flushSync(() => onClose()); flushSync(() => { composerApi.replaceText(`[${text}](${url})`, selection); composerApi.setCursorToEnd(); @@ -90,17 +85,11 @@ export const formattingButtons: ReadonlyArray = [ label: 'KaTeX' as TranslationKey, icon: 'katex', text: () => { - if (!settings.peek('Katex_Enabled')) { - return; - } - if (settings.peek('Katex_Dollar_Syntax')) { - return '$$KaTeX$$'; - } - if (settings.peek('Katex_Parenthesis_Syntax')) { - return '\\[KaTeX\\]'; - } + if (!settings.peek('Katex_Enabled')) return; + if (settings.peek('Katex_Dollar_Syntax')) return '$$KaTeX$$'; + if (settings.peek('Katex_Parenthesis_Syntax')) return '\\[KaTeX\\]'; }, link: 'https://khan.github.io/KaTeX/function-support.html', condition: () => settings.watch('Katex_Enabled') ?? true, }, -] as const; +] as const; \ No newline at end of file diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxFormattingToolbar/MessageBoxFormattingToolbar.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxFormattingToolbar/MessageBoxFormattingToolbar.tsx index 00df0e6d9e813..03c5bdb364f13 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxFormattingToolbar/MessageBoxFormattingToolbar.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxFormattingToolbar/MessageBoxFormattingToolbar.tsx @@ -1,5 +1,5 @@ import { MessageComposerAction } from '@rocket.chat/ui-composer'; -import { memo } from 'react'; +import { memo, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import FormattingToolbarDropdown from './FormattingToolbarDropdown'; @@ -16,6 +16,26 @@ type MessageBoxFormattingToolbarProps = { const MessageBoxFormattingToolbar = ({ items, variant = 'large', composer, disabled }: MessageBoxFormattingToolbarProps) => { const { t } = useTranslation(); + const [activeModes, setActiveModes] = useState>({}); + + useEffect(() => { + const textarea = composer.composerRef.current?.querySelector('textarea'); + if (!textarea) return; + + const handleInput = () => { + if (!textarea.value) { + setActiveModes({}); + } + }; + + textarea.addEventListener('input', handleInput); + return () => textarea.removeEventListener('input', handleInput); + }, [composer]); + + const toggleMode = (pattern: string, label: string) => { + composer.wrapSelection(pattern); + setActiveModes((prev) => ({ ...prev, [label]: !prev[label] })); + }; if (variant === 'small') { const collapsedItems = [...items]; @@ -26,11 +46,14 @@ const MessageBoxFormattingToolbar = ({ items, variant = 'large', composer, disab {'icon' in featuredFormatter && ( - isPromptButton(featuredFormatter) ? featuredFormatter.prompt(composer) : composer.wrapSelection(featuredFormatter.pattern) + isPromptButton(featuredFormatter) + ? featuredFormatter.prompt(composer) + : toggleMode(featuredFormatter.pattern, featuredFormatter.label) } icon={featuredFormatter.icon} title={t(featuredFormatter.label)} disabled={disabled} + pressed={!!activeModes[featuredFormatter.label]} /> )} @@ -43,33 +66,21 @@ const MessageBoxFormattingToolbar = ({ items, variant = 'large', composer, disab {items.map((formatter) => 'icon' in formatter ? ( { - if (isPromptButton(formatter)) { - formatter.prompt(composer); - return; - } - if ('link' in formatter) { - window.open(formatter.link, '_blank', 'rel=noreferrer noopener'); - return; - } - composer.wrapSelection(formatter.pattern); + disabled={disabled} + pressed={!!activeModes[formatter.label]} + onClick={() => { + if (isPromptButton(formatter)) return formatter.prompt(composer); + if ('link' in formatter) return window.open(formatter.link, '_blank', 'rel=noreferrer noopener'); + toggleMode(formatter.pattern, formatter.label); }} /> - ) : ( - - - {formatter.text()} - - - ), + ) : null, )} ); }; -export default memo(MessageBoxFormattingToolbar); +export default memo(MessageBoxFormattingToolbar); \ No newline at end of file