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

+69 -10
View File
@@ -28,15 +28,22 @@
import { fromBase64, toBase64 } from './crypto.js';
import {
Page, POINT_SIZE, cleanImage, cleanName, cleanPageDescriptor, cleanStroke, cleanText,
pointCount, validId,
POINT_SIZE, Page, cleanHide, cleanImage, cleanName, cleanPageDescriptor, cleanStroke, cleanText, pointCount, textBounds, validId,
} from './page_model.js';
import { partAt } from './stroke_geometry.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP } from './text_layout.js';
import { LEFT_GAP, baselineOf } from './text_layout.js';
/** @typedef {import('./page_model.js').Stroke} Stroke */
const FORMAT_VERSION = '1';
/** A text rectangle's frame: the palette's red, 0.2 mm (Andy's teacher, 2026-10-08). */
export const TEXT_FRAME_COLOR = '#e53935';
export const TEXT_FRAME_WIDTH = 2;
/**
* Free text is marked by an L at its top-left corner, two segments this
* long (5 mm; 3 mm first), in the frame's red (Andy, 2026-10-09).
*/
export const TEXT_MARK = 50;
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
/**
@@ -58,7 +65,7 @@ function imageElement(image) {
text = toBase64(image.data);
imageText.set(image.data, text);
}
return `<image data-hash="${image.hash}" x="${image.x}" y="${image.y}"` +
return `<image data-hash="${image.hash}"${image.by ? ` data-by="${image.by}"` : ''} x="${image.x}" y="${image.y}"` +
` width="${image.width}" height="${image.height}"` +
` preserveAspectRatio="none" href="data:${image.type};base64,${text}"/>`;
}
@@ -86,15 +93,26 @@ function escapedContent(text) {
* @returns {string}
*/
function textElements(block) {
// Each line at its baseline, placed as on the screen.
const lines = block.lines.map((line, i) =>
`<tspan x="${number(block.x + block.size * LEFT_GAP)}"` +
` y="${number(block.y + block.size * (TOP_GAP + i * LINE_HEIGHT))}">${escapedContent(line)}</tspan>`);
` y="${number(block.y + baselineOf(block.size, block.font, i))}">${escapedContent(line)}</tspan>`);
const bounds = textBounds(block);
// A text rectangle's red frame, or free text's corner mark, is part of
// the page (a rectangle's white ground is written beneath all layers:
// see pageToSvg).
const frame = block.height === null
? `<path d="M${block.x} ${block.y + TEXT_MARK}V${block.y}H${block.x + TEXT_MARK}"` +
` fill="none" stroke="${TEXT_FRAME_COLOR}" stroke-width="${TEXT_FRAME_WIDTH}"/>`
: `<rect x="${bounds.left}" y="${bounds.top}" width="${bounds.width}" height="${bounds.height}"` +
` fill="none" stroke="${TEXT_FRAME_COLOR}" stroke-width="${TEXT_FRAME_WIDTH}"/>`;
return `<g data-text="${escaped(block.id)}" data-x="${block.x}" data-y="${block.y}"` +
` data-width="${block.width ?? ''}" data-size="${block.size}" data-font="${block.font}"` +
` data-width="${block.width ?? ''}" data-height="${block.height ?? ''}"` +
` data-size="${block.size}" data-font="${block.font}"` +
` data-box="${block.boxWidth} ${block.boxHeight}">` +
`<desc>${escapedContent(block.text)}</desc>` +
`<desc>${escapedContent(block.text)}</desc>${frame}` +
`<text font-family="${block.font}" font-size="${block.size}" fill="${block.color}"` +
` dominant-baseline="text-before-edge" xml:space="preserve">${lines.join('')}</text></g>`;
` xml:space="preserve">${lines.join('')}</text></g>`;
}
/**
@@ -153,7 +171,9 @@ export function pageToSvg(page, { session, hostId, hidden }) {
`<svg xmlns="${SVG_NAMESPACE}" data-kokkoja="${FORMAT_VERSION}"` +
` data-session="${escaped(session)}" data-host="${escaped(hostId)}"` +
` data-page="${escaped(page.id)}" data-number="${page.number}"` +
` data-base="${page.baseWidth} ${page.baseHeight}" data-revision="${page.imagesRevision}"` +
` data-base="${page.baseWidth} ${page.baseHeight}` +
`${page.baseLeft || page.baseTop ? ` ${page.baseLeft} ${page.baseTop}` : ''}"` +
` data-revision="${page.imagesRevision}"` +
` viewBox="${page.left} ${page.top} ${page.width} ${page.height}"` +
` width="${page.width / 100}cm" height="${page.height / 100}cm">`,
`<title>Kokkoja page ${page.number}</title>`,
@@ -164,6 +184,20 @@ export function pageToSvg(page, { session, hostId, hidden }) {
const shown = imageElement(image);
if (shown) lines.push(shown);
}
// The white grounds of the text rectangles: above the images, beneath
// every layer, so that they hide the image and never anyone's writing.
const grounds = [];
for (const id of page.layerOrder(hostId)) {
const layer = page.layers.get(id);
if (!layer || hidden?.has(id)) continue;
for (const block of layer.texts.values()) {
if (block.height === null) continue;
const b = textBounds(block);
grounds.push(`<rect data-ground="${escaped(block.id)}" x="${b.left}" y="${b.top}"` +
` width="${b.width}" height="${b.height}" fill="#ffffff"/>`);
}
}
if (grounds.length) lines.push('<g data-grounds="">', ...grounds, '</g>');
for (const id of page.layerOrder(hostId)) {
const layer = page.layers.get(id);
if (!layer || layer.isEmpty() || hidden?.has(id)) continue;
@@ -178,6 +212,15 @@ export function pageToSvg(page, { session, hostId, hidden }) {
for (const block of layer.texts.values()) lines.push(textElements(block));
lines.push('</g>');
}
// Hide rectangles: white, above all writing.
if (page.hides.length) {
lines.push('<g data-hides="">');
for (const hide of page.hides) {
lines.push(`<rect data-hide="${escaped(hide.id)}" x="${hide.x}" y="${hide.y}"` +
` width="${hide.width}" height="${hide.height}" fill="#ffffff"/>`);
}
lines.push('</g>');
}
lines.push('</svg>', '');
return lines.join('\n');
}
@@ -195,14 +238,19 @@ export function pageFromSvg(text) {
if (box.length !== 4 || !(box[2] > 0) || !(box[3] > 0)) return null;
const base = (root.getAttribute('data-base') ?? '').split(/\s+/).map(Number);
// data-base: the sheet's width and height, then its left and top edges
// when they are not 0 (a sheet changed by "Widen" or "Shrink").
const descriptor = cleanPageDescriptor({
id: root.getAttribute('data-page'), number: Number(root.getAttribute('data-number')),
width: base.length === 2 ? base[0] : box[2], height: base.length === 2 ? base[1] : box[3],
width: base.length >= 2 ? base[0] : box[2], height: base.length >= 2 ? base[1] : box[3],
left: base.length >= 3 ? base[2] : 0, top: base.length >= 4 ? base[3] : 0,
revision: Number(root.getAttribute('data-revision') ?? 0),
});
if (!descriptor) return null;
const page = new Page(descriptor.width, descriptor.height, descriptor.id, descriptor.number);
page.baseLeft = descriptor.left;
page.baseTop = descriptor.top;
page.imagesRevision = descriptor.revision;
/** @type {Map<string, Uint8Array<ArrayBuffer>>} An image shown twice is held once. */
const held = new Map();
@@ -215,11 +263,20 @@ export function pageFromSvg(text) {
type: address[1], size: data.length, hash,
x: Number(shown.getAttribute('x')), y: Number(shown.getAttribute('y')),
width: Number(shown.getAttribute('width')), height: Number(shown.getAttribute('height')),
by: shown.getAttribute('data-by') ?? '',
});
if (!image) continue;
held.set(hash, data);
page.images.push({ ...image, data });
}
for (const shown of root.querySelectorAll('g[data-hides] rect[data-hide]')) {
const hide = cleanHide({
id: shown.getAttribute('data-hide'),
x: Number(shown.getAttribute('x')), y: Number(shown.getAttribute('y')),
width: Number(shown.getAttribute('width')), height: Number(shown.getAttribute('height')),
});
if (hide) page.hides.push(hide);
}
for (const group of root.querySelectorAll('g[data-layer]')) {
const layerId = group.getAttribute('data-layer');
if (!validId(layerId)) continue;
@@ -237,11 +294,13 @@ export function pageFromSvg(text) {
}
for (const element of group.querySelectorAll('g[data-text]')) {
const widthText = element.getAttribute('data-width') ?? '';
const heightText = element.getAttribute('data-height') ?? '';
const box = (element.getAttribute('data-box') ?? '').split(' ').map(Number);
const block = cleanText({
id: element.getAttribute('data-text'),
x: Number(element.getAttribute('data-x')), y: Number(element.getAttribute('data-y')),
width: widthText === '' ? null : Number(widthText),
height: heightText === '' ? null : Number(heightText),
size: Number(element.getAttribute('data-size')),
font: element.getAttribute('data-font') ?? 'sans-serif',
color: element.querySelector('text')?.getAttribute('fill'),