diff --git a/packages/editor/package.json b/packages/editor/package.json new file mode 100644 index 0000000..98ad2ae --- /dev/null +++ b/packages/editor/package.json @@ -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" + } +} diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx new file mode 100644 index 0000000..327abef --- /dev/null +++ b/packages/editor/src/Editor.tsx @@ -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 { + /** 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(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?: string): TipTapEditor { + return createPubScriptEditor(content === undefined ? {} : { content: content as Content }); +} + +export { createPubScriptEditor, pubScriptExtensions }; diff --git a/packages/editor/src/constants.ts b/packages/editor/src/constants.ts new file mode 100644 index 0000000..be22e9f --- /dev/null +++ b/packages/editor/src/constants.ts @@ -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}` } : {}, + }, + }; +} diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts new file mode 100644 index 0000000..996365c --- /dev/null +++ b/packages/editor/src/index.ts @@ -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"; diff --git a/packages/editor/src/keys.ts b/packages/editor/src/keys.ts new file mode 100644 index 0000000..bca8baf --- /dev/null +++ b/packages/editor/src/keys.ts @@ -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(), + }, +]; diff --git a/packages/editor/src/markdown.test.ts b/packages/editor/src/markdown.test.ts new file mode 100644 index 0000000..7252880 --- /dev/null +++ b/packages/editor/src/markdown.test.ts @@ -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); + }); +}); diff --git a/packages/editor/src/markdown.ts b/packages/editor/src/markdown.ts new file mode 100644 index 0000000..6dae49c --- /dev/null +++ b/packages/editor/src/markdown.ts @@ -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 }>; + 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(``); + }; + + 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(`
${escapeHtml(codeLines.join("\n"))}
`); + 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(`${inlineToHtml(heading[2] ?? "")}`); + continue; + } + + // Scene break: a centered divider written as `***` or `* * *`. + const sceneBreak = line.trim(); + if (sceneBreak === "***" || sceneBreak === "* * *" || sceneBreak === "• • •") { + closeLists(); + html.push('
'); + 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(`
  • ${inlineToHtml(itemText ?? "")}
  • `); + continue; + } + + closeLists(); + if (line.trim() === "") { + html.push(""); + } else { + html.push(`

    ${inlineToHtml(line)}

    `); + } + } + + closeLists(); + if (inCode) { + html.push(`
    ${escapeHtml(codeLines.join("\n"))}
    `); + } + + return html.filter((l) => l !== "").join("\n"); +} + +function inlineToHtml(text: string): string { + let out = escapeHtml(text); + out = out.replace(/\*\*(.+?)\*\*/g, "$1"); + out = out.replace(/__(.+?)__/g, "$1"); + out = out.replace(/\*(.+?)\*/g, "$1"); + out = out.replace(/__(.+?)__/g, "$1"); + out = out.replace(/~~(.+)~~/g, "$1"); + out = out.replace(/`(.+)`/g, "$1"); + return out; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/packages/editor/src/nodes/CharacterMention.tsx b/packages/editor/src/nodes/CharacterMention.tsx new file mode 100644 index 0000000..d704da1 --- /dev/null +++ b/packages/editor/src/nodes/CharacterMention.tsx @@ -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 `@Name`. + */ +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; diff --git a/packages/editor/src/nodes/FootnoteArea.tsx b/packages/editor/src/nodes/FootnoteArea.tsx new file mode 100644 index 0000000..922b77e --- /dev/null +++ b/packages/editor/src/nodes/FootnoteArea.tsx @@ -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 + * `