Files
PubScript/packages/editor/src/Editor.tsx
T

130 lines
4.1 KiB
TypeScript

/**
* 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<UseEditorOptions, "extensions" | "onUpdate"> {
/** 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<TipTapEditor | null>(sharedEditor ?? null);
sharedRef.current = sharedEditor ?? null;
useEffect(() => {
if (sharedRef.current) {
sharedRef.current.setEditable(editable);
}
}, [editable]);
const [lastState, setLastState] = useState<EditorState | null>(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 };