Files
InkNote/frontend/src/editor/noteLink.js
T
marcandClaude Opus 5 a9391cdcf2 One direction to drop in, and a new note that stays on top
Every new note pulled the arrangement apart. A note was made with no
position and left to the tie-break, which put it near the top by age --
which is not the same thing. A separator's position counts the rows above
it, so a row appearing without a renumber left every line one row too
high. placeOnTop builds the order from the list *without* the new note and
puts it in front: measuring the room after the furniture has moved would
write the misreading back as the truth.

A drop now lands the row under the one it was released on, and only there.
Dropping above a row and dropping below the row before it are the same move
described twice, so every gap could be aimed at from two sides. The strip
above the first row is the one place a drop under something cannot reach.

A chapter could only be dropped into, never dragged past: taking the whole
row as a place quietly took reordering away. The bottom edge of its header
is the gap now, the rest of the row is still the chapter.

A link showed the sheet of paper whatever it pointed at. The @ menu dropped
the mark on the way into the link, and the refresh that keeps a link current
ran once, while the editor was still empty -- content is poured in afterwards
without an update event, so nothing ever looked again.

The mark in the title bar and the box in a task list were each centred on a
line box rather than on the letters in it, which is not the same as being on
one line. The mark shares the title's baseline; the box is centred on the
capitals and sized in em.

Move to... is divided under headings that are deliberately not shaped like
entries, and each row carries the mark its chapter or notebook wears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 11:45:16 +02:00

242 lines
7.1 KiB
JavaScript

/**
* 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 = '<div class="note-link-empty">No matching note</div>';
return;
}
el.innerHTML = items
.map(
(
it,
i
) => `<button type="button" class="note-link-option${i === selected ? " is-selected" : ""}" data-index="${i}">
<span class="note-link-kind">${it.icon || (it.kind === "notebook" ? "📓" : "📝")}</span>
<span class="note-link-label"></span>
</button>`
)
.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;
},
};
}