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

+691
View File
@@ -0,0 +1,691 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNoteEditor, EditorSurface } from "../../editor/useNoteEditor.jsx";
import { EditorToolbar } from "../../editor/EditorToolbar.jsx";
import { DrawingLayer } from "../drawing/DrawingLayer.jsx";
import {
CANON_W,
CANON_H,
PEN_SIZES,
DEFAULT_PEN_SIZE,
HIGHLIGHTER_FACTOR,
ERASER_FACTOR,
MIN_POINT_DIST,
newSaveToken,
strokeDocOf,
docStrokes,
paintStroke,
paintStrokeDoc,
sizeCanvas,
} from "../drawing/strokes.js";
import { COLORS } from "../../ui/constants.jsx";
import { I } from "../../ui/icons.jsx";
import { BacklinksPanel } from "../../ui/BacklinksPanel.jsx";
/* ═══════ NOTE EDITOR ═══════ */
export function NoteEditor({
note,
onUpdate,
dark,
onOpenNote,
searchLinkTargets,
resolveLinkLabel,
backlinks,
}) {
const [mode, setMode] = useState("text");
const canvasRef = useRef(null);
const [tool, setTool] = useState("pen");
const [penColor, setPenColor] = useState(dark ? "#e6edf3" : "#1a1a2e");
const [penSizeIdx, setPenSizeIdx] = useState(DEFAULT_PEN_SIZE);
const [undoStack, setUndoStack] = useState([]);
const [redoStack, setRedoStack] = useState([]);
const [showColors, setShowColors] = useState(false);
const [showSizes, setShowSizes] = useState(false);
const [hideDrawing, setHideDrawing] = useState(false);
const [drawingBlend, setDrawingBlend] = useState({ mode: "normal", opacity: 1 });
const [isMobileViewport, setIsMobileViewport] = useState(window.innerWidth < 768);
const stageRef = useRef(null);
const saveTimerRef = useRef(null);
const dirtyRef = useRef(false);
const SAVE_DEBOUNCE_MS = 600;
const [stageSize, setStageSize] = useState({ w: CANON_W, h: CANON_H });
/* ── DRAWING STATE ──
* strokesRef is what the pointer handlers read and write synchronously;
* `strokes` mirrors it so React can repaint the read-only layer. */
const strokesRef = useRef([]);
const [strokes, setStrokes] = useState([]);
const liveRef = useRef(null); // stroke currently under the pointer
const drawingRef = useRef(false);
const committedRef = useRef(null); // offscreen canvas holding all finished strokes
const appliedDocRef = useRef(undefined);
const pendingTokenRef = useRef(null);
const hasStrokes = strokes.length > 0;
// Old notes may still carry a raster drawing. It stays visible underneath any
// new vector strokes so migrating a note never loses what was already drawn.
const legacyRaster = note.drawingData && !hideDrawing;
const commitStrokes = (next) => {
strokesRef.current = next;
setStrokes(next);
};
const T = useMemo(
() => ({
bg: dark ? "#0d1117" : "#ffffff",
toolbar: dark ? "#161b22" : "#f6f8fa",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
active: dark ? "#388bfd" : "#0969da",
faint: dark ? "#484f58" : "#afb8c1",
}),
[dark]
);
/* The note body. Structured TipTap document — tables, task lists and note
* links are real nodes, not HTML that happened to survive execCommand. */
const [editorNotice, setEditorNotice] = useState("");
const textEditor = useNoteEditor({
noteId: note.id,
content: note.content,
editable: mode === "text",
searchLinkTargets,
onChange: ({ content, links }) => onUpdate({ content, links }),
onError: (msg) => {
setEditorNotice(msg);
setTimeout(() => setEditorNotice(""), 5000);
},
});
useEffect(() => {
setHideDrawing(false);
const isLegacyRaster =
typeof note.drawingData === "string" && /^data:image\/(jpeg|jpg|webp)/i.test(note.drawingData);
if (!note.drawingData) {
setDrawingBlend({ mode: "normal", opacity: 1 });
return;
}
let cancelled = false;
const img = new Image();
img.onload = () => {
if (cancelled) return;
try {
const c = document.createElement("canvas");
c.width = 32;
c.height = 32;
const cx = c.getContext("2d", { willReadFrequently: true });
if (!cx) throw new Error("ctx");
cx.drawImage(img, 0, 0, 32, 32);
const data = cx.getImageData(0, 0, 32, 32).data;
let solid = 0,
black = 0;
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a < 245) continue;
solid++;
if (data[i] < 16 && data[i + 1] < 16 && data[i + 2] < 16) black++;
}
const blackRatio = solid ? black / solid : 0;
if (blackRatio > 0.9) setDrawingBlend({ mode: "screen", opacity: 0.62 });
else if (isLegacyRaster) setDrawingBlend({ mode: "screen", opacity: 0.42 });
else setDrawingBlend({ mode: "normal", opacity: 1 });
} catch {
setDrawingBlend(isLegacyRaster ? { mode: "screen", opacity: 0.42 } : { mode: "normal", opacity: 1 });
}
};
img.onerror = () => {
if (!cancelled) setHideDrawing(true);
};
img.src = note.drawingData;
return () => {
cancelled = true;
};
}, [note.id, note.drawingData]);
// Keep one fixed-aspect drawing stage regardless of device dimensions.
useEffect(() => {
const fitStage = () => {
const host = stageRef.current;
if (!host) return;
const W = Math.max(1, host.clientWidth);
const mobile = window.innerWidth < 768;
setIsMobileViewport(mobile);
const sidePad = mobile ? 10 : 28;
const desktopMax = 760;
const minW = mobile ? 220 : 320;
const maxAllowed = Math.max(220, W - sidePad * 2);
const targetW = mobile ? maxAllowed : Math.min(desktopMax, maxAllowed);
const w = Math.floor(Math.max(minW, targetW));
const h = Math.floor((w * CANON_H) / CANON_W);
setStageSize({ w, h });
};
fitStage();
if (typeof ResizeObserver !== "undefined") {
const ro = new ResizeObserver(fitStage);
if (stageRef.current) ro.observe(stageRef.current);
window.addEventListener("resize", fitStage);
return () => {
ro.disconnect();
window.removeEventListener("resize", fitStage);
};
}
window.addEventListener("resize", fitStage);
return () => window.removeEventListener("resize", fitStage);
}, []);
const saveDrawingNow = () => {
if (!dirtyRef.current) return;
const token = newSaveToken();
const doc = strokeDocOf(strokesRef.current, token);
appliedDocRef.current = doc; // ignore the echo coming straight back through props
pendingTokenRef.current = token; // ...and any server read still in flight
dirtyRef.current = false;
onUpdate({ drawingStrokes: doc });
};
const scheduleDrawingSave = () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null;
saveDrawingNow();
}, SAVE_DEBOUNCE_MS);
};
/* ── DRAWING: painting ── */
const renderFrame = () => {
const c = canvasRef.current;
if (!c) return;
const ctx = c.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, c.width, c.height);
if (committedRef.current) ctx.drawImage(committedRef.current, 0, 0);
const live = liveRef.current;
if (live) {
ctx.save();
ctx.scale(c.width / CANON_W, c.height / CANON_H);
ctx.lineCap = "round";
ctx.lineJoin = "round";
paintStroke(ctx, live);
ctx.restore();
}
};
// Repaint the offscreen canvas from scratch, then show it. Cheap enough at
// stroke boundaries, and it keeps eraser strokes ordered correctly.
const repaintAll = () => {
const c = canvasRef.current;
if (!c) return;
const { bw, bh } = sizeCanvas(c, stageSize.w, stageSize.h);
let off = committedRef.current;
if (!off) {
off = document.createElement("canvas");
committedRef.current = off;
}
if (off.width !== bw || off.height !== bh) {
off.width = bw;
off.height = bh;
}
paintStrokeDoc(off.getContext("2d"), strokeDocOf(strokesRef.current), bw, bh);
renderFrame();
};
/* Adopt strokes arriving from props (note switch, or a sync push from another
* device) without throwing away strokes we have not saved yet. */
useEffect(() => {
const doc = note.drawingStrokes || null;
if (appliedDocRef.current === doc) return;
appliedDocRef.current = doc;
if (dirtyRef.current) return; // strokes in hand that are not saved yet
if (pendingTokenRef.current) {
// A save is on its way to the server. Until it comes back, any doc we get
// is a read that started before it — adopting it would drop fresh strokes.
if (doc?.t !== pendingTokenRef.current) return;
pendingTokenRef.current = null; // our own save echoed back; in sync again
return;
}
commitStrokes(docStrokes(doc));
setUndoStack([]);
setRedoStack([]);
repaintAll();
}, [note.id, note.drawingStrokes]);
useEffect(() => {
if (mode === "draw") repaintAll();
}, [mode, stageSize.w, stageSize.h]);
/* ── DRAWING: input ── */
const canonPos = (e) => {
const r = canvasRef.current.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return { x: (t.clientX - r.left) * (CANON_W / r.width), y: (t.clientY - r.top) * (CANON_H / r.height) };
};
const currentStrokeWidth = () => {
const base = PEN_SIZES[penSizeIdx].w;
if (tool === "highlighter") return base * HIGHLIGHTER_FACTOR;
if (tool === "eraser") return base * ERASER_FACTOR;
return base;
};
const r1 = (n) => Math.round(n * 10) / 10;
const startDraw = (e) => {
if (mode !== "draw") return;
e.preventDefault();
const p = canonPos(e);
liveRef.current = { t: tool, c: penColor, s: r1(currentStrokeWidth()), p: [r1(p.x), r1(p.y)] };
drawingRef.current = true;
renderFrame();
};
const doDraw = (e) => {
if (!drawingRef.current) return;
e.preventDefault();
const p = canonPos(e),
live = liveRef.current,
n = live.p.length;
const dx = p.x - live.p[n - 2],
dy = p.y - live.p[n - 1];
if (dx * dx + dy * dy < MIN_POINT_DIST * MIN_POINT_DIST) return;
live.p.push(r1(p.x), r1(p.y));
renderFrame();
};
const stopDraw = (e) => {
if (!drawingRef.current) return;
e?.preventDefault();
drawingRef.current = false;
const live = liveRef.current;
liveRef.current = null;
if (!live) {
renderFrame();
return;
}
pushUndo();
commitStrokes([...strokesRef.current, live]);
dirtyRef.current = true;
repaintAll();
scheduleDrawingSave();
};
/* ── DRAWING: history ── */
const pushUndo = () => {
setUndoStack((p) => [...p.slice(-30), strokesRef.current]);
setRedoStack([]);
};
const applyHistory = (next) => {
commitStrokes(next);
dirtyRef.current = true;
repaintAll();
scheduleDrawingSave();
};
const undo = () => {
if (!undoStack.length) return;
const prev = undoStack[undoStack.length - 1];
setUndoStack((p) => p.slice(0, -1));
setRedoStack((p) => [...p, strokesRef.current]);
applyHistory(prev);
};
const redo = () => {
if (!redoStack.length) return;
const next = redoStack[redoStack.length - 1];
setRedoStack((p) => p.slice(0, -1));
setUndoStack((p) => [...p, strokesRef.current]);
applyHistory(next);
};
const clearCanvas = () => {
pushUndo();
applyHistory([]);
};
/* Save drawing on mode switch and debounced stroke-end updates. */
const switchMode = (m) => {
if (mode === "draw" && m !== "draw" && dirtyRef.current) {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
saveDrawingNow();
}
setMode(m);
setShowColors(false);
setShowSizes(false);
};
useEffect(
() => () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveDrawingNow();
},
[]
);
/* ── TEXT ──
* Image paste, drag-to-resize and the execCommand toolbar used to live
* here. TipTap owns the document now, so formatting goes through editor
* commands rather than a deprecated browser API. */
/* ── RENDER ── */
const tb = (a) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
height: 30,
borderRadius: 6,
border: "none",
cursor: "pointer",
background: a ? T.active + "22" : "transparent",
color: a ? T.active : T.text,
transition: "all .12s",
flexShrink: 0,
});
const mb = (m, ic, lb) => (
<button
onClick={() => switchMode(m)}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
borderRadius: 8,
fontSize: 12,
fontWeight: 600,
border: `1.5px solid ${mode === m ? T.active : T.border}`,
background: mode === m ? T.active + "15" : "transparent",
color: mode === m ? T.active : T.muted,
cursor: "pointer",
flexShrink: 0,
}}
>
{ic}
<span>{lb}</span>
</button>
);
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
background: isMobileViewport
? T.bg
: dark
? "linear-gradient(180deg,#0f141b 0%,#0d1117 60%)"
: "linear-gradient(180deg,#f8fafd 0%,#eef3f8 70%)",
}}
>
{/* Main toolbar */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "7px 14px",
borderBottom: `1px solid ${T.border}`,
background: T.toolbar,
flexWrap: "wrap",
minHeight: 46,
}}
>
{mb("text", I.text, "Write")}
{mb("draw", I.draw, "Draw")}
<div style={{ width: 1, height: 22, background: T.border, margin: "0 2px", flexShrink: 0 }} />
{mode === "text" ? (
<EditorToolbar
editor={textEditor}
T={{ ...T, danger: "#e5534b" }}
icons={I}
compact={isMobileViewport}
/>
) : (
<>
<button style={tb(tool === "pen")} onClick={() => setTool("pen")}>
{I.pen}
</button>
<button style={tb(tool === "highlighter")} onClick={() => setTool("highlighter")}>
{I.highlight}
</button>
<button style={tb(tool === "eraser")} onClick={() => setTool("eraser")}>
{I.eraser}
</button>
<div style={{ width: 1, height: 22, background: T.border, margin: "0 2px" }} />
<button style={tb()} onClick={undo}>
{I.undo}
</button>
<button style={tb()} onClick={redo}>
{I.redo}
</button>
<div style={{ width: 1, height: 22, background: T.border, margin: "0 2px" }} />
<div style={{ position: "relative" }}>
<button
style={tb(showColors)}
onClick={() => {
setShowColors(!showColors);
setShowSizes(false);
}}
>
<div
style={{
width: 18,
height: 18,
borderRadius: "50%",
background: penColor,
border: `2px solid ${T.border}`,
}}
/>
</button>
{showColors && (
<div
style={{
position: "absolute",
top: 36,
left: 0,
background: T.toolbar,
border: `1px solid ${T.border}`,
borderRadius: 12,
padding: 8,
display: "grid",
gridTemplateColumns: "repeat(5,1fr)",
gap: 4,
zIndex: 100,
boxShadow: "0 8px 20px rgba(0,0,0,.2)",
}}
>
{COLORS.map((c) => (
<button
key={c}
onClick={() => {
setPenColor(c);
setShowColors(false);
}}
style={{
width: 26,
height: 26,
borderRadius: "50%",
background: c,
border: penColor === c ? `3px solid ${T.active}` : `1px solid ${T.border}`,
cursor: "pointer",
}}
/>
))}
</div>
)}
</div>
<div style={{ position: "relative" }}>
<button
style={tb(showSizes)}
onClick={() => {
setShowSizes(!showSizes);
setShowColors(false);
}}
>
<div
style={{
width: Math.min(4 + penSizeIdx * 2, 16),
height: Math.min(4 + penSizeIdx * 2, 16),
borderRadius: "50%",
background: T.text,
}}
/>
</button>
{showSizes && (
<div
style={{
position: "absolute",
top: 36,
left: 0,
background: T.toolbar,
border: `1px solid ${T.border}`,
borderRadius: 12,
padding: 8,
display: "flex",
flexDirection: "column",
gap: 4,
zIndex: 100,
boxShadow: "0 8px 20px rgba(0,0,0,.2)",
}}
>
{PEN_SIZES.map((s, i) => (
<button
key={s.label}
onClick={() => {
setPenSizeIdx(i);
setShowSizes(false);
}}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "3px 8px",
borderRadius: 6,
border: "none",
cursor: "pointer",
background: penSizeIdx === i ? T.active + "22" : "transparent",
}}
>
<div
style={{
width: Math.min(4 + i * 3, 22),
height: Math.min(4 + i * 3, 22),
borderRadius: "50%",
background: T.text,
flexShrink: 0,
}}
/>
<span style={{ fontSize: 11, color: T.muted }}>{s.label}</span>
</button>
))}
</div>
)}
</div>
<div style={{ flex: 1 }} />
<button style={{ ...tb(), color: "#e5534b" }} onClick={clearCanvas}>
{I.trash}
</button>
</>
)}
</div>
{editorNotice && (
<div
style={{
padding: "7px 14px",
background: "#e5534b18",
color: "#e5534b",
fontSize: 12,
borderBottom: `1px solid ${T.border}`,
}}
>
{editorNotice}
</div>
)}
{/* Content area */}
<div ref={stageRef} style={{ flex: 1, position: "relative", overflow: "hidden" }}>
<div
style={{
position: "absolute",
inset: 0,
overflowY: "auto",
WebkitOverflowScrolling: "touch",
zIndex: 1,
padding: isMobileViewport ? "10px 0 18px" : "18px 0 28px",
}}
>
<div
style={{
position: "relative",
width: stageSize.w,
minHeight: stageSize.h,
margin: isMobileViewport ? "0 auto" : "0 0 0 28px",
background: T.bg,
border: isMobileViewport ? "none" : `1px solid ${T.border}`,
borderRadius: isMobileViewport ? 0 : 14,
boxShadow: isMobileViewport ? "none" : "0 16px 36px rgba(0,0,0,0.14)",
}}
>
<div
style={{
minHeight: stageSize.h,
width: "100%",
pointerEvents: mode === "text" ? "auto" : "none",
}}
>
<EditorSurface
editor={textEditor}
dark={dark}
minHeight={stageSize.h}
onOpenNote={onOpenNote}
resolveLinkLabel={resolveLinkLabel}
/>
</div>
{/* Legacy raster drawing from before vector strokes — kept underneath so nothing is lost */}
{legacyRaster && (
<img
src={note.drawingData}
alt=""
onError={() => setHideDrawing(true)}
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: stageSize.h,
pointerEvents: "none",
objectFit: "fill",
zIndex: 2,
opacity: drawingBlend.opacity,
mixBlendMode: drawingBlend.mode,
}}
/>
)}
{/* Saved drawing — re-rendered from vectors, so identical on every screen */}
{hasStrokes && mode !== "draw" && (
<DrawingLayer strokes={strokes} width={stageSize.w} height={stageSize.h} zIndex={2} />
)}
{mode === "draw" && (
<canvas
ref={canvasRef}
onMouseDown={startDraw}
onMouseMove={doDraw}
onMouseUp={stopDraw}
onMouseLeave={stopDraw}
onTouchStart={startDraw}
onTouchMove={doDraw}
onTouchEnd={stopDraw}
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: stageSize.h,
touchAction: "none",
cursor: "crosshair",
background: "transparent",
zIndex: 3,
}}
/>
)}
</div>
</div>
</div>
{mode === "text" && <BacklinksPanel backlinks={backlinks} onOpenNote={onOpenNote} dark={dark} />}
</div>
);
}