Files
kokkoja-rel/client/js/page_model.js
T
ackandClaude Fable 5.1 60ad747e32 Everyone has the same buttons; the view is personal
The host's rights and the co-host are gone: anyone in a session may
start a new page, put an image on the page, load a kept page, hide,
expand and shrink, and move or remove images and Hides; the page left
is kept in every notebook.  Fit and Normal scale act on your own screen
only.  A session goes on without whoever started it, and a newcomer to
an empty room starts page 1 with New.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 20:22:13 +02:00

908 lines
33 KiB
JavaScript

// @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.
* @property {boolean} filled The points are the outline of a closed
* polygon filled with the colour, rather than a line (the Shape
* tool's arrow, 2026-10-14); the width then matters only to the
* eraser's and the page's reckoning of the stroke's reach.
* @property {string | null} fill A colour the closed polygon of the
* points is filled with beneath the line (the Shape tool's fill
* colour, Andy, 2026-10-14), or null.
* @property {ShapeKind | null} shape Which shape of the Shape tool the
* stroke is, so that Arrange can move and resize it, or null for a
* stroke of the pen.
*/
/**
* The shapes of the Shape tool (shapes.js), as a stroke records its kind.
* @typedef {'rectangle' | 'circle' | 'ellipse' | 'line' | 'arrow'} ShapeKind
*/
export const SHAPE_KINDS = /** @type {const} */ (['rectangle', 'circle', 'ellipse', 'line', 'arrow']);
/** 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), wrapping inside and
* growing downward by lines when the text needs more; an *implied*
* rectangle (Andy, 2026-10-14), marked on screen by blue corners and
* drawing nothing on the page (a red frame on a white ground until
* then, from a misreading of the teacher's rectangles, which are
* marks of their own: the Shape tool). 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;
const fill = typeof received.fill === 'string' && COLOR_PATTERN.test(received.fill) ? received.fill : null;
const shape = SHAPE_KINDS.find((kind) => kind === received.shape) ?? null;
return {
id: received.id, color: received.color, width: received.width,
points: points.slice(), done: received.done !== false, filled: received.filled === true, fill, shape,
};
}
/**
* The rectangle around a stroke's points (not its line's width).
* @param {Stroke} stroke
* @returns {Rectangle | null} Null for a stroke without points.
*/
export function strokeBox(stroke) {
const p = stroke.points;
if (p.length < POINT_SIZE) return null;
let left = Infinity;
let top = Infinity;
let right = -Infinity;
let bottom = -Infinity;
for (let i = 0; i < p.length; i += POINT_SIZE) {
left = Math.min(left, p[i]);
right = Math.max(right, p[i]);
top = Math.min(top, p[i + 1]);
bottom = Math.max(bottom, p[i + 1]);
}
return { left, top, width: right - left, height: bottom - top };
}
/**
* @param {Stroke} stroke
* @returns {boolean} Whether a stroke of a line shape ends where it began.
*/
export function isClosed(stroke) {
const p = stroke.points;
return p.length >= 2 * POINT_SIZE && p[0] === p[p.length - POINT_SIZE] && p[1] === p[p.length - POINT_SIZE + 1];
}
/**
* 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 paper's colour, in which a mark cannot be seen. */
export const PAPER_WHITE = '#ffffff';
/**
* @param {string} color
* @returns {boolean} Whether a mark of this colour is invisible on the paper.
*/
export function isPaperWhite(color) {
return color.trim().toLowerCase() === PAPER_WHITE;
}
/**
* 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<string, Stroke>} In drawing sequence. */
this.strokes = new Map();
/** @type {Map<string, TextBlock>} 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<ArrayBuffer> | 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<PageImage, 'data'>}
*/
function withoutData(image) {
const { data, ...rest } = image;
return rest;
}
/**
* What identifies a page and gives its size. Whoever starts or changes
* a page sends it to the others.
* @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<PageImage, 'data'>[]} 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, and one taller lengthens it downward, so the
* whole image has room; one larger 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 someone joining
* shows before a 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<string, Layer>} */
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() {
// What can be seen (Andy, 2026-10-14): a stroke or free text in the
// paper's white is left out (over an image it shows, but lies within
// the image's box, which counts). The record that grows the page while
// writing (strokeBounds) is not used here, so a white stroke still
// gets room as it is written.
/** @type {{box: Rectangle | null}} A holder, since the closure's assignments are not seen by the type checker. */
const found = { box: null };
/** @param {number} left @param {number} top @param {number} width @param {number} height */
const add = (left, top, width, height) => {
const box = found.box;
if (!box) {
found.box = { left, top, width, height };
return;
}
const right = Math.max(box.left + box.width, left + width);
const bottom = Math.max(box.top + box.height, top + height);
box.left = Math.min(box.left, left);
box.top = Math.min(box.top, top);
box.width = right - box.left;
box.height = bottom - box.top;
};
for (const layer of this.layers.values()) {
for (const stroke of layer.strokes.values()) {
if (isPaperWhite(stroke.color) && (stroke.fill === null || isPaperWhite(stroke.fill))) continue;
const reach = stroke.width * 2; // As includeStroke reckons it.
const p = stroke.points;
for (let i = 0; i < p.length; i += POINT_SIZE) add(p[i] - reach, p[i + 1] - reach, 2 * reach, 2 * reach);
}
for (const block of layer.texts.values()) {
// A text rectangle draws nothing of its own since 2026-10-14, so
// white text in one is as invisible as white free text.
if (isPaperWhite(block.color)) continue;
const b = textBounds(block);
add(b.left, b.top, b.width, b.height);
}
}
for (const item of [...this.images, ...this.hides]) add(item.x, item.y, item.width, item.height);
const box = found.box;
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, filled: false, fill: null, shape: null });
}
/** 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 layer of whoever started the
* session is on top, so that a teacher's corrections lie over the
* writing they correct (a drawing order, not a right).
* @param {string} hostId Who started the session.
* @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;
}