Skip to content

Commit c7744b1

Browse files
Copilotggazzo
andauthored
feat: auto-wrap selected text in composer with matching delimiters (RocketChat#39393)
Co-authored-by: Guilherme Gazzo <guilherme@gazzo.xyz>
1 parent 46faa43 commit c7744b1

File tree

5 files changed

+97
-4
lines changed

5 files changed

+97
-4
lines changed

.changeset/warm-cups-dress.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@rocket.chat/meteor': minor
3+
---
4+
5+
Added auto-wrap selected text in composer with matching delimiters

apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ export const createComposerAPI = (
224224
stopFormatterTracker.stop();
225225
};
226226

227-
const wrapSelection = (pattern: string): void => {
227+
const wrapSelection = (pattern: string): { selectionStart: number; selectionEnd: number; value: string } => {
228228
const { selectionEnd = input.value.length, selectionStart = 0 } = input;
229229
const initText = input.value.slice(0, selectionStart);
230230
const selectedText = input.value.slice(selectionStart, selectionEnd);
@@ -254,7 +254,11 @@ export const createComposerAPI = (
254254
triggerEvent(input, 'change');
255255

256256
focus();
257-
return;
257+
return {
258+
selectionStart: input.selectionStart,
259+
selectionEnd: input.selectionEnd,
260+
value: input.value,
261+
};
258262
}
259263
}
260264

@@ -268,6 +272,12 @@ export const createComposerAPI = (
268272
triggerEvent(input, 'change');
269273

270274
focus();
275+
276+
return {
277+
selectionStart: input.selectionStart,
278+
selectionEnd: input.selectionEnd,
279+
value: input.value,
280+
};
271281
};
272282

273283
const insertNewLine = (): void => insertText('\n');

apps/meteor/client/lib/chats/ChatAPI.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ export type ComposerAPI = {
2323
| ((previous: { readonly start: number; readonly end: number }) => { readonly start?: number; readonly end?: number });
2424
},
2525
): void;
26-
wrapSelection(pattern: string): void;
26+
wrapSelection(pattern: string): {
27+
selectionStart: number;
28+
selectionEnd: number;
29+
value: string;
30+
};
2731
insertText(text: string): void;
2832
insertNewLine(): void;
2933
clear(): void;

apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@ import MessageBoxFormattingToolbar from './MessageBoxFormattingToolbar';
2121
import MessageBoxHint from './MessageBoxHint';
2222
import MessageBoxReplies from './MessageBoxReplies';
2323
import MessageComposerFiles from './MessageComposerFiles';
24+
import { handleSelectionWrapping } from './wrapSelection';
2425
import { createComposerAPI } from '../../../../../app/ui-message/client/messageBox/createComposerAPI';
2526
import type { FormattingButton } from '../../../../../app/ui-message/client/messageBox/messageBoxFormatting';
2627
import { formattingButtons } from '../../../../../app/ui-message/client/messageBox/messageBoxFormatting';
2728
import { getImageExtensionFromMime } from '../../../../../lib/getImageExtensionFromMime';
2829
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
30+
import { useIsFederationEnabled } from '../../../../hooks/useIsFederationEnabled';
2931
import { useReactiveValue } from '../../../../hooks/useReactiveValue';
3032
import type { ComposerAPI } from '../../../../lib/chats/ChatAPI';
3133
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
@@ -45,7 +47,6 @@ import { useEnablePopupPreview } from '../hooks/useEnablePopupPreview';
4547
import { useMessageComposerMergedRefs } from '../hooks/useMessageComposerMergedRefs';
4648
import { useMessageBoxAutoFocus } from './hooks/useMessageBoxAutoFocus';
4749
import { useMessageBoxPlaceholder } from './hooks/useMessageBoxPlaceholder';
48-
import { useIsFederationEnabled } from '../../../../hooks/useIsFederationEnabled';
4950

5051
const reducer = (_: unknown, event: FormEvent<HTMLInputElement>): boolean => {
5152
const target = event.target as HTMLInputElement;
@@ -379,13 +380,28 @@ const MessageBox = ({
379380
),
380381
);
381382

383+
const beforeInputHandlerCallbackRef = useSafeRefCallback(
384+
useCallback(
385+
(node: HTMLTextAreaElement) => {
386+
const eventHandler = (e: Event) => handleSelectionWrapping(e as InputEvent, chat);
387+
node.addEventListener('beforeinput', eventHandler);
388+
389+
return () => {
390+
node.removeEventListener('beforeinput', eventHandler);
391+
};
392+
},
393+
[chat],
394+
),
395+
);
396+
382397
const mergedRefs = useMessageComposerMergedRefs(
383398
popup.callbackRef,
384399
textareaRef,
385400
autoGrowRef,
386401
callbackRef,
387402
autofocusRef,
388403
keyDownHandlerCallbackRef,
404+
beforeInputHandlerCallbackRef,
389405
);
390406

391407
const shouldPopupPreview = useEnablePopupPreview(popup.filter, popup.option);
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { ChatAPI } from '../../../../lib/chats/ChatAPI';
2+
3+
const wrapSelectionPatterns: Record<string, string> = {
4+
'`': '`{{text}}`',
5+
'"': '"{{text}}"',
6+
"'": "'{{text}}'",
7+
'(': '({{text}})',
8+
'<': '<{{text}}>',
9+
'{': '{{{text}}}',
10+
'[': '[{{text}}]',
11+
'*': '*{{text}}*',
12+
'_': '_{{text}}_',
13+
'~': '~{{text}}~',
14+
'˜': '~{{text}}~',
15+
};
16+
17+
const once = (target: EventTarget, eventName: string, callback: (event: Event) => void) => {
18+
const handleEvent = (e: Event) => {
19+
callback(e);
20+
target.removeEventListener(eventName, handleEvent);
21+
};
22+
target.addEventListener(eventName, handleEvent);
23+
};
24+
25+
export const handleSelectionWrapping = (event: InputEvent, chat: ChatAPI): boolean => {
26+
const { composer } = chat;
27+
if (!composer) {
28+
return false;
29+
}
30+
const input = event.target as HTMLTextAreaElement;
31+
const { selectionStart, selectionEnd } = input;
32+
33+
if (selectionStart === selectionEnd) {
34+
return false;
35+
}
36+
37+
const key = event.data;
38+
if (!key) {
39+
return false;
40+
}
41+
const pattern = wrapSelectionPatterns[key];
42+
if (!pattern) {
43+
return false;
44+
}
45+
46+
const selection = composer.wrapSelection(pattern);
47+
// this is a workaround when we are using MAC
48+
if (event.isComposing) {
49+
once(input, 'input', (event) => {
50+
input.value = selection.value;
51+
input.setSelectionRange(selection.selectionStart, selection.selectionEnd);
52+
event.preventDefault();
53+
});
54+
}
55+
56+
event.preventDefault();
57+
return true;
58+
};

0 commit comments

Comments
 (0)