Kokkoja: first public assembly

Assembled from the development repository by tools/make_release.sh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-06 15:58:02 +02:00
commit 95c80d04c9
39 files changed
+9905

No files matched your search

+535
View File
@@ -0,0 +1,535 @@
// @ts-check
/**
* Draws the page. Each layer has its own canvas, so hiding a layer or
* redrawing it after an erasure does not touch the others.
*
* A stroke is drawn as a chain of parts, one per point (see
* stroke_geometry.js). The part for a point can be drawn only when the
* following point is known, so the newest end of a stroke in progress (its
* *tail*) is drawn on a separate canvas that is cleared and redrawn as the
* stroke grows.
*/
import { BLANK_PAGE_HEIGHT, BLANK_PAGE_WIDTH, POINT_SIZE, pointCount } from './page_model.js';
import { partAt, widthAt } from './stroke_geometry.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, fontOf } from './text_layout.js';
/** @typedef {import('./page_model.js').Page} Page */
/** @typedef {import('./page_model.js').Stroke} Stroke */
/** @typedef {import('./page_model.js').TextBlock} TextBlock */
/**
* A rectangle in page units.
* @typedef {{x0: number, y0: number, x1: number, y1: number}} Region
*/
/**
* One step of zooming, by button or scroll wheel, is this factor (10%,
* Andy's first choice to try, 2026-10-03).
*/
export const ZOOM_STEP = 1.1;
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.
* @typedef {'fit' | number} Zoom
*/
/**
* Draw a text block.
* @param {CanvasRenderingContext2D} context
* @param {TextBlock} block
*/
function drawText(context, block) {
context.font = fontOf(block.size, block.font);
context.fillStyle = block.color;
context.textBaseline = 'top';
block.lines.forEach((line, i) => {
context.fillText(line, block.x + block.size * LEFT_GAP,
block.y + block.size * (TOP_GAP + i * LINE_HEIGHT));
});
}
/**
* Draw the part of a stroke that belongs to point `k`.
* @param {CanvasRenderingContext2D} context
* @param {Stroke} stroke
* @param {number} k
*/
function drawPart(context, stroke, k) {
const part = partAt(stroke, k);
context.beginPath();
if (part.dot) {
context.arc(part.x, part.y, part.width / 2, 0, 2 * Math.PI);
context.fillStyle = stroke.color;
context.fill();
return;
}
context.moveTo(part.fromX, part.fromY);
context.quadraticCurveTo(part.x, part.y, part.toX, part.toY);
context.strokeStyle = stroke.color;
context.lineWidth = part.width;
context.stroke();
}
export class Renderer {
/**
* @param {HTMLElement} pageElement Element that holds the canvases.
* @param {Page} page
*/
constructor(pageElement, page) {
this.pageElement = pageElement;
this.page = page;
/** @type {Map<string, HTMLCanvasElement>} */
this.canvases = new Map();
/** @type {Set<string>} */
this.hidden = new Set();
/** @type {WeakMap<Stroke, number>} Parts of each stroke already drawn. */
this.drawn = new WeakMap();
/** @type {Map<Stroke, string>} Strokes in progress and their layers. */
this.active = new Map();
/** Region of the tail canvas in use, in page units, or null. */
this.tailRegion = /** @type {Region | null} */ (null);
this.tailCanvas = this.makeCanvas();
this.tailCanvas.classList.add('tails');
this.pageElement.append(this.tailCanvas);
/**
* 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}[]}
*/
this.imageElements = [];
/** Canvas pixels per page unit. */
this.scale = 1;
/** The page's left and top as last shown, for keeping the view still. */
this.shownLeft = /** @type {number | null} */ (null);
this.shownTop = /** @type {number | null} */ (null);
/** @type {Zoom} */
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;
/**
* 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
* to scroll through (Andy, 2026-10-05), so it is first shown at the
* scale at which one sheet fits the window, not all of it made small.
* (A reloaded tab shows it as it was last seen there: see showView.)
*/
this.sheetFirst = false;
/**
* The text block being edited, if any, which is shown by the editor's
* own text field and so not drawn here.
* @type {string | null}
*/
this.editingText = null;
this.setPage(page);
// Ctrl and the scroll wheel zoom about the pointer, as elsewhere.
this.desk().addEventListener('wheel', (event) => {
if (!event.ctrlKey) return;
event.preventDefault();
this.zoomBy(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, event.clientX, event.clientY);
}, { passive: false });
}
/** @returns {HTMLElement} The scrolling container of the page. */
desk() {
return /** @type {HTMLElement} */ (this.pageElement.parentElement);
}
/**
* Zoom in or out by a factor, from whatever the zoom is now.
* @param {number} factor
* @param {number} [clientX] The point to zoom about; else the middle.
* @param {number} [clientY]
*/
zoomBy(factor, clientX, clientY) {
this.setZoom(this.cssPixelsPerUnit * factor, clientX, clientY);
}
/**
* Change the zoom. With a position given, the page point under it stays
* under it; otherwise the point at the middle of the view stays.
* @param {Zoom} zoom
* @param {number} [clientX]
* @param {number} [clientY]
*/
setZoom(zoom, clientX, clientY) {
if (typeof zoom === 'number') {
zoom = Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT, zoom));
}
const desk = this.desk();
const deskBox = desk.getBoundingClientRect();
const anchorX = clientX ?? deskBox.left + desk.clientWidth / 2;
const anchorY = clientY ?? deskBox.top + desk.clientHeight / 2;
const [pageX, pageY] = this.pageFromClient(anchorX, anchorY);
this.zoom = zoom;
this.sheetFirst = false;
this.resize();
// Scroll so that the anchored page point is back under the anchor.
const box = this.pageElement.getBoundingClientRect();
const nowX = box.left + (pageX - this.page.left) * this.cssPixelsPerUnit;
const nowY = box.top + (pageY - this.page.top) * this.cssPixelsPerUnit;
desk.scrollLeft += nowX - anchorX;
desk.scrollTop += nowY - anchorY;
this.onZoomChanged?.();
}
/**
* Show another page in place of the present one.
* @param {Page} page
*/
setPage(page) {
this.page = page;
this.shownLeft = null;
this.shownTop = null;
this.zoom = 'fit';
this.sheetFirst = page.isLargerThanSheet();
this.onZoomChanged?.();
// A new page is seen from its top.
this.desk().scrollTop = 0;
this.desk().scrollLeft = 0;
for (const canvas of this.canvases.values()) canvas.remove();
this.canvases.clear();
this.hidden.clear();
this.active.clear();
this.showImages();
this.resize();
// Once more after the browser has laid the page out, in case anything
// above (a dialog closing, say) left the measurements stale. Andy saw
// a loaded page's image appear only after pressing Fit (2026-10-03),
// which could not be reproduced.
requestAnimationFrame(() => {
if (this.page === page) this.resize();
});
}
/**
* How the page is being shown, so that it can be shown the same way
* after the browser page is reloaded.
* @returns {{zoom: Zoom, x: number, y: number}} The zoom, and the page
* point at the top left corner of the view.
*/
view() {
const desk = this.desk();
return {
zoom: this.zoom,
x: this.page.left + desk.scrollLeft / this.cssPixelsPerUnit,
y: this.page.top + desk.scrollTop / this.cssPixelsPerUnit,
};
}
/**
* Show the page as view() described it.
* @param {{zoom: Zoom, x: number, y: number}} view
*/
showView(view) {
this.zoom = view.zoom === 'fit' ? 'fit'
: Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT, view.zoom));
this.sheetFirst = false;
this.resize();
const desk = this.desk();
desk.scrollLeft = (view.x - this.page.left) * this.cssPixelsPerUnit;
desk.scrollTop = (view.y - this.page.top) * this.cssPixelsPerUnit;
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;
for (const [i, image] of page.images.entries()) {
const style = this.imageElements[i]?.element.style;
if (!style) continue;
style.left = `${(image.x - page.left) / page.width * 100}%`;
style.top = `${(image.y - page.top) / page.height * 100}%`;
style.width = `${image.width / page.width * 100}%`;
style.height = `${image.height / page.height * 100}%`;
}
}
/**
* 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.
*/
showImages() {
const images = this.page.images;
for (const [i, image] of images.entries()) {
let shown = this.imageElements[i];
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 };
}
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');
}
}
for (const gone of this.imageElements.splice(images.length)) {
if (gone.element.src) URL.revokeObjectURL(gone.element.src);
gone.element.remove();
}
this.layoutImages();
}
makeCanvas() {
const canvas = document.createElement('canvas');
canvas.className = 'layer';
return canvas;
}
/**
* @param {HTMLCanvasElement} canvas
* @returns {CanvasRenderingContext2D}
*/
context(canvas) {
const context = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d'));
context.setTransform(this.scale, 0, 0, this.scale,
-this.page.left * this.scale, -this.page.top * this.scale);
context.lineCap = 'round';
context.lineJoin = 'round';
return context;
}
/** @param {HTMLCanvasElement} canvas */
sizeCanvas(canvas) {
canvas.width = Math.round(this.page.width * this.scale);
canvas.height = Math.round(this.page.height * this.scale);
}
/**
* Match the canvases to the page's rectangle and its displayed size, and
* 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).
*/
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);
if (!(availableWidth > 0) || !(availableHeight > 0)) return;
const first = this.zoom === 'fit' && this.sheetFirst;
this.sheetFirst = false;
if (first) {
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);
this.cssPixelsPerUnit = 1 / unitsPerCssPixel;
this.pageElement.style.width = `${this.page.width / unitsPerCssPixel}px`;
this.pageElement.style.height = `${this.page.height / unitsPerCssPixel}px`;
this.layoutImages();
// 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;
}
if (this.shownTop !== null && this.page.top < this.shownTop) {
desk.scrollTop += (this.shownTop - this.page.top) / unitsPerCssPixel;
}
this.shownLeft = this.page.left;
this.shownTop = this.page.top;
this.scale = (window.devicePixelRatio || 1) / unitsPerCssPixel;
this.sizeCanvas(this.tailCanvas);
this.tailRegion = null;
for (const id of this.canvases.keys()) this.redrawLayer(id, true);
this.drawTails();
if (first) this.onZoomChanged?.();
}
/**
* Put the layer canvases in the given sequence, bottom to top, creating
* canvases for new layers.
* @param {string[]} ids
*/
setLayerOrder(ids) {
for (const id of ids) {
let canvas = this.canvases.get(id);
if (!canvas) {
canvas = this.makeCanvas();
this.sizeCanvas(canvas);
this.canvases.set(id, canvas);
}
this.pageElement.insertBefore(canvas, this.tailCanvas);
}
}
/**
* @param {string} id
* @param {boolean} visible
*/
setLayerVisible(id, visible) {
if (visible) this.hidden.delete(id);
else this.hidden.add(id);
const canvas = this.canvases.get(id);
if (canvas) canvas.hidden = !visible;
this.drawTails();
}
/**
* Draw a layer again from the page.
* @param {string} id
* @param {boolean} [resized] The canvas size must change as well.
*/
redrawLayer(id, resized = false) {
const canvas = this.canvases.get(id);
const layer = this.page.layers.get(id);
if (!canvas) return;
if (resized) this.sizeCanvas(canvas);
const context = this.context(canvas);
context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height);
for (const [stroke, layerId] of this.active) {
if (layerId === id) this.active.delete(stroke);
}
if (!layer) return;
for (const stroke of layer.strokes.values()) {
this.drawn.set(stroke, 0);
this.drawNewParts(context, id, stroke);
}
for (const block of layer.texts.values()) {
if (block.id !== this.editingText) drawText(context, block);
}
this.drawTails();
}
/**
* A stroke gained points or was finished: draw what is new.
* @param {string} layerId
* @param {Stroke} stroke
*/
strokeProgress(layerId, stroke) {
const canvas = this.canvases.get(layerId);
if (!canvas) return;
this.drawNewParts(this.context(canvas), layerId, stroke);
this.drawTails();
}
/**
* @param {CanvasRenderingContext2D} context
* @param {string} layerId
* @param {Stroke} stroke
*/
drawNewParts(context, layerId, stroke) {
const count = pointCount(stroke);
// The part for the newest point waits until the stroke is finished or
// another point arrives.
const ready = stroke.done ? count : count - 1;
let k = this.drawn.get(stroke) ?? 0;
for (; k < ready; k++) drawPart(context, stroke, k);
this.drawn.set(stroke, Math.max(k, 0));
if (stroke.done) this.active.delete(stroke);
else this.active.set(stroke, layerId);
}
/** Redraw the tails of the strokes in progress. */
drawTails() {
const context = this.context(this.tailCanvas);
if (this.tailRegion) {
const r = this.tailRegion;
context.clearRect(r.x0, r.y0, r.x1 - r.x0, r.y1 - r.y0);
this.tailRegion = null;
}
for (const [stroke, layerId] of this.active) {
const count = pointCount(stroke);
if (count === 0 || this.hidden.has(layerId)) continue;
const p = stroke.points;
const end = (count - 1) * POINT_SIZE;
const width = widthAt(stroke, count - 1);
context.beginPath();
let startX = p[end];
let startY = p[end + 1];
if (count === 1) {
context.arc(p[end], p[end + 1], width / 2, 0, 2 * Math.PI);
context.fillStyle = stroke.color;
context.fill();
} else {
startX = (p[end - POINT_SIZE] + p[end]) / 2;
startY = (p[end - POINT_SIZE + 1] + p[end + 1]) / 2;
context.moveTo(startX, startY);
context.lineTo(p[end], p[end + 1]);
context.strokeStyle = stroke.color;
context.lineWidth = width;
context.stroke();
}
const margin = width + 2 / this.scale;
/** @type {Region} */
const r = this.tailRegion ?? { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
r.x0 = Math.min(r.x0, startX - margin, p[end] - margin);
r.y0 = Math.min(r.y0, startY - margin, p[end + 1] - margin);
r.x1 = Math.max(r.x1, startX + margin, p[end] + margin);
r.y1 = Math.max(r.y1, startY + margin, p[end + 1] + margin);
this.tailRegion = r;
}
}
/**
* Convert a position in the window to page units.
* @param {number} clientX
* @param {number} clientY
* @returns {[number, number]}
*/
pageFromClient(clientX, clientY) {
const box = this.pageElement.getBoundingClientRect();
return [
this.page.left + (clientX - box.left) * this.page.width / box.width,
this.page.top + (clientY - box.top) * this.page.height / box.height,
];
}
/**
* @param {number} cssPixels
* @returns {number} The same length in page units.
*/
pageLength(cssPixels) {
return cssPixels * this.page.width / this.pageElement.getBoundingClientRect().width;
}
}