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

+305 -64
View File
@@ -10,9 +10,10 @@
* stroke grows.
*/
import { BLANK_PAGE_HEIGHT, BLANK_PAGE_WIDTH, POINT_SIZE, pointCount } from './page_model.js';
import { BLANK_PAGE_HEIGHT, BLANK_PAGE_WIDTH, POINT_SIZE, pointCount, textBounds } from './page_model.js';
import { TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js';
import { partAt, widthAt } from './stroke_geometry.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, fontOf } from './text_layout.js';
import { LEFT_GAP, LINE_HEIGHT, baselineOf, fontOf } from './text_layout.js';
/** @typedef {import('./page_model.js').Page} Page */
/** @typedef {import('./page_model.js').Stroke} Stroke */
@@ -24,31 +25,140 @@ import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, fontOf } from './text_layout.js';
*/
/**
* One step of zooming, by button or scroll wheel, is this factor (10%,
* Andy's first choice to try, 2026-10-03).
* One click of the scroll wheel zooms by this factor (10%, Andy's choice
* to try, 2026-10-03).
*/
export const ZOOM_STEP = 1.1;
/**
* A pen drag with Ctrl held zooms by this factor for every 100 screen
* pixels of movement, continuously (Andy, 2026-10-08; a constant so that
* it can be tuned).
*/
export const ZOOM_PER_100_PIXELS = 2;
const LEAST_CSS_PIXELS_PER_UNIT = 0.05;
const MOST_CSS_PIXELS_PER_UNIT = 8;
/**
* How the page is scaled on screen: fitted to the window, or a fixed
* number of CSS pixels per page unit.
* How near a corner of an image a press must be, in screen pixels, to
* scale the image rather than move it (Andy, 2026-10-08; a constant to
* tune). In screen pixels so that the handle is the same size under
* the pen at any zoom.
*/
export const CORNER_REACH_PX = 24;
/**
* How a text rectangle's frame is shown on screen: the whole rectangle,
* as Andy's teacher is used to, or only its corners, which is quieter
* (Andy, 2026-10-08: a constant, to try both). The page file and the
* export always hold the whole rectangle.
* @type {'rectangle' | 'corners'}
*/
export const TEXT_FRAME_STYLE = 'rectangle';
/** The length of a corner mark, in page units, when only corners are shown. */
const CORNER_MARK = 60;
/** Hide rectangles are marked by blue corners on screen, for everyone. */
const HIDE_CORNER_COLOR = '#1e88e5';
const HIDE_CORNER_WIDTH = 2;
/**
* How the page is scaled on screen: a number of CSS pixels per page unit.
* 'fit' asks for the scale at which the whole page fits the window, and
* is replaced by that number as soon as the page is shown: "Fit" is a
* command, not a mode (Andy, 2026-10-08), so a page that grows afterwards
* extends beyond the window instead of being refitted.
* @typedef {'fit' | number} Zoom
*/
/**
* Draw a text block.
* Stroke lines on the device pixel grid, so that they are as crisp as a
* CSS border and the same width: a line of `width` page units becomes a
* whole number of pixels, at least one, and its edges land on pixels.
* The lines are given in page units and drawn through the context's
* transform, which maps page units to device pixels.
* @param {CanvasRenderingContext2D} context
* @param {[number, number][][]} polylines
* @param {string} color
* @param {number} width
*/
function strokeCrisp(context, polylines, color, width) {
const t = context.getTransform();
const pixels = Math.max(1, Math.round(width * t.a));
/** @param {number} v */
const snap = (v) => Math.round(v);
context.save();
context.setTransform(1, 0, 0, 1, 0, 0);
context.strokeStyle = color;
context.lineWidth = pixels;
context.lineCap = 'butt';
context.lineJoin = 'miter';
for (const line of polylines) {
context.beginPath();
line.forEach(([x, y], i) => {
// A line's centre at half a pixel in: its edges on the grid.
const dx = snap(t.a * x + t.e);
const dy = snap(t.d * y + t.f);
const px = dx + (pixels % 2 ? 0.5 : 0);
const py = dy + (pixels % 2 ? 0.5 : 0);
if (i === 0) context.moveTo(px, py);
else context.lineTo(px, py);
});
context.stroke();
}
context.restore();
}
/**
* Draw the corners of a rectangle as short L-shaped marks.
* @param {CanvasRenderingContext2D} context
* @param {import('./page_model.js').Rectangle} r
* @param {string} color
* @param {number} width
*/
function drawCornerMarks(context, r, color, width) {
const mark = Math.min(CORNER_MARK, r.width / 2, r.height / 2);
/** @type {[number, number][][]} */
const lines = [];
for (const [cx, cy] of [[0, 0], [1, 0], [0, 1], [1, 1]]) {
const x = r.left + cx * r.width;
const y = r.top + cy * r.height;
lines.push([[x, y + (cy ? -mark : mark)], [x, y], [x + (cx ? -mark : mark), y]]);
}
strokeCrisp(context, lines, color, width);
}
/**
* Draw a text block: a text rectangle's red frame, then its lines.
* @param {CanvasRenderingContext2D} context
* @param {TextBlock} block
*/
function drawText(context, block) {
if (block.height === null) {
// Free text: an L at its top-left corner.
strokeCrisp(context, [[[block.x, block.y + TEXT_MARK], [block.x, block.y], [block.x + TEXT_MARK, block.y]]],
TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH);
} else {
const f = textBounds(block);
if (TEXT_FRAME_STYLE === 'rectangle') {
const r = f.left + f.width;
const b = f.top + f.height;
strokeCrisp(context, [[[f.left, f.top], [r, f.top], [r, b], [f.left, b], [f.left, f.top]]],
TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH);
} else {
drawCornerMarks(context, f, TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH);
}
}
context.font = fontOf(block.size, block.font);
context.fillStyle = block.color;
context.textBaseline = 'top';
context.textBaseline = 'alphabetic';
// On whole device pixels, the lines a whole number of pixels apart, as
// the text field lays them out (TextEditor.layout): the same pixels in
// both, whatever the zoom (Andy, 2026-10-09).
const t = context.getTransform();
const x = Math.round(t.a * (block.x + block.size * LEFT_GAP) + t.e);
const first = Math.round(t.d * (block.y + baselineOf(block.size, block.font, 0)) + t.f);
const spacing = Math.round(t.d * block.size * LINE_HEIGHT);
block.lines.forEach((line, i) => {
context.fillText(line, block.x + block.size * LEFT_GAP,
block.y + block.size * (TOP_GAP + i * LINE_HEIGHT));
context.fillText(line, (x - t.e) / t.a, (first + i * spacing - t.f) / t.d);
});
}
@@ -93,16 +203,31 @@ export class Renderer {
/** Region of the tail canvas in use, in page units, or null. */
this.tailRegion = /** @type {Region | null} */ (null);
/** The white grounds of text rectangles: above the images, beneath every layer. */
this.groundCanvas = this.makeCanvas();
this.groundCanvas.classList.add('grounds');
this.pageElement.append(this.groundCanvas);
this.tailCanvas = this.makeCanvas();
this.tailCanvas.classList.add('tails');
this.pageElement.append(this.tailCanvas);
/** The Hide rectangles, above all writing, with their blue corners. */
this.hideCanvas = this.makeCanvas();
this.hideCanvas.classList.add('hides');
this.pageElement.append(this.hideCanvas);
/**
* The elements that show the page's images beneath the layers, one for
* each image whose bytes are here, and the bytes each one shows.
* @type {{element: HTMLImageElement, data: Uint8Array | null}[]}
* each image, in the images' sequence, and the bytes each one shows.
* @type {{element: HTMLImageElement, data: Uint8Array | null, hash: string}[]}
*/
this.imageElements = [];
/** The green cue around an image being handled by hand. */
this.grip = document.createElement('div');
this.grip.className = 'image_grip';
this.grip.hidden = true;
this.pageElement.append(this.grip);
/** @type {{rect: import('./page_model.js').Rectangle, corner: [number, number] | null} | null} */
this.gripShown = null;
/** Canvas pixels per page unit. */
this.scale = 1;
@@ -113,14 +238,10 @@ export class Renderer {
this.zoom = 'fit';
/** CSS pixels per page unit as last shown. */
this.cssPixelsPerUnit = 1;
/**
* True while the user is drawing a stroke. At "Fit" the page is not
* refitted then: if it were, writing past an edge would move the edge
* away from the pen with every step of growth.
*/
this.holdFit = false;
/** @type {(() => void) | null} Told when the zoom changes. */
this.onZoomChanged = null;
/** A zoom by pen drag in progress: the scale it started from and its anchor. */
this.zoomDrag = /** @type {{zoom: number, clientX: number, clientY: number} | null} */ (null);
/**
* True until a page that its images have made larger than a sheet has
* been shown for the first time. Such a page is a series of exercises
@@ -137,9 +258,10 @@ export class Renderer {
this.editingText = null;
this.setPage(page);
// Ctrl and the scroll wheel zoom about the pointer, as elsewhere.
// Ctrl (Command on a Mac) and the scroll wheel zoom about the
// pointer, as elsewhere.
this.desk().addEventListener('wheel', (event) => {
if (!event.ctrlKey) return;
if (!(event.ctrlKey || event.metaKey)) return;
event.preventDefault();
this.zoomBy(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, event.clientX, event.clientY);
}, { passive: false });
@@ -150,6 +272,63 @@ export class Renderer {
return /** @type {HTMLElement} */ (this.pageElement.parentElement);
}
/**
* The room the page can be shown in, in CSS pixels.
* @returns {{width: number, height: number}}
*/
available() {
const desk = this.desk();
const style = getComputedStyle(desk);
// offsetWidth, not clientWidth: a fitted page needs no scrollbars, so
// the room they take while zoomed in must not count.
return {
width: desk.offsetWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
height: desk.offsetHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom),
};
}
/**
* Show the page at the scale a new page has: that at which one A4
* sheet fits the window ("A4", Andy, 2026-10-08, so that a shrunk page
* is seen at its size on the sheet rather than blown up by "Fit").
*/
sheetScale() {
const { width, height } = this.available();
if (!(width > 0) || !(height > 0)) return;
this.setZoom(1 / Math.max(BLANK_PAGE_WIDTH / width, BLANK_PAGE_HEIGHT / height));
}
/** Show the whole page in the window. */
fit() {
this.setZoom('fit');
this.desk().scrollTop = 0;
this.desk().scrollLeft = 0;
}
/**
* A zoom by pen drag begins at this point, which stays under the pen.
* @param {number} clientX
* @param {number} clientY
*/
beginZoomDrag(clientX, clientY) {
this.zoomDrag = { zoom: this.cssPixelsPerUnit, clientX, clientY };
}
/**
* The pen of a zoom drag has moved this far up from where it began
* (negative: down). ZOOM_PER_100_PIXELS per 100 pixels, continuously.
* @param {number} pixelsUp
*/
zoomDragTo(pixelsUp) {
const drag = this.zoomDrag;
if (!drag) return;
this.setZoom(drag.zoom * ZOOM_PER_100_PIXELS ** (pixelsUp / 100), drag.clientX, drag.clientY);
}
endZoomDrag() {
this.zoomDrag = null;
}
/**
* Zoom in or out by a factor, from whatever the zoom is now.
* @param {number} factor
@@ -208,6 +387,7 @@ export class Renderer {
this.canvases.clear();
this.hidden.clear();
this.active.clear();
this.gripShown = null;
this.showImages();
this.resize();
// Once more after the browser has laid the page out, in case anything
@@ -249,18 +429,6 @@ export class Renderer {
this.onZoomChanged?.();
}
/**
* Keep the present scale from now on, if the page is being fitted to the
* window. Called when an image is added or taken back: the page then
* extends beyond the window, to be scrolled to, and what is in view
* stays as it is (Andy, 2026-10-05).
*/
holdScale() {
if (this.zoom !== 'fit' || this.shownLeft === null) return;
this.zoom = this.cssPixelsPerUnit;
this.onZoomChanged?.();
}
/** Place the image elements where the images lie on the page. */
layoutImages() {
const page = this.page;
@@ -275,42 +443,82 @@ export class Renderer {
}
/**
* Show the page's images whose bytes are here. An element that already
* shows the right bytes is left alone, so that adding an image does not
* make the others flicker.
* Show the page's images whose bytes are here, in their sequence (the
* last in front). An element that already shows an image keeps it when
* the images are reordered, so that nothing flickers.
*/
showImages() {
const images = this.page.images;
for (const [i, image] of images.entries()) {
let shown = this.imageElements[i];
const spare = this.imageElements.slice();
/** @type {typeof this.imageElements} */
const shownNow = [];
for (const image of images) {
const at = spare.findIndex((one) => one.hash === image.hash);
let shown = at >= 0 ? spare.splice(at, 1)[0] : null;
if (!shown) {
const element = document.createElement('img');
element.className = 'page_image';
element.alt = '';
element.draggable = false;
element.hidden = true;
// Beneath the layers, in the sequence of the images.
const before = this.imageElements[i - 1]?.element.nextSibling ?? this.pageElement.firstChild;
this.pageElement.insertBefore(element, before);
shown = this.imageElements[i] = { element, data: null };
shown = { element, data: null, hash: image.hash };
}
if (shown.data === image.data) continue;
if (shown.element.src) URL.revokeObjectURL(shown.element.src);
shown.data = image.data;
shown.element.hidden = !image.data;
if (image.data) {
shown.element.src = URL.createObjectURL(new Blob([image.data], { type: image.type }));
} else {
shown.element.removeAttribute('src');
if (shown.data !== image.data) {
if (shown.element.src) URL.revokeObjectURL(shown.element.src);
shown.data = image.data;
shown.element.hidden = !image.data;
if (image.data) {
shown.element.src = URL.createObjectURL(new Blob([image.data], { type: image.type }));
} else {
shown.element.removeAttribute('src');
}
}
shownNow.push(shown);
}
for (const gone of this.imageElements.splice(images.length)) {
for (const gone of spare) {
if (gone.element.src) URL.revokeObjectURL(gone.element.src);
gone.element.remove();
}
// Beneath the layers, in the sequence of the images.
const firstLayer = this.pageElement.querySelector('canvas');
for (const shown of shownNow) this.pageElement.insertBefore(shown.element, firstLayer);
this.imageElements = shownNow;
this.layoutImages();
}
/**
* Show, or hide, the cue around an image being handled: its outline,
* and the corner being dragged if it is being scaled.
* @param {import('./page_model.js').Rectangle | null} rect Page units.
* @param {[number, number] | null} [corner]
*/
showGrip(rect, corner = null) {
this.gripShown = rect ? { rect, corner } : null;
this.layoutGrip();
}
layoutGrip() {
const shown = this.gripShown;
this.grip.hidden = !shown;
if (!shown) return;
const page = this.page;
const style = this.grip.style;
style.left = `${(shown.rect.left - page.left) / page.width * 100}%`;
style.top = `${(shown.rect.top - page.top) / page.height * 100}%`;
style.width = `${shown.rect.width / page.width * 100}%`;
style.height = `${shown.rect.height / page.height * 100}%`;
this.grip.classList.toggle('corner', shown.corner !== null);
if (shown.corner) {
style.setProperty('--corner-x', shown.corner[0] ? '100%' : '0%');
style.setProperty('--corner-y', shown.corner[1] ? '100%' : '0%');
}
}
/** @returns {number} CORNER_REACH_PX in page units at the present zoom. */
cornerReach() {
return CORNER_REACH_PX / this.cssPixelsPerUnit;
}
makeCanvas() {
const canvas = document.createElement('canvas');
canvas.className = 'layer';
@@ -341,18 +549,13 @@ export class Renderer {
* redraw. Called when the window changes, when the page grows and when
* the zoom changes.
*
* At "Fit", the whole page is shown within the window, and refitted
* when the page grows, except while the user is drawing (see holdFit).
* A zoom of 'fit' (a page shown for the first time, or "Fit" pressed)
* becomes the scale at which the whole page fits the window, and stays
* a number from then on: growth extends the page beyond the window.
*/
resize() {
const desk = this.desk();
const deskStyle = getComputedStyle(desk);
// offsetWidth, not clientWidth: a fitted page needs no scrollbars, so
// the room they take while zoomed in must not count.
const availableWidth = desk.offsetWidth -
parseFloat(deskStyle.paddingLeft) - parseFloat(deskStyle.paddingRight);
const availableHeight = desk.offsetHeight -
parseFloat(deskStyle.paddingTop) - parseFloat(deskStyle.paddingBottom);
const { width: availableWidth, height: availableHeight } = this.available();
if (!(availableWidth > 0) || !(availableHeight > 0)) return;
const first = this.zoom === 'fit' && this.sheetFirst;
this.sheetFirst = false;
@@ -360,14 +563,16 @@ export class Renderer {
this.zoom = Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT,
1 / Math.max(BLANK_PAGE_WIDTH / availableWidth, BLANK_PAGE_HEIGHT / availableHeight)));
}
let unitsPerCssPixel;
if (this.zoom !== 'fit') unitsPerCssPixel = 1 / this.zoom;
else if (this.holdFit && this.shownLeft !== null) unitsPerCssPixel = 1 / this.cssPixelsPerUnit;
else unitsPerCssPixel = Math.max(this.page.width / availableWidth, this.page.height / availableHeight);
const fitted = this.zoom === 'fit';
const unitsPerCssPixel = this.zoom === 'fit'
? Math.max(this.page.width / availableWidth, this.page.height / availableHeight)
: 1 / this.zoom;
this.cssPixelsPerUnit = 1 / unitsPerCssPixel;
if (fitted) this.zoom = this.cssPixelsPerUnit;
this.pageElement.style.width = `${this.page.width / unitsPerCssPixel}px`;
this.pageElement.style.height = `${this.page.height / unitsPerCssPixel}px`;
this.layoutImages();
this.layoutGrip();
// When the page grew to the left or upward, keep what is in view in view.
if (this.shownLeft !== null && this.page.left < this.shownLeft) {
desk.scrollLeft += (this.shownLeft - this.page.left) / unitsPerCssPixel;
@@ -380,10 +585,14 @@ export class Renderer {
this.scale = (window.devicePixelRatio || 1) / unitsPerCssPixel;
this.sizeCanvas(this.tailCanvas);
this.sizeCanvas(this.groundCanvas);
this.sizeCanvas(this.hideCanvas);
this.tailRegion = null;
for (const id of this.canvases.keys()) this.redrawLayer(id, true);
this.drawGrounds();
this.drawHides();
this.drawTails();
if (first) this.onZoomChanged?.();
if (first || fitted) this.onZoomChanged?.();
}
/**
@@ -412,6 +621,7 @@ export class Renderer {
else this.hidden.add(id);
const canvas = this.canvases.get(id);
if (canvas) canvas.hidden = !visible;
this.drawGrounds();
this.drawTails();
}
@@ -438,9 +648,40 @@ export class Renderer {
for (const block of layer.texts.values()) {
if (block.id !== this.editingText) drawText(context, block);
}
this.drawGrounds();
this.drawTails();
}
/**
* Draw the white grounds of every text rectangle of every layer shown
* here, beneath the layers: they hide the image, never anyone's writing.
*/
drawGrounds() {
const context = this.context(this.groundCanvas);
context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height);
context.fillStyle = '#ffffff';
for (const [id, layer] of this.page.layers) {
if (this.hidden.has(id)) continue;
for (const block of layer.texts.values()) {
if (block.height === null || block.id === this.editingText) continue;
const b = textBounds(block);
context.fillRect(b.left, b.top, b.width, b.height);
}
}
}
/** Draw the Hide rectangles, white with blue corners, above everything. */
drawHides() {
const context = this.context(this.hideCanvas);
context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height);
for (const hide of this.page.hides) {
context.fillStyle = '#ffffff';
context.fillRect(hide.x, hide.y, hide.width, hide.height);
drawCornerMarks(context, { left: hide.x, top: hide.y, width: hide.width, height: hide.height },
HIDE_CORNER_COLOR, HIDE_CORNER_WIDTH);
}
}
/**
* A stroke gained points or was finished: draw what is new.
* @param {string} layerId