TipTap editor, version history, search, attachments — and a cleanup

The app is meant to run in daily private use now, so this pass was mostly
about the ways it could quietly lose data.

Reliability and security:
- the server no longer invents a JWT_SECRET or ENCRYPTION_KEY when one is
  missing; it refuses to start. The old fallback silently made every stored
  note unreadable after a restart.
- notes are encrypted with AES-256-GCM (v2:); existing CBC bodies still read.
- login is rate limited and compares against a dummy hash when the user does
  not exist, so timing does not reveal who is registered.
- a server response issued before a local save is never adopted. This had
  already eaten drawing strokes and note links; sync.hasPendingWrites() now
  guards every path that takes a body from the server.

Editor and features:
- TipTap replaces the contenteditable editor: tables, task lists, and links
  to other notes and notebooks, with a backlinks bar on the target.
- content format v2 ({v:2,doc}); v1 {html,tasks} bodies migrate on open.
- version history: the state before each write is snapshotted, coalesced
  into 5-minute windows, 50 versions or 90 days per note.
- full-text search runs in the client, because the server cannot read
  encrypted bodies and a server index would defeat the encryption.
- image attachments are stored by SHA-256 and deduplicated; the client
  fetches them authenticated and caches the blob, so they work offline.
- drawings are vector strokes in a fixed 1200x1600 page space and now look
  the same on every screen (0.0-0.2% deviation across four widths).
- login is by username; existing email accounts migrate automatically.

Operations:
- data lives on host bind mounts, not Docker volumes, so Proxmox Backup
  covers it. Back up .env with it — without ENCRYPTION_KEY the dump is
  unreadable.

Structure:
- NoteApp.jsx is gone. App.jsx now holds only where the user is; note data,
  search, layout, history and drag each live behind a hook, and the four
  panes are stateless.
- the whole data layer is TypeScript, with 81 unit tests concentrated on
  the places that have actually broken.
- fixed along the way: the browser back button had a dead press, because
  restoring a history entry pushed a duplicate one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-07 17:00:14 +02:00
1 parent d97591b242
commit 92d525b3ab
71 files changed
+14354 -1826

No files matched your search

+342
View File
@@ -0,0 +1,342 @@
import { useEffect, useState } from "react";
/* Icons the main app does not already provide, drawn to match its stroke style. */
const strokeProps = {
width: 15,
height: 15,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round",
strokeLinejoin: "round",
};
const TableIcon = () => (
<svg {...strokeProps}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="3" y1="15" x2="21" y2="15" />
<line x1="9" y1="3" x2="9" y2="21" />
</svg>
);
const LinkIcon = () => (
<svg {...strokeProps}>
<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7" />
<path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7" />
</svg>
);
const QuoteIcon = () => (
<svg {...strokeProps}>
<path d="M6 17h3l2-4V7H5v6h3z" />
<path d="M16 17h3l2-4V7h-6v6h3z" />
</svg>
);
const CodeIcon = () => (
<svg {...strokeProps}>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
);
const AtIcon = () => (
<svg {...strokeProps}>
<circle cx="12" cy="12" r="4" />
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.9 7.9" />
</svg>
);
/**
* Formatting controls for the note body.
*
* Reads its active states straight off the editor, so the buttons reflect the
* cursor even when formatting changed by keyboard shortcut.
*/
export function EditorToolbar({ editor, T, icons, compact }) {
const [, force] = useState(0);
const [showTable, setShowTable] = useState(false);
// ProseMirror state changes do not go through React; subscribe so the active
// states stay in sync with the cursor.
useEffect(() => {
if (!editor) return;
const rerender = () => force((n) => n + 1);
editor.on("transaction", rerender);
editor.on("selectionUpdate", rerender);
return () => {
editor.off("transaction", rerender);
editor.off("selectionUpdate", rerender);
};
}, [editor]);
useEffect(() => {
if (!showTable) return;
const close = (e) => {
if (!e.target.closest?.("[data-table-menu]")) setShowTable(false);
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, [showTable]);
if (!editor) return null;
const btn = (active) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
height: 30,
borderRadius: 6,
border: "none",
cursor: "pointer",
background: active ? T.active + "22" : "transparent",
color: active ? T.active : T.text,
transition: "all .12s",
flexShrink: 0,
});
const divider = (
<div style={{ width: 1, height: 22, background: T.border, margin: "0 2px", flexShrink: 0 }} />
);
const inTable = editor.isActive("table");
const menuItem = {
display: "block",
width: "100%",
padding: "6px 10px",
border: "none",
borderRadius: 6,
background: "transparent",
color: T.text,
fontSize: 12,
textAlign: "left",
cursor: "pointer",
};
const promptLink = () => {
const previous = editor.getAttributes("link").href || "";
const url = window.prompt("Link URL (leave empty to remove)", previous);
if (url === null) return;
if (url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
if (!/^(https?|mailto):/i.test(url)) {
window.alert("Only http(s) and mailto links are allowed.");
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
};
return (
<>
<button
style={btn(editor.isActive("bold"))}
title="Bold (Ctrl+B)"
onClick={() => editor.chain().focus().toggleBold().run()}
>
{icons.bold}
</button>
<button
style={btn(editor.isActive("italic"))}
title="Italic (Ctrl+I)"
onClick={() => editor.chain().focus().toggleItalic().run()}
>
{icons.italic}
</button>
<button
style={btn(editor.isActive("underline"))}
title="Underline (Ctrl+U)"
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
{icons.underline}
</button>
{divider}
<button
style={btn(editor.isActive("bulletList"))}
title="Bullet list"
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
{icons.list}
</button>
<button
style={btn(editor.isActive("orderedList"))}
title="Numbered list"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
{icons.listOrd}
</button>
<button
style={btn(editor.isActive("taskList"))}
title="Task list"
onClick={() => editor.chain().focus().toggleTaskList().run()}
>
{icons.check}
</button>
{divider}
{/* Tables */}
<div style={{ position: "relative" }} data-table-menu>
<button style={btn(inTable || showTable)} title="Table" onClick={() => setShowTable((v) => !v)}>
<TableIcon />
</button>
{showTable && (
<div
style={{
position: "absolute",
top: 36,
left: 0,
minWidth: 190,
background: T.toolbar,
border: `1px solid ${T.border}`,
borderRadius: 12,
padding: 6,
zIndex: 200,
boxShadow: "0 8px 20px rgba(0,0,0,.2)",
}}
>
<button
style={menuItem}
onClick={() => {
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
setShowTable(false);
}}
>
Insert 3 × 3 table
</button>
{inTable && (
<>
<div style={{ height: 1, background: T.border, margin: "5px 2px" }} />
<button style={menuItem} onClick={() => editor.chain().focus().addColumnBefore().run()}>
Add column left
</button>
<button style={menuItem} onClick={() => editor.chain().focus().addColumnAfter().run()}>
Add column right
</button>
<button style={menuItem} onClick={() => editor.chain().focus().addRowBefore().run()}>
Add row above
</button>
<button style={menuItem} onClick={() => editor.chain().focus().addRowAfter().run()}>
Add row below
</button>
<div style={{ height: 1, background: T.border, margin: "5px 2px" }} />
<button style={menuItem} onClick={() => editor.chain().focus().toggleHeaderRow().run()}>
Toggle header row
</button>
<button style={menuItem} onClick={() => editor.chain().focus().mergeOrSplit().run()}>
Merge / split cells
</button>
<div style={{ height: 1, background: T.border, margin: "5px 2px" }} />
<button
style={{ ...menuItem, color: T.danger || "#e5534b" }}
onClick={() => editor.chain().focus().deleteColumn().run()}
>
Delete column
</button>
<button
style={{ ...menuItem, color: T.danger || "#e5534b" }}
onClick={() => editor.chain().focus().deleteRow().run()}
>
Delete row
</button>
<button
style={{ ...menuItem, color: T.danger || "#e5534b" }}
onClick={() => {
editor.chain().focus().deleteTable().run();
setShowTable(false);
}}
>
Delete table
</button>
</>
)}
</div>
)}
</div>
<button
style={btn(false)}
title="Link to another note (or type @)"
onClick={() => editor.chain().focus().insertContent("@").run()}
>
<AtIcon />
</button>
<button style={btn(editor.isActive("link"))} title="Web link" onClick={promptLink}>
<LinkIcon />
</button>
{!compact && (
<>
{divider}
<button
style={btn(editor.isActive("blockquote"))}
title="Quote"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<QuoteIcon />
</button>
<button
style={btn(editor.isActive("codeBlock"))}
title="Code block"
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
>
<CodeIcon />
</button>
</>
)}
{divider}
<select
value={
editor.isActive("heading", { level: 1 })
? "h1"
: editor.isActive("heading", { level: 2 })
? "h2"
: editor.isActive("heading", { level: 3 })
? "h3"
: "p"
}
onChange={(e) => {
const v = e.target.value;
if (v === "p") editor.chain().focus().setParagraph().run();
else
editor
.chain()
.focus()
.toggleHeading({ level: Number(v[1]) })
.run();
}}
style={{
border: `1px solid ${T.border}`,
borderRadius: 6,
padding: "3px 6px",
background: T.toolbar,
color: T.text,
fontSize: 11,
cursor: "pointer",
}}
>
<option value="p">Body</option>
<option value="h1">H1</option>
<option value="h2">H2</option>
<option value="h3">H3</option>
</select>
{divider}
<button
style={{ ...btn(false), opacity: editor.can().undo() ? 1 : 0.35 }}
title="Undo"
onClick={() => editor.chain().focus().undo().run()}
>
{icons.undo}
</button>
<button
style={{ ...btn(false), opacity: editor.can().redo() ? 1 : 0.35 }}
title="Redo"
onClick={() => editor.chain().focus().redo().run()}
>
{icons.redo}
</button>
</>
);
}
export default EditorToolbar;
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useMemo } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { buildExtensions } from "./extensions.js";
import { parseNoteContent } from "./content";
import "./editor.css";
/**
* Read-only render of a stored note body.
*
* Uses the same extension set as the real editor, so a past version is shown
* with the formatting it actually had — including tables and note links —
* rather than a stripped-down approximation.
*/
export function VersionPreview({ content, dark }) {
const extensions = useMemo(() => buildExtensions(), []);
const editor = useEditor(
{
extensions,
editable: false,
content: parseNoteContent(content, extensions),
},
[]
);
useEffect(() => {
if (!editor || editor.isDestroyed) return;
editor.commands.setContent(parseNoteContent(content, extensions), false);
}, [editor, content, extensions]);
return (
<div className="note-editor" data-theme={dark ? "dark" : "light"}>
<EditorContent editor={editor} />
</div>
);
}
export default VersionPreview;
+82
View File
@@ -0,0 +1,82 @@
/**
* Images in a note body.
*
* New images are stored as attachments and the document only keeps their id.
* Older notes inlined the bytes as a base64 data URI; those still render, so
* nothing written before this change is lost — they simply stay heavy until
* re-inserted.
*/
import Image from "@tiptap/extension-image";
import { resolveUrl } from "./attachments.js";
export const AttachmentImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
attachmentId: {
default: null,
parseHTML: (el) => el.getAttribute("data-attachment"),
renderHTML: (attrs) => (attrs.attachmentId ? { "data-attachment": attrs.attachmentId } : {}),
},
// Preserved from the old inline-style images so their sizing survives.
width: {
default: null,
parseHTML: (el) => el.style?.width || el.getAttribute("width") || null,
renderHTML: (attrs) => (attrs.width ? { style: `width: ${attrs.width}` } : {}),
},
};
},
renderHTML({ HTMLAttributes }) {
// A blob URL is meaningless outside this page, so an attachment-backed image
// is serialised without a src; the node view supplies one at display time.
const attrs = { ...HTMLAttributes };
if (attrs["data-attachment"]) delete attrs.src;
return ["img", attrs];
},
addNodeView() {
return ({ node }) => {
const dom = document.createElement("img");
dom.className = "note-image";
if (node.attrs.alt) dom.alt = node.attrs.alt;
if (node.attrs.title) dom.title = node.attrs.title;
if (node.attrs.width) dom.style.width = node.attrs.width;
let currentId = node.attrs.attachmentId;
const paint = (attrs) => {
if (attrs.attachmentId) {
dom.dataset.attachment = attrs.attachmentId;
resolveUrl(attrs.attachmentId)
.then((url) => {
if (url) dom.src = url;
})
.catch(() => {
dom.alt = attrs.alt || "Image unavailable";
dom.dataset.missing = "true";
});
} else if (attrs.src) {
dom.src = attrs.src; // legacy inline data URI
}
};
paint(node.attrs);
return {
dom,
update(updated) {
if (updated.type.name !== node.type.name) return false;
if (updated.attrs.width !== dom.style.width) dom.style.width = updated.attrs.width || "";
if (updated.attrs.attachmentId !== currentId) {
currentId = updated.attrs.attachmentId;
paint(updated.attrs);
}
return true;
},
};
};
},
});
export default AttachmentImage;
+73
View File
@@ -0,0 +1,73 @@
/**
* Attachment plumbing for the editor.
*
* An `<img>` tag cannot carry an Authorization header, so attachment bytes are
* fetched with an authenticated request, kept in IndexedDB and handed to the
* DOM as a blob URL. That also means images render offline, and each one is
* downloaded at most once per device.
*/
import * as sync from "../syncEngine";
import * as db from "../offlineStore";
const _urls = new Map(); // attachmentId -> object URL
const _inflight = new Map(); // attachmentId -> Promise<string>
export const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024;
export const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/avif"];
const readAsDataUrl = (file) =>
new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(r.result);
r.onerror = () => reject(r.error || new Error("Could not read file"));
r.readAsDataURL(file);
});
/** Uploads a pasted or dropped image and returns its attachment id. */
export async function uploadImage(file) {
if (!ACCEPTED_TYPES.includes(file.type))
throw new Error(`Unsupported image type: ${file.type || "unknown"}`);
if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("Image is larger than 15 MB");
const dataUrl = await readAsDataUrl(file);
const { id } = await sync.uploadAttachment(dataUrl, file.name);
// Seed the cache from the bytes already in hand — no round trip to display it.
try {
await db.putAttachmentBlob(id, file);
} catch {
/* cache is best-effort */
}
return id;
}
/** A displayable URL for an attachment, from cache when possible. */
export function resolveUrl(id) {
if (!id) return Promise.resolve(null);
const cached = _urls.get(id);
if (cached) return Promise.resolve(cached);
if (_inflight.has(id)) return _inflight.get(id);
const job = (async () => {
let blob = await db.getAttachmentBlob(id).catch(() => null);
if (!blob) {
blob = await sync.fetchAttachmentBlob(id);
db.putAttachmentBlob(id, blob).catch(() => {});
}
const url = URL.createObjectURL(blob);
_urls.set(id, url);
return url;
})().finally(() => _inflight.delete(id));
_inflight.set(id, job);
return job;
}
/* Object URLs live as long as the page does. They are a few dozen bytes of
* bookkeeping each and the same image usually reappears as the user moves
* between notes, so they are kept rather than revoked per unmount. */
export function releaseAll() {
for (const url of _urls.values()) URL.revokeObjectURL(url);
_urls.clear();
}
+183
View File
@@ -0,0 +1,183 @@
import { describe, it, expect } from "vitest";
import { parseNoteContent, serializeNoteContent, extractNoteLinks, docToText } from "./content";
import { parsingExtensions } from "./extensions.js";
const parse = (raw: unknown) => parseNoteContent(raw, parsingExtensions);
/** Collects every node type in a document, for asserting on structure. */
const typesIn = (doc: any): string[] => {
const out: string[] = [];
(function walk(n: any) {
if (!n) return;
if (n.type) out.push(n.type);
if (Array.isArray(n.content)) n.content.forEach(walk);
})(doc);
return out;
};
describe("parseNoteContent", () => {
it("returns an empty document for empty input", () => {
for (const empty of [null, undefined, ""]) {
expect(parse(empty)).toEqual({ type: "doc", content: [{ type: "paragraph" }] });
}
});
it("passes a v2 document through untouched", () => {
const doc = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "hi" }] }] };
expect(parse(JSON.stringify({ v: 2, doc }))).toEqual(doc);
});
describe("v1 migration", () => {
it("keeps headings, marks and lists", () => {
const doc = parse(
JSON.stringify({
html: "<h2>Title</h2><p>Text with <b>bold</b> and <i>italic</i>.</p><ul><li>one</li></ul>",
tasks: [],
})
);
const types = typesIn(doc);
expect(types).toContain("heading");
expect(types).toContain("bulletList");
const marks = JSON.stringify(doc);
expect(marks).toContain("bold");
expect(marks).toContain("italic");
});
it("turns the detached task array into a task list, preserving checked state", () => {
const doc = parse(
JSON.stringify({
html: "<p>body</p>",
tasks: [
{ id: "a", text: "done thing", done: true },
{ id: "b", text: "open thing", done: false },
],
})
);
expect(typesIn(doc)).toContain("taskList");
const json = JSON.stringify(doc);
expect(json).toContain("done thing");
expect(json).toContain("open thing");
// exactly one of the two items is checked
expect((json.match(/"checked":true/g) || []).length).toBe(1);
});
it("does not let task text smuggle in markup", () => {
const doc = parse(
JSON.stringify({ html: "", tasks: [{ id: "a", text: "<script>alert(1)</script>", done: false }] })
);
expect(typesIn(doc)).not.toContain("script");
expect(docToText(doc)).toContain("<script>");
});
it("accepts a bare HTML string, which the very first version stored", () => {
expect(typesIn(parse("<p>plain</p>"))).toContain("paragraph");
});
it("falls back to plain text rather than throwing on unparseable input", () => {
expect(() => parse("{ not json at all")).not.toThrow();
});
});
});
describe("serializeNoteContent", () => {
it("round-trips through parse", () => {
const doc = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "x" }] }] };
expect(parse(serializeNoteContent(doc))).toEqual(doc);
});
it("tags the stored format so future versions can tell them apart", () => {
expect(JSON.parse(serializeNoteContent({ type: "doc" })).v).toBe(2);
});
});
describe("extractNoteLinks", () => {
const docWith = (...ids: string[]) => ({
type: "doc",
content: ids.map((id) => ({
type: "paragraph",
content: [{ type: "noteLink", attrs: { noteId: id, label: id } }],
})),
});
it("finds every linked id", () => {
expect(extractNoteLinks(docWith("a", "b")).sort()).toEqual(["a", "b"]);
});
it("deduplicates repeated links", () => {
expect(extractNoteLinks(docWith("a", "a", "a"))).toEqual(["a"]);
});
it("drops a link to the note itself", () => {
expect(extractNoteLinks(docWith("self", "other"), "self")).toEqual(["other"]);
});
it("finds links nested inside tables", () => {
const doc = {
type: "doc",
content: [
{
type: "table",
content: [
{
type: "tableRow",
content: [
{
type: "tableCell",
content: [
{
type: "paragraph",
content: [{ type: "noteLink", attrs: { noteId: "deep", label: "d" } }],
},
],
},
],
},
],
},
],
};
expect(extractNoteLinks(doc)).toEqual(["deep"]);
});
it("returns nothing for a document without links", () => {
expect(extractNoteLinks({ type: "doc", content: [{ type: "paragraph" }] })).toEqual([]);
});
});
describe("docToText", () => {
it("joins text across nested nodes", () => {
const doc = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "first" }] },
{
type: "bulletList",
content: [
{
type: "listItem",
content: [{ type: "paragraph", content: [{ type: "text", text: "second" }] }],
},
],
},
],
};
expect(docToText(doc)).toBe("first second");
});
it("includes note link labels, so linked notes are searchable by name", () => {
const doc = {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "noteLink", attrs: { label: "Recipes" } }] }],
};
expect(docToText(doc)).toContain("Recipes");
});
it("respects the length limit", () => {
const doc = {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "x".repeat(500) }] }],
};
expect(docToText(doc, 50).length).toBeLessThanOrEqual(50);
});
});
+140
View File
@@ -0,0 +1,140 @@
/**
* Note content format and migration.
*
* v1 (contentEditable era): JSON.stringify({ html, tasks }) — unstructured HTML
* from document.execCommand plus a separate checkbox list. Tables could not
* be represented reliably and links had no identity.
* v2 (current): JSON.stringify({ v: 2, doc }) where `doc` is a TipTap/ProseMirror
* document. Structured, so tables, task lists and note links are real nodes.
*
* v1 notes are converted on read and rewritten as v2 the next time they are
* saved. Nothing is migrated in bulk, so an old client reading a note it has
* not touched still sees its own format.
*/
import { generateJSON } from "@tiptap/html";
import type { Extensions } from "@tiptap/core";
/** A TipTap/ProseMirror node. Deliberately loose: the schema is the authority. */
export interface DocNode {
type?: string;
text?: string;
attrs?: Record<string, any>;
content?: DocNode[];
}
export const CONTENT_VERSION = 2;
export const emptyDoc = (): DocNode => ({ type: "doc", content: [{ type: "paragraph" }] });
const escapeHtml = (s: unknown): string =>
String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
/** Renders a v1 `tasks` array as the HTML TipTap's TaskList parses. */
function tasksToHtml(tasks: unknown): string {
if (!Array.isArray(tasks) || tasks.length === 0) return "";
const items = tasks
.map(
(t: { done?: boolean; text?: string }) =>
`<li data-type="taskItem" data-checked="${t.done ? "true" : "false"}"><p>${escapeHtml(t.text)}</p></li>`
)
.join("");
return `<ul data-type="taskList">${items}</ul>`;
}
/**
* Turns whatever is stored in `note.content` into a TipTap document.
* Never throws: a note that cannot be parsed opens empty rather than blocking
* the editor, and the original string is left untouched until the user edits.
*/
export function parseNoteContent(raw: unknown, extensions: Extensions): DocNode {
if (!raw) return emptyDoc();
let parsed: any = null;
if (typeof raw === "string") {
try {
parsed = JSON.parse(raw);
} catch {
parsed = null;
}
} else if (typeof raw === "object") {
parsed = raw;
}
// v2 — already a TipTap document.
if (parsed && parsed.v === CONTENT_VERSION && parsed.doc) return parsed.doc;
// v1 — HTML plus a detached task list, or a bare HTML string.
const html =
typeof parsed?.html === "string"
? parsed.html
: typeof parsed === "string"
? parsed
: typeof raw === "string"
? raw
: "";
const combined = html + tasksToHtml(parsed?.tasks);
if (!combined.trim()) return emptyDoc();
try {
return generateJSON(combined, extensions);
} catch (err) {
console.error("[content] could not migrate v1 note, opening as plain text:", err);
return {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: stripHtml(combined) }] }],
};
}
}
export function serializeNoteContent(doc: DocNode): string {
return JSON.stringify({ v: CONTENT_VERSION, doc });
}
function stripHtml(html: string): string {
const el = document.createElement("div");
el.innerHTML = html;
return el.textContent || "";
}
/** Depth-first walk over every node in a TipTap document. */
function walk(node: DocNode | undefined | null, visit: (n: DocNode) => void): void {
if (!node) return;
visit(node);
if (Array.isArray(node.content)) for (const child of node.content) walk(child, visit);
}
/**
* Ids of every note this document links to.
*
* The server stores content encrypted and cannot read it, so the client is what
* tells it which links exist. Deduplicated and self-links dropped.
*/
export function extractNoteLinks(doc: DocNode, selfId?: string): string[] {
const ids = new Set<string>();
walk(doc, (n) => {
if (n.type === "noteLink" && n.attrs?.noteId && n.attrs.noteId !== selfId) ids.add(n.attrs.noteId);
});
return [...ids];
}
/** Plain text of a document — used for list previews and local search. */
export function docToText(doc: unknown, limit = 400): string {
const parts: string[] = [];
let len = 0;
walk(doc as DocNode, (n) => {
if (len >= limit) return;
if (n.type === "text" && n.text) {
parts.push(n.text);
len += n.text.length;
} else if (n.type === "noteLink" && n.attrs?.label) {
parts.push(n.attrs.label);
len += n.attrs.label.length;
}
});
return parts.join(" ").replace(/\s+/g, " ").trim().slice(0, limit);
}
+262
View File
@@ -0,0 +1,262 @@
/* Editor content styling.
*
* The rest of the app styles inline, but ProseMirror renders its own DOM, so
* these nodes can only be reached from a stylesheet. Colours come from custom
* properties the editor container sets per theme. */
.note-editor {
--ne-text: #1f2328;
--ne-muted: #656d76;
--ne-faint: #afb8c1;
--ne-border: #d0d7de;
--ne-accent: #0969da;
--ne-surface: #ffffff;
--ne-subtle: #f6f8fa;
}
.note-editor[data-theme="dark"] {
--ne-text: #e6edf3;
--ne-muted: #8b949e;
--ne-faint: #484f58;
--ne-border: #30363d;
--ne-accent: #388bfd;
--ne-surface: #0d1117;
--ne-subtle: #161b22;
}
.note-editor .ProseMirror {
outline: none;
color: var(--ne-text);
font-family: "Newsreader", Georgia, serif;
font-size: 15px;
line-height: 1.7;
padding: 20px 24px;
min-height: 100%;
word-break: break-word;
}
.note-editor .ProseMirror > * + * {
margin-top: 0.6em;
}
.note-editor .ProseMirror p {
margin: 0;
}
.note-editor .ProseMirror h1 {
font-size: 1.7em;
font-weight: 700;
line-height: 1.3;
margin-top: 0.8em;
}
.note-editor .ProseMirror h2 {
font-size: 1.4em;
font-weight: 700;
line-height: 1.3;
margin-top: 0.7em;
}
.note-editor .ProseMirror h3 {
font-size: 1.15em;
font-weight: 700;
line-height: 1.4;
margin-top: 0.6em;
}
.note-editor .ProseMirror ul,
.note-editor .ProseMirror ol {
padding-left: 1.4em;
}
.note-editor .ProseMirror li p {
margin: 0;
}
.note-editor .ProseMirror blockquote {
border-left: 3px solid var(--ne-border);
padding-left: 12px;
color: var(--ne-muted);
}
.note-editor .ProseMirror code {
background: var(--ne-subtle);
border: 1px solid var(--ne-border);
border-radius: 4px;
padding: 1px 5px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.88em;
}
.note-editor .ProseMirror pre {
background: var(--ne-subtle);
border: 1px solid var(--ne-border);
border-radius: 10px;
padding: 12px 14px;
overflow-x: auto;
}
.note-editor .ProseMirror pre code {
background: none;
border: none;
padding: 0;
}
.note-editor .ProseMirror hr {
border: none;
border-top: 1px solid var(--ne-border);
margin: 1.2em 0;
}
.note-editor .ProseMirror img {
max-width: 100%;
height: auto;
border-radius: 8px;
display: block;
}
.note-editor .ProseMirror img.note-image {
background: var(--ne-subtle);
min-height: 24px;
}
/* An attachment whose bytes could not be fetched — visible, not a blank gap */
.note-editor .ProseMirror img[data-missing="true"] {
border: 1px dashed var(--ne-border);
padding: 12px;
min-width: 120px;
}
.note-editor .ProseMirror a {
color: var(--ne-accent);
}
/* Placeholder on the first empty line */
.note-editor .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: var(--ne-faint);
float: left;
height: 0;
pointer-events: none;
}
/* ── Tables ── */
.note-editor .ProseMirror table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
margin: 0.8em 0;
overflow: hidden;
}
.note-editor .ProseMirror td,
.note-editor .ProseMirror th {
border: 1px solid var(--ne-border);
padding: 7px 10px;
vertical-align: top;
position: relative;
min-width: 1em;
}
.note-editor .ProseMirror th {
background: var(--ne-subtle);
font-weight: 600;
text-align: left;
}
.note-editor .ProseMirror .selectedCell::after {
content: "";
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--ne-accent) 18%, transparent);
pointer-events: none;
}
/* Drag handle between columns */
.note-editor .ProseMirror .column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 4px;
background: var(--ne-accent);
pointer-events: none;
}
.note-editor .ProseMirror.resize-cursor {
cursor: col-resize;
}
/* ── Task lists ── */
.note-editor .ProseMirror ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.note-editor .ProseMirror ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
gap: 8px;
}
.note-editor .ProseMirror ul[data-type="taskList"] li > label {
flex: 0 0 auto;
margin-top: 0.35em;
user-select: none;
}
.note-editor .ProseMirror ul[data-type="taskList"] li > div {
flex: 1 1 auto;
}
.note-editor .ProseMirror ul[data-type="taskList"] input[type="checkbox"] {
accent-color: var(--ne-accent);
width: 15px;
height: 15px;
cursor: pointer;
}
.note-editor .ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div {
color: var(--ne-muted);
text-decoration: line-through;
}
/* ── Note links ── */
.note-editor .ProseMirror a.note-link,
.note-link {
display: inline-flex;
align-items: baseline;
gap: 3px;
padding: 1px 6px;
border-radius: 6px;
background: color-mix(in srgb, var(--ne-accent) 14%, transparent);
color: var(--ne-accent);
text-decoration: none;
font-family: "DM Sans", system-ui, sans-serif;
font-size: 0.9em;
white-space: nowrap;
cursor: pointer;
}
.note-editor .ProseMirror a.note-link:hover {
background: color-mix(in srgb, var(--ne-accent) 26%, transparent);
}
/* A link whose target is gone — still readable, visibly broken */
.note-editor .ProseMirror a.note-link[data-missing="true"] {
background: color-mix(in srgb, #cf222e 14%, transparent);
color: #cf222e;
text-decoration: line-through;
}
/* ── @-mention popup ── */
.note-link-popup {
position: fixed;
z-index: 3000;
max-height: 240px;
overflow-y: auto;
padding: 5px;
border-radius: 12px;
border: 1px solid var(--popup-border, #d0d7de);
background: var(--popup-bg, #ffffff);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22);
font-family: "DM Sans", system-ui, sans-serif;
}
.note-link-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--popup-text, #1f2328);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.note-link-option.is-selected {
background: var(--popup-hover, #eaeef2);
}
.note-link-option .note-link-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-link-empty {
padding: 9px 11px;
font-size: 12px;
color: var(--popup-muted, #656d76);
}
+49
View File
@@ -0,0 +1,49 @@
/** The one place that decides which nodes and marks a note may contain. */
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableHeader from "@tiptap/extension-table-header";
import TableCell from "@tiptap/extension-table-cell";
import { NoteLink } from "./noteLink.js";
import { AttachmentImage } from "./attachmentImage.js";
export function buildExtensions({ search = () => [], placeholder = "Start writing..." } = {}) {
return [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
// History is on by default; the drawing layer keeps its own undo stack.
codeBlock: { HTMLAttributes: { class: "note-code-block" } },
}),
Underline,
Link.configure({
openOnClick: false, // the app decides what a click does
autolink: true,
protocols: ["http", "https", "mailto"],
HTMLAttributes: { rel: "noopener noreferrer nofollow", target: "_blank" },
}),
// allowBase64 stays on so notes written before attachments still render.
AttachmentImage.configure({ inline: false, allowBase64: true }),
Placeholder.configure({ placeholder }),
TaskList,
TaskItem.configure({ nested: true }),
Table.configure({ resizable: true, lastColumnResizable: false, allowTableNodeSelection: true }),
TableRow,
TableHeader,
TableCell,
NoteLink.configure({ search }),
];
}
/**
* A static extension list for parsing stored HTML outside of a live editor
* (`generateJSON`). It must accept everything `buildExtensions` produces, or
* migrating an old note would silently drop nodes.
*/
export const parsingExtensions = buildExtensions();
+221
View File
@@ -0,0 +1,221 @@
/**
* Note links — type `@` to reference another note or notebook.
*
* 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.
*/
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.
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.kind === "notebook" ? "📓" : "📝"} ${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" },
},
{ 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.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];
if (item && command) command({ id: item.id, label: item.label, kind: item.kind });
};
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;
},
};
}
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useMemo, useRef, useCallback } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { buildExtensions } from "./extensions.js";
import { parseNoteContent, serializeNoteContent, extractNoteLinks } from "./content";
import { uploadImage, ACCEPTED_TYPES } from "./attachments.js";
import "./editor.css";
/* Marks transactions this module dispatches itself, so they are not mistaken
* for the user editing the note. */
const LABEL_REFRESH = "noteLinkLabelRefresh";
/**
* Uploads images from a paste or drop and inserts them as attachments.
*
* Returns true only when images were actually taken over, so pasting text or a
* screenshot-free clipboard still behaves normally.
*/
function insertDroppedImages(view, transfer, at, onErrorRef) {
const files = [...(transfer?.files || [])].filter((f) => ACCEPTED_TYPES.includes(f.type));
if (files.length === 0) return false;
(async () => {
for (const file of files) {
try {
const id = await uploadImage(file);
const node = view.state.schema.nodes.image.create({ attachmentId: id, alt: file.name || null });
const pos = at ?? view.state.selection.from;
view.dispatch(view.state.tr.insert(Math.min(pos, view.state.doc.content.size), node));
} catch (err) {
console.error("[attachment] upload failed:", err);
onErrorRef?.current?.(err.message || "Could not attach that image");
}
}
})();
return true; // handled — do not also paste the raw file
}
/**
* Builds the note body editor.
*
* The instance is returned rather than kept inside a component because the
* toolbar lives elsewhere in the tree and needs the same editor to drive it.
*/
export function useNoteEditor({ noteId, content, editable = true, onChange, searchLinkTargets, onError }) {
// Held in refs so a changing callback identity never rebuilds the editor,
// which would drop the cursor and the undo history mid-typing.
const searchRef = useRef(searchLinkTargets);
searchRef.current = searchLinkTargets;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
const extensions = useMemo(
() => buildExtensions({ search: (query) => (searchRef.current ? searchRef.current(query) : []) }),
[]
);
const editor = useEditor(
{
extensions,
content: parseNoteContent(content, extensions),
editable,
editorProps: {
attributes: { spellcheck: "true" },
handlePaste: (view, event) => insertDroppedImages(view, event.clipboardData, null, onErrorRef),
handleDrop: (view, event) => {
const at = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos ?? null;
return insertDroppedImages(view, event.dataTransfer, at, onErrorRef);
},
},
onUpdate: ({ editor: ed, transaction }) => {
// Refreshing a link label is not a user edit and must not mark the note dirty.
if (transaction?.getMeta(LABEL_REFRESH)) return;
const doc = ed.getJSON();
onChangeRef.current?.({
content: serializeNoteContent(doc),
links: extractNoteLinks(doc, noteId),
});
},
// Rebuilt per note so each one starts with its own document and history.
},
[noteId]
);
useEffect(() => {
if (editor && editor.isEditable !== editable) editor.setEditable(editable);
}, [editor, editable]);
/* Adopt content that arrived from another device. Skipped while the editor
* has focus, so a sync push can never yank the document out from under the
* cursor, and skipped when nothing actually differs. */
useEffect(() => {
if (!editor || editor.isDestroyed || editor.isFocused) return;
const incoming = parseNoteContent(content, extensions);
if (JSON.stringify(incoming) === JSON.stringify(editor.getJSON())) return;
editor.commands.setContent(incoming, false);
}, [editor, content, extensions]);
return editor;
}
/** The editable page itself. */
export function EditorSurface({ editor, dark, minHeight, onOpenNote, resolveLinkLabel }) {
/* Note links carry the label they were created with, so renaming a note would
* leave every reference to it showing the old title.
*
* This has to go through a transaction. Writing the new label straight into
* `editor.view.dom` looks simpler and works on screen, but ProseMirror's
* mutation observer then re-reads the document from that DOM and drops the
* node — the link survived on screen and vanished on the next reload. */
useEffect(() => {
if (!editor || editor.isDestroyed || !resolveLinkLabel) return;
const edits = [];
editor.state.doc.descendants((node, pos) => {
if (node.type.name !== "noteLink") return;
const label = resolveLinkLabel(node.attrs.noteId);
const missing = label === null;
const nextLabel = missing ? node.attrs.label : label;
if (nextLabel !== node.attrs.label || missing !== !!node.attrs.missing) {
edits.push([pos, { ...node.attrs, label: nextLabel, missing }]);
}
});
if (edits.length === 0) return;
const tr = editor.state.tr;
for (const [pos, attrs] of edits) tr.setNodeMarkup(pos, undefined, attrs);
tr.setMeta(LABEL_REFRESH, true);
tr.setMeta("addToHistory", false); // undo should not step through renames
editor.view.dispatch(tr);
}, [editor, resolveLinkLabel]);
const handleClick = useCallback(
(e) => {
const noteLink = e.target.closest("a.note-link");
if (noteLink) {
e.preventDefault();
const id = noteLink.getAttribute("data-note-link");
if (id && !noteLink.hasAttribute("data-missing")) {
onOpenNote?.(id, noteLink.getAttribute("data-kind") || "note");
}
return;
}
const external = e.target.closest("a[href]");
if (external) {
const url = external.getAttribute("href");
if (url && /^(https?|mailto):/i.test(url)) {
e.preventDefault();
window.open(url, "_blank", "noopener");
}
}
},
[onOpenNote]
);
return (
<div
className="note-editor"
data-theme={dark ? "dark" : "light"}
onClick={handleClick}
style={{ minHeight }}
>
<EditorContent editor={editor} />
</div>
);
}