// @ts-check /** * The page: a stack of layers, one per participant, each holding that * participant's strokes. Coordinates are in page units of 0.1 mm. * * This module holds data only. It does not draw and does not talk to the * network. */ /** A blank page is A4. */ export const BLANK_PAGE_WIDTH = 2100; export const BLANK_PAGE_HEIGHT = 2970; /** * The page grows to keep this much room around everything written on it, * in steps of the same amount so that it does not shift with every pen * point. 8 mm, Andy's choice (2026-10-03; 15 mm before). */ export const WRITING_MARGIN = 80; /** * An image is placed this far inside the sheet's edge. 8 mm, Andy's * choice; it is deliberately a separate setting from WRITING_MARGIN. */ export const IMAGE_MARGIN = 80; /** * @typedef {object} Stroke * @property {string} id * @property {string} color CSS color. * @property {number} width Nominal width in page units. * @property {number[]} points x, y, pressure for each point, in sequence. * Pressure is 0 to 1. * @property {boolean} done False while the stroke is being drawn. */ /** Numbers per point in Stroke.points. */ export const POINT_SIZE = 3; /** * Typed text on a layer. Its top-left corner is (x, y); with a width it * wraps there, without one it breaks lines only at the writer's Enter. * @typedef {object} TextBlock * @property {string} id * @property {number} x * @property {number} y * @property {number | null} width Page units, or null for no wrapping. * @property {string} text As typed, with the writer's line breaks. * @property {string[]} lines As shown, laid out by the writer's client. * @property {number} size Font size in page units. * @property {TextFont} font One of the families every computer has. * @property {string} color * @property {number} boxWidth The rectangle the lines take up. * @property {number} boxHeight */ /** * The font families a block may use: the generic ones, so that the page * file looks right on any computer. * @typedef {'sans-serif' | 'serif' | 'monospace'} TextFont */ export const TEXT_FONTS = /** @type {const} */ (['sans-serif', 'serif', 'monospace']); /** Page units (0.1 mm) in a typographic point. */ export const UNITS_PER_POINT = 254 / 72; /** Text sizes in points that may be chosen. */ export const LEAST_TEXT_POINTS = 6; export const MOST_TEXT_POINTS = 100; /** The size to begin with: 14 points (Andy, 2026-10-03). */ export const TEXT_SIZE = Math.round(14 * UNITS_PER_POINT * 10) / 10; const MAX_TEXT_CHARACTERS = 20000; const MAX_TEXT_LINES = 2000; /** * @param {Stroke} stroke * @returns {number} */ export function pointCount(stroke) { return stroke.points.length / POINT_SIZE; } /** * Add points to a stroke. `at` is the number of points that preceded these * when they were sent, so that points received twice are added only once. * @param {Stroke} stroke * @param {number} at * @param {number[]} points */ export function appendPoints(stroke, at, points) { const overlap = Math.max(0, pointCount(stroke) - at); for (let i = overlap * POINT_SIZE; i < points.length; i++) stroke.points.push(points[i]); } /** * Round a coordinate or a pressure for storage and sending. * @param {number} value * @param {number} scale 10 for coordinates (0.01 mm), 1000 for pressure. */ export function rounded(value, scale) { return Math.round(value * scale) / scale; } // Checking data that comes from outside: other participants and page files. export const MAX_NAME_LENGTH = 40; const MAX_ID_LENGTH = 40; const MAX_STROKE_WIDTH = 200; const COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; /** A longer stroke is split, so that one stroke always fits in a message. */ export const MAX_STROKE_POINTS = 20000; /** * @param {unknown} name * @returns {string} */ export function cleanName(name) { return typeof name === 'string' ? name.trim().slice(0, MAX_NAME_LENGTH) : ''; } /** * @param {unknown} id * @returns {id is string} */ export function validId(id) { return typeof id === 'string' && id.length > 0 && id.length <= MAX_ID_LENGTH; } /** * @param {unknown} points * @returns {points is number[]} */ export function validPoints(points) { return Array.isArray(points) && points.length % POINT_SIZE === 0 && points.length <= MAX_STROKE_POINTS * POINT_SIZE && points.every(Number.isFinite); } /** * Check a stroke that came from outside. * @param {any} received * @returns {Stroke | null} A stroke that is safe to keep, or null. */ export function cleanStroke(received) { if (!received || !validId(received.id) || !COLOR_PATTERN.test(received.color) || !(received.width > 0 && received.width <= MAX_STROKE_WIDTH)) return null; const points = received.points ?? []; if (!validPoints(points)) return null; return { id: received.id, color: received.color, width: received.width, points: points.slice(), done: received.done !== false, }; } /** * Check a text block that came from outside. * @param {any} received * @returns {TextBlock | null} */ export function cleanText(received) { if (!received || !validId(received.id) || !COLOR_PATTERN.test(received.color) || typeof received.text !== 'string' || received.text.length > MAX_TEXT_CHARACTERS || !Array.isArray(received.lines) || received.lines.length > MAX_TEXT_LINES || !received.lines.every((/** @type {unknown} */ line) => typeof line === 'string') || ![received.x, received.y].every((v) => Number.isFinite(v) && Math.abs(v) <= 10 * MAX_PAGE_SIZE) || (received.width !== null && !(received.width >= 10 && received.width <= MAX_PAGE_SIZE)) || !(received.size >= 10 && received.size <= 400) || !TEXT_FONTS.includes(received.font) || !(received.boxWidth >= 0 && received.boxWidth <= MAX_PAGE_SIZE) || !(received.boxHeight >= 0 && received.boxHeight <= 10 * MAX_PAGE_SIZE)) return null; return { id: received.id, x: received.x, y: received.y, width: received.width, text: received.text, lines: received.lines.slice(), size: received.size, font: received.font, color: received.color, boxWidth: received.boxWidth, boxHeight: received.boxHeight, }; } export class Layer { /** * @param {string} id The participant's id. * @param {string} name The participant's display name. */ constructor(id, name) { this.id = id; this.name = name; /** @type {Map} In drawing sequence. */ this.strokes = new Map(); /** @type {Map} In the sequence of making. */ this.texts = new Map(); } /** @returns {boolean} */ isEmpty() { return this.strokes.size === 0 && this.texts.size === 0; } } /** * An image on a page, usually a scanned exercise, and where it lies on the * page. A page may hold several, one below the other. * @typedef {object} PageImage * @property {'image/png' | 'image/jpeg'} type * @property {number} size Bytes. * @property {string} hash SHA-256 of the bytes, base64url. * @property {number} x Its rectangle on the page, in page units. * @property {number} y * @property {number} width * @property {number} height * @property {Uint8Array | null} data Null until the bytes * have arrived from another participant. */ /** * What a descriptor says about an image: everything but the bytes. * @param {PageImage} image * @returns {Omit} */ function withoutData(image) { const { data, ...rest } = image; return rest; } /** * What identifies a page and gives its size. The host sends it to the * others when the page changes. * @typedef {object} PageDescriptor * @property {string} id * @property {number} number Counts the pages of a session from 1. * @property {number} width * @property {number} height * @property {Omit[]} images From the top of the page down. * @property {number} revision Counts the changes made to `images`, so * that of two lists the later one can be told. */ /** * A rectangle in page units. * @typedef {{left: number, top: number, width: number, height: number}} Rectangle */ const MAX_PAGE_SIZE = 20000; /** An image larger than this is made smaller before it is shared. */ export const MAX_IMAGE_BYTES = 4 << 20; /** * A page holds at most this many images, and this many bytes of them, * because the page file holds them all and is rewritten at every save. */ export const MAX_PAGE_IMAGES = 12; export const MAX_PAGE_IMAGE_BYTES = 24 << 20; /** * Check the description of a page's image that came from outside. * @param {any} received * @returns {PageImage | null} */ export function cleanImage(received) { if (!received || (received.type !== 'image/png' && received.type !== 'image/jpeg') || !Number.isInteger(received.size) || received.size < 1 || received.size > MAX_IMAGE_BYTES || typeof received.hash !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(received.hash) || ![received.x, received.y].every((v) => Number.isFinite(v) && Math.abs(v) <= MAX_PAGE_SIZE) || ![received.width, received.height].every((v) => v >= 1 && v <= MAX_PAGE_SIZE)) return null; return { type: received.type, size: received.size, hash: received.hash, x: received.x, y: received.y, width: received.width, height: received.height, data: null, }; } /** * Where an image of a given natural size goes on a page: centred across * the A4 sheet, inside the margin (Andy, 2026-10-03: his teacher's scans * are often partial and wider than they are tall). The first image lies * at the top of the sheet; each later one lies below the lowest image * already there, the margin apart, so that a page can be made of a series * of images (Andy, 2026-10-05). * * An image wider than the sheet's width inside the margin is scaled down * to it, or, if `shrink` is false, keeps its natural size: it then begins * at the left margin and the sheet is widened to hold it (Andy, * 2026-10-05, as a choice to try beside the scaling). * @param {number} naturalWidth In page units. * @param {number} naturalHeight * @param {number} [below] The bottom edge of the lowest image already on * the page; 0 if there is none. * @param {boolean} [shrink] * @returns {{x: number, y: number, width: number, height: number, * pageWidth: number, pageHeight: number}} * The image's rectangle, and how large the sheet must be to hold it. */ export function placeImage(naturalWidth, naturalHeight, below = 0, shrink = true) { const room = BLANK_PAGE_WIDTH - 2 * IMAGE_MARGIN; // At its natural size an image may still be no wider than a page can be. const scale = Math.min(1, (shrink ? room : MAX_PAGE_SIZE - 2 * IMAGE_MARGIN) / naturalWidth); const width = Math.round(naturalWidth * scale); const height = Math.round(naturalHeight * scale); const x = width <= room ? Math.round((BLANK_PAGE_WIDTH - width) / 2) : IMAGE_MARGIN; const y = Math.round(below) + IMAGE_MARGIN; return { x, y, width, height, pageWidth: Math.max(BLANK_PAGE_WIDTH, x + width + IMAGE_MARGIN), pageHeight: Math.max(BLANK_PAGE_HEIGHT, y + height + IMAGE_MARGIN), }; } /** * Check a page descriptor that came from outside. * @param {any} received * @returns {PageDescriptor | null} */ export function cleanPageDescriptor(received) { if (!received || !validId(received.id) || !Number.isInteger(received.number) || received.number < 1 || !(received.width >= 100 && received.width <= MAX_PAGE_SIZE) || !(received.height >= 100 && received.height <= MAX_PAGE_SIZE)) return null; const listed = received.images ?? []; if (!Array.isArray(listed) || listed.length > MAX_PAGE_IMAGES) return null; const images = []; for (const one of listed) { const image = cleanImage(one); if (!image) return null; images.push(withoutData(image)); } return { id: received.id, number: received.number, width: received.width, height: received.height, images, revision: Number.isInteger(received.revision) && received.revision >= 0 ? received.revision : 0, }; } export class Page { /** * @param {number} [width] * @param {number} [height] * @param {string} [id] Empty for the placeholder a guest shows * before the host's page arrives. * @param {number} [number] 0 for the placeholder. */ constructor(width = BLANK_PAGE_WIDTH, height = BLANK_PAGE_HEIGHT, id = '', number = 0) { /** * The size of the sheet before anything is written on it: A4, or taller * if its images need the room. */ this.baseWidth = width; this.baseHeight = height; this.id = id; this.number = number; /** @type {PageImage[]} From the top of the page down. */ this.images = []; /** Counts the changes made to `images`. */ this.imagesRevision = 0; /** The page's present rectangle. See updateExtent(). */ this.left = 0; this.top = 0; this.width = width; this.height = height; /** @type {Rectangle | null} Around every stroke, or null if none. */ this.strokeBounds = null; /** @type {Map} */ this.layers = new Map(); } /** @returns {PageDescriptor} */ descriptor() { return { id: this.id, number: this.number, width: this.baseWidth, height: this.baseHeight, images: this.images.map(withoutData), revision: this.imagesRevision, }; } /** @returns {number} The bottom edge of the lowest image; 0 if there is none. */ imagesBottom() { return Math.max(0, ...this.images.map((image) => image.y + image.height)); } /** @returns {number} How tall the sheet must be to hold its images: A4 at least. */ sheetHeight() { return Math.max(BLANK_PAGE_HEIGHT, this.imagesBottom() + IMAGE_MARGIN); } /** @returns {number} How wide the sheet must be to hold its images: A4 at least. */ sheetWidth() { return Math.max(BLANK_PAGE_WIDTH, ...this.images.map((image) => image.x + image.width + IMAGE_MARGIN)); } /** @returns {boolean} Whether its images have made the sheet larger than A4. */ isLargerThanSheet() { return this.baseWidth > BLANK_PAGE_WIDTH || this.baseHeight > BLANK_PAGE_HEIGHT; } /** * Whether one more image may be added, given what the page holds. * @param {number} size Bytes of the image. * @param {number} bottom Where its bottom edge would lie. * @returns {boolean} */ hasRoomForImage(size, bottom) { const bytes = this.images.reduce((sum, image) => sum + image.size, 0); return this.images.length < MAX_PAGE_IMAGES && bytes + size <= MAX_PAGE_IMAGE_BYTES && bottom + IMAGE_MARGIN <= MAX_PAGE_SIZE; } /** * The rectangle the page has before anything is written on it: the * sheet, which holds the images if there are any. * @returns {Rectangle} */ baseExtent() { return { left: 0, top: 0, width: this.baseWidth, height: this.baseHeight }; } /** * Widen the record of where strokes lie to include these points. * @param {Stroke} stroke * @param {number} [from] Index of the first point not yet included. */ includeStroke(stroke, from = 0) { const p = stroke.points; const reach = stroke.width * 2; // The drawn width at full pressure, and a little more. let b = this.strokeBounds; for (let i = from * POINT_SIZE; i < p.length; i += POINT_SIZE) { const x = p[i]; const y = p[i + 1]; if (!b) { b = this.strokeBounds = { left: x - reach, top: y - reach, width: 2 * reach, height: 2 * reach }; continue; } const right = Math.max(b.left + b.width, x + reach); const bottom = Math.max(b.top + b.height, y + reach); b.left = Math.min(b.left, x - reach); b.top = Math.min(b.top, y - reach); b.width = right - b.left; b.height = bottom - b.top; } } /** * Widen the record of where things lie to include a text block. * @param {TextBlock} block */ includeText(block) { const corners = [block.x, block.y, 0, block.x + block.boxWidth, block.y + block.boxHeight, 0]; this.includeStroke({ id: '', color: '', width: 0, points: corners, done: true }); } /** Find where strokes and text lie again, after some were removed or replaced. */ recomputeStrokeBounds() { this.strokeBounds = null; for (const layer of this.layers.values()) { for (const stroke of layer.strokes.values()) this.includeStroke(stroke); for (const block of layer.texts.values()) this.includeText(block); } } /** * Set the page's rectangle from what is on it: the base rectangle, * widened to hold every stroke with the margin around it, in steps of * the margin. The same contents give the same rectangle on every * participant's computer. * @returns {boolean} Whether the rectangle changed. */ updateExtent() { const base = this.baseExtent(); let { left, top } = base; let right = base.left + base.width; let bottom = base.top + base.height; const b = this.strokeBounds; if (b) { const step = WRITING_MARGIN; left = Math.min(left, Math.floor((b.left - WRITING_MARGIN) / step) * step); top = Math.min(top, Math.floor((b.top - WRITING_MARGIN) / step) * step); right = Math.max(right, Math.ceil((b.left + b.width + WRITING_MARGIN) / step) * step); bottom = Math.max(bottom, Math.ceil((b.top + b.height + WRITING_MARGIN) / step) * step); } const changed = left !== this.left || top !== this.top || right - left !== this.width || bottom - top !== this.height; this.left = left; this.top = top; this.width = right - left; this.height = bottom - top; return changed; } /** * An empty page from its descriptor. * @param {PageDescriptor} descriptor * @returns {Page} */ static from(descriptor) { const page = new Page(descriptor.width, descriptor.height, descriptor.id, descriptor.number); page.images = descriptor.images.map((image) => ({ ...image, data: null })); page.imagesRevision = descriptor.revision; page.updateExtent(); return page; } /** @returns {boolean} Nothing is written on the page. */ isBlank() { for (const layer of this.layers.values()) { if (!layer.isEmpty()) return false; } return true; } /** * Layer ids from bottom to top. The host's layer is on top so that * corrections lie over the writing they correct. * @param {string} hostId * @returns {string[]} */ layerOrder(hostId) { return [...this.layers.keys()].sort((a, b) => Number(a === hostId) - Number(b === hostId) || a.localeCompare(b)); } /** * The layer of a participant, created if it does not exist. * @param {string} id * @param {string} [name] * @returns {Layer} */ layer(id, name) { let layer = this.layers.get(id); if (!layer) { layer = new Layer(id, name ?? ''); this.layers.set(id, layer); } else if (name) { layer.name = name; } return layer; } } /** * Squared distance from point (px, py) to the segment (ax, ay)-(bx, by). * @param {number} px * @param {number} py * @param {number} ax * @param {number} ay * @param {number} bx * @param {number} by */ function distanceSquaredToSegment(px, py, ax, ay, bx, by) { const dx = bx - ax; const dy = by - ay; const lengthSquared = dx * dx + dy * dy; let t = lengthSquared === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / lengthSquared; t = Math.max(0, Math.min(1, t)); const cx = ax + t * dx - px; const cy = ay + t * dy - py; return cx * cx + cy * cy; } /** * The text blocks of a layer whose rectangle the segment from (x0, y0) to * (x1, y1) passes through, widened by `radius`. * @param {Layer} layer * @param {number} x0 * @param {number} y0 * @param {number} x1 * @param {number} y1 * @param {number} radius * @returns {string[]} Text block ids. */ export function textsTouched(layer, x0, y0, x1, y1, radius) { const touched = []; const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / radius)); for (const block of layer.texts.values()) { for (let s = 0; s <= steps; s++) { const x = x0 + (x1 - x0) * s / steps; const y = y0 + (y1 - y0) * s / steps; if (x >= block.x - radius && x <= block.x + block.boxWidth + radius && y >= block.y - radius && y <= block.y + block.boxHeight + radius) { touched.push(block.id); break; } } } return touched; } /** * @param {Layer} layer * @param {number} x * @param {number} y * @returns {TextBlock | null} The block of the layer at the point, if any. */ export function textAt(layer, x, y) { for (const block of [...layer.texts.values()].reverse()) { if (x >= block.x && x <= block.x + block.boxWidth && y >= block.y && y <= block.y + block.boxHeight) { return block; } } return null; } /** * The strokes of a layer that pass within `radius` of the segment from * (x0, y0) to (x1, y1): what an eraser moved along that segment touches. * @param {Layer} layer * @param {number} x0 * @param {number} y0 * @param {number} x1 * @param {number} y1 * @param {number} radius * @returns {string[]} Stroke ids. */ export function strokesTouched(layer, x0, y0, x1, y1, radius) { const touched = []; const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / radius)); for (const stroke of layer.strokes.values()) { const points = stroke.points; const reach = radius + stroke.width; const reachSquared = reach * reach; let hit = false; for (let s = 0; s <= steps && !hit; s++) { const ex = x0 + (x1 - x0) * s / steps; const ey = y0 + (y1 - y0) * s / steps; for (let i = 0; i < points.length && !hit; i += POINT_SIZE) { const j = Math.min(i + POINT_SIZE, points.length - POINT_SIZE); hit = distanceSquaredToSegment( ex, ey, points[i], points[i + 1], points[j], points[j + 1]) <= reachSquared; } } if (hit) touched.push(stroke.id); } return touched; }