Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "session-desktop",
"productName": "Session",
"description": "Private messaging from your desktop",
"version": "1.16.4",
"version": "1.16.5",
"license": "GPL-3.0",
"author": {
"name": "Session Foundation",
Expand Down
69 changes: 68 additions & 1 deletion ts/components/conversation/composition/CompositionInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,12 @@ export type ContentEditableProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'o
/** If true, disables editing (e.g., remove contentEditable or make read-only). */
disabled?: boolean;
innerRef?: React.Ref<HTMLDivElement>;
/** Called when IME composition starts */
onCompositionStart?: (e: React.CompositionEvent<HTMLDivElement>) => void;
/** Called when IME composition updates */
onCompositionUpdate?: (e: React.CompositionEvent<HTMLDivElement>) => void;
/** Called when IME composition ends */
onCompositionEnd?: (e: React.CompositionEvent<HTMLDivElement>) => void;
};

const UnstyledCompositionInput = forwardRef<CompositionInputRef, ContentEditableProps>(
Expand All @@ -287,6 +293,9 @@ const UnstyledCompositionInput = forwardRef<CompositionInputRef, ContentEditable
onKeyUp,
onKeyDown,
onClick,
onCompositionStart,
onCompositionUpdate,
onCompositionEnd,
children,
...rest
} = props;
Expand All @@ -297,6 +306,11 @@ const UnstyledCompositionInput = forwardRef<CompositionInputRef, ContentEditable
const lastPosition = useRef<number | null>(null);
const lastHtmlIndex = useRef<number>(0);

// IME composition state
const isComposing = useRef(false);
const compositionStartIndex = useRef<number>(0);
const compositionData = useRef<string>('');

useDebouncedSpellcheck({
elementRef: elRef,
});
Expand Down Expand Up @@ -447,6 +461,11 @@ const UnstyledCompositionInput = forwardRef<CompositionInputRef, ContentEditable
return;
}

// Don't update selection during IME composition
if (isComposing.current) {
return;
}

lastPosition.current = getHtmlIndexFromSelection(el);
lastHtmlIndex.current = lastPosition.current;
};
Expand Down Expand Up @@ -652,6 +671,11 @@ const UnstyledCompositionInput = forwardRef<CompositionInputRef, ContentEditable

const onInput = useCallback(
(e: Omit<ContentEditableEvent, 'target'>) => {
// Skip input handling during IME composition
if (isComposing.current) {
return;
}

const hasChanged = handleChange();
if (hasChanged) {
emitChangeEvent(e);
Expand Down Expand Up @@ -769,6 +793,45 @@ const UnstyledCompositionInput = forwardRef<CompositionInputRef, ContentEditable
[createSyntheticEvent, handleHistory]
);

// IME Composition Event Handlers
const handleCompositionStart = useCallback(
(e: React.CompositionEvent<HTMLDivElement>) => {
isComposing.current = true;
compositionStartIndex.current = lastHtmlIndex.current;
compositionData.current = '';

onCompositionStart?.(e);
},
[onCompositionStart]
);

const handleCompositionUpdate = useCallback(
(e: React.CompositionEvent<HTMLDivElement>) => {
if (isComposing.current) {
compositionData.current = e.data;
}

onCompositionUpdate?.(e);
},
[onCompositionUpdate]
);

const handleCompositionEnd = useCallback(
(e: React.CompositionEvent<HTMLDivElement>) => {
isComposing.current = false;

// Process the final composition result
const hasChanged = handleChange();
if (hasChanged) {
emitChangeEvent(e as any);
}

compositionData.current = '';
onCompositionEnd?.(e);
},
[handleChange, emitChangeEvent, onCompositionEnd]
);

useEffect(() => {
const el = elRef.current;
if (!el) {
Expand Down Expand Up @@ -802,7 +865,8 @@ const UnstyledCompositionInput = forwardRef<CompositionInputRef, ContentEditable
el.innerHTML = normalizedHtml;
lastHtml.current = normalizedHtml;
isMount.current = false;
} else if (normalizedHtml !== normalizedCurrentHtml) {
} else if (normalizedHtml !== normalizedCurrentHtml && !isComposing.current) {
// Don't update DOM during IME composition
el.innerHTML = normalizedHtml;
lastHtml.current = normalizedHtml;
handleChange();
Expand All @@ -828,6 +892,9 @@ const UnstyledCompositionInput = forwardRef<CompositionInputRef, ContentEditable
onKeyDown={_onKeyDown}
onCopy={onCopy}
onClick={_onClick}
onCompositionStart={handleCompositionStart}
onCompositionUpdate={handleCompositionUpdate}
onCompositionEnd={handleCompositionEnd}
>
{children}
</div>
Expand Down
83 changes: 75 additions & 8 deletions ts/hooks/useDebuncedSpellcheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,72 @@ interface DebouncedSpellcheckProps {
delay?: number;
}

export const useDebouncedSpellcheck = ({ delay = 300, elementRef }: DebouncedSpellcheckProps) => {
const enableSpellcheck = useCallback(() => {
elementRef.current?.setAttribute('spellcheck', 'true');
// Global reference counter for the hook
let hookUsageCount = 0;
const STYLE_ID = 'debounced-spellcheck-styles';

const cssStyles = `
.spellcheck-hidden::-webkit-spelling-error {
text-decoration: none !important;
}

.spellcheck-hidden::-webkit-grammar-error {
text-decoration: none !important;
}

.spellcheck-hidden::-moz-spelling-error {
text-decoration: none !important;
}

.spellcheck-hidden::-moz-grammar-error {
text-decoration: none !important;
}

.spellcheck-hidden::spelling-error {
text-decoration: none !important;
}

.spellcheck-hidden::grammar-error {
text-decoration: none !important;
}
`;

export const useDebouncedSpellcheck = ({ delay = 600, elementRef }: DebouncedSpellcheckProps) => {
// Inject CSS styles if they don't exist
useEffect(() => {
hookUsageCount++;

// Only inject styles on first usage
if (hookUsageCount === 1 && !document.getElementById(STYLE_ID)) {
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = cssStyles;
document.head.appendChild(style);
}

// Remove styles only when no components are using the hook
return () => {
hookUsageCount--;
if (hookUsageCount === 0) {
const existingStyle = document.getElementById(STYLE_ID);
if (existingStyle) {
existingStyle.remove();
}
}
};
}, []);

const hideSpellcheckLines = useCallback(() => {
elementRef.current?.classList.add('spellcheck-hidden');
}, [elementRef]);

const showSpellcheckLines = useCallback(() => {
elementRef.current?.classList.remove('spellcheck-hidden');
}, [elementRef]);

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

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

const handleInput = () => {
el.setAttribute('spellcheck', 'false');
debouncedSpellcheck();
// Hide spellcheck lines immediately while typing
hideSpellcheckLines();
// Show them again after user stops typing
debouncedShowSpellcheck();
};

el.addEventListener('input', handleInput);

// eslint-disable-next-line consistent-return -- This return is the destructor
return () => {
el.removeEventListener('input', handleInput);
// Clean up: show spellcheck lines when component unmounts
showSpellcheckLines();
};
}, [debouncedSpellcheck, elementRef]);
}, [debouncedShowSpellcheck, hideSpellcheckLines, showSpellcheckLines, elementRef]);

return {
hideSpellcheckLines,
showSpellcheckLines,
};
};
Loading