Files
InkNote/frontend/src/features/drawing/strokes.js
T
marcandClaude Opus 5 228552d453 Let a drawing run over as many pages as the note
The ink was stuck on page one while the writing carried on without it,
which stopped making sense the moment a note could run to four pages.
Every page is exported anyway, so every page can be drawn on.

Pages are numbered the way the PDF reader numbers them, and only pages
that carry ink are stored, so the keys are the drawn pages. While drawing
there is always one blank page past the end: pages otherwise come from
how far the writing runs, and to draw on page three you would first have
had to write three pages.

A drawing that lives entirely on page one is still written in the old
single-page shape. Nothing requires that — it means an ordinary note is
stored exactly as it always was, and a device running an older build
still shows it.

The bin clears the page being looked at. Undo and redo work on the
drawing as a whole, because one Undo button that means four different
things depending on where you scrolled is not a button anyone can use.

And a real bug found on the way out: the exporter has written
drawings/<note>.png since the interface was put into English, while the
importer was still looking for zeichnungen/<note>.png. A drawing exported
by this very app came back from its own archive as nothing at all. Both
names are read now, and so are the further pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:25:56 +02:00

193 lines
6.9 KiB
JavaScript
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 * 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;
/* A drawing spread over more than one page.
*
* A note used to have exactly one sheet to draw on, which stopped making sense
* once the writing could run to four pages: the ink was stuck on the first one
* while the text carried on without it. Pages are numbered the way the PDF
* reader numbers them, and each keeps its own strokes in the same canonical
* space — so page 3 of a note means the same thing on a phone as on a desktop.
*/
export const SHEETS_VERSION = 2;
// 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 } : {}),
});
/** The strokes on one canvas — page one, for a drawing that has several. */
export const docStrokes = (doc) => sheetStrokes(doc, 1);
/**
* A drawing's pages, keyed by 1-based page number.
*
* Reads either shape, so a note drawn before there were pages needs no
* migration: its single list of strokes is page one, which is where it was.
* Empty pages are dropped rather than stored, so "which pages are drawn on" is
* simply the keys.
*/
export function noteSheets(doc) {
if (!doc || typeof doc !== "object") return {};
const source =
doc.sheets && typeof doc.sheets === "object"
? doc.sheets
: Array.isArray(doc.strokes)
? { 1: doc.strokes }
: {};
const out = {};
for (const [key, strokes] of Object.entries(source)) {
const page = Number(key);
if (!Number.isInteger(page) || page < 1) continue;
if (!Array.isArray(strokes) || strokes.length === 0) continue;
out[String(page)] = strokes;
}
return out;
}
export const sheetStrokes = (doc, page) => noteSheets(doc)[String(page)] ?? [];
/** Page numbers carrying ink, in reading order. */
export const drawnSheets = (doc) =>
Object.keys(noteSheets(doc))
.map(Number)
.sort((a, b) => a - b);
/** Every stroke in the drawing — for "is there anything here at all". */
export const allStrokes = (doc) => {
const sheets = noteSheets(doc);
return drawnSheets(doc).flatMap((page) => sheets[String(page)]);
};
/**
* Builds a stroke document from pages.
*
* A drawing that lives entirely on page one is written in the old single-page
* shape. Nothing needs it to be — but it means an ordinary note's drawing is
* stored exactly as it always was, and a client that has not been updated yet
* still shows it.
*/
export function sheetDocOf(sheets, t) {
const clean = noteSheets({ sheets });
const pages = Object.keys(clean);
if (pages.length === 0 || (pages.length === 1 && pages[0] === "1")) {
return strokeDocOf(clean["1"] ?? [], t);
}
return { v: SHEETS_VERSION, w: CANON_W, h: CANON_H, sheets: clean, ...(t ? { t } : {}) };
}
/** The pages of `doc` with one page replaced — an empty page is removed. */
export function sheetsWith(doc, page, strokes) {
const sheets = { ...noteSheets(doc) };
if (strokes?.length) sheets[String(page)] = strokes;
else delete sheets[String(page)];
return sheets;
}
/* Compared page by page. Flattening first would call a stroke moved from page
* two to page three "unchanged", and the move would never be saved. */
export const sameStrokeDoc = (a, b) => {
if (a === b) return true;
const x = noteSheets(a),
y = noteSheets(b);
const keys = Object.keys(x);
if (keys.length !== Object.keys(y).length) return false;
return keys.every(
(k) => y[k] && x[k].length === y[k].length && x[k].every((s, i) => s === y[k][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 one page of a drawing into a context of targetW × targetH device
// pixels. Single-page drawings are page one, which is what every caller that
// never heard of pages asks for.
export function paintStrokeDoc(ctx, doc, targetW, targetH, page = 1) {
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 sheetStrokes(doc, page)) 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. */