Skip to content

Commit 18a26c8

Browse files
authored
regression: fix multiple compounding keys in sequence (RocketChat#40108)
1 parent dd56c98 commit 18a26c8

2 files changed

Lines changed: 199 additions & 0 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { handleSelectionWrapping } from './wrapSelection';
2+
import type { ChatAPI, ComposerAPI } from '../../../../lib/chats/ChatAPI';
3+
4+
const createTextarea = (value: string, selectionStart: number, selectionEnd: number): HTMLTextAreaElement => {
5+
const textarea = document.createElement('textarea');
6+
textarea.value = value;
7+
textarea.selectionStart = selectionStart;
8+
textarea.selectionEnd = selectionEnd;
9+
return textarea;
10+
};
11+
12+
const createMockComposer = (text: string): Pick<ComposerAPI, 'text' | 'wrapSelection'> => ({
13+
text,
14+
wrapSelection: jest.fn(() => ({ selectionStart: 0, selectionEnd: 0, value: '' })),
15+
});
16+
17+
const createInputEvent = (textarea: HTMLTextAreaElement, data: string, isComposing = false): InputEvent => {
18+
const event = new InputEvent('input', {
19+
data,
20+
inputType: 'insertText',
21+
isComposing,
22+
bubbles: true,
23+
cancelable: true,
24+
});
25+
26+
Object.defineProperty(event, 'target', { value: textarea, writable: false });
27+
28+
return event;
29+
};
30+
31+
describe('handleSelectionWrapping', () => {
32+
describe('dead key / IME composition scenarios', () => {
33+
it('should NOT wrap when the selected text is the same character just typed (dead key double-press)', () => {
34+
// Simulates: user presses ' twice on a Brazilian/international keyboard
35+
// After composition ends, the browser has:
36+
// - inserted the character ' into the textarea
37+
// - selected it (selectionStart=0, selectionEnd=1)
38+
// - fired an InputEvent with data="'"
39+
const textarea = createTextarea("'", 0, 1);
40+
const composer = createMockComposer("'");
41+
const chat = { composer } as unknown as ChatAPI;
42+
43+
const event = createInputEvent(textarea, "'");
44+
45+
const result = handleSelectionWrapping(event, chat);
46+
47+
expect(result).toBe(false);
48+
expect(composer.wrapSelection).not.toHaveBeenCalled();
49+
});
50+
51+
it('should NOT wrap when dead key composition produces a character mid-text', () => {
52+
// User is typing "hello'" — the dead key inserts ' and selects it
53+
const textarea = createTextarea("hello'", 5, 6);
54+
const composer = createMockComposer("hello'");
55+
const chat = { composer } as unknown as ChatAPI;
56+
57+
const event = createInputEvent(textarea, "'");
58+
59+
const result = handleSelectionWrapping(event, chat);
60+
61+
expect(result).toBe(false);
62+
expect(composer.wrapSelection).not.toHaveBeenCalled();
63+
});
64+
65+
it('should NOT wrap when backtick dead key is pressed twice', () => {
66+
const textarea = createTextarea('`', 0, 1);
67+
const composer = createMockComposer('`');
68+
const chat = { composer } as unknown as ChatAPI;
69+
70+
const event = createInputEvent(textarea, '`');
71+
72+
const result = handleSelectionWrapping(event, chat);
73+
74+
expect(result).toBe(false);
75+
expect(composer.wrapSelection).not.toHaveBeenCalled();
76+
});
77+
78+
it('should NOT wrap when tilde dead key is pressed twice', () => {
79+
const textarea = createTextarea('˜', 0, 1);
80+
const composer = createMockComposer('˜');
81+
const chat = { composer } as unknown as ChatAPI;
82+
83+
const event = createInputEvent(textarea, '˜');
84+
85+
const result = handleSelectionWrapping(event, chat);
86+
87+
expect(result).toBe(false);
88+
expect(composer.wrapSelection).not.toHaveBeenCalled();
89+
});
90+
});
91+
92+
describe('legitimate wrapping scenarios', () => {
93+
it('should wrap when user selects text and types a wrapping character', () => {
94+
// User selects "hello" and types '
95+
const textarea = createTextarea('hello', 0, 5);
96+
const composer = createMockComposer('hello');
97+
const chat = { composer } as unknown as ChatAPI;
98+
99+
const event = createInputEvent(textarea, "'");
100+
101+
const result = handleSelectionWrapping(event, chat);
102+
103+
expect(result).toBe(true);
104+
expect(composer.wrapSelection).toHaveBeenCalledWith("'{{text}}'");
105+
});
106+
107+
it('should wrap when user selects partial text and types a wrapping character', () => {
108+
// User selects "world" in "hello world" and types *
109+
const textarea = createTextarea('hello world', 6, 11);
110+
const composer = createMockComposer('hello world');
111+
const chat = { composer } as unknown as ChatAPI;
112+
113+
const event = createInputEvent(textarea, '*');
114+
115+
const result = handleSelectionWrapping(event, chat);
116+
117+
expect(result).toBe(true);
118+
expect(composer.wrapSelection).toHaveBeenCalledWith('*{{text}}*');
119+
});
120+
121+
it('should wrap with backtick when text is selected', () => {
122+
const textarea = createTextarea('some code here', 5, 9);
123+
const composer = createMockComposer('some code here');
124+
const chat = { composer } as unknown as ChatAPI;
125+
126+
const event = createInputEvent(textarea, '`');
127+
128+
const result = handleSelectionWrapping(event, chat);
129+
130+
expect(result).toBe(true);
131+
expect(composer.wrapSelection).toHaveBeenCalledWith('`{{text}}`');
132+
});
133+
});
134+
135+
describe('no-op scenarios', () => {
136+
it('should return false when there is no selection (cursor only)', () => {
137+
const textarea = createTextarea('hello', 3, 3);
138+
const composer = createMockComposer('hello');
139+
const chat = { composer } as unknown as ChatAPI;
140+
141+
const event = createInputEvent(textarea, "'");
142+
143+
const result = handleSelectionWrapping(event, chat);
144+
145+
expect(result).toBe(false);
146+
expect(composer.wrapSelection).not.toHaveBeenCalled();
147+
});
148+
149+
it('should return false when there is no composer', () => {
150+
const textarea = createTextarea('hello', 0, 5);
151+
const chat = {} as unknown as ChatAPI;
152+
153+
const event = createInputEvent(textarea, "'");
154+
155+
const result = handleSelectionWrapping(event, chat);
156+
157+
expect(result).toBe(false);
158+
});
159+
160+
it('should return false for non-wrapping characters', () => {
161+
const textarea = createTextarea('hello', 0, 5);
162+
const composer = createMockComposer('hello');
163+
const chat = { composer } as unknown as ChatAPI;
164+
165+
const event = createInputEvent(textarea, 'a');
166+
167+
const result = handleSelectionWrapping(event, chat);
168+
169+
expect(result).toBe(false);
170+
expect(composer.wrapSelection).not.toHaveBeenCalled();
171+
});
172+
173+
it('should return false when event.data is null', () => {
174+
const textarea = createTextarea('hello', 0, 5);
175+
const composer = createMockComposer('hello');
176+
const chat = { composer } as unknown as ChatAPI;
177+
178+
const event = new InputEvent('input', {
179+
data: null,
180+
inputType: 'deleteContentBackward',
181+
bubbles: true,
182+
});
183+
Object.defineProperty(event, 'target', { value: textarea, writable: false });
184+
185+
const result = handleSelectionWrapping(event, chat);
186+
187+
expect(result).toBe(false);
188+
expect(composer.wrapSelection).not.toHaveBeenCalled();
189+
});
190+
});
191+
});

apps/meteor/client/views/room/composer/messageBox/wrapSelection.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ export const handleSelectionWrapping = (event: InputEvent, chat: ChatAPI): boole
3030
const input = event.target as HTMLTextAreaElement;
3131
const { selectionStart, selectionEnd } = input;
3232

33+
const testSelection = input.value.slice(selectionStart, selectionEnd);
34+
// if the selection is the same of the data, return false
35+
if (testSelection === event.data) {
36+
return false;
37+
}
38+
if (event.data === chat.composer?.text) {
39+
return false;
40+
}
3341
if (selectionStart === selectionEnd) {
3442
return false;
3543
}

0 commit comments

Comments
 (0)