2026-10-06 15:58:02 +02:00
|
|
|
// @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. */
|
2026-10-10 19:01:20 +02:00
|
|
|
// An A4 sheet in landscape (Andy, 2026-10-08; portrait until then).
|
|
|
|
|
export const BLANK_PAGE_WIDTH = 2970;
|
|
|
|
|
export const BLANK_PAGE_HEIGHT = 2100;
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
2026-10-10 19:01:20 +02:00
|
|
|
* point; "Shrink" leaves the same room. 5 mm, Andy's choice (2026-10-08;
|
|
|
|
|
* 8 mm from 2026-10-03, 15 mm before that).
|
2026-10-06 15:58:02 +02:00
|
|
|
*/
|
2026-10-10 19:01:20 +02:00
|
|
|
export const WRITING_MARGIN = 50;
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
/**
|
2026-10-10 19:01:20 +02:00
|
|
|
* 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.
|
2026-10-06 15:58:02 +02:00
|
|
|
*/
|
2026-10-10 19:01:20 +02:00
|
|
|
export const IMAGE_MARGIN = 50;
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @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.
|
2026-10-10 19:01:20 +02:00
|
|
|
* @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.
|
2026-10-06 15:58:02 +02:00
|
|
|
* @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)) ||
|
2026-10-10 19:01:20 +02:00
|
|
|
(received.height != null && !(received.height >= 10 && received.height <= 10 * MAX_PAGE_SIZE)) ||
|
2026-10-06 15:58:02 +02:00
|
|
|
!(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,
|
2026-10-10 19:01:20 +02:00
|
|
|
height: received.height == null || received.width === null ? null : received.height,
|
2026-10-06 15:58:02 +02:00
|
|
|
text: received.text, lines: received.lines.slice(), size: received.size, font: received.font,
|
|
|
|
|
color: received.color, boxWidth: received.boxWidth, boxHeight: received.boxHeight,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
|
2026-10-06 15:58:02 +02:00
|
|
|
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
|
2026-10-10 19:01:20 +02:00
|
|
|
* @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).
|
2026-10-06 15:58:02 +02:00
|
|
|
* @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. 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.
|
2026-10-10 19:01:20 +02:00
|
|
|
* @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".
|
2026-10-06 15:58:02 +02:00
|
|
|
* @property {number} width
|
|
|
|
|
* @property {number} height
|
2026-10-10 19:01:20 +02:00
|
|
|
* @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.
|
2026-10-06 15:58:02 +02:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
2026-10-10 19:01:20 +02:00
|
|
|
x: received.x, y: received.y, width: received.width, height: received.height,
|
|
|
|
|
by: validId(received.by) ? received.by : '', data: null,
|
2026-10-06 15:58:02 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-10 19:01:20 +02:00
|
|
|
* Where an image goes on the page, and the sheet that then holds it.
|
2026-10-06 15:58:02 +02:00
|
|
|
*
|
2026-10-10 19:01:20 +02:00
|
|
|
* 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
|
2026-10-11 19:57:55 +02:00
|
|
|
* 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.
|
2026-10-10 19:01:20 +02:00
|
|
|
* @param {Page} page
|
2026-10-06 15:58:02 +02:00
|
|
|
* @param {number} naturalWidth In page units.
|
|
|
|
|
* @param {number} naturalHeight
|
2026-10-10 19:01:20 +02:00
|
|
|
* @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.
|
2026-10-06 15:58:02 +02:00
|
|
|
*/
|
2026-10-10 19:01:20 +02:00
|
|
|
export function placeImage(page, naturalWidth, naturalHeight) {
|
|
|
|
|
const scale = Math.min(1, (MAX_PAGE_SIZE - 2 * IMAGE_MARGIN) / naturalWidth);
|
2026-10-06 15:58:02 +02:00
|
|
|
const width = Math.round(naturalWidth * scale);
|
|
|
|
|
const height = Math.round(naturalHeight * scale);
|
2026-10-10 19:01:20 +02:00
|
|
|
const x = page.left + Math.round((page.width - width) / 2);
|
|
|
|
|
const below = page.images.length ? page.imagesBottom() : page.top;
|
2026-10-06 15:58:02 +02:00
|
|
|
const y = Math.round(below) + IMAGE_MARGIN;
|
2026-10-10 19:01:20 +02:00
|
|
|
return { x, y, width, height, sheet: sheetHolding(page, { left: x, top: y, width, height }) };
|
2026-10-06 15:58:02 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
|
2026-10-06 15:58:02 +02:00
|
|
|
/**
|
|
|
|
|
* 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));
|
|
|
|
|
}
|
2026-10-10 19:01:20 +02:00
|
|
|
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);
|
2026-10-06 15:58:02 +02:00
|
|
|
return {
|
2026-10-10 19:01:20 +02:00
|
|
|
id: received.id, number: received.number, left: edge(received.left), top: edge(received.top),
|
|
|
|
|
width: received.width, height: received.height,
|
|
|
|
|
images, hides,
|
2026-10-06 15:58:02 +02:00
|
|
|
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.
|
|
|
|
|
*/
|
2026-10-10 19:01:20 +02:00
|
|
|
this.baseLeft = 0;
|
|
|
|
|
this.baseTop = 0;
|
2026-10-06 15:58:02 +02:00
|
|
|
this.baseWidth = width;
|
|
|
|
|
this.baseHeight = height;
|
|
|
|
|
this.id = id;
|
|
|
|
|
this.number = number;
|
|
|
|
|
/** @type {PageImage[]} From the top of the page down. */
|
|
|
|
|
this.images = [];
|
2026-10-10 19:01:20 +02:00
|
|
|
/** @type {HideRect[]} In stacking order. */
|
|
|
|
|
this.hides = [];
|
2026-10-06 15:58:02 +02:00
|
|
|
/** 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 {
|
2026-10-10 19:01:20 +02:00
|
|
|
id: this.id, number: this.number, left: this.baseLeft, top: this.baseTop,
|
|
|
|
|
width: this.baseWidth, height: this.baseHeight,
|
2026-10-06 15:58:02 +02:00
|
|
|
images: this.images.map(withoutData),
|
2026-10-10 19:01:20 +02:00
|
|
|
hides: this.hides.map((hide) => ({ ...hide })),
|
2026-10-06 15:58:02 +02:00
|
|
|
revision: this.imagesRevision,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
/**
|
|
|
|
|
* 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);
|
2026-10-06 15:58:02 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
/**
|
|
|
|
|
* 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);
|
2026-10-06 15:58:02 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
/** @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));
|
2026-10-06 15:58:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @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() {
|
2026-10-10 19:01:20 +02:00
|
|
|
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),
|
|
|
|
|
};
|
2026-10-06 15:58:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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) {
|
2026-10-10 19:01:20 +02:00
|
|
|
const bounds = textBounds(block);
|
|
|
|
|
const corners = [bounds.left, bounds.top, 0, bounds.left + bounds.width, bounds.top + bounds.height, 0];
|
2026-10-06 15:58:02 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 15:58:02 +02:00
|
|
|
/**
|
|
|
|
|
* 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) {
|
2026-10-10 19:01:20 +02:00
|
|
|
// 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).
|
2026-10-06 15:58:02 +02:00
|
|
|
const step = WRITING_MARGIN;
|
2026-10-10 19:01:20 +02:00
|
|
|
/** @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);
|
2026-10-06 15:58:02 +02:00
|
|
|
}
|
|
|
|
|
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);
|
2026-10-10 19:01:20 +02:00
|
|
|
page.baseLeft = descriptor.left;
|
|
|
|
|
page.baseTop = descriptor.top;
|
2026-10-06 15:58:02 +02:00
|
|
|
page.images = descriptor.images.map((image) => ({ ...image, data: null }));
|
2026-10-10 19:01:20 +02:00
|
|
|
page.hides = descriptor.hides.map((hide) => ({ ...hide }));
|
2026-10-06 15:58:02 +02:00
|
|
|
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()) {
|
2026-10-10 19:01:20 +02:00
|
|
|
const b = textBounds(block);
|
2026-10-06 15:58:02 +02:00
|
|
|
for (let s = 0; s <= steps; s++) {
|
|
|
|
|
const x = x0 + (x1 - x0) * s / steps;
|
|
|
|
|
const y = y0 + (y1 - y0) * s / steps;
|
2026-10-10 19:01:20 +02:00
|
|
|
if (x >= b.left - radius && x <= b.left + b.width + radius &&
|
|
|
|
|
y >= b.top - radius && y <= b.top + b.height + radius) {
|
2026-10-06 15:58:02 +02:00
|
|
|
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()) {
|
2026-10-10 19:01:20 +02:00
|
|
|
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;
|
2026-10-06 15:58:02 +02:00
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
}
|