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

+105
View File
@@ -0,0 +1,105 @@
import * as sync from "../../syncEngine";
/* ═══════ DRAWING MODEL ═══════
* A drawing is a list of vector strokes in one canonical page space (CANON_W ×
* CANON_H), never a bitmap. Every screen scales that same page to whatever room
* it has, so a note looks identical on a phone and on a desktop — and stays
* sharp at any pixel density, because we rasterize at render time.
*/
export const CANON_W = 1200,
CANON_H = 1600;
export const PREVIEW_DRAWING_W = 260; // history preview thumbnail
export const STROKES_VERSION = 1;
// Widths are canonical units, so a "Medium" pen covers the same fraction of the
// page everywhere. At the 760px desktop page width, M lands at roughly 3.5 CSS px.
export const PEN_SIZES = [
{ label: "XS", w: 2 },
{ label: "S", w: 3.5 },
{ label: "M", w: 5.5 },
{ label: "L", w: 9 },
{ label: "XL", w: 14 },
{ label: "2XL", w: 22 },
{ label: "3XL", w: 34 },
];
export const DEFAULT_PEN_SIZE = 2; // index into PEN_SIZES → "M"
export const HIGHLIGHTER_FACTOR = 5;
export const ERASER_FACTOR = 4;
export const HIGHLIGHTER_ALPHA = "55";
// Minimum canonical distance between recorded points — keeps payloads small
// without visibly changing the curve.
export const MIN_POINT_DIST = 2.5;
// `t` tags a save so the editor can recognise its own drawing coming back from
// the server and ignore reads that were already in flight when it saved.
let saveSeq = 0;
export const newSaveToken = () => `${sync.DEVICE_ID}:${Date.now()}:${++saveSeq}`;
export const strokeDocOf = (strokes, t) => ({
v: STROKES_VERSION,
w: CANON_W,
h: CANON_H,
strokes,
...(t ? { t } : {}),
});
export const docStrokes = (doc) => (Array.isArray(doc?.strokes) ? doc.strokes : []);
export const sameStrokeDoc = (a, b) =>
a === b ||
(docStrokes(a).length === docStrokes(b).length && docStrokes(a).every((s, i) => s === docStrokes(b)[i]));
export function paintStroke(ctx, s) {
const p = s?.p;
if (!p || p.length < 2) return;
const eraser = s.t === "eraser";
ctx.globalCompositeOperation = eraser ? "destination-out" : "source-over";
const color = s.t === "highlighter" ? s.c + HIGHLIGHTER_ALPHA : s.c;
ctx.strokeStyle = color;
ctx.lineWidth = s.s;
if (p.length === 2) {
// a tap, not a drag — draw the nib itself
ctx.beginPath();
ctx.arc(p[0], p[1], Math.max(s.s / 2, 0.4), 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.globalCompositeOperation = "source-over";
return;
}
// One path per stroke: a translucent highlighter stays evenly translucent
// instead of darkening at every joint.
ctx.beginPath();
ctx.moveTo(p[0], p[1]);
for (let i = 2; i < p.length - 2; i += 2) {
ctx.quadraticCurveTo(p[i], p[i + 1], (p[i] + p[i + 2]) / 2, (p[i + 1] + p[i + 3]) / 2);
}
ctx.lineTo(p[p.length - 2], p[p.length - 1]);
ctx.stroke();
ctx.globalCompositeOperation = "source-over";
}
// Replays a whole drawing into a context of targetW × targetH device pixels.
export function paintStrokeDoc(ctx, doc, targetW, targetH) {
const w = doc?.w || CANON_W,
h = doc?.h || CANON_H;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, targetW, targetH);
ctx.scale(targetW / w, targetH / h);
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const s of docStrokes(doc)) paintStroke(ctx, s);
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
export const backingScale = () => Math.min(window.devicePixelRatio || 1, 3);
export function sizeCanvas(c, cssW, cssH) {
const dpr = backingScale();
const bw = Math.max(1, Math.round(cssW * dpr)),
bh = Math.max(1, Math.round(cssH * dpr));
if (c.width !== bw || c.height !== bh) {
c.width = bw;
c.height = bh;
}
return { bw, bh };
}
/* Read-only drawing layer — used wherever a saved drawing is shown but not edited. */