// @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. */ // An A4 sheet in landscape (Andy, 2026-10-08; portrait until then). export const BLANK_PAGE_WIDTH = 2970; export const BLANK_PAGE_HEIGHT = 2100; /** * 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; "Shrink" leaves the same room. 5 mm, Andy's choice (2026-10-08; * 8 mm from 2026-10-03, 15 mm before that). */ export const WRITING_MARGIN = 50; /** * An image is placed this far inside the sheet's edge, and below the * image above. 5 mm, Andy's choice (2026-10-08; 8 mm before); it is * deliberately a separate setting from WRITING_MARGIN. */ export const IMAGE_MARGIN = 50; /** * @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 {number | null} height With a width: the block is a *text * rectangle* (Andy's teacher, 2026-10-08), drawn with a red frame on * a white ground that hides the image beneath, wrapping inside and * growing downward by lines when the text needs more. Null for free * text, which is only as large as its lines. * @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.height != null && !(received.height >= 10 && received.height <= 10 * 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, height: received.height == null || received.width === null ? null : received.height, text: received.text, lines: received.lines.slice(), size: received.size, font: received.font, color: received.color, boxWidth: received.boxWidth, boxHeight: received.boxHeight, }; } /** * The rectangle a text block takes up: its own, grown to its lines, for a * text rectangle; the lines' box for free text. * @param {TextBlock} block * @returns {Rectangle} */ export function textBounds(block) { if (block.width !== null && block.height !== null) { return { left: block.x, top: block.y, width: block.width, height: Math.max(block.height, block.boxHeight) }; } return { left: block.x, top: block.y, width: block.boxWidth, height: block.boxHeight }; } /** * A *Hide* rectangle: white, drawn above all writing, hiding what lies * beneath (Andy's teacher, 2026-10-08). A page object like an image, * made, moved and removed by those who may make pages, unaffected by * anyone hiding a layer on their own screen. * @typedef {object} HideRect * @property {string} id * @property {number} x * @property {number} y * @property {number} width * @property {number} height */ /** * @param {any} received * @returns {HideRect | null} */ export function cleanHide(received) { if (!received || !validId(received.id) || ![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 { id: received.id, x: Math.round(received.x), y: Math.round(received.y), width: Math.round(received.width), height: Math.round(received.height), }; } /** A page holds at most this many Hide rectangles. */ export const MAX_PAGE_HIDES = 50; 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 {string} by The participant who added it ('' if unknown): * they may remove it even without the right to make pages (Andy, * 2026-10-08). * @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} left The sheet's left edge: 0 to begin with; less * once "Widen" has grown the sheet, which grows it equally on both * sides so that nothing on it moves; anything once "Shrink" has made * the sheet the contents' bounding box (Andy, 2026-10-08). * @property {number} top The sheet's top edge: 0 until "Shrink". * @property {number} width * @property {number} height * @property {Omit[]} images In stacking order, the last in front. * @property {HideRect[]} hides The Hide rectangles, in stacking order. * @property {number} revision Counts the changes made to `images`, `hides` * and the sheet, 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, by: validId(received.by) ? received.by : '', data: null, }; } /** * Where an image goes on the page, and the sheet that then holds it. * * Below the lowest image on the page, or at the page's top if there is * none, the margin apart, centred across the page's width, at its * natural size: one way for every image (Andy, 2026-10-08, dropping the * shrink-to-fit choice and composition mode, since an image can be moved * and scaled by hand). An image wider than the page widens the sheet * equally on both sides; one wider than a page can be is scaled to that. * @param {Page} page * @param {number} naturalWidth In page units. * @param {number} naturalHeight * @returns {{x: number, y: number, width: number, height: number, sheet: Rectangle}} * The image's rectangle, and the sheet that holds it and everything * the sheet held before. */ export function placeImage(page, naturalWidth, naturalHeight) { const scale = Math.min(1, (MAX_PAGE_SIZE - 2 * IMAGE_MARGIN) / naturalWidth); const width = Math.round(naturalWidth * scale); const height = Math.round(naturalHeight * scale); const x = page.left + Math.round((page.width - width) / 2); const below = page.images.length ? page.imagesBottom() : page.top; const y = Math.round(below) + IMAGE_MARGIN; return { x, y, width, height, sheet: sheetHolding(page, { left: x, top: y, width, height }) }; } /** * Which of some rectangles lies under a point, the last (frontmost) first, * and whether the point is within `reach` of one of its corners, which * counts before lying inside. * @param {Rectangle[]} rects * @param {number} x * @param {number} y * @param {number} reach * @returns {{index: number, corner: [number, number] | null} | null} * The corner as (0 or 1, 0 or 1): left or right, top or bottom. */ export function rectAt(rects, x, y, reach) { for (let index = rects.length - 1; index >= 0; index -= 1) { const r = rects[index]; for (const cx of [0, 1]) { for (const cy of [0, 1]) { if (Math.hypot(x - (r.left + cx * r.width), y - (r.top + cy * r.height)) <= reach) { return { index, corner: [cx, cy] }; } } } if (x >= r.left && x <= r.left + r.width && y >= r.top && y <= r.top + r.height) { return { index, corner: null }; } } return null; } /** * The sheet that holds what it holds now and the given rectangle with * the image margin around it. * @param {Page} page * @param {Rectangle} rect * @returns {Rectangle} */ export function sheetHolding(page, rect) { const left = Math.min(page.baseLeft, rect.left - IMAGE_MARGIN); const top = Math.min(page.baseTop, rect.top - IMAGE_MARGIN); const right = Math.max(page.baseLeft + page.baseWidth, rect.left + rect.width + IMAGE_MARGIN); const bottom = Math.max(page.baseTop + page.baseHeight, rect.top + rect.height + IMAGE_MARGIN); return { left, top, width: right - left, height: bottom - top }; } /** An image is never scaled below this, in page units (1 cm). */ export const LEAST_IMAGE_SIDE = 100; /** * 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)); } const listedHides = received.hides ?? []; if (!Array.isArray(listedHides) || listedHides.length > MAX_PAGE_HIDES) return null; const hides = []; for (const one of listedHides) { const hide = cleanHide(one); if (!hide) return null; hides.push(hide); } /** @param {any} value */ const edge = (value) => (Number.isFinite(value) && Math.abs(value) <= MAX_PAGE_SIZE ? Math.round(value) : 0); return { id: received.id, number: received.number, left: edge(received.left), top: edge(received.top), width: received.width, height: received.height, images, hides, 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.baseLeft = 0; this.baseTop = 0; this.baseWidth = width; this.baseHeight = height; this.id = id; this.number = number; /** @type {PageImage[]} From the top of the page down. */ this.images = []; /** @type {HideRect[]} In stacking order. */ this.hides = []; /** 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, left: this.baseLeft, top: this.baseTop, width: this.baseWidth, height: this.baseHeight, images: this.images.map(withoutData), hides: this.hides.map((hide) => ({ ...hide })), revision: this.imagesRevision, }; } /** * The Hide rectangle under a point, and whether the point is near one of * its corners, the frontmost first; as imageAt. * @param {number} x * @param {number} y * @param {number} reach * @returns {{index: number, corner: [number, number] | null} | null} */ hideAt(x, y, reach) { return rectAt(this.hides.map((hide) => ({ left: hide.x, top: hide.y, width: hide.width, height: hide.height })), x, y, reach); } /** * The image under a point, and whether the point is near one of its * corners: the frontmost (last) image whose rectangle holds the point * or has a corner within `reach` of it. * @param {number} x * @param {number} y * @param {number} reach Page units. * @returns {{index: number, corner: [number, number] | null} | null} * The corner as (0 or 1, 0 or 1): left or right, top or bottom. */ imageAt(x, y, reach) { return rectAt(this.images.map((image) => ({ left: image.x, top: image.y, width: image.width, height: image.height })), x, y, reach); } /** @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 {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: this.baseLeft, top: this.baseTop, width: this.baseWidth, height: this.baseHeight }; } /** * The smallest sheet that holds everything on the page with the margin * around it: what "Shrink" makes the sheet (Andy, 2026-10-08). * @returns {Rectangle | null} Null if the page is blank. */ contentSheet() { /** @type {Rectangle | null} */ let box = this.strokeBounds ? { ...this.strokeBounds } : null; for (const item of [...this.images, ...this.hides]) { if (!box) { box = { left: item.x, top: item.y, width: item.width, height: item.height }; continue; } const right = Math.max(box.left + box.width, item.x + item.width); const bottom = Math.max(box.top + box.height, item.y + item.height); box.left = Math.min(box.left, item.x); box.top = Math.min(box.top, item.y); box.width = right - box.left; box.height = bottom - box.top; } if (!box) return null; // The greater of the two margins (Andy, 2026-10-08), exactly, the same // on all four sides (a grid made them uneven, Andy, 2026-10-08). const margin = Math.max(WRITING_MARGIN, IMAGE_MARGIN); return { left: Math.floor(box.left - margin), top: Math.floor(box.top - margin), width: Math.ceil(box.left + box.width + margin) - Math.floor(box.left - margin), height: Math.ceil(box.top + box.height + margin) - Math.floor(box.top - margin), }; } /** * 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 bounds = textBounds(block); const corners = [bounds.left, bounds.top, 0, bounds.left + bounds.width, bounds.top + bounds.height, 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); } } /** * Whether a point of a stroke of the given nominal width lies where the * page would not grow to hold it: inside the edges by the margin and * the stroke's reach. With "Extend" off, writing stops there (Andy, * 2026-10-08), so that the page keeps its size on everyone's screen. * @param {number} x * @param {number} y * @param {number} width * @returns {boolean} */ holdsWriting(x, y, width) { const inset = WRITING_MARGIN + width * 2; // The reach of includeStroke. return x >= this.left + inset && x <= this.left + this.width - inset && y >= this.top + inset && y <= this.top + this.height - inset; } /** * 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) { // Writing that comes within the margin of a sheet's edge moves that // edge out in steps of the margin, counted from the sheet's edge // (not from a fixed grid, since 2026-10-08: a sheet that "Shrink" // made exact stays exact until writing reaches its margin). const step = WRITING_MARGIN; /** @param {number} short How far the writing's margin reaches past an edge. */ const steps = (short) => (short > 0 ? Math.ceil(short / step) * step : 0); left -= steps(left - (b.left - WRITING_MARGIN)); top -= steps(top - (b.top - WRITING_MARGIN)); right += steps(b.left + b.width + WRITING_MARGIN - right); bottom += steps(b.top + b.height + WRITING_MARGIN - bottom); } 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.baseLeft = descriptor.left; page.baseTop = descriptor.top; page.images = descriptor.images.map((image) => ({ ...image, data: null })); page.hides = descriptor.hides.map((hide) => ({ ...hide })); 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()) { const b = textBounds(block); for (let s = 0; s <= steps; s++) { const x = x0 + (x1 - x0) * s / steps; const y = y0 + (y1 - y0) * s / steps; if (x >= b.left - radius && x <= b.left + b.width + radius && y >= b.top - radius && y <= b.top + b.height + 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()) { const b = textBounds(block); // A text rectangle counts whole, empty part included. if (x >= b.left && x <= b.left + b.width && y >= b.top && y <= b.top + b.height) 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; }