Skip to content

Commit 290e276

Browse files
authored
Merge pull request #1520 from session-foundation/dev
Session 1.16.5
2 parents f167977 + e73c363 commit 290e276

File tree

3 files changed

+144
-10
lines changed

3 files changed

+144
-10
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "session-desktop",
33
"productName": "Session",
44
"description": "Private messaging from your desktop",
5-
"version": "1.16.4",
5+
"version": "1.16.5",
66
"license": "GPL-3.0",
77
"author": {
88
"name": "Session Foundation",

ts/components/conversation/composition/CompositionInput.tsx

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,12 @@ export type ContentEditableProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'o
273273
/** If true, disables editing (e.g., remove contentEditable or make read-only). */
274274
disabled?: boolean;
275275
innerRef?: React.Ref<HTMLDivElement>;
276+
/** Called when IME composition starts */
277+
onCompositionStart?: (e: React.CompositionEvent<HTMLDivElement>) => void;
278+
/** Called when IME composition updates */
279+
onCompositionUpdate?: (e: React.CompositionEvent<HTMLDivElement>) => void;
280+
/** Called when IME composition ends */
281+
onCompositionEnd?: (e: React.CompositionEvent<HTMLDivElement>) => void;
276282
};
277283

278284
const UnstyledCompositionInput = forwardRef<CompositionInputRef, ContentEditableProps>(
@@ -287,6 +293,9 @@ const UnstyledCompositionInput = forwardRef<CompositionInputRef, ContentEditable
287293
onKeyUp,
288294
onKeyDown,
289295
onClick,
296+
onCompositionStart,
297+
onCompositionUpdate,
298+
onCompositionEnd,
290299
children,
291300
...rest
292301
} = props;
@@ -297,6 +306,11 @@ const UnstyledCompositionInput = forwardRef<CompositionInputRef, ContentEditable
297306
const lastPosition = useRef<number | null>(null);
298307
const lastHtmlIndex = useRef<number>(0);
299308

309+
// IME composition state
310+
const isComposing = useRef(false);
311+
const compositionStartIndex = useRef<number>(0);
312+
const compositionData = useRef<string>('');
313+
300314
useDebouncedSpellcheck({
301315
elementRef: elRef,
302316
});
@@ -447,6 +461,11 @@ const UnstyledCompositionInput = forwardRef<CompositionInputRef, ContentEditable
447461
return;
448462
}
449463

464+
// Don't update selection during IME composition
465+
if (isComposing.current) {
466+
return;
467+
}
468+
450469
lastPosition.current = getHtmlIndexFromSelection(el);
451470
lastHtmlIndex.current = lastPosition.current;
452471
};
@@ -652,6 +671,11 @@ const UnstyledCompositionInput = forwardRef<CompositionInputRef, ContentEditable
652671

653672
const onInput = useCallback(
654673
(e: Omit<ContentEditableEvent, 'target'>) => {
674+
// Skip input handling during IME composition
675+
if (isComposing.current) {
676+
return;
677+
}
678+
655679
const hasChanged = handleChange();
656680
if (hasChanged) {
657681
emitChangeEvent(e);
@@ -769,6 +793,45 @@ const UnstyledCompositionInput = forwardRef<CompositionInputRef, ContentEditable
769793
[createSyntheticEvent, handleHistory]
770794
);
771795

796+
// IME Composition Event Handlers
797+
const handleCompositionStart = useCallback(
798+
(e: React.CompositionEvent<HTMLDivElement>) => {
799+
isComposing.current = true;
800+
compositionStartIndex.current = lastHtmlIndex.current;
801+
compositionData.current = '';
802+
803+
onCompositionStart?.(e);
804+
},
805+
[onCompositionStart]
806+
);
807+
808+
const handleCompositionUpdate = useCallback(
809+
(e: React.CompositionEvent<HTMLDivElement>) => {
810+
if (isComposing.current) {
811+
compositionData.current = e.data;
812+
}
813+
814+
onCompositionUpdate?.(e);
815+
},
816+
[onCompositionUpdate]
817+
);
818+
819+
const handleCompositionEnd = useCallback(
820+
(e: React.CompositionEvent<HTMLDivElement>) => {
821+
isComposing.current = false;
822+
823+
// Process the final composition result
824+
const hasChanged = handleChange();
825+
if (hasChanged) {
826+
emitChangeEvent(e as any);
827+
}
828+
829+
compositionData.current = '';
830+
onCompositionEnd?.(e);
831+
},
832+
[handleChange, emitChangeEvent, onCompositionEnd]
833+
);
834+
772835
useEffect(() => {
773836
const el = elRef.current;
774837
if (!el) {
@@ -802,7 +865,8 @@ const UnstyledCompositionInput = forwardRef<CompositionInputRef, ContentEditable
802865
el.innerHTML = normalizedHtml;
803866
lastHtml.current = normalizedHtml;
804867
isMount.current = false;
805-
} else if (normalizedHtml !== normalizedCurrentHtml) {
868+
} else if (normalizedHtml !== normalizedCurrentHtml && !isComposing.current) {
869+
// Don't update DOM during IME composition
806870
el.innerHTML = normalizedHtml;
807871
lastHtml.current = normalizedHtml;
808872
handleChange();
@@ -828,6 +892,9 @@ const UnstyledCompositionInput = forwardRef<CompositionInputRef, ContentEditable
828892
onKeyDown={_onKeyDown}
829893
onCopy={onCopy}
830894
onClick={_onClick}
895+
onCompositionStart={handleCompositionStart}
896+
onCompositionUpdate={handleCompositionUpdate}
897+
onCompositionEnd={handleCompositionEnd}
831898
>
832899
{children}
833900
</div>

ts/hooks/useDebuncedSpellcheck.ts

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,72 @@ interface DebouncedSpellcheckProps {
66
delay?: number;
77
}
88

9-
export const useDebouncedSpellcheck = ({ delay = 300, elementRef }: DebouncedSpellcheckProps) => {
10-
const enableSpellcheck = useCallback(() => {
11-
elementRef.current?.setAttribute('spellcheck', 'true');
9+
// Global reference counter for the hook
10+
let hookUsageCount = 0;
11+
const STYLE_ID = 'debounced-spellcheck-styles';
12+
13+
const cssStyles = `
14+
.spellcheck-hidden::-webkit-spelling-error {
15+
text-decoration: none !important;
16+
}
17+
18+
.spellcheck-hidden::-webkit-grammar-error {
19+
text-decoration: none !important;
20+
}
21+
22+
.spellcheck-hidden::-moz-spelling-error {
23+
text-decoration: none !important;
24+
}
25+
26+
.spellcheck-hidden::-moz-grammar-error {
27+
text-decoration: none !important;
28+
}
29+
30+
.spellcheck-hidden::spelling-error {
31+
text-decoration: none !important;
32+
}
33+
34+
.spellcheck-hidden::grammar-error {
35+
text-decoration: none !important;
36+
}
37+
`;
38+
39+
export const useDebouncedSpellcheck = ({ delay = 600, elementRef }: DebouncedSpellcheckProps) => {
40+
// Inject CSS styles if they don't exist
41+
useEffect(() => {
42+
hookUsageCount++;
43+
44+
// Only inject styles on first usage
45+
if (hookUsageCount === 1 && !document.getElementById(STYLE_ID)) {
46+
const style = document.createElement('style');
47+
style.id = STYLE_ID;
48+
style.textContent = cssStyles;
49+
document.head.appendChild(style);
50+
}
51+
52+
// Remove styles only when no components are using the hook
53+
return () => {
54+
hookUsageCount--;
55+
if (hookUsageCount === 0) {
56+
const existingStyle = document.getElementById(STYLE_ID);
57+
if (existingStyle) {
58+
existingStyle.remove();
59+
}
60+
}
61+
};
62+
}, []);
63+
64+
const hideSpellcheckLines = useCallback(() => {
65+
elementRef.current?.classList.add('spellcheck-hidden');
66+
}, [elementRef]);
67+
68+
const showSpellcheckLines = useCallback(() => {
69+
elementRef.current?.classList.remove('spellcheck-hidden');
1270
}, [elementRef]);
1371

1472
// eslint-disable-next-line react-hooks/exhaustive-deps -- TODO: see if we can create our own useDebounce hook
15-
const debouncedSpellcheck = useCallback(debounce(enableSpellcheck, delay), [
16-
enableSpellcheck,
73+
const debouncedShowSpellcheck = useCallback(debounce(showSpellcheckLines, delay), [
74+
showSpellcheckLines,
1775
delay,
1876
]);
1977

@@ -24,15 +82,24 @@ export const useDebouncedSpellcheck = ({ delay = 300, elementRef }: DebouncedSpe
2482
}
2583

2684
const handleInput = () => {
27-
el.setAttribute('spellcheck', 'false');
28-
debouncedSpellcheck();
85+
// Hide spellcheck lines immediately while typing
86+
hideSpellcheckLines();
87+
// Show them again after user stops typing
88+
debouncedShowSpellcheck();
2989
};
3090

3191
el.addEventListener('input', handleInput);
3292

3393
// eslint-disable-next-line consistent-return -- This return is the destructor
3494
return () => {
3595
el.removeEventListener('input', handleInput);
96+
// Clean up: show spellcheck lines when component unmounts
97+
showSpellcheckLines();
3698
};
37-
}, [debouncedSpellcheck, elementRef]);
99+
}, [debouncedShowSpellcheck, hideSpellcheckLines, showSpellcheckLines, elementRef]);
100+
101+
return {
102+
hideSpellcheckLines,
103+
showSpellcheckLines,
104+
};
38105
};

0 commit comments

Comments
 (0)