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