/** * Note links — type `@` to reference another note, a notebook, or, inside a PDF, * one of the notes pinned to its pages. * * A link is an atomic inline node carrying the target's id, so it survives * renames: the label is refreshed from the current title on render, and the id * is what the backlink index is built from. A `pin` link is deliberately left * out of that index — its target is a marker in the note's own overlay, so no * note has that id. */ import { Node, mergeAttributes } from "@tiptap/core"; import Suggestion from "@tiptap/suggestion"; import { PluginKey } from "@tiptap/pm/state"; export const NoteLinkPluginKey = new PluginKey("noteLinkSuggestion"); export const NoteLink = Node.create({ name: "noteLink", group: "inline", inline: true, atom: true, selectable: true, draggable: false, addOptions() { return { // (query) => [{ id, label, kind: 'note' | 'notebook' }] search: () => [], HTMLAttributes: {}, }; }, addAttributes() { return { noteId: { default: null, parseHTML: (el) => el.getAttribute("data-note-link"), renderHTML: (a) => ({ "data-note-link": a.noteId }), }, label: { default: "", parseHTML: (el) => el.getAttribute("data-label") || el.textContent, renderHTML: (a) => ({ "data-label": a.label }), }, kind: { default: "note", parseHTML: (el) => el.getAttribute("data-kind") || "note", renderHTML: (a) => ({ "data-kind": a.kind }), }, // Set when the target no longer exists, so a dead link reads as broken // instead of silently going nowhere. /* The target's own mark, kept on the link and refreshed like the label is. * A reference that shows the same sheet-of-paper glyph whatever it points * at tells you nothing; the mark you gave the note is how you recognise * it everywhere else. */ icon: { default: null, parseHTML: (el) => el.getAttribute("data-icon") || null, renderHTML: (a) => (a.icon ? { "data-icon": a.icon } : {}), }, missing: { default: false, parseHTML: (el) => el.getAttribute("data-missing") === "true", renderHTML: (a) => (a.missing ? { "data-missing": "true" } : {}), }, }; }, parseHTML() { return [{ tag: "a[data-note-link]" }, { tag: "span[data-note-link]" }]; }, renderHTML({ node, HTMLAttributes }) { return [ "a", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { class: "note-link", // Real href would navigate away; the app intercepts the click instead. href: "#", title: node.attrs.label, }), `${node.attrs.icon || { notebook: "📓", pin: "📌" }[node.attrs.kind] || "📝"} ${node.attrs.label}`, ]; }, renderText({ node }) { return `@${node.attrs.label}`; }, addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: "@", pluginKey: NoteLinkPluginKey, allowSpaces: true, startOfLine: false, items: ({ query }) => this.options.search(query), command: ({ editor, range, props }) => { editor .chain() .focus() .insertContentAt(range, [ { type: "noteLink", attrs: { noteId: props.id, label: props.label, kind: props.kind || "note", icon: props.icon || null, }, }, { type: "text", text: " " }, ]) .run(); }, render: renderSuggestionList, }), ]; }, }); /** * A small dropdown driven by the suggestion plugin. * * Deliberately plain DOM rather than a positioning library — it is one list in * one place, and pulling in tippy/popper for it is not worth the bundle. */ function renderSuggestionList() { let el = null; let items = []; let selected = 0; let command = null; const paint = () => { if (!el) return; if (items.length === 0) { el.innerHTML = '