diff --git a/apps/web/res/css/views/rooms/wysiwyg_composer/_DocumentView.pcss b/apps/web/res/css/views/rooms/wysiwyg_composer/_DocumentView.pcss index 85992ced01..2a894b7367 100644 --- a/apps/web/res/css/views/rooms/wysiwyg_composer/_DocumentView.pcss +++ b/apps/web/res/css/views/rooms/wysiwyg_composer/_DocumentView.pcss @@ -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; + } +} 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 7c87522e44..5ee808dce7 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx @@ -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, - onContentChanged: () => void, + committedTextRef: React.MutableRefObject, 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; saveStatus: SaveStatus; } { const [isLoaded, setIsLoaded] = useState(false); const [saveStatus, setSaveStatus] = useState("idle"); const debounceTimer = useRef | null>(null); const savedClearTimer = useRef | 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([]); @@ -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>(() => new Map()); + const scrollContainerRef = useRef(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 | 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 )} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} -
+
+
diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/RemoteCursorOverlay.tsx b/apps/web/src/components/views/rooms/wysiwyg_composer/RemoteCursorOverlay.tsx new file mode 100644 index 0000000000..22674f1ff3 --- /dev/null +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/RemoteCursorOverlay.tsx @@ -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; + /** Ref to the scroll container (the `.mx_DocumentView_content` div). */ + scrollContainerRef: React.RefObject; + /** Map of actorId → cursor state for all remote peers. */ + cursors: ReadonlyMap; +} + +// ─── 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( +
, + ); + } + + // Caret line. + if (caretRect) { + elements.push( +
, + ); + } + } + + return ( + + ); +}); diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/useDocumentRTC.ts b/apps/web/src/components/views/rooms/wysiwyg_composer/useDocumentRTC.ts index ff9730b71d..f8a5854dbd 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/useDocumentRTC.ts +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/useDocumentRTC.ts @@ -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(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 }; }