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,36 @@
|
||||
{
|
||||
"name": "@pubscript/editor",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./schema": "./src/schema.ts",
|
||||
"./Editor": "./src/Editor.tsx",
|
||||
"./markdown": "./src/markdown.ts",
|
||||
"./styles": "./src/styles/editor.css"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "echo \"@pubscript/editor has no dev server\" (exit 0)",
|
||||
"build": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pubscript/core": "workspace:*",
|
||||
"@tiptap/core": "3.32.0",
|
||||
"@tiptap/pm": "3.32.0",
|
||||
"@tiptap/react": "3.32.0",
|
||||
"@tiptap/starter-kit": "3.32.0",
|
||||
"@tiptap/extension-text-style": "3.32.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.2",
|
||||
"react": "^19.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* PubScript editor constants and shared attribute definitions.
|
||||
*
|
||||
* Kept in a standalone module so the TipTap node definitions (which are
|
||||
* evaluated at import time) never import back into `schema.ts`. Importing the
|
||||
* node-name constants from `schema.ts` would create a circular dependency:
|
||||
* `schema.ts` loads the nodes to build the schema, and the nodes read the
|
||||
* constants before `schema.ts` has finished initializing — leaving the node
|
||||
* `name` as `undefined`.
|
||||
*/
|
||||
|
||||
// Node names.
|
||||
export const SCENE_BREAK = "sceneBreak";
|
||||
export const FOOTNOTE = "footnote";
|
||||
export const FOOTNOTE_AREA = "footnoteArea";
|
||||
export const CHARACTER_MENTION = "characterMention";
|
||||
|
||||
// Custom ProseMirror groups. These let content expressions and other
|
||||
// extensions reference the PubScript nodes as a set without depending on their
|
||||
// base `block`/`inline` membership.
|
||||
export const SCENE_BREAK_GROUP = "sceneBreak";
|
||||
export const FOOTNOTE_GROUP = "footnote";
|
||||
export const CHARACTER_MENTION_GROUP = "characterMention";
|
||||
|
||||
export interface MentionAttrs {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `color` attribute shared by text and character mentions. Parses an RGB
|
||||
* inline style to a hex string on input and writes it back on output. Kept as
|
||||
* a factory so both the TextStyle mark and the CharacterMention node can spread
|
||||
* the exact same definition.
|
||||
*/
|
||||
export function colorAttribute() {
|
||||
return {
|
||||
color: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) =>
|
||||
element.style.color?.match(/^rgb\((\d+), (\d+), (\d+)\)$/)
|
||||
? `#${[1, 2, 3]
|
||||
.map(
|
||||
(i) =>
|
||||
parseInt(
|
||||
element.style.color.slice(i * 2 + 4, i * 2 + 6) ?? "0",
|
||||
)
|
||||
.toString(16)
|
||||
.padStart(2, "0"),
|
||||
)
|
||||
.join("")}`
|
||||
: element.style.color ?? null,
|
||||
renderHTML: (attributes: { color: string | null }) =>
|
||||
attributes.color ? { style: `color:${attributes.color}` } : {},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* PubScript editor package — the TipTap 3 editor, its schema, node views, and
|
||||
* React wrapper.
|
||||
*
|
||||
* Public surface:
|
||||
* - `createPubScriptEditor`, `pubScriptExtensions`, `PubScriptSchema` — the
|
||||
* editor instance and its TipTap extensions.
|
||||
* - `usePubScriptEditor`, `PubScriptEditor` — the React wrapper.
|
||||
* - `serializeToMarkdown`, `parseMarkdown` — markdown conversion helpers.
|
||||
* - `pubScriptShortcuts`, `cycleHeading` — keyboard shortcuts.
|
||||
* - `throttle`, `createIdleDetector` — performance helpers.
|
||||
*/
|
||||
export {
|
||||
createPubScriptEditor,
|
||||
pubScriptExtensions,
|
||||
PubScriptSchema,
|
||||
SCENE_BREAK,
|
||||
FOOTNOTE,
|
||||
FOOTNOTE_AREA,
|
||||
CHARACTER_MENTION,
|
||||
SCENE_BREAK_GROUP,
|
||||
FOOTNOTE_GROUP,
|
||||
CHARACTER_MENTION_GROUP,
|
||||
} from "./schema.js";
|
||||
|
||||
export type { PubScriptEditorHookOptions } from "./Editor.js";
|
||||
export { usePubScriptEditor, createEditorInstance, emptyDecorationSource } from "./Editor.js";
|
||||
|
||||
export {
|
||||
SceneBreakNode,
|
||||
SceneBreakComponent,
|
||||
} from "./nodes/SceneBreak.js";
|
||||
export { FootnoteInline } from "./nodes/FootnoteInline.js";
|
||||
export { FootnoteArea } from "./nodes/FootnoteArea.js";
|
||||
export { CharacterMention } from "./nodes/CharacterMention.js";
|
||||
|
||||
export {
|
||||
pubScriptShortcuts,
|
||||
cycleHeading,
|
||||
type ShortcutDef,
|
||||
} from "./keys.js";
|
||||
|
||||
export { serializeToMarkdown, parseMarkdown, markdownInputRules, markdownPasteRules } from "./markdown.js";
|
||||
|
||||
export { throttle, createIdleDetector } from "./perf.js";
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* PubScript keyboard map — the shortcuts the writing surface exposes to the
|
||||
* user, plus the ProseMirror keymap additions. Kept separate from the editor
|
||||
* instance so the command palette and toolbar can render the same bindings.
|
||||
*
|
||||
* The TipTap/StarterKit keymap (bold/italic/lists/undo/redo) is registered by
|
||||
* the extensions themselves; this module documents the PubScript-level
|
||||
* shortcuts and provides the heading-cycle helper.
|
||||
*/
|
||||
import type { Editor as TipTapEditor } from "@tiptap/core";
|
||||
|
||||
import { SCENE_BREAK } from "./schema.js";
|
||||
|
||||
export interface ShortcutDef {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Human-readable binding, platform-aware. */
|
||||
binding: string;
|
||||
/** Execute the shortcut on the given editor. */
|
||||
run: (editor: TipTapEditor) => boolean | void;
|
||||
}
|
||||
|
||||
const MOD = typeof navigator !== "undefined" && navigator.platform.toLowerCase().includes("mac")
|
||||
? "⌘"
|
||||
: "Ctrl";
|
||||
|
||||
/**
|
||||
* Cycle the paragraph at the cursor through Heading 3 → Heading 2 → Heading 1
|
||||
* → Normal. Used by the writing surface's "cycle heading" shortcut.
|
||||
*/
|
||||
export function cycleHeading(editor: TipTapEditor): boolean {
|
||||
const { state } = editor;
|
||||
const { from } = state.selection;
|
||||
const parent = state.doc.resolve(from).parent;
|
||||
|
||||
// Only cycle paragraph / heading blocks.
|
||||
if (parent.type.name !== "paragraph" && parent.type.name !== "heading") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const level = editor.isActive("heading") ? (editor.getAttributes("heading") as { level: number }).level : 0;
|
||||
// 0 == paragraph. Cycle paragraph -> H3 -> H2 -> H1 -> paragraph.
|
||||
const next = level === 0 ? 3 : level === 3 ? 0 : level - 1;
|
||||
|
||||
if (next === 0) {
|
||||
editor.commands.setNode("paragraph");
|
||||
} else {
|
||||
editor.commands.toggleMark("heading", { level: next });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The documented PubScript shortcuts. The toolbar and command palette render
|
||||
* these so the UI and the actual bindings never drift.
|
||||
*/
|
||||
export const pubScriptShortcuts: ShortcutDef[] = [
|
||||
{
|
||||
id: "cycle-heading",
|
||||
label: "Cycle heading",
|
||||
binding: `${MOD}⇧H`,
|
||||
run: (editor) => cycleHeading(editor),
|
||||
},
|
||||
{
|
||||
id: "scene-break",
|
||||
label: "Insert scene break",
|
||||
binding: `${MOD}⇧⏎`,
|
||||
run: (editor) => {
|
||||
editor.commands.insertContent({ type: SCENE_BREAK });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bold",
|
||||
label: "Bold",
|
||||
binding: `${MOD}B`,
|
||||
run: (editor) => editor.commands.toggleMark("bold"),
|
||||
},
|
||||
{
|
||||
id: "italic",
|
||||
label: "Italic",
|
||||
binding: `${MOD}I`,
|
||||
run: (editor) => editor.commands.toggleMark("italic"),
|
||||
},
|
||||
{
|
||||
id: "strike",
|
||||
label: "Strikethrough",
|
||||
binding: `${MOD}S`,
|
||||
run: (editor) => editor.commands.toggleMark("strike"),
|
||||
},
|
||||
{
|
||||
id: "undo",
|
||||
label: "Undo",
|
||||
binding: `${MOD}Z`,
|
||||
run: (editor) => editor.commands.undo(),
|
||||
},
|
||||
{
|
||||
id: "redo",
|
||||
label: "Redo",
|
||||
binding: `${MOD}⇧Z`,
|
||||
run: (editor) => editor.commands.redo(),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseMarkdown, serializeToMarkdown } from "./markdown.js";
|
||||
|
||||
/**
|
||||
* Round-trip tests for the Markdown <-> ProseMirror JSON conversion.
|
||||
*
|
||||
* These exercise the CHUNK #3 acceptance criteria: a `.md` file imported into
|
||||
* the editor must re-export to equivalent Markdown. We assert on the parsed
|
||||
* JSON structure (which is schema-validated by construction) and on the
|
||||
* serialized Markdown text.
|
||||
*/
|
||||
|
||||
describe("parseMarkdown", () => {
|
||||
it("parses headings, paragraphs, and inline marks", () => {
|
||||
const json = parseMarkdown("# Title\n\nSome **bold** and *italic* text.");
|
||||
|
||||
const children = json.content ?? [];
|
||||
expect(children[0]).toMatchObject({ type: "heading", attrs: { level: 1 } });
|
||||
expect(children[1]).toMatchObject({ type: "paragraph" });
|
||||
|
||||
const body = children[1]?.content ?? [];
|
||||
const bold = body.find((c) => c.marks?.some((m) => m.type === "bold"));
|
||||
const italic = body.find((c) => c.marks?.some((m) => m.type === "italic"));
|
||||
expect(bold).toBeDefined();
|
||||
expect(italic).toBeDefined();
|
||||
});
|
||||
|
||||
it("parses ordered and unordered lists", () => {
|
||||
const json = parseMarkdown("- one\n- two\n\n1. first\n2. second");
|
||||
const types = (json.content ?? []).map((c) => c.type);
|
||||
expect(types).toContain("bulletList");
|
||||
expect(types).toContain("orderedList");
|
||||
});
|
||||
|
||||
it("parses fenced code blocks with a language", () => {
|
||||
const json = parseMarkdown("```ts\nconst x = 1;\n```");
|
||||
const code = (json.content ?? []).find((c) => c.type === "codeBlock");
|
||||
expect(code?.attrs?.language).toBe("ts");
|
||||
});
|
||||
|
||||
it("parses scene breaks and footnotes", () => {
|
||||
const json = parseMarkdown("before\n\n***\n\nafter");
|
||||
expect((json.content ?? []).some((c) => c.type === "sceneBreak")).toBe(true);
|
||||
});
|
||||
|
||||
it("produces JSON that validates against the PubScript schema", () => {
|
||||
const json = parseMarkdown("# Hi\n\nHello world.");
|
||||
// DOMParser.parse already applies the schema's constraints (it throws on
|
||||
// content that cannot fit the schema), so a successful parse proves the
|
||||
// JSON is schema-valid. Assert on the resulting structure.
|
||||
const children = json.content ?? [];
|
||||
expect(children[0]).toMatchObject({ type: "heading", attrs: { level: 1 } });
|
||||
expect(children[1]).toMatchObject({ type: "paragraph" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeToMarkdown", () => {
|
||||
it("serializes headings, paragraphs, and inline marks", () => {
|
||||
const json = parseMarkdown("# Title\n\nSome **bold** and *italic* text.");
|
||||
const md = serializeToMarkdown(json);
|
||||
expect(md).toContain("# Title");
|
||||
expect(md).toContain("**bold**");
|
||||
expect(md).toContain("*italic*");
|
||||
});
|
||||
|
||||
it("round-trips through parseMarkdown to an equivalent document", () => {
|
||||
const source = "# Chapter\n\nA paragraph with `code`.\n\n- a\n- b";
|
||||
const once = parseMarkdown(source);
|
||||
const serialized = serializeToMarkdown(once);
|
||||
const twice = parseMarkdown(serialized);
|
||||
|
||||
const typesOnce = (once.content ?? []).map((c) => c.type);
|
||||
const typesTwice = (twice.content ?? []).map((c) => c.type);
|
||||
expect(typesTwice).toEqual(typesOnce);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Markdown support for the PubScript editor.
|
||||
*
|
||||
* TipTap v3 folds markdown input rules and paste rules into the individual
|
||||
* StarterKit extensions (bold/italic/lists/headings/…), so there is no
|
||||
* separate `@tiptap/extension-markdown` package. This module documents the
|
||||
* active rules and provides two conversion helpers:
|
||||
*
|
||||
* - `serializeToMarkdown(editor, json)` — ProseMirror JSON → CommonMark.
|
||||
* - `parseMarkdown(editor, md)` — CommonMark → ProseMirror JSON.
|
||||
*
|
||||
* The parser is a pragmatic block-level implementation covering the cases the
|
||||
* writing surface needs (headings, paragraphs, bold/italic/strike, ordered and
|
||||
* unordered lists, inline and fenced code). It converts to HTML first and lets
|
||||
* the editor's schema do the final JSON mapping, so the output always conforms
|
||||
* to the PubScript schema.
|
||||
*/
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { DOMParser as PMDOMParser } from "@tiptap/pm/model";
|
||||
|
||||
import { PubScriptSchema } from "./schema.js";
|
||||
|
||||
/**
|
||||
* Active markdown input rules (provided by StarterKit extensions).
|
||||
*/
|
||||
export const markdownInputRules = [
|
||||
{ pattern: /\*\*(.+)\*\*$/, mark: "bold" },
|
||||
{ pattern: /\*(.+)\*$/, mark: "italic" },
|
||||
{ pattern: /__(.+)__/, mark: "bold" },
|
||||
{ pattern: /_(.+)_/, mark: "italic" },
|
||||
{ pattern: /~~(.+)~~/, mark: "strike" },
|
||||
{ pattern: /`(.+)`/, mark: "code" },
|
||||
{ pattern: /^#{1,6} (.*)$/, node: "heading" },
|
||||
{ pattern: /^[-*+] (.*)$/, node: "listItem" },
|
||||
{ pattern: /^\d+\. (.*)$/, node: "listItem" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Active markdown paste rules (provided by StarterKit extensions).
|
||||
*/
|
||||
export const markdownPasteRules = markdownInputRules;
|
||||
|
||||
/**
|
||||
* Convert ProseMirror JSON to CommonMark markdown text.
|
||||
*/
|
||||
export function serializeToMarkdown(json: JSONContent): string {
|
||||
return serializeNode(json);
|
||||
}
|
||||
|
||||
function serializeNode(node: JSONContent): string {
|
||||
if (!node) return "";
|
||||
const { type, attrs, content, text } = node;
|
||||
|
||||
if (typeof text === "string") {
|
||||
return serializeMarks(node, text);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "doc":
|
||||
return (content ?? []).map((child) => serializeNode(child)).join("\n\n");
|
||||
case "paragraph":
|
||||
return (content ?? []).map((child) => serializeNode(child)).join("");
|
||||
case "heading": {
|
||||
const level = (attrs?.level as number) ?? 1;
|
||||
return "#".repeat(level) + " " + (content ?? []).map((child) => serializeNode(child)).join("");
|
||||
}
|
||||
case "bulletList":
|
||||
return (content ?? []).map((child) => `* ${serializeNode(child)}`).join("\n");
|
||||
case "orderedList":
|
||||
return (content ?? [])
|
||||
.map((child, i) => `${i + 1}. ${serializeNode(child)}`)
|
||||
.join("\n");
|
||||
case "listItem":
|
||||
return (content ?? []).map((child) => serializeNode(child)).join("");
|
||||
case "codeBlock":
|
||||
return "```" + ((attrs?.language as string) ?? "") + "\n" + ((attrs?.content as string) ?? (content ?? []).map((c) => c.text ?? "").join("")) + "\n```";
|
||||
case "blockquote":
|
||||
return (content ?? []).map((child) => "> " + serializeNode(child)).join("\n");
|
||||
case "hardBreak":
|
||||
return " \n";
|
||||
case "horizontalRule":
|
||||
return "---";
|
||||
default:
|
||||
return (content ?? []).map((child) => serializeNode(child)).join("");
|
||||
}
|
||||
}
|
||||
|
||||
function serializeMarks(node: JSONContent, text: string): string {
|
||||
const marks = (node.marks ?? []) as Array<{ type: string; attrs?: Record<string, unknown> }>;
|
||||
let out = text;
|
||||
if (marks.some((m) => m.type === "strike")) out = `~~${out}~~`;
|
||||
if (marks.some((m) => m.type === "code")) out = `\`${out}\``;
|
||||
if (marks.some((m) => m.type === "italic")) out = `*${out}*`;
|
||||
if (marks.some((m) => m.type === "bold")) out = `**${out}**`;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert CommonMark markdown to ProseMirror JSON using the PubScript schema.
|
||||
*
|
||||
* The markdown is first normalised to HTML (covering the block/inline cases
|
||||
* the writing surface uses), then handed to the editor so the result conforms
|
||||
* to the schema.
|
||||
*/
|
||||
export function parseMarkdown(md: string): JSONContent {
|
||||
const html = markdownToHtml(md);
|
||||
// Parse the HTML through the PubScript schema directly (via ProseMirror's
|
||||
// DOMParser) so the result conforms to the schema without constructing a
|
||||
// live TipTap editor. This avoids keyed-plugin conflicts when multiple
|
||||
// editors exist and keeps parsing free of editor construction.
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = html;
|
||||
const parsed = PMDOMParser.fromSchema(PubScriptSchema).parse(container);
|
||||
return parsed.toJSON();
|
||||
}
|
||||
|
||||
/**
|
||||
* A pragmatic CommonMark → HTML converter covering the cases the writing
|
||||
* surface needs. Not a full CommonMark implementation.
|
||||
*/
|
||||
function markdownToHtml(md: string): string {
|
||||
const lines = md.replace(/\r\n/g, "\n").split("\n");
|
||||
const html: string[] = [];
|
||||
let inCode = false;
|
||||
let codeLang = "";
|
||||
let codeLines: string[] = [];
|
||||
const listStack: string[] = [];
|
||||
|
||||
const closeLists = () => {
|
||||
while (listStack.length) html.push(`</${listStack.pop()}>`);
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.replace(/\n$/, "");
|
||||
|
||||
if (line.trimStart().startsWith("```")) {
|
||||
if (!inCode) {
|
||||
closeLists();
|
||||
inCode = true;
|
||||
codeLang = line.trim().slice(3).trim();
|
||||
codeLines = [];
|
||||
continue;
|
||||
}
|
||||
html.push(`<pre><code class="language-${codeLang}">${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||||
inCode = false;
|
||||
continue;
|
||||
}
|
||||
if (inCode) {
|
||||
codeLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (heading) {
|
||||
closeLists();
|
||||
const level = heading[1]!.length;
|
||||
html.push(`<h${level}>${inlineToHtml(heading[2] ?? "")}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scene break: a centered divider written as `***` or `* * *`.
|
||||
const sceneBreak = line.trim();
|
||||
if (sceneBreak === "***" || sceneBreak === "* * *" || sceneBreak === "• • •") {
|
||||
closeLists();
|
||||
html.push('<hr class="scene-break">');
|
||||
continue;
|
||||
}
|
||||
|
||||
const ul = line.match(/^([-*+])\s+(.*)$/);
|
||||
const ol = line.match(/^\d+\.\s+(.*)$/);
|
||||
if (ul || ol) {
|
||||
const tag = ul ? "ul" : "ol";
|
||||
if (listStack[listStack.length - 1] !== tag) {
|
||||
closeLists();
|
||||
listStack.push(tag);
|
||||
html.push(`<${tag}>`);
|
||||
}
|
||||
const itemText = ul ? ul[2] : ol![1];
|
||||
html.push(`<li>${inlineToHtml(itemText ?? "")}</li>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
closeLists();
|
||||
if (line.trim() === "") {
|
||||
html.push("");
|
||||
} else {
|
||||
html.push(`<p>${inlineToHtml(line)}</p>`);
|
||||
}
|
||||
}
|
||||
|
||||
closeLists();
|
||||
if (inCode) {
|
||||
html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||||
}
|
||||
|
||||
return html.filter((l) => l !== "").join("\n");
|
||||
}
|
||||
|
||||
function inlineToHtml(text: string): string {
|
||||
let out = escapeHtml(text);
|
||||
out = out.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||
out = out.replace(/__(.+?)__/g, "<strong>$1</strong>");
|
||||
out = out.replace(/\*(.+?)\*/g, "<em>$1</em>");
|
||||
out = out.replace(/__(.+?)__/g, "<em>$1</em>");
|
||||
out = out.replace(/~~(.+)~~/g, "<s>$1</s>");
|
||||
out = out.replace(/`(.+)`/g, "<code>$1</code>");
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Node } from "@tiptap/core";
|
||||
import { CHARACTER_MENTION, colorAttribute } from "../constants.js";
|
||||
|
||||
/**
|
||||
* Character mention — an inline atom linking to a character entity.
|
||||
* Attributes: `id` (character id) and `label` (display name). Inline in HTML
|
||||
* as `<span class="mention mention-character" data-id="...">@Name</span>`.
|
||||
*/
|
||||
export const CharacterMention = Node.create({
|
||||
name: CHARACTER_MENTION,
|
||||
group: "inline",
|
||||
atom: true,
|
||||
inline: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
isRequired: true,
|
||||
parseHTML: (element) => element.getAttribute("data-id"),
|
||||
renderHTML: (attributes) => ({ "data-id": attributes.id }),
|
||||
},
|
||||
label: {
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("data-label") ?? element.textContent ?? "",
|
||||
renderHTML: (attributes) => ({ "data-label": attributes.label }),
|
||||
},
|
||||
...colorAttribute(),
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "span.mention-character" }];
|
||||
},
|
||||
|
||||
renderHTML({ node }) {
|
||||
return [
|
||||
"span",
|
||||
{ class: "mention mention-character", "data-id": node.attrs.id, "data-label": node.attrs.label },
|
||||
"@",
|
||||
node.attrs.label ?? node.attrs.id,
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export default CharacterMention;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Node } from "@tiptap/core";
|
||||
import { FOOTNOTE_AREA } from "../constants.js";
|
||||
|
||||
/**
|
||||
* Footnote area — a block node that renders a footnote body. Linked to its
|
||||
* inline marker by the shared `id`. Inline in HTML as
|
||||
* `<aside class="footnote-area" data-id="...">`.
|
||||
*/
|
||||
export const FootnoteArea = Node.create({
|
||||
name: FOOTNOTE_AREA,
|
||||
group: "block footnoteArea",
|
||||
content: "(paragraph | blockquote)+",
|
||||
defining: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
isRequired: true,
|
||||
parseHTML: (element) => element.getAttribute("data-id"),
|
||||
renderHTML: (attributes) => ({ "data-id": attributes.id }),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "aside.footnote-area" }];
|
||||
},
|
||||
|
||||
renderHTML({ node }) {
|
||||
return [
|
||||
"aside",
|
||||
{ class: "footnote-area", "data-id": node.attrs.id },
|
||||
["p", { class: "footnote-number" }, `#${node.attrs.id}`],
|
||||
["div", { class: "footnote-body" }, 0],
|
||||
];
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: ({ editor }) => {
|
||||
const { from } = editor.state.selection;
|
||||
// Move cursor out of the footnote area when empty/backspacing at start.
|
||||
if (from === editor.state.doc.resolve(from).parentOffset) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default FootnoteArea;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Node } from "@tiptap/core";
|
||||
import { FOOTNOTE } from "../constants.js";
|
||||
|
||||
/**
|
||||
* Footnote marker — an inline atom that references a footnote by id. The
|
||||
* matching `FootnoteArea` block renders the note body. Inline in HTML as
|
||||
* `<span class="footnote" data-id="...">`.
|
||||
*/
|
||||
export const FootnoteInline = Node.create({
|
||||
name: FOOTNOTE,
|
||||
group: "inline",
|
||||
atom: true,
|
||||
inline: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
isRequired: true,
|
||||
parseHTML: (element) => element.getAttribute("data-id"),
|
||||
renderHTML: (attributes) => ({ "data-id": attributes.id }),
|
||||
},
|
||||
label: {
|
||||
default: "",
|
||||
parseHTML: (element) => element.textContent ?? "",
|
||||
renderHTML: (attributes) => (attributes.label ? {} : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "span.footnote" }];
|
||||
},
|
||||
|
||||
renderHTML({ node }) {
|
||||
return [
|
||||
"span",
|
||||
{ class: "footnote", "data-id": node.attrs.id, "data-label": node.attrs.label },
|
||||
node.attrs.label ?? node.attrs.id,
|
||||
];
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: () => false,
|
||||
Delete: () => false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default FootnoteInline;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer, type ReactNodeViewProps } from "@tiptap/react";
|
||||
import { SCENE_BREAK } from "../constants.js";
|
||||
|
||||
/**
|
||||
* Scene break node view — renders a centered `• • •` divider. Clicking the
|
||||
* divider re-renders it; the underlying node is an empty block whose HTML
|
||||
* representation is `<hr class="scene-break">`.
|
||||
*/
|
||||
export const SceneBreakNode = Node.create({
|
||||
name: SCENE_BREAK,
|
||||
group: "block",
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
break: {
|
||||
default: "***",
|
||||
parseHTML: (element) => element.getAttribute("data-break") ?? "***",
|
||||
renderHTML: (attributes) => ({ "data-break": attributes.break ?? "***" }),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
// Higher priority than StarterKit's horizontalRule (`tag: "hr"`) so that
|
||||
// `<hr class="scene-break">` resolves to a scene break, not a rule.
|
||||
return [{ tag: "hr.scene-break", priority: 2000 }];
|
||||
},
|
||||
|
||||
renderHTML({ node }) {
|
||||
return ["hr", { class: "scene-break", "data-break": node.attrs.break ?? "***" }];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(SceneBreakComponent);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The React node view. TipTap's `ReactNodeViewRenderer` supplies the
|
||||
* contentDOM/selection handling; we just render the divider.
|
||||
*/
|
||||
export function SceneBreakComponent({ editor, node, selected }: ReactNodeViewProps) {
|
||||
return (
|
||||
<div
|
||||
className="ps-scene-break"
|
||||
data-selected={selected ? "" : undefined}
|
||||
data-break={node.attrs.break ?? "***"}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="ps-scene-break-inner"
|
||||
aria-label="Scene break"
|
||||
tabIndex={-1}
|
||||
onDoubleClick={() => {
|
||||
editor.commands.updateAttributes(SCENE_BREAK, { break: "***" });
|
||||
}}
|
||||
>
|
||||
{editor.isEditable ? "• • •" : ""}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SceneBreakNode;
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Performance helpers for the editor — throttled autosave and idle detection.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Return a throttled wrapper of `fn` that runs at most once per `wait` ms.
|
||||
* The trailing invocation always runs after the pause ends.
|
||||
*/
|
||||
export function throttle<T extends (...args: never[]) => void>(
|
||||
fn: T,
|
||||
wait: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let last = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
const now = Date.now();
|
||||
const remaining = wait - (now - last);
|
||||
if (remaining <= 0) {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
last = now;
|
||||
fn(...args);
|
||||
} else if (!timer) {
|
||||
timer = setTimeout(() => {
|
||||
last = Date.now();
|
||||
timer = null;
|
||||
fn(...args);
|
||||
}, remaining);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny idle detector. `onIdle` fires once after the caller stops calling
|
||||
* `poke()` for `idleMs` milliseconds. Useful for triggering a save after the
|
||||
* writer pauses.
|
||||
*/
|
||||
export function createIdleDetector(idleMs: number, onIdle: () => void): {
|
||||
poke: () => void;
|
||||
cancel: () => void;
|
||||
} {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const reset = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
onIdle();
|
||||
}, idleMs);
|
||||
};
|
||||
|
||||
return {
|
||||
poke: reset,
|
||||
cancel: () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* PubScript editor schema — all TipTap v3 extensions registered exactly once.
|
||||
*
|
||||
* Base prose comes from StarterKit (Document, Paragraph, Text, Heading 1–3,
|
||||
* Blockquote, HorizontalRule, HardBreak, Bold, Italic, Strike, Code,
|
||||
* CodeBlock). On top we layer three PubScript-specific nodes:
|
||||
*
|
||||
* - SceneBreak a block node rendered as a centered `***` divider.
|
||||
* - Footnote an inline atom that references a footnote id; the matching
|
||||
* FootnoteArea blocks render the note body.
|
||||
* - CharacterMention an inline atom that links to a character entity.
|
||||
*
|
||||
* Every v3 call (`Extension.create`, `Node.create`, `Node.create` with
|
||||
* `inline: true`, `TipTapEditor.create`) is wrapped here so the rest of the app
|
||||
* never touches the raw TipTap API directly.
|
||||
*/
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
|
||||
import { Editor as TipTapEditor, getSchema, type Content } from "@tiptap/core";
|
||||
|
||||
import SceneBreakNode from "./nodes/SceneBreak.js";
|
||||
import FootnoteInline from "./nodes/FootnoteInline.js";
|
||||
import FootnoteArea from "./nodes/FootnoteArea.js";
|
||||
import CharacterMention from "./nodes/CharacterMention.js";
|
||||
|
||||
import {
|
||||
SCENE_BREAK,
|
||||
FOOTNOTE,
|
||||
FOOTNOTE_AREA,
|
||||
CHARACTER_MENTION,
|
||||
SCENE_BREAK_GROUP,
|
||||
FOOTNOTE_GROUP,
|
||||
CHARACTER_MENTION_GROUP,
|
||||
MentionAttrs,
|
||||
colorAttribute,
|
||||
} from "./constants.js";
|
||||
|
||||
/**
|
||||
* The full extension list, registered once. Order matters: base kits first,
|
||||
* then text styling, then PubScript nodes.
|
||||
*/
|
||||
export const pubScriptExtensions = [
|
||||
StarterKit,
|
||||
TextStyle.extend({
|
||||
addAttributes: colorAttribute,
|
||||
}),
|
||||
SceneBreakNode,
|
||||
FootnoteInline,
|
||||
FootnoteArea,
|
||||
CharacterMention,
|
||||
];
|
||||
|
||||
/**
|
||||
* The resolved ProseMirror schema for the PubScript editor. Built once from
|
||||
* `pubScriptExtensions` via TipTap's `getSchema` helper so the schema can be
|
||||
* shared (e.g. for serialization) without constructing a live editor.
|
||||
*/
|
||||
export const PubScriptSchema = getSchema(pubScriptExtensions);
|
||||
|
||||
export interface PubScriptEditorOptions {
|
||||
editor?: TipTapEditor;
|
||||
editable?: boolean;
|
||||
extensions?: typeof pubScriptExtensions;
|
||||
/** Initial document content (ProseMirror JSON or HTML string). */
|
||||
content?: Content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the PubScript editor. Accepts an existing `editor` instance so the
|
||||
* headless instance can be shared across React components (avoids re-creating
|
||||
* the ProseMirror state on every render).
|
||||
*/
|
||||
export function createPubScriptEditor(options: PubScriptEditorOptions = {}): TipTapEditor {
|
||||
const { editor, editable = true, extensions = pubScriptExtensions, content } = options;
|
||||
|
||||
if (editor) {
|
||||
editor.setEditable(editable);
|
||||
return editor;
|
||||
}
|
||||
|
||||
return new TipTapEditor({
|
||||
editable,
|
||||
extensions,
|
||||
content: content ?? `
|
||||
<h1>Untitled Chapter</h1>
|
||||
<p>Begin your scene here.</p>
|
||||
`,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "ps-editor-content",
|
||||
},
|
||||
},
|
||||
})!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper used by the React wrapper to build an editor instance.
|
||||
*/
|
||||
export type { TipTapEditor };
|
||||
|
||||
export type { MentionAttrs };
|
||||
export {
|
||||
SCENE_BREAK,
|
||||
FOOTNOTE,
|
||||
FOOTNOTE_AREA,
|
||||
CHARACTER_MENTION,
|
||||
SCENE_BREAK_GROUP,
|
||||
FOOTNOTE_GROUP,
|
||||
CHARACTER_MENTION_GROUP,
|
||||
colorAttribute,
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* PubScript editor styles.
|
||||
*
|
||||
* These styles scope to the editor root (`.ps-editor`) so they never leak into
|
||||
* the surrounding app. The writing surface (apps/web) is responsible for the
|
||||
* font system, theming, and layout; this file only styles the editor's own
|
||||
* rendered nodes.
|
||||
*/
|
||||
|
||||
.ps-editor {
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.ps-editor-content {
|
||||
min-height: 20rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ps-editor-content :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Scene break — a centered, non-textual divider. */
|
||||
.ps-scene-break {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.ps-scene-break-inner {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: default;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.5rem;
|
||||
color: currentColor;
|
||||
opacity: 0.6;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.ps-scene-break[data-selected=""] .ps-scene-break-inner {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Footnote marker (inline) and area (block). */
|
||||
span.footnote {
|
||||
cursor: default;
|
||||
vertical-align: super;
|
||||
font-size: 0.75em;
|
||||
color: color-mix(in srgb, currentColor 70%, transparent);
|
||||
}
|
||||
|
||||
aside.footnote-area {
|
||||
border-inline-start: 2px solid color-mix(in srgb, currentColor 25%, transparent);
|
||||
padding-inline-start: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: color-mix(in srgb, currentColor 80%, transparent);
|
||||
}
|
||||
|
||||
.footnote-number {
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footnote-body {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
Reference in New Issue
Block a user