/** * 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 = ''; return; } el.innerHTML = items .map( ( it, i ) => `` ) .join(""); // Titles are user text — set them as text, never as markup. el.querySelectorAll(".note-link-option").forEach((btn, i) => { btn.querySelector(".note-link-label").textContent = items[i].label; btn.addEventListener("mousedown", (e) => { e.preventDefault(); pick(i); }); }); }; const pick = (i) => { const item = items[i]; // The mark travels with the choice. Dropped here, every new link was born // wearing the default sheet of paper — the menu showed the note's own mark // and the link that came out of it did not. if (item && command) command({ id: item.id, label: item.label, kind: item.kind, icon: item.icon || null }); }; const place = (rect) => { if (!el || !rect) return; const r = rect(); if (!r) return; const width = 260; const left = Math.min(Math.max(8, r.left), window.innerWidth - width - 8); // Flip above the caret when there is no room below. const below = window.innerHeight - r.bottom; el.style.width = `${width}px`; el.style.left = `${left}px`; if (below < 220) { el.style.top = "auto"; el.style.bottom = `${window.innerHeight - r.top + 6}px`; } else { el.style.bottom = "auto"; el.style.top = `${r.bottom + 6}px`; } }; return { onStart: (props) => { items = props.items || []; selected = 0; command = props.command; el = document.createElement("div"); el.className = "note-link-popup"; document.body.appendChild(el); paint(); place(props.clientRect); }, onUpdate: (props) => { items = props.items || []; selected = Math.min(selected, Math.max(0, items.length - 1)); command = props.command; paint(); place(props.clientRect); }, onKeyDown: (props) => { const key = props.event.key; if (key === "Escape") { props.event.preventDefault(); el?.remove(); el = null; return true; } if (items.length === 0) return false; if (key === "ArrowDown") { selected = (selected + 1) % items.length; paint(); return true; } if (key === "ArrowUp") { selected = (selected - 1 + items.length) % items.length; paint(); return true; } if (key === "Enter" || key === "Tab") { props.event.preventDefault(); pick(selected); return true; } return false; }, onExit: () => { el?.remove(); el = null; items = []; command = null; }, }; }