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;