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:
1 parent
d97591b242
commit
92d525b3ab
71 files changed
+14354
-1826
No files matched your search
@@ -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;
|
||||
Reference in new issue
Block a user