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. */