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,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. */
|
||||
Reference in new issue
Block a user