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,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;
|
||||
Reference in New Issue
Block a user