Skip to content

Commit 2aa8288

Browse files
authored
fix: Ensure correct caret placement and rect calculation after paste (#1238)
[CLNP-5528](https://sendbird.atlassian.net/browse/CLNP-5528) [SBISSUE-17384](https://sendbird.atlassian.net/browse/SBISSUE-17384) This PR resolves an issue where `getBoundingClientRect()` would return `(0, 0, 0, 0)` after a paste operation due to improper caret placement. The following changes ensure accurate caret positioning and consistent rect values: 1. **Zero-width space (`\u200B`)**: Appended to the pasted content to ensure the caret has a valid text node to reside in. 2. **Selection and Range Synchronization**: The range is collapsed and selection is reset to align with the updated DOM. 3. **Caret Adjustment**: The caret is moved to the end of the newly inserted content to maintain user expectation. [CLNP-5528]: https://sendbird.atlassian.net/browse/CLNP-5528?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [SBISSUE-17384]: https://sendbird.atlassian.net/browse/SBISSUE-17384?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
1 parent 2b1ab97 commit 2aa8288

File tree

2 files changed

+94
-69
lines changed

2 files changed

+94
-69
lines changed

src/ui/MessageInput/hooks/usePaste/index.ts

Lines changed: 54 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,36 @@ import DOMPurify from 'dompurify';
44
import { inserTemplateToDOM } from './insertTemplate';
55
import { sanitizeString } from '../../utils';
66
import { DynamicProps } from './types';
7-
import { createPasteNode, domToMessageTemplate, extractTextFromNodes, getLeafNodes, getUsersFromWords, hasMention } from './utils';
8-
9-
// exported, should be backward compatible
10-
// conditions to test:
11-
// 1. paste simple text
12-
// 2. paste text with mention
13-
// 3. paste text with mention and text
14-
// 4. paste text with mention and text and paste again before and after
15-
// 5. copy message with mention(only one mention, no other text) and paste
16-
// 6. copy message with mention from input and paste(before and after)
7+
import { domToMessageTemplate, extractTextFromNodes, getLeafNodes, getUsersFromWords, hasMention } from './utils';
8+
9+
function pasteContentAtCaret(content: string) {
10+
const selection = window.getSelection(); // Get the current selection
11+
if (selection && selection.rangeCount > 0) {
12+
const range = selection.getRangeAt(selection.rangeCount - 1); // Get the last range
13+
14+
range.deleteContents(); // Clear any existing content
15+
16+
// Create a new text node with the content and a Zero-width space
17+
const textNode = document.createTextNode(content + '\u200B');
18+
range.insertNode(textNode); // Insert the new text node at the caret position
19+
20+
// Move the caret to the end of the inserted content
21+
range.setStart(textNode, textNode.length);
22+
range.collapse(true); // Collapse the range (no text selection)
23+
24+
// Reset the selection with the updated range
25+
selection.removeAllRanges();
26+
selection.addRange(range); // Apply the updated selection
27+
}
28+
}
29+
30+
function createPasteNodeWithContent(html: string): HTMLDivElement {
31+
const pasteNode = document.createElement('div');
32+
pasteNode.innerHTML = html;
33+
return pasteNode;
34+
}
35+
36+
// usePaste Hook
1737
export function usePaste({
1838
ref,
1939
setIsInput,
@@ -22,42 +42,40 @@ export function usePaste({
2242
}: DynamicProps): (e: React.ClipboardEvent<HTMLDivElement>) => void {
2343
return useCallback((e) => {
2444
e.preventDefault();
45+
2546
const html = e.clipboardData.getData('text/html');
26-
// simple text, continue as normal
47+
const text = e.clipboardData.getData('text') || getURIListText(e);
48+
49+
// 1. Simple text paste: no HTML present
2750
if (!html) {
28-
const text = e.clipboardData.getData('text') || getURIListText(e);
29-
document.execCommand('insertText', false, sanitizeString(text));
51+
pasteContentAtCaret(sanitizeString(text));
3052
setIsInput(true);
3153
return;
3254
}
3355

34-
// has html, check if there are mentions, sanitize and insert
56+
// 2. HTML paste: process mentions and sanitized content
3557
const purifier = DOMPurify(window);
36-
const clean = purifier.sanitize(html);
37-
const pasteNode = createPasteNode();
38-
if (pasteNode) {
39-
pasteNode.innerHTML = clean;
40-
// does not have mention, continue as normal
41-
if (!hasMention(pasteNode)) {
42-
// to preserve space between words
43-
const text = extractTextFromNodes(Array.from(pasteNode.children) as HTMLSpanElement[]);
44-
document.execCommand('insertText', false, sanitizeString(text));
45-
pasteNode.remove();
46-
setIsInput(true);
47-
return;
48-
}
49-
50-
// has mention, collect leaf nodes and parse words
51-
const leafNodes = getLeafNodes(pasteNode);
52-
const words = domToMessageTemplate(leafNodes);
53-
const mentionedUsers = channel.isGroupChannel() ? getUsersFromWords(words, channel) : [];
58+
const cleanHtml = purifier.sanitize(html);
59+
const pasteNode = createPasteNodeWithContent(cleanHtml);
5460

55-
// side effects
56-
setMentionedUsers(mentionedUsers);
57-
inserTemplateToDOM(words);
61+
if (!hasMention(pasteNode)) {
62+
// No mention, paste as plain text
63+
const extractedText = extractTextFromNodes(Array.from(pasteNode.children) as HTMLSpanElement[]);
64+
pasteContentAtCaret(sanitizeString(extractedText));
5865
pasteNode.remove();
66+
setIsInput(true);
67+
return;
5968
}
6069

70+
// 3. Mentions present: process mentions and update state
71+
const leafNodes = getLeafNodes(pasteNode);
72+
const words = domToMessageTemplate(leafNodes);
73+
const mentionedUsers = channel.isGroupChannel() ? getUsersFromWords(words, channel) : [];
74+
75+
setMentionedUsers(mentionedUsers); // Update mentioned users state
76+
inserTemplateToDOM(words); // Insert mentions and content into the DOM
77+
pasteNode.remove();
78+
6179
setIsInput(true);
6280
}, [ref, setIsInput, channel, setMentionedUsers]);
6381
}
@@ -78,5 +96,5 @@ function getURIListText(e: React.ClipboardEvent<HTMLDivElement>) {
7896
}, '');
7997
}
8098

81-
// to do -> In the future donot export default
99+
// to do -> In the future don't export default
82100
export default usePaste;

src/ui/MessageInput/index.tsx

Lines changed: 40 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import useSendbirdStateContext from '../../hooks/useSendbirdStateContext';
1414

1515
import { extractTextAndMentions, isChannelTypeSupportsMultipleFilesMessage, nodeListToArray, sanitizeString } from './utils';
1616
import { arrayEqual, getMimeTypesUIKitAccepts } from '../../utils';
17-
import usePaste from './hooks/usePaste';
17+
import { usePaste } from './hooks/usePaste';
1818
import { tokenizeMessage } from '../../modules/Message/utils/tokens/tokenize';
1919
import { USER_MENTION_PREFIX } from '../../modules/Message/consts';
2020
import { TOKEN_TYPES } from '../../modules/Message/utils/tokens/types';
@@ -32,25 +32,6 @@ const noop = () => {
3232
return null;
3333
};
3434

35-
const scrollToCaret = () => {
36-
const selection = window.getSelection();
37-
if (selection && selection.rangeCount > 0) {
38-
const range = selection.getRangeAt(0);
39-
const caretNode = range.endContainer;
40-
41-
// Ensure the caret is in a text node
42-
if (caretNode.nodeType === NodeTypes.TextNode) {
43-
const parentElement = caretNode.parentElement;
44-
45-
// Scroll the parent element of the caret into view
46-
parentElement?.scrollIntoView?.({
47-
behavior: 'smooth',
48-
block: 'nearest',
49-
});
50-
}
51-
}
52-
};
53-
5435
const resetInput = (ref: MutableRefObject<HTMLInputElement | null> | null) => {
5536
if (ref && ref.current) {
5637
ref.current.innerHTML = '';
@@ -425,10 +406,33 @@ const MessageInput = React.forwardRef<HTMLInputElement, MessageInputProps>((prop
425406
}
426407
};
427408

428-
const handleCommonBehavior = (handleEvent) => {
429-
scrollToCaret();
430-
type CommonEvent<T> = React.KeyboardEvent<T> | React.MouseEvent<T> | React.ClipboardEvent<T>;
431-
return (e: CommonEvent<HTMLDivElement>) => handleEvent(e);
409+
const adjustScrollToCaret = () => {
410+
const inputRef = internalRef;
411+
const selection = window.getSelection();
412+
if (!selection || selection.rangeCount === 0) return;
413+
414+
// Get the last range (caret or selected text position) from the selection
415+
const range = selection.getRangeAt(selection.rangeCount - 1);
416+
const rect = range.getBoundingClientRect();
417+
const container = inputRef.current?.getBoundingClientRect();
418+
if (!container || !inputRef.current) return;
419+
420+
// If the caret (or selection) is below the visible container area, scroll down
421+
if (rect.bottom > container.bottom) {
422+
const scrollAmount = Math.min(
423+
rect.bottom - container.bottom, // Calculate how much we need to scroll
424+
inputRef.current.scrollHeight - inputRef.current.clientHeight, // Prevent over-scrolling
425+
);
426+
inputRef.current.scrollTop += scrollAmount; // Adjust the scroll position downward
427+
}
428+
// If the caret (or selection) is above the visible container area, scroll up
429+
else if (rect.top < container.top) {
430+
const scrollAmount = Math.min(
431+
container.top - rect.top, // Calculate how much we need to scroll
432+
inputRef.current.scrollTop, // Prevent scrolling beyond the top of the container
433+
);
434+
inputRef.current.scrollTop -= scrollAmount; // Adjust the scroll position upward
435+
}
432436
};
433437

434438
return (
@@ -456,7 +460,7 @@ const MessageInput = React.forwardRef<HTMLInputElement, MessageInputProps>((prop
456460
// @ts-ignore
457461
disabled={disabled}
458462
maxLength={maxLength}
459-
onKeyDown={handleCommonBehavior((e) => {
463+
onKeyDown={(e) => {
460464
const preventEvent = onKeyDown(e);
461465
if (preventEvent) {
462466
e.preventDefault();
@@ -487,24 +491,27 @@ const MessageInput = React.forwardRef<HTMLInputElement, MessageInputProps>((prop
487491
internalRef.current.removeChild(internalRef.current.childNodes[1]);
488492
}
489493
}
490-
})}
491-
onKeyUp={handleCommonBehavior((e) => {
494+
}}
495+
onKeyUp={(e) => {
492496
const preventEvent = onKeyUp(e);
493497
if (preventEvent) {
494498
e.preventDefault();
495499
} else {
496500
useMentionInputDetection();
497501
}
498-
})}
499-
onClick={handleCommonBehavior(() => {
502+
}}
503+
onClick={() => {
500504
useMentionInputDetection();
501-
})}
502-
onInput={handleCommonBehavior(() => {
505+
}}
506+
onInput={() => {
503507
onStartTyping();
504508
setIsInput(internalRef?.current?.textContent ? internalRef.current.textContent.trim().length > 0 : false);
505509
useMentionedLabelDetection();
506-
})}
507-
onPaste={handleCommonBehavior(onPaste)}
510+
}}
511+
onPaste={(e) => {
512+
onPaste(e);
513+
setTimeout(adjustScrollToCaret);
514+
}}
508515
/>
509516
{/* placeholder */}
510517
{(internalRef?.current?.textContent?.length ?? 0) === 0 && (

0 commit comments

Comments
 (0)