Files
InkNote/frontend/src/editor/EditorToolbar.jsx
T
marcandClaude Opus 5 92d525b3ab 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>
2026-08-07 17:00:14 +02:00

343 lines
10 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;