SPB Git
5.0 KB · 53 lines typescript
1import { Node, mergeAttributes, Extension } from "@tiptap/core";2import { Plugin, PluginKey } from "@tiptap/pm/state";3import { nanoid } from "nanoid";45/** Callout block: kind = note|info|warning|danger|ai, icon = emoji. */6export const Callout = Node.create({7  name: "callout", group: "block", content: "block+", defining: true,8  addAttributes() { return { kind: { default: "note", parseHTML: (el) => el.getAttribute("data-kind") ?? "note", renderHTML: (a) => ({ "data-kind": a.kind }) }, icon: { default: "💡", parseHTML: (el) => el.getAttribute("data-icon") ?? "💡", renderHTML: (a) => ({ "data-icon": a.icon }) } }; },9  parseHTML() { return [{ tag: "div.callout" }]; },10  renderHTML({ HTMLAttributes, node }) { return ["div", mergeAttributes(HTMLAttributes, { class: "callout" }), ["span", { class: "callout-icon", contenteditable: "false" }, node.attrs.icon], ["div", { class: "callout-body flex-1 min-w-0" }, 0]]; },11  addCommands() { return { setCallout: (attrs: { kind?: string; icon?: string } = {}) => ({ commands }: any) => commands.wrapIn(this.name, attrs), toggleCallout: (attrs: { kind?: string; icon?: string } = {}) => ({ commands }: any) => commands.toggleWrap(this.name, attrs) } as any; },12});13/** AI-generated content block: keeps provenance (model, prompt) around the generated blocks. */14export const AIBlock = Node.create({15  name: "aiBlock", group: "block", content: "block+", defining: true,16  addAttributes() { return { model: { default: null, parseHTML: (el) => el.getAttribute("data-model"), renderHTML: (a) => ({ "data-model": a.model }) }, prompt: { default: null, parseHTML: (el) => el.getAttribute("data-prompt"), renderHTML: (a) => ({ "data-prompt": a.prompt }) }, at: { default: null, parseHTML: (el) => el.getAttribute("data-at"), renderHTML: (a) => ({ "data-at": a.at }) } }; },17  parseHTML() { return [{ tag: "div.ai-block" }]; },18  renderHTML({ HTMLAttributes }) { return ["div", mergeAttributes(HTMLAttributes, { class: "ai-block", title: `Generated by ${HTMLAttributes["data-model"] ?? "AI"}` }), 0]; },19});20/** File attachment block (atom). */21export const FileBlock = Node.create({22  name: "fileBlock", group: "block", atom: true, draggable: true,23  addAttributes() { return { src: { default: null }, name: { default: "file" }, size: { default: 0 }, mime: { default: "" }, attachmentId: { default: null } }; },24  parseHTML() { return [{ tag: "a.file-block" }]; },25  renderHTML({ HTMLAttributes, node }) { const kb = node.attrs.size ? `${(node.attrs.size / 1024).toFixed(0)} KB` : ""; return ["a", mergeAttributes({ class: "file-block", href: node.attrs.src, target: "_blank", rel: "noopener", "data-attachment-id": node.attrs.attachmentId, "data-name": node.attrs.name, "data-size": node.attrs.size, "data-mime": node.attrs.mime }), ["span", { class: "text-lg" }, "📎"], ["span", { class: "flex-1 min-w-0 truncate text-sm font-medium" }, node.attrs.name], ["span", { class: "text-xs text-fg-faint" }, kb]]; },26});27/** Footnotes: inline reference (atom) + block definition. */28export const FootnoteRef = Node.create({29  name: "footnoteRef", group: "inline", inline: true, atom: true,30  addAttributes() { return { id: { default: null } }; },31  parseHTML() { return [{ tag: "sup.footnote-ref" }]; },32  renderHTML({ node }) { return ["sup", { class: "footnote-ref", "data-id": node.attrs.id }, `[${node.attrs.id}]`]; },33});34export const Footnote = Node.create({35  name: "footnote", group: "block", content: "inline*",36  addAttributes() { return { id: { default: null } }; },37  parseHTML() { return [{ tag: "div.footnote" }]; },38  renderHTML({ node }) { return ["div", { class: "footnote text-[13px] text-fg-muted border-t border-line pt-1 mt-2", "data-id": node.attrs.id }, ["span", { class: "mr-1 text-accent", contenteditable: "false" }, `${node.attrs.id}.`], ["span", {}, 0]]; },39});40/** Stable block ids (data-id) on block-level nodes, used for citation anchors (#block-<id>). */41export const BlockId = Extension.create({42  name: "blockId",43  addGlobalAttributes() { return [{ types: ["paragraph", "heading", "blockquote", "codeBlock", "bulletList", "orderedList", "taskList", "table", "callout", "details", "aiBlock", "blockMath", "image", "fileBlock"], attributes: { id: { default: null, keepOnSplit: false, parseHTML: (el) => el.getAttribute("data-id"), renderHTML: (a) => (a.id ? { "data-id": a.id, id: `block-${a.id}` } : {}) } } }]; },44  addProseMirrorPlugins() {45    const types = new Set(["paragraph", "heading", "blockquote", "codeBlock", "bulletList", "orderedList", "taskList", "table", "callout", "details", "aiBlock", "blockMath", "image", "fileBlock"]);46    return [new Plugin({ key: new PluginKey("blockId"), appendTransaction: (trs, _old, state) => {47      if (!trs.some((t) => t.docChanged)) return null; const seen = new Set<string>(); let tr = state.tr; let mod = false;48      state.doc.descendants((node, pos) => { if (!types.has(node.type.name)) return; const id = node.attrs.id; if (!id || seen.has(id)) { tr = tr.setNodeMarkup(pos, undefined, { ...node.attrs, id: nanoid(10) }); mod = true; } else seen.add(id); });49      return mod ? tr : null;50    } })];51  },52});53
Files · main
Menu

Theme