mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-06 04:36:21 +02:00
feat: refactor DocumentView to use model as source of truth + remote cursors
This commit is contained in:
parent
930cc4d51c
commit
0c95060797
@ -60,6 +60,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
padding: var(--cpd-space-8x) var(--cpd-space-12x);
|
||||
/* Forward clicks anywhere in this area to the editor */
|
||||
cursor: text;
|
||||
/* Positioning context for the remote cursor overlay. */
|
||||
position: relative;
|
||||
|
||||
/*
|
||||
* The composer's editor stack is designed for a small multi-line input.
|
||||
@ -87,3 +89,46 @@ Please see LICENSE files in the repository root for full details.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Remote cursor overlay ─────────────────────────────────────────────── */
|
||||
|
||||
.mx_RemoteCursorOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin coloured line drawn at the remote peer's caret position.
|
||||
* The colour is set via inline `borderColor` style.
|
||||
*/
|
||||
.mx_RemoteCursorOverlay_caret {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
border-left: 2px solid;
|
||||
/* Inherit borderColor from inline style */
|
||||
box-sizing: border-box;
|
||||
animation: mx_RemoteCursorOverlay_pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semi-transparent rectangle covering a portion of a remote peer's selection.
|
||||
* The colour is set via inline `backgroundColor` style.
|
||||
*/
|
||||
.mx_RemoteCursorOverlay_selection {
|
||||
position: absolute;
|
||||
opacity: 0.25;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@keyframes mx_RemoteCursorOverlay_pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,13 +8,19 @@ Please see LICENSE files in the repository root for full details.
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type Room, type MatrixClient, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { useWysiwyg, type UseWysiwyg } from "@vector-im/matrix-wysiwyg";
|
||||
import {
|
||||
useWysiwyg,
|
||||
renderProjections,
|
||||
selectContent,
|
||||
type BlockProjection,
|
||||
} from "@vector-im/matrix-wysiwyg";
|
||||
|
||||
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext.tsx";
|
||||
import { FormattingButtons } from "./components/FormattingButtons.tsx";
|
||||
import { Editor } from "./components/Editor.tsx";
|
||||
import { ComposerContext, getDefaultContextValue } from "./ComposerContext.ts";
|
||||
import { useDocumentRTC } from "./useDocumentRTC.ts";
|
||||
import { useDocumentRTC, type CursorPayload } from "./useDocumentRTC.ts";
|
||||
import { RemoteCursorOverlay, colorForActor, type RemoteCursor } from "./RemoteCursorOverlay.tsx";
|
||||
|
||||
/**
|
||||
* Matrix event type for incremental Automerge deltas sent as timeline events.
|
||||
@ -45,6 +51,9 @@ const SAVED_CLEAR_DELAY_MS = 2000;
|
||||
/** Save a full snapshot to room state every N Matrix timeline deltas. */
|
||||
const SNAPSHOT_EVERY_N_DELTAS = 20;
|
||||
|
||||
/** Throttle interval for broadcasting cursor position changes. */
|
||||
const CURSOR_THROTTLE_MS = 50;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Collaboration type augmentation
|
||||
//
|
||||
@ -61,17 +70,11 @@ interface CollaborativeComposerModel {
|
||||
get_heads(): string[];
|
||||
set_actor_id(actor: string): void;
|
||||
get_content_as_html(): string;
|
||||
get_block_projections(): BlockProjection[];
|
||||
selection_start(): number;
|
||||
selection_end(): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* UseWysiwyg extended with the composerModel field exposed in langleyd/automerge.
|
||||
* The npm-published 2.40.0 package does not include this field; we use an
|
||||
* intersection type + runtime cast so code is forward-compatible.
|
||||
*/
|
||||
type UseWysiwygExtended = UseWysiwyg & {
|
||||
composerModel?: unknown;
|
||||
};
|
||||
|
||||
function isCollaborative(model: unknown): model is CollaborativeComposerModel {
|
||||
return typeof (model as CollaborativeComposerModel | null)?.save_incremental === "function";
|
||||
}
|
||||
@ -93,58 +96,6 @@ function base64Decode(b64: string): Uint8Array {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a UTF-8 string as a lowercase hex string, as required by
|
||||
* the Automerge `set_actor_id` API.
|
||||
@ -167,7 +118,7 @@ function useDocumentSync(
|
||||
client: MatrixClient,
|
||||
composerModel: unknown,
|
||||
editorRef: React.RefObject<HTMLDivElement | null>,
|
||||
onContentChanged: () => void,
|
||||
committedTextRef: React.MutableRefObject<string>,
|
||||
rtc?: {
|
||||
publishDelta: (bytes: Uint8Array) => void;
|
||||
onDeltaRef: React.MutableRefObject<((bytes: Uint8Array) => void) | null>;
|
||||
@ -176,14 +127,12 @@ function useDocumentSync(
|
||||
): {
|
||||
isLoaded: boolean;
|
||||
scheduleDeltaSend: () => void;
|
||||
suppressMutations: React.MutableRefObject<boolean>;
|
||||
saveStatus: SaveStatus;
|
||||
} {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const savedClearTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const suppressMutations = useRef(false);
|
||||
// Tracks the Automerge heads at the time of the last RTC publish so that
|
||||
// save_after() gives exactly the delta since the previous keystroke send.
|
||||
const lastRtcHeadsRef = useRef<string[]>([]);
|
||||
@ -256,16 +205,15 @@ function useDocumentSync(
|
||||
// captures only the changes made after this load point.
|
||||
lastRtcHeadsRef.current = composerModel.get_heads();
|
||||
|
||||
// 3. Update the editor DOM to reflect the loaded + replayed state.
|
||||
// 3. Update the editor DOM to reflect the loaded + replayed state
|
||||
// using projection-based rendering for correct UTF-16 offset mapping.
|
||||
if (editorRef.current) {
|
||||
suppressMutations.current = true;
|
||||
editorRef.current.innerHTML = composerModel.get_content_as_html();
|
||||
requestAnimationFrame(() => { suppressMutations.current = false; });
|
||||
onContentChanged();
|
||||
const projections = composerModel.get_block_projections();
|
||||
committedTextRef.current = renderProjections(projections, editorRef.current);
|
||||
}
|
||||
|
||||
setIsLoaded(true);
|
||||
}, [room, composerModel, editorRef, onContentChanged]);
|
||||
}, [room, composerModel, editorRef, committedTextRef]);
|
||||
|
||||
// Apply incoming delta events from the room timeline and update the DOM.
|
||||
// Use a ref for composerModel so the listener closure always has the latest
|
||||
@ -291,18 +239,27 @@ function useDocumentSync(
|
||||
// keystroke doesn't re-transmit the just-received remote changes.
|
||||
lastRtcHeadsRef.current = model.get_heads();
|
||||
if (editorRef.current) {
|
||||
suppressMutations.current = true;
|
||||
const caretOffset = saveCaretOffset(editorRef.current);
|
||||
editorRef.current.innerHTML = model.get_content_as_html();
|
||||
restoreCaretOffset(editorRef.current, caretOffset);
|
||||
requestAnimationFrame(() => { suppressMutations.current = false; });
|
||||
onContentChanged();
|
||||
// Save the model's current selection so we can restore it after
|
||||
// re-rendering. The model selection is kept in sync by
|
||||
// useListeners' selectionchange handler.
|
||||
const selStart = model.selection_start();
|
||||
const selEnd = model.selection_end();
|
||||
|
||||
// Re-render from the model's block projections (same pipeline
|
||||
// as useListeners uses for local edits) and keep
|
||||
// committedTextRef in sync so reconcileNative() produces
|
||||
// correct diffs on the next input event.
|
||||
const projections = model.get_block_projections();
|
||||
committedTextRef.current = renderProjections(projections, editorRef.current);
|
||||
|
||||
// Restore the local cursor position.
|
||||
selectContent(editorRef.current, selStart, selEnd);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn("[DocumentView] Failed to apply remote delta", e);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editorRef, onContentChanged]);
|
||||
}, [editorRef, committedTextRef]);
|
||||
|
||||
// Wire the LiveKit onDeltaRef callback when RTC is provided.
|
||||
useEffect(() => {
|
||||
@ -479,7 +436,7 @@ function useDocumentSync(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client, room.roomId]);
|
||||
|
||||
return { isLoaded, scheduleDeltaSend, suppressMutations, saveStatus };
|
||||
return { isLoaded, scheduleDeltaSend, saveStatus };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@ -496,11 +453,12 @@ export const DocumentView = memo(function DocumentView({ room }: DocumentViewPro
|
||||
// ComposerContext is required by the Editor's useSelection hook.
|
||||
const composerContext = useMemo(() => getDefaultContextValue(), []);
|
||||
|
||||
// Cast to the extended type to access composerModel when available (requires
|
||||
// @vector-im/matrix-wysiwyg >= langleyd/automerge build).
|
||||
const wysiwygResult = useWysiwyg({ isAutoFocusEnabled: true }) as UseWysiwygExtended;
|
||||
const { ref, isWysiwygReady, wysiwyg, actionStates } = wysiwygResult;
|
||||
const composerModel = wysiwygResult.composerModel;
|
||||
// useWysiwyg sets up the full input pipeline: useListeners handles input
|
||||
// events, feeds them into the Rust model, re-renders via renderProjections,
|
||||
// and updates `content` after each model change. `committedTextRef` tracks
|
||||
// the last plain text committed to the editor so reconcileNative() works.
|
||||
const { ref, isWysiwygReady, wysiwyg, actionStates, content, composerModel, committedTextRef } =
|
||||
useWysiwyg({ isAutoFocusEnabled: true });
|
||||
|
||||
// LiveKit real-time transport (falls back gracefully if unavailable).
|
||||
const rtc = useDocumentRTC(room, client);
|
||||
@ -508,60 +466,121 @@ export const DocumentView = memo(function DocumentView({ room }: DocumentViewPro
|
||||
// Track whether the editor has content so we can hide the placeholder.
|
||||
const [hasContent, setHasContent] = useState(false);
|
||||
|
||||
// Stable callback ref so useDocumentSync doesn't re-register its listener
|
||||
// every time the component re-renders.
|
||||
const notifyContentChangedRef = useRef(() => {
|
||||
setHasContent(Boolean(ref.current?.textContent?.trim()));
|
||||
});
|
||||
// Remote cursor state: map of actorId → cursor position + colour.
|
||||
const [remoteCursors, setRemoteCursors] = useState<Map<string, RemoteCursor>>(() => new Map());
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { isLoaded, scheduleDeltaSend, suppressMutations, saveStatus } = useDocumentSync(
|
||||
const { isLoaded, scheduleDeltaSend, saveStatus } = useDocumentSync(
|
||||
room,
|
||||
client,
|
||||
composerModel,
|
||||
ref,
|
||||
notifyContentChangedRef.current,
|
||||
committedTextRef,
|
||||
rtc,
|
||||
);
|
||||
|
||||
// Trigger Automerge sync whenever useListeners updates `content` (i.e.
|
||||
// after every local edit that goes through the model). This replaces the
|
||||
// old manual onInput + MutationObserver approach: the Rust model is now
|
||||
// the single source of truth and useListeners keeps it in sync with the DOM.
|
||||
const prevContentRef = useRef(content);
|
||||
useEffect(() => {
|
||||
if (content !== prevContentRef.current) {
|
||||
prevContentRef.current = content;
|
||||
setHasContent(Boolean(ref.current?.textContent?.trim()));
|
||||
if (isLoaded) {
|
||||
scheduleDeltaSend();
|
||||
}
|
||||
}
|
||||
}, [content, isLoaded, scheduleDeltaSend, ref]);
|
||||
|
||||
// Place the cursor at position 0 (document start, like Google Docs) once the
|
||||
// WASM model is ready AND the document content has been written to the DOM.
|
||||
useEffect(() => {
|
||||
if (!isWysiwygReady || !isLoaded || !ref.current) return;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(ref.current);
|
||||
range.collapse(true); // collapse to start
|
||||
const sel = document.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
selectContent(ref.current, 0, 0);
|
||||
ref.current.focus();
|
||||
}, [isWysiwygReady, isLoaded, ref]);
|
||||
|
||||
const handleInput = useCallback(() => {
|
||||
notifyContentChangedRef.current();
|
||||
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();
|
||||
}, [ref]);
|
||||
|
||||
// ── Remote cursor sharing ─────────────────────────────────────────────
|
||||
|
||||
// Wire RTC cursor and peer-leave callbacks.
|
||||
useEffect(() => {
|
||||
rtc.onCursorRef.current = (cursor: CursorPayload): void => {
|
||||
setRemoteCursors((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(cursor.id, {
|
||||
anchor: cursor.a,
|
||||
focus: cursor.f,
|
||||
color: colorForActor(cursor.id),
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
rtc.onPeerLeaveRef.current = (identity: string): void => {
|
||||
setRemoteCursors((prev) => {
|
||||
if (!prev.has(identity)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.delete(identity);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
return () => {
|
||||
rtc.onCursorRef.current = null;
|
||||
rtc.onPeerLeaveRef.current = null;
|
||||
};
|
||||
}, [rtc]);
|
||||
|
||||
// Broadcast the local cursor position on every selectionchange, throttled.
|
||||
// Now that useListeners keeps the model selection in sync via
|
||||
// composerModel.select(), we can read selection_start/end directly.
|
||||
const actorId = useMemo(
|
||||
() => toHex(`${client.getUserId()}:${client.getDeviceId()}`),
|
||||
[client],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaded || !isCollaborative(composerModel)) return;
|
||||
|
||||
let lastSentAt = 0;
|
||||
let pending: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const send = (): void => {
|
||||
if (!isCollaborative(composerModel)) return;
|
||||
const anchor = composerModel.selection_start();
|
||||
const focus = composerModel.selection_end();
|
||||
rtc.publishCursor(anchor, focus, actorId);
|
||||
lastSentAt = Date.now();
|
||||
};
|
||||
|
||||
const onSelectionChange = (): void => {
|
||||
// Only broadcast when the editor is focused.
|
||||
const active = document.activeElement;
|
||||
if (!ref.current || !ref.current.contains(active)) return;
|
||||
|
||||
const elapsed = Date.now() - lastSentAt;
|
||||
if (elapsed >= CURSOR_THROTTLE_MS) {
|
||||
send();
|
||||
} else if (!pending) {
|
||||
pending = setTimeout(() => {
|
||||
pending = null;
|
||||
send();
|
||||
}, CURSOR_THROTTLE_MS - elapsed);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("selectionchange", onSelectionChange);
|
||||
return () => {
|
||||
document.removeEventListener("selectionchange", onSelectionChange);
|
||||
if (pending) clearTimeout(pending);
|
||||
};
|
||||
}, [isLoaded, composerModel, rtc, actorId, ref]);
|
||||
|
||||
// Expose a lightweight diagnostic on `window.__docDebug()` so we can
|
||||
// compare CRDT state across clients from the browser console without
|
||||
// flooding the log. Returns a plain object — safe to JSON.stringify.
|
||||
@ -651,11 +670,20 @@ export const DocumentView = memo(function DocumentView({ room }: DocumentViewPro
|
||||
)}
|
||||
</div>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div className="mx_DocumentView_content" onInput={handleInput} onClick={handleContentClick}>
|
||||
<div
|
||||
className="mx_DocumentView_content"
|
||||
ref={scrollContainerRef}
|
||||
onClick={handleContentClick}
|
||||
>
|
||||
<Editor
|
||||
ref={ref}
|
||||
disabled={!isWysiwygReady}
|
||||
placeholder={hasContent ? undefined : "Start typing your document…"}
|
||||
placeholder={hasContent ? undefined : "Start typing your document\u2026"}
|
||||
/>
|
||||
<RemoteCursorOverlay
|
||||
editorRef={ref}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
cursors={remoteCursors}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,247 @@
|
||||
/*
|
||||
Copyright 2026 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { memo, useEffect, useState } from "react";
|
||||
|
||||
// ─── Colour palette ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compound-flavoured cursor colours — enough variety for a busy room but
|
||||
* visually distinct from the local system caret.
|
||||
*/
|
||||
const CURSOR_COLORS = [
|
||||
"#0DBD8B", // green
|
||||
"#AC3BA8", // purple
|
||||
"#FF812D", // orange
|
||||
"#1E7DDC", // blue
|
||||
"#E34979", // pink
|
||||
"#368BD6", // lighter blue
|
||||
"#F5B731", // gold
|
||||
"#E26D69", // salmon
|
||||
] as const;
|
||||
|
||||
/** FNV-1a (32-bit) hash for deterministic colour assignment. */
|
||||
function fnv32a(str: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
export function colorForActor(actorId: string): string {
|
||||
return CURSOR_COLORS[fnv32a(actorId) % CURSOR_COLORS.length];
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RemoteCursor {
|
||||
/** UTF-16 character offset of the selection anchor. */
|
||||
anchor: number;
|
||||
/** UTF-16 character offset of the selection focus (equals anchor for a plain caret). */
|
||||
focus: number;
|
||||
/** Colour string assigned to this remote participant. */
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface RemoteCursorOverlayProps {
|
||||
/** Ref to the contentEditable editor element. */
|
||||
editorRef: React.RefObject<HTMLElement | null>;
|
||||
/** Ref to the scroll container (the `.mx_DocumentView_content` div). */
|
||||
scrollContainerRef: React.RefObject<HTMLElement | null>;
|
||||
/** Map of actorId → cursor state for all remote peers. */
|
||||
cursors: ReadonlyMap<string, RemoteCursor>;
|
||||
}
|
||||
|
||||
// ─── Geometry helpers ────────────────────────────────────────────────────────
|
||||
|
||||
interface Rect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute client rects for a selection range described by UTF-16 offsets
|
||||
* inside the editor. Uses a temporary DOM Range via `selectContent()` would
|
||||
* be disruptive (it moves the real selection), so we walk text nodes manually.
|
||||
*/
|
||||
function rectsForRange(
|
||||
editor: HTMLElement,
|
||||
start: number,
|
||||
end: number,
|
||||
containerRect: DOMRect,
|
||||
scrollTop: number,
|
||||
): { caretRect: Rect | null; selectionRects: Rect[] } {
|
||||
// Walk text nodes, converting UTF-16 code-unit offsets to DOM positions.
|
||||
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT);
|
||||
let consumed = 0;
|
||||
|
||||
let startNode: Text | null = null;
|
||||
let startOffset = 0;
|
||||
let endNode: Text | null = null;
|
||||
let endOffset = 0;
|
||||
|
||||
const min = Math.min(start, end);
|
||||
const max = Math.max(start, end);
|
||||
|
||||
while (walker.nextNode()) {
|
||||
const text = walker.currentNode as Text;
|
||||
const len = text.length; // JS string length === UTF-16 code units
|
||||
|
||||
if (!startNode && consumed + len >= min) {
|
||||
startNode = text;
|
||||
startOffset = min - consumed;
|
||||
}
|
||||
if (!endNode && consumed + len >= max) {
|
||||
endNode = text;
|
||||
endOffset = max - consumed;
|
||||
break;
|
||||
}
|
||||
consumed += len;
|
||||
}
|
||||
|
||||
if (!startNode || !endNode) return { caretRect: null, selectionRects: [] };
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(startNode, startOffset);
|
||||
range.setEnd(endNode, endOffset);
|
||||
|
||||
const rects = Array.from(range.getClientRects());
|
||||
const translate = (r: DOMRect): Rect => ({
|
||||
top: r.top - containerRect.top + scrollTop,
|
||||
left: r.left - containerRect.left,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
});
|
||||
|
||||
if (start === end) {
|
||||
// Collapsed caret: single zero-width rect.
|
||||
const r = rects[0];
|
||||
if (!r) return { caretRect: null, selectionRects: [] };
|
||||
return { caretRect: translate(r), selectionRects: [] };
|
||||
}
|
||||
|
||||
// Selection: caretRect at the focus end, selectionRects for highlighting.
|
||||
const focusIsEnd = end === Math.max(start, end);
|
||||
const caretDomRect = focusIsEnd ? rects[rects.length - 1] : rects[0];
|
||||
const caretRect = caretDomRect
|
||||
? {
|
||||
// Position the caret at the correct edge of the rect.
|
||||
top: caretDomRect.top - containerRect.top + scrollTop,
|
||||
left: focusIsEnd
|
||||
? caretDomRect.right - containerRect.left
|
||||
: caretDomRect.left - containerRect.left,
|
||||
width: 0,
|
||||
height: caretDomRect.height,
|
||||
}
|
||||
: null;
|
||||
|
||||
return { caretRect, selectionRects: rects.map(translate) };
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const RemoteCursorOverlay = memo(function RemoteCursorOverlay({
|
||||
editorRef,
|
||||
scrollContainerRef,
|
||||
cursors,
|
||||
}: RemoteCursorOverlayProps) {
|
||||
// Force a re-render when the DOM geometry changes so cursor positions
|
||||
// update after scrolling, resizing, or remote content changes.
|
||||
const [, setTick] = useState(0);
|
||||
const bump = (): void => setTick((t) => t + 1);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Scroll → reposition.
|
||||
container.addEventListener("scroll", bump, { passive: true });
|
||||
|
||||
// Resize → reposition.
|
||||
const ro = new ResizeObserver(bump);
|
||||
ro.observe(container);
|
||||
|
||||
// Content changes → reposition.
|
||||
const mo = new MutationObserver(bump);
|
||||
if (editorRef.current) {
|
||||
mo.observe(editorRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", bump);
|
||||
ro.disconnect();
|
||||
mo.disconnect();
|
||||
};
|
||||
}, [editorRef, scrollContainerRef]);
|
||||
|
||||
const editor = editorRef.current;
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!editor || !scrollContainer || cursors.size === 0) return null;
|
||||
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const scrollTop = scrollContainer.scrollTop;
|
||||
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
for (const [actorId, cursor] of cursors) {
|
||||
const { caretRect, selectionRects } = rectsForRange(
|
||||
editor,
|
||||
cursor.anchor,
|
||||
cursor.focus,
|
||||
containerRect,
|
||||
scrollTop,
|
||||
);
|
||||
|
||||
// Selection highlight rects.
|
||||
for (let i = 0; i < selectionRects.length; i++) {
|
||||
const r = selectionRects[i];
|
||||
elements.push(
|
||||
<div
|
||||
key={`${actorId}-sel-${i}`}
|
||||
className="mx_RemoteCursorOverlay_selection"
|
||||
style={{
|
||||
top: r.top,
|
||||
left: r.left,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
backgroundColor: cursor.color,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// Caret line.
|
||||
if (caretRect) {
|
||||
elements.push(
|
||||
<div
|
||||
key={`${actorId}-caret`}
|
||||
className="mx_RemoteCursorOverlay_caret"
|
||||
style={{
|
||||
top: caretRect.top,
|
||||
left: caretRect.left,
|
||||
height: caretRect.height,
|
||||
borderColor: cursor.color,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_RemoteCursorOverlay" aria-hidden="true">
|
||||
{elements}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@ -26,12 +26,29 @@ import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { Room as LivekitRoom, RoomEvent, ConnectionState } from "livekit-client";
|
||||
|
||||
const LIVEKIT_DATA_TOPIC = "org.element.doc.delta";
|
||||
const CURSOR_DATA_TOPIC = "org.element.doc.cursor";
|
||||
|
||||
/** Wire format for a remote cursor/selection position. */
|
||||
export interface CursorPayload {
|
||||
/** Character offset of the selection anchor. */
|
||||
a: number;
|
||||
/** Character offset of the selection focus (equals anchor for a plain caret). */
|
||||
f: number;
|
||||
/** Hex-encoded actor ID (userId:deviceId) of the sender. */
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface UseDocumentRTCResult {
|
||||
/** Send an Automerge incremental delta to all connected peers. */
|
||||
publishDelta: (bytes: Uint8Array) => void;
|
||||
/** Set this to receive deltas from peers. Updated value is used without re-registering listeners. */
|
||||
/** Send this client's cursor/selection position to all peers. */
|
||||
publishCursor: (anchor: number, focus: number, actorId: string) => void;
|
||||
/** Set this to receive deltas from peers. */
|
||||
onDeltaRef: React.MutableRefObject<((bytes: Uint8Array) => void) | null>;
|
||||
/** Set this to receive cursor updates from peers. */
|
||||
onCursorRef: React.MutableRefObject<((cursor: CursorPayload) => void) | null>;
|
||||
/** Set this to be notified when a peer leaves (receives their actor identity string). */
|
||||
onPeerLeaveRef: React.MutableRefObject<((identity: string) => void) | null>;
|
||||
/** True when connected to the LiveKit room. */
|
||||
isConnected: boolean;
|
||||
}
|
||||
@ -77,6 +94,8 @@ export function useDocumentRTC(
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const livekitRoomRef = useRef<LivekitRoom | null>(null);
|
||||
const onDeltaRef = useRef<((bytes: Uint8Array) => void) | null>(null);
|
||||
const onCursorRef = useRef<((cursor: CursorPayload) => void) | null>(null);
|
||||
const onPeerLeaveRef = useRef<((identity: string) => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@ -157,15 +176,29 @@ export function useDocumentRTC(
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Receive Automerge deltas from peers.
|
||||
// 4. Receive data from peers — route by topic.
|
||||
livekitRoom.on(
|
||||
RoomEvent.DataReceived,
|
||||
(payload: Uint8Array, _participant: unknown, _kind: unknown, topic?: string) => {
|
||||
if (topic !== LIVEKIT_DATA_TOPIC) return;
|
||||
onDeltaRef.current?.(payload);
|
||||
if (topic === LIVEKIT_DATA_TOPIC) {
|
||||
onDeltaRef.current?.(payload);
|
||||
} else if (topic === CURSOR_DATA_TOPIC) {
|
||||
try {
|
||||
const cursor = JSON.parse(new TextDecoder().decode(payload)) as CursorPayload;
|
||||
onCursorRef.current?.(cursor);
|
||||
} catch (e) {
|
||||
logger.warn("[DocumentRTC] Failed to parse cursor payload", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 5. Notify when a peer leaves so their cursor can be cleared.
|
||||
livekitRoom.on(RoomEvent.ParticipantDisconnected, (participant: unknown) => {
|
||||
const identity = (participant as { identity?: string })?.identity;
|
||||
if (identity) onPeerLeaveRef.current?.(identity);
|
||||
});
|
||||
|
||||
try {
|
||||
await livekitRoom.connect(livekitCreds.url, livekitCreds.jwt, {
|
||||
// Data-only: don't auto-subscribe to audio/video tracks.
|
||||
@ -200,5 +233,16 @@ export function useDocumentRTC(
|
||||
.catch((e: unknown) => logger.warn("[DocumentRTC] Failed to publish delta", e));
|
||||
}, []);
|
||||
|
||||
return { publishDelta, onDeltaRef, isConnected };
|
||||
const publishCursor = useCallback((anchor: number, focus: number, actorId: string): void => {
|
||||
const room = livekitRoomRef.current;
|
||||
if (!room || room.state !== ConnectionState.Connected) return;
|
||||
const payload = new TextEncoder().encode(
|
||||
JSON.stringify({ a: anchor, f: focus, id: actorId } satisfies CursorPayload),
|
||||
);
|
||||
room.localParticipant
|
||||
.publishData(payload, { reliable: false, topic: CURSOR_DATA_TOPIC })
|
||||
.catch((e: unknown) => logger.warn("[DocumentRTC] Failed to publish cursor", e));
|
||||
}, []);
|
||||
|
||||
return { publishDelta, publishCursor, onDeltaRef, onCursorRef, onPeerLeaveRef, isConnected };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user