Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
5.6 kB · 135 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136import { useState, useEffect, useMemo } from "react";import * as Y from "yjs";import { Awareness } from "y-protocols/awareness";import * as base64 from "base64-js";import { useEntity, useReplicache } from "src/replicache";import { useYjsRealtime, YjsRealtimeConnection } from "src/yjsRealtime";import { remoteCursorPlugin } from "./remoteCursorPlugin";import { RemoteCursors } from "./RemoteCursors";import { SCHEMA_VERSION } from "./schema";import { markClientStale, stampDocSchemaVersion, updateSchemaVersion, useStaleClient,} from "./schemaVersion";
// Everything a collaboratively-edited text entity needs for live multiplayer,// in one place so the moving parts can't drift apart between the text block// and footnote call sites:// - the synced yjs value to bind ySyncPlugin to (`yText`, from// useYJSValue below, which owns the doc and its replicache/realtime// sync),// - the ProseMirror plugin that publishes the local selection to awareness// and decorates remote selections (`cursorPlugin`), and// - the overlay that draws remote carets outside the contenteditable// (`overlay`).// The plugin is memoized on the (stable) awareness so it's built once, and// the overlay element carries the same awareness, so a caller only has to// drop `cursorPlugin` into its plugin list and render `overlay`.export function useCollabText(entityID: string) { let { yText, awareness } = useYJSValue(entityID); let cursorPlugin = useMemo( () => remoteCursorPlugin(awareness, entityID), [awareness, entityID], ); let overlay = <RemoteCursors entityID={entityID} awareness={awareness} />; return { yText, awareness, cursorPlugin, overlay };}
// The yjs document behind a collaboratively-edited text entity, wired to its// two sync paths:// - replicache holds the source of truth (the `block/text` fact is the full// encoded doc state): applied here on load and on pokes, and written back// (debounced) when local edits change the fragment;// - the realtime channel carries live incremental updates, and its shared// connection-level awareness carries cursor state (tagged with the// entityID it points into — see src/yjsRealtime).// Both inbound doc paths are gated on the doc's schema version (see// ./schemaVersion): content from a newer schema is never applied to a doc an// editor binds to. The channel side of that gate lives in yjsRealtime; the// replicache side is here.export function useYJSValue(entityID: string) { const [ydoc] = useState(() => new Y.Doc()); const docStateFromReplicache = useEntity(entityID, "block/text"); let rep = useReplicache(); let realtime = useYjsRealtime(); // Outside a YjsRealtimeProvider (read-only contexts) there are never any // peers; a throwaway awareness keeps the cursor plugin and overlay // unconditional. const [fallbackAwareness] = useState(() => realtime ? null : new Awareness(new Y.Doc()), ); const awareness = realtime?.awareness ?? fallbackAwareness!; useEffect(() => { return () => fallbackAwareness?.destroy(); }, [fallbackAwareness]); const [yText] = useState(() => ydoc.getXmlFragment("prosemirror"));
// Content written by a newer schema must never reach the editor's doc (see // ./schemaVersion) — leave it unapplied and go stale; the read-only // fallback renders the fact directly. const factValue = docStateFromReplicache?.data.value; const factTooNew = useMemo( () => !!factValue && updateSchemaVersion(base64.toByteArray(factValue)) > SCHEMA_VERSION, [factValue], ); useEffect(() => { if (factTooNew) markClientStale(); }, [factTooNew]);
if (docStateFromReplicache && !factTooNew) { const update = base64.toByteArray(docStateFromReplicache.data.value); Y.applyUpdate(ydoc, update); }
// Broadcast local edits to connected peers and apply theirs as they // arrive. Cursors travel through the connection's shared awareness; doc // updates also flow through replicache below. let connection = realtime?.connection ?? null; useEffect(() => { if (!connection) return; return connection.register(entityID, ydoc); }, [connection, entityID, ydoc]);
useEffect(() => { if (!rep.rep) return; let timeout = null as null | number; const updateReplicache = async () => { // The debounced flush can fire after going stale unmounts the editor; // writing then could clobber newer content already in the fact. if (useStaleClient.getState().stale) return; stampDocSchemaVersion(ydoc); const update = Y.encodeStateAsUpdate(ydoc); await rep.rep?.mutate.assertFact({ //These undos are handled above in the Prosemirror context ignoreUndo: true, entity: entityID, attribute: "block/text", data: { value: base64.fromByteArray(update), type: "text", }, }); }; const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => { // Transactions with no origin come from replicache itself, and ones // originating from the realtime channel are persisted by the peer that // authored them — only local edits should be written back. if (!transaction.origin) return; if (transaction.origin instanceof YjsRealtimeConnection) return; if (timeout) clearTimeout(timeout); timeout = window.setTimeout(async () => { updateReplicache(); }, 300); };
yText.observeDeep(f); return () => { yText.unobserveDeep(f); }; }, [yText, entityID, rep, ydoc]); return { yText, awareness };}