/** * PubScript editor React wrapper — the single place that constructs a TipTap * editor instance and exposes it to the app. * * Responsibilities: * - Build the editor once (shared instance, no re-create on re-render). * - Expose a decoration pipeline (`decorations` + `applyEdit`). The current * decoration source is a fake/stub (`emptyDecorationSource`) so the rest * of the app can be built against a stable contract; the real * collaboration source is swapped in later (CHUNK #4). * - Provide `applyEdit` to apply an arbitrary ProseMirror transaction. */ import { useEffect, useRef, useState } from "react"; import { useEditor, type UseEditorOptions } from "@tiptap/react"; import { DecorationSet, } from "@tiptap/pm/view"; import type { EditorState, Transaction } from "@tiptap/pm/state"; import type { Editor as TipTapEditor, Content } from "@tiptap/core"; import { createPubScriptEditor, pubScriptExtensions } from "./schema.js"; export type { UseEditorOptions }; /** * A collaboration decoration source: the outer set decorates the shared * document, the inner set decorates a local, non-shared view. Currently a * stub that yields no decorations; the real collaboration source (CHUNK #4) * will implement this shape. */ export interface PubScriptDecorationSource { decorations: DecorationSet; innerDecorations: DecorationSet; } /** * Empty decoration source used as the default until a real collaboration * source is wired in (CHUNK #4). */ export const emptyDecorationSource: PubScriptDecorationSource = { decorations: DecorationSet.empty, innerDecorations: DecorationSet.empty, }; export interface PubScriptEditorHookOptions extends Omit { /** Pre-existing editor instance to reuse (avoids re-creating ProseMirror state). */ editor?: TipTapEditor; /** Decoration source to render. Defaults to the empty stub. */ decorationSource?: PubScriptDecorationSource; /** Initial document content (ProseMirror JSON). */ content?: Content; /** Called on every transaction with the live editor instance. */ onUpdate?: (editor: TipTapEditor) => void; /** Whether the editor is editable. */ editable?: boolean; } /** * Hook that builds and manages the PubScript editor. Returns the editor * instance, the current decoration source, and an `applyEdit` function. */ export function usePubScriptEditor(options: PubScriptEditorHookOptions = {}) { const { editor: sharedEditor, decorationSource = emptyDecorationSource, content, onUpdate, editable = true, ...useEditorOptions } = options; const editor = useEditor({ editable, extensions: pubScriptExtensions, editorProps: { attributes: { class: "ps-editor-content", }, }, ...(content !== undefined ? { content } : {}), ...useEditorOptions, }); // If a shared headless instance is provided, mount it into the React wrapper. const sharedRef = useRef(sharedEditor ?? null); sharedRef.current = sharedEditor ?? null; useEffect(() => { if (sharedRef.current) { sharedRef.current.setEditable(editable); } }, [editable]); const [lastState, setLastState] = useState(null); // Surface updates to consumers and track the latest state. const onUpdateRef = useRef(onUpdate); useEffect(() => { onUpdateRef.current = onUpdate; }, [onUpdate]); useEffect(() => { if (!editor) return; const handler = () => { setLastState(editor.state); onUpdateRef.current?.(editor); }; editor.on("update", handler); return () => { editor.off("update", handler); }; }, [editor]); const applyEdit = (transaction: Transaction) => { if (!editor) return; editor.view.dispatch(transaction); }; return { editor, decorationSource, lastState, applyEdit }; } /** * Convenience factory for a headless editor instance, shared across the app. */ export function createEditorInstance(content?: Content): TipTapEditor { return createPubScriptEditor(content === undefined ? {} : { content }); } export { createPubScriptEditor, pubScriptExtensions };