editor: fix circular import that dropped PubScript nodes from schema
The TipTap node definitions imported their node-name constants from schema.ts, while schema.ts imported the nodes to build the schema. This circular dependency evaluated the nodes before the constants were assigned, leaving sceneBreak/footnote/footnoteArea/characterMention with name 'undefined' — so they never registered in PubScriptSchema. As a result <hr class="scene-break"> parsed as StarterKit's horizontalRule instead of a scene break. - Move node-name constants, groups, MentionAttrs, and colorAttribute into a new constants.ts module that both schema.ts and the node files import from, breaking the cycle. - Remove the redundant separate History extension (already in StarterKit), which also silenced a TipTap 'Duplicate extension names: undoRedo' warn. - Drop unused TipTap extension and prosemirror-* deps (all access goes through @tiptap/pm).
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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 or HTML string). */
|
||||
content?: string;
|
||||
/** 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,
|
||||
...(content !== undefined ? { content: content as 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?: string): TipTapEditor {
|
||||
return createPubScriptEditor(content === undefined ? {} : { content: content as Content });
|
||||
}
|
||||
|
||||
export { createPubScriptEditor, pubScriptExtensions };
|
||||
Reference in New Issue
Block a user