spb/note-spboucher-ai
Public
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