UNPKG

@convex-dev/prosemirror-sync

Version:

Sync ProseMirror documents for Tiptap using this Convex component.

402 lines (386 loc) 11.3 kB
import { ConvexReactClient, useConvex, useMutation, useQuery, Watch, } from "convex/react"; import { Content, Editor, Extension, JSONContent } from "@tiptap/core"; import * as collab from "@tiptap/pm/collab"; import { Step } from "@tiptap/pm/transform"; import { useCallback, useMemo, useRef } from "react"; import { SyncApi } from "../client"; // How many steps we will attempt to sync in one request. const MAX_STEPS_SYNC = 1000; const SNAPSHOT_DEBOUNCE_MS = 1000; export type UseSyncOptions = { onSyncError?: (error: Error) => void; snapshotDebounceMs?: number; debug?: boolean; }; export function useTiptapSync( syncApi: SyncApi, id: string, opts?: UseSyncOptions ) { const log: typeof console.log = opts?.debug ? console.debug : () => {}; const convex = useConvex(); const initial = useInitialState(syncApi, id); const extension = useMemo(() => { const { loading, ...initialState } = initial; if (loading || !initialState.initialContent) return null; return syncExtension(convex, id, syncApi, initialState, opts); // // eslint-disable-next-line react-hooks/exhaustive-deps }, [convex, id, initial.loading, initial.initialContent]); const submitSnapshot = useMutation( syncApi.submitSnapshot ).withOptimisticUpdate((localQueryStore, args) => { // This update will allow the useInitialState to respond immediately to // creating documents, as if it came from the server. const existing = localQueryStore.getQuery(syncApi.getSnapshot, { id }); if (!existing?.content) { localQueryStore.setQuery( syncApi.getSnapshot, { id }, { version: args.version, content: args.content, } ); } const version = localQueryStore.getQuery(syncApi.latestVersion, { id }); if (version === null) { localQueryStore.setQuery(syncApi.latestVersion, { id }, args.version); } }); const create = useCallback( async (content: JSONContent) => { log("Creating new document", { id }); await submitSnapshot({ id, version: 1, content: JSON.stringify(content), }); }, [convex, id] ); if (initial.loading) { return { extension: null, isLoading: true, initialContent: null, /** * Create the document without waiting to hear from the server. * Warning: Only call this if you just created the document id. * It's safer to wait until loading is false. * It's also best practice to pass in the same initial content everywhere, * so if two clients create the same document id, they'll both end up * with the same initial content. Otherwise the second client will * throw an exception on the snapshot creation. */ create, } as const; } if (!initial.initialContent) { return { extension: null, isLoading: false, initialContent: null, create, } as const; } return { extension: extension!, isLoading: false, initialContent: initial.initialContent, } as const; } export function syncExtension( convex: ConvexReactClient, id: string, syncApi: SyncApi, initialState: InitialState, opts?: UseSyncOptions ) { const log: typeof console.log = opts?.debug ? console.debug : () => {}; let snapshotTimer: NodeJS.Timeout | undefined; const trySubmitSnapshot = (version: number, content: string) => { if (snapshotTimer) { clearTimeout(snapshotTimer); } snapshotTimer = setTimeout(() => { void convex .mutation(syncApi.submitSnapshot, { id, version, content }) .catch(opts?.onSyncError); }, opts?.snapshotDebounceMs ?? SNAPSHOT_DEBOUNCE_MS); }; let active: boolean = false; let pending: | { resolve: () => void; reject: () => void; promise: Promise<void> } | undefined; let watch: Watch<number | null> | undefined; async function trySync(editor: Editor) { const serverVersion = watch?.localQueryResult(); if (serverVersion === undefined) { return; } if (serverVersion && serverVersion > collab.getVersion(editor.state)) { clearTimeout(snapshotTimer); snapshotTimer = undefined; } if (active) { if (!pending) { let resolve = () => {}; let reject = () => {}; const promise = new Promise<void>((res, rej) => { resolve = res; reject = rej; }); pending = { resolve, reject, promise }; } return pending.promise; } active = true; try { if ( await doSync( editor, convex, syncApi, id, serverVersion, initialState, opts?.debug ) ) { const version = collab.getVersion(editor.state); const content = JSON.stringify(editor.state.doc.toJSON()); if (collab.sendableSteps(editor.state)) { throw new Error("Synced but still have sendable steps"); } trySubmitSnapshot(version, content); } } catch (error) { if (opts?.onSyncError) { opts.onSyncError(error as Error); } else { throw error; } } finally { active = false; if (pending) { const { resolve, reject } = pending; pending = undefined; trySync(editor).then(resolve, reject); } } } let unsubscribe: (() => void) | undefined; return Extension.create({ name: "convex-sync", onDestroy() { log("destroying"); unsubscribe?.(); }, onCreate() { if (initialState.restoredSteps?.length) { // TODO: verify that restoring local steps works log("Restoring local steps", initialState.restoredSteps); const tr = this.editor.state.tr; for (const step of initialState.restoredSteps) { tr.step(Step.fromJSON(this.editor.schema, step)); } this.editor.view.dispatch(tr); } watch = convex.watchQuery(syncApi.latestVersion, { id }); unsubscribe = watch.onUpdate(() => { void trySync(this.editor); }); void trySync(this.editor); }, onUpdate() { void trySync(this.editor); }, addProseMirrorPlugins() { log("Adding collab plugin", { version: initialState.initialVersion, }); return [ collab.collab({ version: initialState.initialVersion, }), ]; }, }); } async function doSync( editor: Editor, convex: ConvexReactClient, syncApi: SyncApi, id: string, serverVersion: number | null, initialState: InitialState, debug?: boolean ) { const log: typeof console.log = debug ? console.debug : () => {}; if (serverVersion === null) { if (initialState.initialVersion <= 1) { // This is a new document, so we can create it on the server. // Note: this should only happen if the initial version is loaded from // a local cache. Creating a new document on the client will set the // initial version to 1 optimistically. log("Syncing new document", { id }); await convex.mutation(syncApi.submitSnapshot, { id, version: initialState.initialVersion, content: JSON.stringify(initialState.initialContent), }); } else { // TODO: Handle deletion gracefully throw new Error("Syncing a document that doesn't exist server-side"); } } const version = collab.getVersion(editor.state); if (serverVersion !== null && serverVersion > version) { log("Updating to server version", { id, version, serverVersion, }); const steps = await convex.query(syncApi.getSteps, { id, version, }); receiveSteps( editor, steps.steps.map((step) => Step.fromJSON(editor.schema, JSON.parse(step))), steps.clientIds ); } let anyChanges = false; while (true) { const sendable = collab.sendableSteps(editor.state); if (!sendable) { break; } const steps = sendable.steps .slice(0, MAX_STEPS_SYNC) .map((step) => JSON.stringify(step.toJSON())); log("Sending steps", { steps, version: sendable.version }); const result = await convex.mutation(syncApi.submitSteps, { id, steps, version: sendable.version, clientId: sendable.clientID, }); if (result.status === "synced") { anyChanges = true; // We replay the steps locally to avoid refetching them. receiveSteps( editor, steps.map((step) => Step.fromJSON(editor.schema, JSON.parse(step))), steps.map(() => sendable.clientID) ); log("Synced", { steps, version, newVersion: collab.getVersion(editor.state), }); continue; } if (result.status === "needs-rebase") { receiveSteps( editor, result.steps.map((step) => Step.fromJSON(editor.schema, JSON.parse(step)) ), result.clientIds ); log("Rebased", { steps, newVersion: collab.getVersion(editor.state), }); } } return anyChanges; } function receiveSteps( editor: Editor, steps: Step[], clientIds: (string | number)[] ) { editor.view.dispatch( collab.receiveTransaction(editor.state, steps, clientIds, { mapSelectionBackward: true, }) ); } type InitialState = { initialContent: Content; initialVersion: number; restoredSteps?: object[]; }; export function useInitialState( syncApi: SyncApi, id: string, cacheKeyPrefix?: string ) { const serverRef = useRef<{ id: string; snapshot?: InitialState; }>({ id }); const cachedState = useMemo(() => { return getCachedState(id, cacheKeyPrefix); }, [id, cacheKeyPrefix]); const serverInitial = useQuery( syncApi.getSnapshot, serverRef.current.snapshot && serverRef.current.id === id ? "skip" : { id } ); const snapshot = useMemo(() => { return ( serverInitial && serverInitial.content !== null && { initialContent: JSON.parse(serverInitial.content) as Content, initialVersion: serverInitial.version, } ); }, [serverInitial]); if (snapshot || serverRef.current.id !== id) { serverRef.current = { id, snapshot: snapshot || undefined }; } const data = serverRef.current.snapshot || cachedState; if (data) { return { loading: false, ...data, }; } if (!cachedState && serverInitial?.content === null) { // We couldn't find it locally or on the server. // We could dynamically create a new document here, // not sure if that's generally the right pattern (vs. explicit creation). return { loading: false, initialContent: null, }; } return { loading: true, }; } function getCachedState( id: string, cacheKeyPrefix?: string ): InitialState | undefined { // TODO: Verify that this works const cacheKey = `${cacheKeyPrefix ?? "convex-sync"}-${id}`; const cache = sessionStorage.getItem(cacheKey); if (cache) { const { content, version, steps } = JSON.parse(cache); return { initialContent: content as Content, initialVersion: Number(version), restoredSteps: (steps ?? []) as object[], }; } }