From 6ea1ac6565ac769cf42e27620e350f5be02f73ec Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 4 Mar 2026 14:29:14 +0000 Subject: [PATCH] fix(docs): preserve caret on remote updates; send deltas for formatting changes Two fixes: 1. Caret reset on remote delta: when receive_changes() was applied and innerHTML was set, the browser lost the caret position. Fix: save the caret as a character offset before the update and restore it via a TreeWalker walk after. A suppressMutations flag prevents the MutationObserver from incorrectly scheduling a local delta send while the remote HTML is being written. 2. Formatting/structural edits not sending deltas: onInput doesn't fire for toolbar actions (bold, italic, heading, etc.) because those are applied programmatically via the WASM model. Fix: attach a MutationObserver to the contentEditable div that calls scheduleDeltaSend() on any DOM change (childList, subtree, characterData, attributes). The observer is suppressed during remote innerHTML writes to avoid re-sending remote changes back. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../rooms/wysiwyg_composer/DocumentView.tsx | 80 +++++++++++++++++-- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx b/apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx index 1cd475e549..c0ea8b21c1 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx @@ -80,10 +80,56 @@ function base64Decode(b64: string): Uint8Array { } /** - * Encode a UTF-8 string as a lowercase hex string, as required by - * the Automerge `set_actor_id` API. + * Save the caret position as a character offset from the start of the + * editor's text content. Returns -1 if there is no selection. */ -function toHex(str: string): string { +function saveCaretOffset(editor: HTMLElement): number { + const sel = document.getSelection(); + if (!sel || sel.rangeCount === 0) return -1; + const range = sel.getRangeAt(0).cloneRange(); + range.selectNodeContents(editor); + range.setEnd(sel.getRangeAt(0).endContainer, sel.getRangeAt(0).endOffset); + return range.toString().length; +} + +/** + * Restore a caret position (character offset) inside the editor after an + * innerHTML replacement. Walks text nodes to find the right position. + */ +function restoreCaretOffset(editor: HTMLElement, offset: number): void { + if (offset < 0) return; + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT); + let remaining = offset; + let node: Text | null = null; + let nodeOffset = 0; + while (walker.nextNode()) { + const text = walker.currentNode as Text; + if (text.length >= remaining) { + node = text; + nodeOffset = remaining; + break; + } + remaining -= text.length; + } + if (!node && editor.lastChild) { + // offset past end — place at end + const range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + const sel = document.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + return; + } + if (node) { + const range = document.createRange(); + range.setStart(node, nodeOffset); + range.collapse(true); + const sel = document.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + } +} return Array.from(new TextEncoder().encode(str)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); @@ -101,9 +147,11 @@ function useDocumentSync( ): { isLoaded: boolean; scheduleDeltaSend: () => void; + suppressMutations: React.MutableRefObject; } { const [isLoaded, setIsLoaded] = useState(false); const debounceTimer = useRef | null>(null); + const suppressMutations = useRef(false); // Set actor ID as hex-encoded userId:deviceId for correct CRDT attribution. // set_actor_id() requires a hex string (decoded to raw bytes internally). @@ -133,7 +181,9 @@ function useDocumentSync( composerModel.load_document(base64Decode(data)); // Reflect the loaded document in the editor DOM. if (editorRef.current) { + suppressMutations.current = true; editorRef.current.innerHTML = composerModel.get_content_as_html(); + suppressMutations.current = false; onContentChanged(); } logger.info("[DocumentView] Loaded document from room state"); @@ -171,7 +221,11 @@ function useDocumentSync( try { model.receive_changes(base64Decode(data)); if (editorRef.current) { + suppressMutations.current = true; + const caretOffset = saveCaretOffset(editorRef.current); editorRef.current.innerHTML = model.get_content_as_html(); + restoreCaretOffset(editorRef.current, caretOffset); + suppressMutations.current = false; onContentChanged(); } logger.info("[DocumentView] Applied remote delta successfully"); @@ -228,7 +282,7 @@ function useDocumentSync( }, DELTA_DEBOUNCE_MS); }, [client, composerModel, room.roomId]); - return { isLoaded, scheduleDeltaSend }; + return { isLoaded, scheduleDeltaSend, suppressMutations }; } // ------------------------------------------------------------------ @@ -265,7 +319,7 @@ export const DocumentView = memo(function DocumentView({ room }: DocumentViewPro setHasContent(Boolean(ref.current?.textContent?.trim())); }); - const { isLoaded, scheduleDeltaSend } = useDocumentSync( + const { isLoaded, scheduleDeltaSend, suppressMutations } = useDocumentSync( room, client, composerModel, @@ -278,6 +332,22 @@ export const DocumentView = memo(function DocumentView({ room }: DocumentViewPro scheduleDeltaSend(); }, [scheduleDeltaSend]); + // MutationObserver to catch formatting/structural changes that don't + // fire onInput (e.g. bold, italic applied via the toolbar). + const scheduleDeltaSendRef = useRef(scheduleDeltaSend); + useEffect(() => { scheduleDeltaSendRef.current = scheduleDeltaSend; }, [scheduleDeltaSend]); + + useEffect(() => { + if (!ref.current) return; + const observer = new MutationObserver(() => { + if (suppressMutations.current) return; + scheduleDeltaSendRef.current(); + notifyContentChangedRef.current(); + }); + observer.observe(ref.current, { childList: true, subtree: true, characterData: true, attributes: true }); + return () => observer.disconnect(); + }, [ref, isWysiwygReady, suppressMutations]); // re-attach after editor becomes enabled + // Forward clicks anywhere in the content area to the contentEditable. const handleContentClick = useCallback(() => { ref.current?.focus();