mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-07 05:06:38 +02:00
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>
This commit is contained in:
parent
e7bb3bf146
commit
6ea1ac6565
@ -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<boolean>;
|
||||
} {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | 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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user