Kokkoja as of 2026-10-10: the teacher's requests, the Session window, notices, the QR code, the license

This commit is contained in:
ack committed 2026-10-10 19:01:20 +02:00
1 parent 95c80d04c9
commit 1d087af633
32 files changed
+4917 -890

No files matched your search

+252 -62
View File
@@ -8,21 +8,24 @@
*/
/** A blank page is A4. */
export const BLANK_PAGE_WIDTH = 2100;
export const BLANK_PAGE_HEIGHT = 2970;
// 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. 8 mm, Andy's choice (2026-10-03; 15 mm before).
* 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 = 80;
export const WRITING_MARGIN = 50;
/**
* 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.
* 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 = 80;
export const IMAGE_MARGIN = 50;
/**
* @typedef {object} Stroke
@@ -45,6 +48,11 @@ export const POINT_SIZE = 3;
* @property {number} x
* @property {number} y
* @property {number | null} width Page units, or null for no wrapping.
* @property {number | null} height With a width: the block is a *text
* rectangle* (Andy's teacher, 2026-10-08), drawn with a red frame on
* a white ground that hides the image beneath, wrapping inside and
* growing downward by lines when the text needs more. Null for free
* text, which is only as large as its lines.
* @property {string} text As typed, with the writer's line breaks.
* @property {string[]} lines As shown, laid out by the writer's client.
* @property {number} size Font size in page units.
@@ -163,17 +171,62 @@ export function cleanText(received) {
!received.lines.every((/** @type {unknown} */ line) => typeof line === 'string') ||
![received.x, received.y].every((v) => Number.isFinite(v) && Math.abs(v) <= 10 * MAX_PAGE_SIZE) ||
(received.width !== null && !(received.width >= 10 && received.width <= MAX_PAGE_SIZE)) ||
(received.height != null && !(received.height >= 10 && received.height <= 10 * MAX_PAGE_SIZE)) ||
!(received.size >= 10 && received.size <= 400) ||
!TEXT_FONTS.includes(received.font) ||
!(received.boxWidth >= 0 && received.boxWidth <= MAX_PAGE_SIZE) ||
!(received.boxHeight >= 0 && received.boxHeight <= 10 * MAX_PAGE_SIZE)) return null;
return {
id: received.id, x: received.x, y: received.y, width: received.width,
height: received.height == null || received.width === null ? null : received.height,
text: received.text, lines: received.lines.slice(), size: received.size, font: received.font,
color: received.color, boxWidth: received.boxWidth, boxHeight: received.boxHeight,
};
}
/**
* The rectangle a text block takes up: its own, grown to its lines, for a
* text rectangle; the lines' box for free text.
* @param {TextBlock} block
* @returns {Rectangle}
*/
export function textBounds(block) {
if (block.width !== null && block.height !== null) {
return { left: block.x, top: block.y, width: block.width, height: Math.max(block.height, block.boxHeight) };
}
return { left: block.x, top: block.y, width: block.boxWidth, height: block.boxHeight };
}
/**
* A *Hide* rectangle: white, drawn above all writing, hiding what lies
* beneath (Andy's teacher, 2026-10-08). A page object like an image,
* made, moved and removed by those who may make pages, unaffected by
* anyone hiding a layer on their own screen.
* @typedef {object} HideRect
* @property {string} id
* @property {number} x
* @property {number} y
* @property {number} width
* @property {number} height
*/
/**
* @param {any} received
* @returns {HideRect | null}
*/
export function cleanHide(received) {
if (!received || !validId(received.id) ||
![received.x, received.y].every((v) => Number.isFinite(v) && Math.abs(v) <= MAX_PAGE_SIZE) ||
![received.width, received.height].every((v) => v >= 1 && v <= MAX_PAGE_SIZE)) return null;
return {
id: received.id, x: Math.round(received.x), y: Math.round(received.y),
width: Math.round(received.width), height: Math.round(received.height),
};
}
/** A page holds at most this many Hide rectangles. */
export const MAX_PAGE_HIDES = 50;
export class Layer {
/**
* @param {string} id The participant's id.
@@ -205,6 +258,9 @@ export class Layer {
* @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.
*/
@@ -225,11 +281,17 @@ function withoutData(image) {
* @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 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.
* @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.
*/
/**
@@ -262,46 +324,83 @@ export function cleanImage(received) {
![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,
x: received.x, y: received.y, width: received.width, height: received.height,
by: validId(received.by) ? received.by : '', 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).
* Where an image goes on the page, and the sheet that then holds it.
*
* 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).
* Below the lowest image on the page, or at the page's top if there is
* none, the margin apart, centred across the page's width, at its
* natural size: one way for every image (Andy, 2026-10-08, dropping the
* shrink-to-fit choice and composition mode, since an image can be moved
* and scaled by hand). An image wider than the page widens the sheet
* equally on both sides; one wider than a page can be is scaled to that.
* @param {Page} page
* @param {number} naturalWidth In page units.
* @param {number} naturalHeight
* @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.
* @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(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);
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 = width <= room ? Math.round((BLANK_PAGE_WIDTH - width) / 2) : IMAGE_MARGIN;
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,
pageWidth: Math.max(BLANK_PAGE_WIDTH, x + width + IMAGE_MARGIN),
pageHeight: Math.max(BLANK_PAGE_HEIGHT, y + height + 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
@@ -320,9 +419,20 @@ export function cleanPageDescriptor(received) {
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, width: received.width, height: received.height,
images,
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,
};
}
@@ -340,12 +450,16 @@ export class Page {
* 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(). */
@@ -362,28 +476,47 @@ export class Page {
/** @returns {PageDescriptor} */
descriptor() {
return {
id: this.id, number: this.number, width: this.baseWidth, height: this.baseHeight,
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 {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;
@@ -407,7 +540,38 @@ export class Page {
* @returns {Rectangle}
*/
baseExtent() {
return { left: 0, top: 0, width: this.baseWidth, height: this.baseHeight };
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),
};
}
/**
@@ -440,7 +604,8 @@ export class Page {
* @param {TextBlock} block
*/
includeText(block) {
const corners = [block.x, block.y, 0, block.x + block.boxWidth, block.y + block.boxHeight, 0];
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 });
}
@@ -453,6 +618,22 @@ export class Page {
}
}
/**
* 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
@@ -467,11 +648,17 @@ export class Page {
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;
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);
/** @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;
@@ -489,7 +676,10 @@ export class 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;
@@ -567,11 +757,12 @@ 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 >= block.x - radius && x <= block.x + block.boxWidth + radius &&
y >= block.y - radius && y <= block.y + block.boxHeight + radius) {
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;
}
@@ -588,9 +779,8 @@ export function textsTouched(layer, x0, y0, x1, y1, radius) {
*/
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;
}
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;
}