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

+2 -3
View File
@@ -137,9 +137,8 @@ export const backend = {
async keepPage(notebook) {
return transaction('readwrite', async (pages) => {
const current = /** @type {StoredPage | undefined} */ (await result(pages.get([notebook, CURRENT_PAGE])));
if (!current || !(current.text.includes('data-stroke=') || current.text.includes('<image'))) {
return { kept: null, new: false };
}
const blank = !['data-stroke=', 'data-text=', '<image'].some((mark) => current?.text.includes(mark));
if (!current || blank) return { kept: null, new: false };
const kept = (await pagesOf(pages, notebook)).filter((page) => KEPT_PAGE.test(page.name));
const same = kept.find((page) => page.text === current.text);
if (same) return { kept: same.name, new: false };
+2 -1
View File
@@ -53,6 +53,8 @@ export class NotebookChooser {
try {
const { notebooks } = await listNotebooks();
this.showNotebooks(notebooks);
// The notebook being written in is chosen already, its pages shown;
// the list is always there to see (Andy, 2026-10-06).
const chosen = notebook && notebooks.includes(notebook) ? notebook
: notebooks.length === 1 ? notebooks[0] : '';
await this.showPages(chosen);
@@ -65,7 +67,6 @@ export class NotebookChooser {
showNotebooks(notebooks) {
const list = element('chooser_notebooks');
list.replaceChildren();
list.hidden = notebooks.length <= 1;
for (const notebook of notebooks) {
const button = document.createElement('button');
button.type = 'button';
-17
View File
@@ -13,9 +13,6 @@ import { listFiles, readFile } from './files.js';
/** Where the chooser was last used, for each of its two purposes. */
const DIRECTORY_KEYS = { image: 'kokkoja_image_directory', export: 'kokkoja_export_directory' };
/** Whether an image wider than the page is shrunk to fit it: 'no' if not. */
const SHRINK_KEY = 'kokkoja_image_shrink';
/** The longer side of the small pictures of image files, in pixels. */
const SMALL_PICTURE_SIDE = 240;
/** No small picture is made of a file larger than this. */
@@ -34,11 +31,6 @@ export class FileChooser {
this.dialog = /** @type {HTMLDialogElement} */ (element('file_chooser'));
this.directoryField = /** @type {HTMLInputElement} */ (element('files_directory'));
this.nameField = /** @type {HTMLInputElement} */ (element('files_name'));
this.shrinkBox = /** @type {HTMLInputElement} */ (element('files_shrink'));
this.shrinkBox.checked = localStorage.getItem(SHRINK_KEY) !== 'no';
this.shrinkBox.addEventListener('change', () => {
localStorage.setItem(SHRINK_KEY, this.shrinkBox.checked ? 'yes' : 'no');
});
/** @type {'image' | 'export'} */
this.purpose = 'image';
/** @type {import('./files.js').DirectoryListing | null} */
@@ -84,14 +76,6 @@ export class FileChooser {
return this.open('image', 'Image');
}
/**
* @returns {boolean} Whether the user wants an image wider than the page
* shrunk to fit it (the other choice keeps its size and widens the page).
*/
shrinksWideImages() {
return this.shrinkBox.checked;
}
/**
* Let the user choose a directory and a name, and save there.
* @param {string} title
@@ -119,7 +103,6 @@ export class FileChooser {
this.purpose = purpose;
element('files_title').textContent = title;
element('files_save').hidden = purpose !== 'export';
element('files_shrink_line').hidden = purpose !== 'image';
element('files_directories').replaceChildren();
element('files_entries').replaceChildren();
this.dialog.showModal();
+9 -1
View File
@@ -11,13 +11,21 @@
/** Resolutions outside this range are mistakes, not information. */
const LEAST_DPI = 30;
const MOST_DPI = 2400;
/**
* The nominal screen resolutions that cameras, phones and screenshots
* write without meaning them; an image so marked arrives at the default
* size instead (Andy, 2026-10-08, with every image now placed at its
* natural size: at 72 dpi a phone's photo would be over a metre wide).
*/
const NOMINAL_DPI = [72, 96];
/**
* @param {number | null} dpi
* @returns {number | null}
*/
function plausible(dpi) {
return dpi !== null && dpi >= LEAST_DPI && dpi <= MOST_DPI ? dpi : null;
if (dpi === null || dpi < LEAST_DPI || dpi > MOST_DPI) return null;
return NOMINAL_DPI.some((nominal) => Math.abs(dpi - nominal) < 0.5) ? null : dpi;
}
/**
+58
View File
@@ -0,0 +1,58 @@
// @ts-check
/**
* A record of the singular events of a session in the browser's console
* (F12), for finding faults: pages, images, notebook pages, exports, the
* co-host right, the connection, and what the input device delivers.
* Nothing continuous goes here (Andy, 2026-10-08).
*
* Since 2026-10-10 the same record, plus a trace of the input events
* (every press, release and cancellation, not the movement between), is
* kept in memory and shown in the Session window with a Copy button, so
* that a guest on a tablet, with no console to open, can send it when
* the pen misbehaves (Andy's iPad, where the Pencil stopped for a while
* now and then).
*/
/** How many entries are kept; the oldest go first. */
const KEPT = 400;
/** @type {string[]} */
const entries = [];
/** @type {(() => void) | null} Told of every new entry. */
let listener = null;
/** @returns {string} The time to the millisecond, HH:MM:SS.mmm. */
function now() {
const date = new Date();
const two = (/** @type {number} */ n) => String(n).padStart(2, '0');
return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}.` +
String(date.getMilliseconds()).padStart(3, '0');
}
/** @param {string} what */
function keep(what) {
entries.push(`${now()} ${what}`);
if (entries.length > KEPT) entries.shift();
listener?.();
}
/** A singular event: the console and the record. @param {string} what */
export function log(what) {
console.log(`Kokkoja ${new Date().toLocaleTimeString()}: ${what}`);
keep(what);
}
/** An input event: the record only. @param {string} what */
export function trace(what) {
keep(what);
}
/** @returns {string} The record, one entry a line. */
export function logText() {
return entries.join('\n');
}
/** @param {(() => void) | null} onEntry Called at every new entry. */
export function watchLog(onEntry) {
listener = onEntry;
}
+585 -120
View File
@@ -13,6 +13,7 @@ import {
readCurrentPage, readIdentity, readPage, rememberToken, useBrowserStorage,
} from './notebook.js';
import { download, pickImageFile } from './browser_files.js';
import { DEFAULT_NOTEBOOK, NotebookPicker } from './notebook_picker.js';
import { pageFromSvg, pageToSvg } from './page_file.js';
import { hashOf } from './crypto.js';
import { imageResolution } from './image_info.js';
@@ -21,7 +22,10 @@ import {
UNITS_PER_POINT, strokesTouched, textAt, textsTouched,
} from './page_model.js';
import { attachPenInput } from './pen_input.js';
import { Renderer, ZOOM_STEP } from './renderer.js';
import { Renderer } from './renderer.js';
import { LEAST_IMAGE_SIDE, textBounds, rectAt } from './page_model.js';
import { LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js';
import { log, logText, trace, watchLog } from './log.js';
import { Session } from './session.js';
import { NotebookChooser } from './chooser.js';
import { FileChooser } from './file_chooser.js';
@@ -29,13 +33,23 @@ import { writePicture } from './files.js';
import { pageToPng } from './page_picture.js';
import { TextEditor } from './text_editor.js';
import { enableTooltips } from './tooltip.js';
import { guardSelection } from './selection_guard.js';
import { QUIET_ZONE, qrModules, qrPath } from './qr_code.js';
const COLORS = ['#1c1c1e', '#1f4fd8', '#d42a2a', '#1c8a3c'];
/**
* The colours in the toolbar: black, red, green, blue, white (Andy's
* teacher, 2026-10-08), with the palette's own values so that the palette
* can leave them out.
*/
const COLORS = ['#000000', '#e53935', '#43a047', '#1e88e5', '#ffffff'];
/** Everyone begins in black (Andy, 2026-10-03; the host began in red before). */
const DEFAULT_COLOR = COLORS[0];
/** Nominal line widths in page units (0.1 mm). */
const WIDTHS = [4, 7, 12];
/** Nominal line widths in page units (0.1 mm); the last is twice the one before (2026-10-08). */
const WIDTHS = [4, 7, 12, 24];
/** Whether the page grows when one's writing reaches an edge: 'no' if not. */
const EXTEND_KEY = 'kokkoja_extend';
/** Radius of the eraser on the screen, in CSS pixels. */
const ERASER_RADIUS = 10;
@@ -66,7 +80,6 @@ const PALETTE = [
'#ffffff', '#bdbdbd', '#757575', '#424242', '#8d6e63', '#5d4037', '#3e2723', '#000000',
];
const NOTEBOOK_KEY = 'kokkoja_notebook';
const DEFAULT_NOTEBOOK = 'Notebook';
/**
* Where the installed Kokkoja program serves the client, for a guest who
@@ -104,6 +117,20 @@ function input(id) {
return /** @type {HTMLInputElement} */ (document.getElementById(id));
}
/**
* @param {string} id
* @returns {HTMLSelectElement}
*/
function select(id) {
return /** @type {HTMLSelectElement} */ (document.getElementById(id));
}
/** The start screen's notebook picker. */
const startPicker = new NotebookPicker(
select('notebook'), input('notebook_name'),
(message) => { element('start_message').textContent = message; },
() => {});
/**
* Our identity: the signing keys that make us the same participant in
* every session. The local program keeps them, so that they survive the
@@ -129,30 +156,37 @@ function showStart(message = '') {
element('start').hidden = false;
element('start_message').textContent = message;
input('name').value = localStorage.getItem(NAME_KEY) ?? '';
input('notebook').value = localStorage.getItem(NOTEBOOK_KEY) ?? DEFAULT_NOTEBOOK;
const remembered = localStorage.getItem(NOTEBOOK_KEY) ?? DEFAULT_NOTEBOOK;
startPicker.fill([], remembered);
startPicker.current = remembered;
const pending = invitationInAddress();
if (pending) input('invitation_entry').value = pending;
// An invitation is a request to join one session: arriving with one,
// the screen offers joining alone (Andy, 2026-10-06, after starting a
// session of his own by mistake on a guest's machine).
element('start_choice').hidden = Boolean(pending);
// From a web site, a guest who has the Kokkoja program installed may
// prefer it, for a notebook of real files.
const installed = /** @type {HTMLAnchorElement} */ (element('open_installed'));
installed.hidden = !(pending && notebooksInBrowser());
installed.href = `${INSTALLED_ADDRESS}#join=${encodeURIComponent(pending)}`;
showNotebooks().catch((error) => {
showNotebooks(startPicker).catch((error) => {
element('notebook_note').textContent = String(error.message ?? error);
element('notebook_note').classList.add('problem');
});
}
/** Offer the existing notebooks and say where notebooks are kept. */
async function showNotebooks() {
/**
* Offer the existing notebooks in a picker and say where notebooks are kept.
* The picker's present choice stays chosen: the listing arrives later
* than the screen, and a notebook made meanwhile must not be lost.
* @param {NotebookPicker} picker
*/
async function showNotebooks(picker) {
const { directory, notebooks } = await listNotebooks();
const choices = element('notebook_choices');
choices.replaceChildren();
for (const notebook of notebooks) {
const option = document.createElement('option');
option.value = notebook;
choices.append(option);
}
const current = picker.value;
picker.fill(notebooks, current);
picker.current = current;
for (const note of document.querySelectorAll('.notebook_directory')) {
note.textContent = directory ? `Notebooks are kept in ${directory}`
: 'Notebooks are kept in this browser, on this computer. “Keep page” also downloads the page.';
@@ -169,6 +203,96 @@ function addressValues() {
return new URLSearchParams(location.hash.slice(1));
}
/** How long a confirmation stays on the notice line, and a passing problem. */
const NOTICE_MS = 6000;
const PROBLEM_NOTICE_MS = 12000;
/** A reconnection shorter than this is not announced. */
const CONNECTION_GRACE_MS = 1500;
/**
* The notice line between the toolbar and the page. It says nothing
* while all is well. A STANDING notice, kept by its source until that
* source clears it, is a state: not connected, the page not being
* saved, no page from the host yet. A PASSING notice is a report of
* one event, kept for a few seconds: a page kept or exported, a file
* that was not an image. Standing notices come before a passing one.
*/
/** @type {Map<string, {text: string, problem: boolean}>} */
const standing = new Map();
let passing = { text: '', problem: false };
/** @type {ReturnType<typeof setTimeout> | undefined} */
let passingTimer;
function showNotice() {
const line = element('notice');
const states = [...standing.values()];
const shown = states.length ? states : [passing];
line.textContent = shown.map((notice) => notice.text).join(' ');
line.hidden = line.textContent === '';
line.classList.toggle('problem', shown.some((notice) => notice.problem));
}
/**
* @param {string} source
* @param {string} text '' clears the source's notice.
* @param {boolean} [problem]
*/
function standingNotice(source, text, problem = true) {
if (text) standing.set(source, { text, problem });
else standing.delete(source);
showNotice();
}
/**
* @param {string} text
* @param {boolean} [problem]
*/
function passingNotice(text, problem = false) {
passing = { text, problem };
clearTimeout(passingTimer);
passingTimer = setTimeout(() => {
passing = { text: '', problem: false };
showNotice();
}, problem ? PROBLEM_NOTICE_MS : NOTICE_MS);
showNotice();
}
/**
* The last word on saving, in the Session window.
* @param {string} text
* @param {boolean} problem
*/
function showSaveState(text, problem) {
element('save_status').textContent = text;
element('save_status').classList.toggle('problem', problem);
}
/**
* The invitation link as a QR code in the Session window, for the camera
* of a phone or a tablet (Andy, 2026-10-10: there is no good way to send
* a link from a Linux computer to an iPad). None for a bare invitation,
* which a camera could only show as text.
* @param {string} link
*/
function showInvitationCode(link) {
const holder = element('invitation_code');
holder.replaceChildren();
holder.hidden = link === '';
if (link === '') return;
const modules = qrModules(link);
const span = modules.length + 2 * QUIET_ZONE;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', `${-QUIET_ZONE} ${-QUIET_ZONE} ${span} ${span}`);
svg.setAttribute('shape-rendering', 'crispEdges');
svg.setAttribute('role', 'img');
svg.setAttribute('aria-label', 'The invitation as a QR code');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', qrPath(modules));
path.setAttribute('fill', '#000');
svg.append(path);
holder.append(svg);
}
/** @returns {string} The invitation in the page address, or ''. */
function invitationInAddress() {
return addressValues().get('join') ?? '';
@@ -195,9 +319,9 @@ function enteredName() {
return name;
}
/** @returns {string} The notebook entered. */
/** @returns {string} The notebook chosen. */
function enteredNotebook() {
const notebook = input('notebook').value.trim() || DEFAULT_NOTEBOOK;
const notebook = startPicker.value || DEFAULT_NOTEBOOK;
localStorage.setItem(NOTEBOOK_KEY, notebook);
return notebook;
}
@@ -327,9 +451,10 @@ async function enterSession(invitationText, name, notebook) {
// installed. The link carries the invitation itself.
const plainInvitation = formatInvitation(invitation);
input('invitation').value = config.site ? formatInvitationLink(config.site, plainInvitation) : plainInvitation;
showInvitationCode(config.site ? input('invitation').value : '');
const isHost = identity.id === invitation.hostId;
let tool = /** @type {'pen' | 'eraser' | 'text'} */ ('pen');
let tool = /** @type {'pen' | 'eraser' | 'text' | 'hide'} */ ('pen');
let color = DEFAULT_COLOR;
let width = WIDTHS[1];
let textSize = TEXT_SIZE;
@@ -353,9 +478,12 @@ async function enterSession(invitationText, name, notebook) {
const fileOf = (page) => pageToSvg(page, { session: roomId, hostId: invitation.hostId });
function showPageNumber() {
element('page_number').textContent = session.page.number > 0
? `Page ${session.page.number}` : 'Waiting for the host’s page';
const waiting = session.page.number === 0;
element('page_number').textContent = waiting ? 'No page yet' : `Page ${session.page.number}`;
standingNotice('page', waiting ? 'Waiting for the host’s page' : '', false);
}
/** @type {ReturnType<typeof setTimeout> | undefined} */
let connectionNoticeTimer;
const session = new Session(
{
@@ -379,7 +507,15 @@ async function enterSession(invitationText, name, notebook) {
writer.changed();
},
onPageReplaced: (oldPage, newPage) => {
textEditor?.stop();
log(`Page ${newPage.number} (${newPage.images.length} images) replaces page ${oldPage.number}`);
// A block being typed stays on the page being left, where every
// keystroke already put it; finishing the edit now would move it
// to the new page (seen by Andy, 2026-10-06). An empty one is
// dropped from the old page before that page is kept.
const abandoned = textEditor?.discard() ?? null;
if (abandoned && abandoned.text.trim() === '') {
oldPage.layers.get(session.identity.id)?.texts.delete(abandoned.id);
}
// Nothing is lost: the page being left is kept in the notebook.
writer.pageReplaced(oldPage.number > 0 ? fileOf(oldPage) : null);
renderer.setPage(newPage);
@@ -389,17 +525,25 @@ async function enterSession(invitationText, name, notebook) {
showPageNumber();
},
onImagesChanged: () => {
log(`Page ${session.page.number}: ${session.page.images.length} images, ${session.page.hides.length} hides`);
renderer.showImages();
renderer.drawHides();
writer.writeNow();
},
onPageResized: (byImages) => {
// An image added or taken back does not rescale what is in view.
if (byImages) renderer.holdScale();
onPageResized: () => {
// The view keeps its scale; the page extends beyond the window.
renderer.resize();
textEditor?.layout();
writer.changed();
},
onViewCommand: (command) => {
log(`The host's view: ${command === 'fit' ? 'Fit' : 'A4'}`);
if (command === 'fit') renderer.fit();
else renderer.sheetScale();
},
onPermissionsChanged: () => {
log(`May start pages: ${session.mayMakePages() ? 'yes' : 'no'}` +
(session.isHost ? `; guests allowed: ${session.permitted.size}` : ''));
showHostTools();
showParticipants(session, renderer);
},
@@ -409,19 +553,43 @@ async function enterSession(invitationText, name, notebook) {
},
onStatus: (status, detail) => {
const text = { connecting: 'Connecting…', connected: 'Connected', failed: detail ?? 'Disconnected' };
log(`Relay: ${text[status]}`);
element('status').textContent = text[status];
element('status').classList.toggle('problem', status !== 'connected');
// The notice line says nothing while connected, nothing during a
// short reconnection either, and stays up while a longer one lasts.
// Every attempt reports 'connecting' again, so the grace runs from
// the first of them.
if (status === 'connecting') {
if (connectionNoticeTimer === undefined && !standing.has('connection')) {
connectionNoticeTimer = setTimeout(() => {
connectionNoticeTimer = undefined;
standingNotice('connection', 'Not connected. Reconnecting…');
}, CONNECTION_GRACE_MS);
}
return;
}
clearTimeout(connectionNoticeTimer);
connectionNoticeTimer = undefined;
standingNotice('connection', status === 'connected' ? '' : text.failed);
},
});
const writer = new NotebookWriter(
notebook,
() => (session.page.number > 0 ? fileOf(session.page) : null),
(message, problem) => {
element('save_status').textContent = message;
element('save_status').classList.toggle('problem', problem);
{
onSaved: (time) => {
showSaveState(`Saved at ${time}`, false);
standingNotice('saving', '');
},
onProblem: (message) => {
showSaveState(message, true);
standingNotice('saving', message);
},
onKept: (message) => passingNotice(message),
});
element('save_status').textContent = savedPage ? 'Continued from the notebook' : '';
showSaveState(savedPage ? 'Continued from the notebook' : '', false);
const pageElement = element('page');
renderer = new Renderer(pageElement, session.page);
@@ -436,75 +604,305 @@ async function enterSession(invitationText, name, notebook) {
textEditor?.layout();
}).observe(/** @type {Element} */ (pageElement.parentElement));
// The toolbar, when the window is too narrow for one row, centres its
// rows rather than holding the two ends (Andy, 2026-10-09). Wrapping
// is told from the toolbar's height against one row's.
const toolbar = /** @type {HTMLElement} */ (document.querySelector('.toolbar'));
const watchToolbar = () => {
// One row is a control's height plus the toolbar's padding; two rows
// are nearly twice that.
const row = /** @type {HTMLElement} */ (toolbar.firstElementChild).offsetHeight;
toolbar.classList.toggle('wrapped', toolbar.clientHeight > row * 1.9);
};
new ResizeObserver(watchToolbar).observe(toolbar);
watchToolbar();
textEditor = new TextEditor(pageElement, renderer, {
setText: (block) => session.setText(block),
removeText: (id) => session.removeTexts([id]),
// A hold on the field being typed in moves its block, as on the page.
beginHold: (clientX, clientY) => penTarget.beginHold?.(clientX, clientY) ?? false,
holdMove: (clientX, clientY) => penTarget.holdMove?.(clientX, clientY),
holdEnd: () => penTarget.holdEnd?.(),
});
attachPenInput(pageElement, {
// Whether one's own writing may grow the page (the "Extend" box).
let extend = localStorage.getItem(EXTEND_KEY) !== 'no';
const extendBox = input('extend');
extendBox.checked = extend;
extendBox.addEventListener('change', () => {
extend = extendBox.checked;
localStorage.setItem(EXTEND_KEY, extend ? 'yes' : 'no');
});
/**
* With "Extend" off, points that would make the page grow are dropped:
* the stroke stops short of the edge and resumes when the pen is back
* inside. Nothing is stored beyond the page.
* @param {number[]} points
* @returns {number[]}
*/
const withinPage = (points) => {
if (extend) return points;
const page = session.page;
const kept = [];
for (let i = 0; i < points.length; i += 3) {
if (page.holdsWriting(points[i], points[i + 1], width)) kept.push(points[i], points[i + 1], points[i + 2]);
}
return kept;
};
let reportedDevice = '';
/**
* What a hold of the pen is handling: a Hide rectangle, a text block of
* ours, or an image; which; whether a corner is being dragged (and then
* which, with the opposite one fixed); and how the pen's position maps
* to the rectangle. Images keep their shape when scaled; the others
* resize freely (Andy, 2026-10-08).
* @typedef {object} Held
* @property {'hide' | 'text' | 'image'} kind
* @property {number} index Of the Hide rectangle or image.
* @property {string} id Of the text block.
* @property {[number, number] | null} corner
* @property {[number, number]} fixed
* @property {number} aspect
* @property {number} offsetX
* @property {number} offsetY
*/
/** @type {Held | null} */
let held = null;
/** @type {import('./page_model.js').Stroke | null} The stroke a hold began as. */
let strokeInProgress = null;
/**
* The rectangle a hold has moved or resized to, from the pen's position.
* @param {Held} h
* @param {import('./page_model.js').Rectangle} now The rectangle as it is.
* @param {number} x
* @param {number} y
* @param {boolean} keepShape
* @returns {import('./page_model.js').Rectangle}
*/
function heldRect(h, now, x, y, keepShape) {
if (!h.corner) return { left: x - h.offsetX, top: y - h.offsetY, width: now.width, height: now.height };
const [fx, fy] = h.fixed;
const dirX = h.corner[0] ? 1 : -1;
const dirY = h.corner[1] ? 1 : -1;
let w = Math.max((x - fx) * dirX, LEAST_IMAGE_SIDE);
let hgt = Math.max((y - fy) * dirY, LEAST_IMAGE_SIDE);
if (keepShape) {
// Along the diagonal from the fixed corner, so the aspect ratio holds.
w = Math.max((x - fx) * dirX, (y - fy) * dirY * h.aspect, LEAST_IMAGE_SIDE, LEAST_IMAGE_SIDE * h.aspect);
hgt = w / h.aspect;
}
return { left: dirX > 0 ? fx : fx - w, top: dirY > 0 ? fy : fy - hgt, width: w, height: hgt };
}
/**
* @param {Held['kind']} kind
* @param {number} index
* @param {string} id
* @param {import('./page_model.js').Rectangle} r
* @param {[number, number] | null} corner
* @param {number} x
* @param {number} y
* @returns {Held}
*/
const hold = (kind, index, id, r, corner, x, y) => ({
kind, index, id, corner,
fixed: corner ? [r.left + (1 - corner[0]) * r.width, r.top + (1 - corner[1]) * r.height] : [0, 0],
aspect: r.width / r.height, offsetX: x - r.left, offsetY: y - r.top,
});
/**
* @param {Held} h
* @returns {import('./page_model.js').Rectangle | null}
*/
function currentHeldRect(h) {
const page = session.page;
if (h.kind === 'hide') {
const hide = page.hides[h.index];
return hide ? { left: hide.x, top: hide.y, width: hide.width, height: hide.height } : null;
}
if (h.kind === 'image') {
const image = page.images[h.index];
return image ? { left: image.x, top: image.y, width: image.width, height: image.height } : null;
}
const block = session.ownLayer.texts.get(h.id);
return block ? textBounds(block) : null;
}
/** @type {import('./pen_input.js').PenTarget} */
const penTarget = {
toPage: (clientX, clientY) => renderer.pageFromClient(clientX, clientY),
tool: () => tool,
canWrite: () => session.page.number > 0,
beginStroke: () => {
textEditor?.stop();
renderer.holdFit = true;
session.beginStroke(color, width);
strokeInProgress = session.beginStroke(color, width);
},
addPoints: (points) => {
const inside = withinPage(points);
if (inside.length) session.addPoints(inside);
},
addPoints: (points) => session.addPoints(points),
endStroke: () => {
strokeInProgress = null;
session.endStroke();
renderer.holdFit = false;
if (renderer.zoom === 'fit') renderer.resize();
},
beginZoom: (clientX, clientY) => renderer.beginZoomDrag(clientX, clientY),
zoomTo: (pixelsUp) => renderer.zoomDragTo(pixelsUp),
endZoom: () => renderer.endZoomDrag(),
// A hold moves what is under the pen, or resizes it from a corner: a
// Hide rectangle or an image (for the host and co-hosts), or a
// text block of our own. The dot drawn meanwhile goes.
beginHold: (clientX, clientY) => {
const [x, y] = renderer.pageFromClient(clientX, clientY);
const reach = renderer.cornerReach();
const page = session.page;
/** @type {Held | null} */
let found = null;
const hide = session.mayMakePages() ? page.hideAt(x, y, reach) : null;
if (hide) {
const h = page.hides[hide.index];
found = hold('hide', hide.index, h.id, { left: h.x, top: h.y, width: h.width, height: h.height }, hide.corner, x, y);
} else {
const blocks = [...session.ownLayer.texts.values()];
const at = rectAt(blocks.map(textBounds), x, y, reach);
if (at) {
const block = blocks[at.index];
// Free text has no corners to resize by.
const corner = block.height === null ? null : at.corner;
found = hold('text', at.index, block.id, textBounds(block), corner, x, y);
} else if (session.mayMakePages()) {
const image = page.imageAt(x, y, reach);
if (image) {
const index = session.raiseImage(image.index);
const im = page.images[index];
found = hold('image', index, '', { left: im.x, top: im.y, width: im.width, height: im.height }, image.corner, x, y);
}
}
}
if (!found) return false;
if (strokeInProgress) {
const begun = strokeInProgress;
strokeInProgress = null;
session.endStroke();
session.erase([begun.id]);
}
textEditor?.hidePreview();
held = found;
renderer.showGrip(currentHeldRect(found), found.corner);
log(`${found.kind} held${found.corner ? ' at a corner' : ''}`);
return true;
},
holdMove: (clientX, clientY) => {
if (!held) return;
const [x, y] = renderer.pageFromClient(clientX, clientY);
const now = currentHeldRect(held);
if (!now) return;
const rect = heldRect(held, now, x, y, held.kind === 'image');
if (held.kind === 'image') {
held.index = session.placeImageByHand(held.index, rect);
} else if (held.kind === 'hide') {
session.placeHideByHand(held.index, rect);
} else {
const block = session.ownLayer.texts.get(held.id);
if (!block) return;
block.x = Math.round(rect.left);
block.y = Math.round(rect.top);
if (block.height !== null) {
block.width = Math.round(rect.width);
Object.assign(block, layoutText(block.text, block.width, block.size, block.font));
block.height = Math.max(Math.round(rect.height), Math.ceil(block.boxHeight));
}
session.setText(block);
// The field being typed in follows its block.
if (textEditor?.block?.id === block.id) textEditor.layout();
}
renderer.showGrip(currentHeldRect(held), held.corner);
},
holdEnd: () => {
if (!held) return;
const r = currentHeldRect(held);
if (r) log(`${held.kind} placed at ${r.left / 100}, ${r.top / 100} cm, ${r.width / 100} by ${r.height / 100} cm`);
if (held.kind !== 'text') session.sendPageNow();
held = null;
renderer.showGrip(null);
},
eraseAlong: (x0, y0, x1, y1) => {
textEditor?.stop();
const radius = renderer.pageLength(ERASER_RADIUS);
session.erase(strokesTouched(session.ownLayer, x0, y0, x1, y1, radius));
session.removeTexts(textsTouched(session.ownLayer, x0, y0, x1, y1, radius));
const strokes = strokesTouched(session.ownLayer, x0, y0, x1, y1, radius);
const texts = textsTouched(session.ownLayer, x0, y0, x1, y1, radius);
session.erase(strokes);
session.removeTexts(texts);
return { strokes: strokes.length, texts: texts.length };
},
eraseEnded: (x, y, reach) => {
// A tap of the eraser on the image we have just added takes it back.
const image = session.removableImage;
if (image && reach < TAP_DISTANCE && x >= image.x && x <= image.x + image.width &&
y >= image.y && y <= image.y + image.height) {
session.removeAddedImage();
// A tap of the eraser on a Hide rectangle or an image removes it
// (the right to make pages, or having added the image).
if (reach >= TAP_DISTANCE) return;
const hide = session.page.hideAt(x, y, 0);
if (hide && session.removeHide(hide.index)) {
log(`Hide ${hide.index + 1} removed`);
return;
}
const found = session.page.imageAt(x, y, 0);
if (found && session.removeImage(found.index)) log(`Image ${found.index + 1} removed`);
},
previewRect: (x0, y0, x1, y1) => {
if (Math.abs(x1 - x0) >= TAP_DISTANCE || Math.abs(y1 - y0) >= TAP_DISTANCE) {
textEditor?.showPreview(x0, y0, x1, y1);
} else {
textEditor?.hidePreview();
}
},
previewText: (x0, y0, x1) => {
if (Math.abs(x1 - x0) >= TAP_DISTANCE) textEditor?.showPreview(x0, y0, x1);
else textEditor?.hidePreview();
},
placeText: (x0, y0, x1, y1) => {
placeRect: (x0, y0, x1, y1) => {
if (!textEditor) return;
textEditor.hidePreview();
const dragged = Math.hypot(x1 - x0, y1 - y0) >= TAP_DISTANCE;
const rect = {
left: Math.min(x0, x1), top: Math.min(y0, y1),
width: Math.max(100, Math.round(Math.abs(x1 - x0))), height: Math.max(100, Math.round(Math.abs(y1 - y0))),
};
if (tool === 'hide') {
if (dragged && session.addHide(rect)) log('Hide made');
return;
}
const editing = textEditor.block;
textEditor.stop();
const own = textAt(session.ownLayer, x0, y0);
if (Math.hypot(x1 - x0, y1 - y0) < TAP_DISTANCE) {
// A tap: into our own text to change it, or a new block here.
if (!dragged) {
// A tap: into our own text to change it, or free text here. A
// tap elsewhere while typing only finishes the block (2026-10-09).
if (own && own !== editing) textEditor.edit(own);
else if (!own) textEditor.begin(x0, y0, null, { color, size: textSize, font: textFont });
else if (!own && !editing) textEditor.begin(x0, y0, null, null, { color, size: textSize, font: textFont });
} else {
// A drag along the top edge: a new block that wraps at its width.
textEditor.begin(Math.min(x0, x1), y0, Math.max(100, Math.round(Math.abs(x1 - x0))),
// A rectangle dragged open: at least one line tall.
const line = Math.ceil(textSize * (TOP_GAP + LINE_HEIGHT));
textEditor.begin(rect.left, rect.top, rect.width, Math.max(rect.height, line),
{ color, size: textSize, font: textFont });
}
},
onReport: (report) => {
element('pen_report').textContent =
`Input: ${report.pointerType}, pressure ${report.pressure.toFixed(2)}, ` +
`${report.pointsPerSecond} points per second`;
// To the console once, and again when the kind of device changes.
if (report.pointerType === reportedDevice || report.pointsPerSecond === 0) return;
reportedDevice = report.pointerType;
log(`Input: ${report.pointerType}, pressure ${report.pressure.toFixed(2)}, ` +
`${report.pointsPerSecond} points per second`);
},
});
};
attachPenInput(pageElement, penTarget);
buildToolbar({
color, width, textSize, textFont,
setTool: (value) => {
if (value !== 'text') textEditor?.stop();
if (value !== tool) trace(`Tool: ${value}`);
tool = value;
// Only the chosen tool's settings show: the pen's colours and line
// widths, the text's colours, size and font, nothing for the eraser.
element('colors').hidden = value === 'eraser';
element('colors').hidden = value === 'eraser' || value === 'hide';
element('widths').hidden = value !== 'pen';
element('text_style').hidden = value !== 'text';
},
@@ -524,9 +922,6 @@ async function enterSession(invitationText, name, notebook) {
});
// Zoom is each person's own; it changes nothing on the page.
const showZoom = () => {
element('zoom_fit').setAttribute('aria-pressed', String(renderer.zoom === 'fit'));
};
// A reloaded tab shows the page as it was last seen in it (Andy,
// 2026-10-05: the least surprise): the zoom and the place scrolled to
// are remembered in the tab, for the page they belong to.
@@ -541,14 +936,31 @@ async function enterSession(invitationText, name, notebook) {
viewTimer = setTimeout(saveView, 300);
};
renderer.onZoomChanged = () => {
showZoom();
textEditor?.layout();
viewChanged();
};
element('zoom_fit').addEventListener('click', () => renderer.setZoom('fit'));
element('zoom_in').addEventListener('click', () => renderer.zoomBy(ZOOM_STEP));
element('zoom_out').addEventListener('click', () => renderer.zoomBy(1 / ZOOM_STEP));
showZoom();
// The host's Fit and A4 reach everyone (Andy, 2026-10-08); a guest's
// are the guest's own.
element('zoom_fit').addEventListener('click', () => {
renderer.fit();
session.sendViewCommand('fit');
});
element('zoom_sheet').addEventListener('click', () => {
renderer.sheetScale();
session.sendViewCommand('sheet');
});
// "Widen" makes the sheet itself wider, until it fills the window at
// the present zoom; nothing on it moves (Andy, 2026-10-08).
element('zoom_widen').addEventListener('click', () => {
const room = renderer.available().width / renderer.cssPixelsPerUnit;
const widened = session.widenPage(room);
if (widened) log(`Page widened to ${session.page.baseWidth / 100} cm`);
});
element('zoom_shrink').addEventListener('click', () => {
if (session.shrinkPage()) {
log(`Page shrunk to ${session.page.baseWidth / 100} by ${session.page.baseHeight / 100} cm`);
}
});
try {
const seen = JSON.parse(sessionStorage.getItem(viewKey) ?? 'null');
if (seen && seen.page === session.page.id && (seen.zoom === 'fit' || seen.zoom > 0) &&
@@ -564,35 +976,42 @@ async function enterSession(invitationText, name, notebook) {
// The host drives: the host starts new pages, and may let a guest do so.
function showHostTools() {
element('host_tools').hidden = !session.mayMakePages();
// The Hide tool is for the host and co-hosts; losing the right
// while it is chosen puts the pen back in hand.
const hideTool = /** @type {HTMLElement} */ (document.querySelector('[data-tool=hide]'));
hideTool.hidden = !session.mayMakePages();
if (hideTool.hidden && tool === 'hide') /** @type {HTMLElement} */ (document.querySelector('[data-tool=pen]')).click();
element('zoom_widen').hidden = !session.mayMakePages();
element('zoom_shrink').hidden = !session.mayMakePages();
}
showHostTools();
element('new_page').addEventListener('click', () => session.newPage());
element('new_page').addEventListener('click', () => {
log('New page asked for');
session.newPage();
});
/**
* Add an image file to the page, below the images already on it.
* Add an image file to the page, below the images already on it, at
* its natural size; moving and scaling it is done by hand afterwards.
* @param {Blob} file
* @param {boolean} [shrink] Whether an image wider than the sheet is
* scaled down to it.
*/
async function loadImage(file, shrink = true) {
async function loadImage(file) {
if (!session.mayMakePages() || session.page.number === 0) return;
try {
if (!session.addImage(await prepareImage(file), shrink)) {
if (!session.addImage(await prepareImage(file))) {
throw new Error('The page has no room for another image.');
}
} catch (error) {
element('save_status').textContent =
error instanceof Error && error.message ? error.message : 'That file is not an image.';
element('save_status').classList.add('problem');
passingNotice(error instanceof Error && error.message ? error.message : 'That file is not an image.', true);
}
}
const chooser = new NotebookChooser((chosenNotebook, pageName, text) => {
const kept = pageFromSvg(text);
if (!kept) {
element('save_status').textContent = `${pageName} is not a Kokkoja page.`;
element('save_status').classList.add('problem');
passingNotice(`${pageName} is not a Kokkoja page.`, true);
return;
}
log(`Notebook page loaded: ${chosenNotebook} / ${pageName}`);
session.loadPage(kept.page);
});
element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook); });
@@ -608,7 +1027,7 @@ async function enterSession(invitationText, name, notebook) {
return;
}
const file = await fileChooser.chooseImage();
if (file) loadImage(file, fileChooser.shrinksWideImages());
if (file) loadImage(file);
});
/**
@@ -628,8 +1047,8 @@ async function enterSession(invitationText, name, notebook) {
if (notebooksInBrowser()) {
download(`${day}_page_${page.number}.png`,
await pageToPng(page, { session: roomId, hostId: session.hostId, hidden: renderer.hidden }));
element('save_status').textContent = 'Exported to your downloads';
element('save_status').classList.remove('problem');
log(`Exported page ${page.number} to the downloads`);
passingNotice('Exported to your downloads');
return;
}
const saved = await fileChooser.chooseWhereToSave(
@@ -639,38 +1058,64 @@ async function enterSession(invitationText, name, notebook) {
await pageToPng(page, { session: roomId, hostId: session.hostId, hidden: renderer.hidden }),
replace));
if (saved === null) return;
element('save_status').textContent = `Exported to ${saved}`;
element('save_status').classList.remove('problem');
log(`Exported page ${page.number} to ${saved}`);
passingNotice(`Exported to ${saved}`);
}
element('export_page').addEventListener('click', () => { exportPage(); });
// The notebook: its name, and keeping the page.
const notebookField = input('session_notebook');
notebookField.value = notebook;
showNotebooks().catch(() => {});
notebookField.addEventListener('change', () => {
const chosen = notebookField.value.trim();
if (!chosen || chosen === writer.notebook) {
notebookField.value = writer.notebook;
return;
}
sessionStorage.setItem(notebookKey, chosen);
localStorage.setItem(NOTEBOOK_KEY, chosen);
writer.changeNotebook(chosen, (text) => {
const saved = pageFromSvg(text);
return saved !== null && saved.session === roomId && saved.page.id === session.page.id;
}).then(() => showNotebooks()).catch(() => {});
});
// The notebook: choosing one, making one, and keeping the page.
const sessionPicker = new NotebookPicker(
select('session_notebook'), input('session_notebook_name'),
(message) => { if (message) passingNotice(message, true); },
(chosen) => {
if (chosen === writer.notebook) return;
sessionStorage.setItem(notebookKey, chosen);
localStorage.setItem(NOTEBOOK_KEY, chosen);
writer.changeNotebook(chosen, (text) => {
const saved = pageFromSvg(text);
return saved !== null && saved.session === roomId && saved.page.id === session.page.id;
}).then(() => showNotebooks(sessionPicker)).catch(() => {});
});
sessionPicker.fill([], notebook);
sessionPicker.current = notebook;
showNotebooks(sessionPicker).catch(() => {});
element('keep_page').addEventListener('click', async () => {
const kept = await writer.keepPage();
const { kept, new: made } = await writer.keepPage();
log(kept ? `${made ? 'Kept' : 'Already kept'} as ${kept}` : 'Nothing to keep');
// In the browser the notebook is out of reach of the user's own
// programs: a kept page is also downloaded, which is how a page gets out.
if (kept && notebooksInBrowser()) {
// programs: a page kept just now is also downloaded, which is how a
// page gets out. One already kept was downloaded then, and is not
// again (Andy, 2026-10-06: a second press should do nothing new).
if (kept && made && notebooksInBrowser()) {
const text = await readPage(writer.notebook, kept);
if (text !== null) download(kept, new Blob([text], { type: 'image/svg+xml' }));
}
});
const sessionDialog = /** @type {HTMLDialogElement} */ (element('session_dialog'));
element('session_button').addEventListener('click', () => {
showLog();
sessionDialog.showModal();
});
element('session_close').addEventListener('click', () => sessionDialog.close());
// The log in the Session window, kept current while the window is open.
const logView = element('event_log');
function showLog() {
logView.textContent = logText();
logView.scrollTop = logView.scrollHeight;
}
watchLog(() => { if (sessionDialog.open) showLog(); });
element('copy_log').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(logText());
element('copy_log').textContent = 'Copied';
setTimeout(() => { element('copy_log').textContent = 'Copy'; }, 1500);
} catch {
// The text is selectable; the user can copy it by hand.
}
});
element('copy_invitation').addEventListener('click', async () => {
const field = input('invitation');
field.select();
@@ -716,7 +1161,7 @@ function press(group, chosen) {
* @param {number} options.width Initial width.
* @param {number} options.textSize
* @param {import('./page_model.js').TextFont} options.textFont
* @param {(tool: 'pen' | 'eraser' | 'text') => void} options.setTool
* @param {(tool: 'pen' | 'eraser' | 'text' | 'hide') => void} options.setTool
* @param {(color: string) => void} options.setColor
* @param {(width: number) => void} options.setWidth
* @param {(size: number) => void} options.setTextSize
@@ -727,7 +1172,7 @@ function buildToolbar(options) {
button.addEventListener('click', () => {
press(/** @type {Element} */ (button.parentElement), button);
const chosen = button.getAttribute('data-tool');
options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : 'pen');
options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : chosen === 'hide' ? 'hide' : 'pen');
});
}
@@ -778,20 +1223,35 @@ function buildToolbar(options) {
press(colors, button);
options.setColor(color);
};
for (const color of PALETTE) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'swatch';
button.style.setProperty('--swatch', color);
button.setAttribute('aria-label', `Color ${color}`);
button.addEventListener('click', () => choose(color));
palette.append(button);
}
// A colour that is already in the row is missing from the palette, its
// place in the grid left empty (Andy's teacher, 2026-10-08).
const inRow = () => new Set([...colors.querySelectorAll('.swatch:not(.more)')]
.map((swatch) => swatch.getAttribute('aria-label')?.replace('Color ', '')));
const showPalette = () => {
palette.replaceChildren();
const taken = inRow();
for (const color of PALETTE) {
if (taken.has(color)) {
const gap = document.createElement('span');
gap.className = 'swatch missing';
palette.append(gap);
continue;
}
const button = document.createElement('button');
button.type = 'button';
button.className = 'swatch';
button.style.setProperty('--swatch', color);
button.setAttribute('aria-label', `Color ${color}`);
button.addEventListener('click', () => choose(color));
palette.append(button);
}
};
more.addEventListener('click', () => {
if (!palette.hidden) {
palette.hidden = true;
return;
}
showPalette();
const box = more.getBoundingClientRect();
palette.style.left = `${box.left}px`;
palette.style.top = `${box.bottom + 6}px`;
@@ -861,15 +1321,17 @@ function showParticipants(session, renderer) {
item.classList.toggle('absent', !present);
if (session.isHost && !participant.self) {
const allow = document.createElement('button');
allow.type = 'button';
// A checkbox with its label to the left, like Extend (Andy, 2026-10-09).
const allowLine = document.createElement('label');
allowLine.className = 'allow_line';
allowLine.setAttribute('data-help', 'Make this participant a co-host: they may then start pages, put images on the page and arrange them, load notebook pages, widen, shrink and hide, as you can.');
const allow = document.createElement('input');
allow.type = 'checkbox';
allow.className = 'allow';
allow.textContent = 'pages';
allow.setAttribute('data-help', 'Let this participant start new pages and load images.');
const allowed = session.permitted.has(participant.id);
allow.setAttribute('aria-pressed', String(allowed));
allow.addEventListener('click', () => session.setPermitted(participant.id, !allowed));
item.append(allow);
allow.checked = session.permitted.has(participant.id);
allow.addEventListener('change', () => session.setPermitted(participant.id, allow.checked));
allowLine.append('Co-host ', allow);
item.append(allowLine);
}
const label = document.createElement('label');
@@ -884,6 +1346,7 @@ function showParticipants(session, renderer) {
const roles = [];
if (participant.self) roles.push('you');
if (participant.host) roles.push('host');
else if (!session.isHost && session.permitted.has(participant.id)) roles.push('co-host');
if (!present) roles.push('away');
if (roles.length) {
const role = document.createElement('span');
@@ -932,6 +1395,8 @@ async function resume() {
}
enableTooltips();
guardSelection();
log(`Browser: ${navigator.userAgent}; window ${innerWidth}×${innerHeight}, pixel ratio ${devicePixelRatio}`);
const token = addressValues().get('token');
if (token) {
+16 -15
View File
@@ -205,12 +205,15 @@ export class NotebookWriter {
* @param {string} notebook
* @param {() => string | null} content Produces the file of the present
* page, or null if there is no page to save yet.
* @param {(message: string, problem: boolean) => void} onStatus
* @param {object} events
* @param {(time: string) => void} events.onSaved The page was written, at that time of day.
* @param {(message: string) => void} events.onKept What keeping the page did.
* @param {(message: string) => void} events.onProblem A write or keep failed.
*/
constructor(notebook, content, onStatus) {
constructor(notebook, content, events) {
this.notebook = notebook;
this.content = content;
this.onStatus = onStatus;
this.events = events;
/** @type {number | null} */
this.timer = null;
/** Time of the oldest change not yet written, or 0. */
@@ -232,7 +235,7 @@ export class NotebookWriter {
*/
enqueue(work) {
this.queue = this.queue.then(work).catch((error) => {
this.onStatus(error instanceof Error ? error.message : String(error), true);
this.events.onProblem(error instanceof Error ? error.message : String(error));
// The page is still in memory: try to write it again shortly.
if (this.timer === null) this.timer = setTimeout(() => this.writeNow(), RETRY_MS);
});
@@ -272,27 +275,25 @@ export class NotebookWriter {
const text = this.content();
if (text === null) return;
await this.put(text);
this.onStatus(`Saved at ${new Date().toLocaleTimeString()}`, false);
this.events.onSaved(new Date().toLocaleTimeString());
});
}
/**
* The user asked to keep the page as it is now.
* @returns {Promise<string | null>} The name of the kept page, or null
* if there was nothing to keep.
* @returns {Promise<{kept: string | null, new: boolean}>} The name of
* the kept page, or null if there was nothing to keep, and whether
* the copy was made just now.
*/
keepPage() {
this.writeNow();
/** @type {string | null} */
let kept = null;
let result = { kept: /** @type {string | null} */ (null), new: false };
return this.enqueue(async () => {
const result = await keepCurrentPage(this.notebook);
kept = result.kept;
this.onStatus(
result = await keepCurrentPage(this.notebook);
this.events.onKept(
result.kept === null ? 'The page is blank: nothing to keep'
: result.new ? `Kept as ${result.kept}` : `Already kept as ${result.kept}`,
false);
}).then(() => kept);
: result.new ? `Kept as ${result.kept}` : `Already kept as ${result.kept}`);
}).then(() => result);
}
/**
+151
View File
@@ -0,0 +1,151 @@
// @ts-check
/**
* A notebook picker: a list of the notebooks with "New notebook…" as its
* last entry, which asks for the name in a field shown in the list's
* place (not in a browser dialog). Used on the start screen and in the
* session's panel. A notebook exists once a page is written into it,
* so making one is naming it.
*/
import { listNotebooks } from './notebook.js';
/** The notebook used when none is chosen: the one for whatever has no notebook of its own. */
export const DEFAULT_NOTEBOOK = 'General';
/** The value of the "New notebook…" entry; no notebook can be named so. */
const NEW = '/new';
/** What the local program accepts as a notebook name (NAME in kokkoja.py). */
const NAME_ALLOWED = /^[^\x00-\x1f/\\:*?"<>|.][^\x00-\x1f/\\:*?"<>|]{0,119}$/;
const NAME_REFUSED = 'A notebook name cannot contain / \\ : * ? " < > | and cannot begin with a dot.';
export class NotebookPicker {
/**
* @param {HTMLSelectElement} select
* @param {HTMLInputElement} entry The field for a new name.
* @param {(message: string) => void} say Explains a refused name; '' clears.
* @param {(notebook: string) => void} onChosen The choice changed.
*/
constructor(select, entry, say, onChosen) {
this.select = select;
this.entry = entry;
this.say = say;
this.onChosen = onChosen;
/** The choice before "New notebook…" was picked, to return to. */
this.previous = '';
select.addEventListener('change', () => {
if (select.value === NEW) this.beginNew();
else onChosen(select.value);
});
entry.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
this.confirm();
} else if (event.key === 'Escape') {
event.preventDefault();
this.cancel();
}
});
// Leaving the field with a usable name confirms it; with none, gives up.
entry.addEventListener('blur', () => {
if (entry.hidden) return;
if (NAME_ALLOWED.test(entry.value.trim())) this.confirm();
else this.cancel();
});
}
/** @returns {string} The chosen notebook. */
get value() {
return this.select.value === NEW ? this.previous : this.select.value;
}
/**
* List the notebooks, with the given one chosen (added to the list if
* it is not there yet, since a notebook exists once a page is written).
* @param {string} current
*/
async show(current) {
let names = /** @type {string[]} */ ([]);
try {
names = (await listNotebooks()).notebooks;
} catch {
// Without the list, the current notebook alone is offered.
}
this.fill(names, current);
}
/**
* @param {string[]} names
* @param {string} current
*/
fill(names, current) {
const all = [...new Set([...names, current])].filter(Boolean).sort((a, b) => a.localeCompare(b));
this.select.replaceChildren();
for (const name of all) {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
this.select.append(option);
}
const make = document.createElement('option');
make.value = NEW;
make.textContent = 'New notebook…';
this.select.append(make);
this.select.value = current;
}
beginNew() {
this.previous = this.previous || this.select.options[0]?.value || '';
this.say('');
this.select.hidden = true;
this.entry.value = '';
this.entry.hidden = false;
this.entry.focus();
}
confirm() {
const name = this.entry.value.trim();
if (!NAME_ALLOWED.test(name)) {
this.say(name ? NAME_REFUSED : '');
if (!name) this.cancel();
return;
}
this.say('');
if (![...this.select.options].some((option) => option.value === name)) {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
this.select.insertBefore(option, this.select.options[this.select.options.length - 1]);
}
this.select.value = name;
this.previous = name;
this.endEntry();
this.onChosen(name);
}
cancel() {
this.select.value = this.previous;
this.endEntry();
}
endEntry() {
this.entry.hidden = true;
this.select.hidden = false;
}
/**
* The choice, made from outside; it is also the one to return to if
* "New notebook…" is given up.
* @param {string} name
*/
set current(name) {
this.previous = name;
this.select.value = name;
}
/** @returns {string} */
get current() {
return this.value;
}
}
+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'),
+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;
}
+174 -21
View File
@@ -5,6 +5,7 @@
*/
import { rounded } from './page_model.js';
import { trace } from './log.js';
/** Bit in PointerEvent.buttons for the eraser end of a pen. */
const ERASER_BUTTON = 32;
@@ -19,23 +20,53 @@ const NOMINAL_PRESSURE = 0.4;
* @typedef {object} PenTarget
* @property {(clientX: number, clientY: number) => [number, number]} toPage
* Convert a window position to page units.
* @property {() => 'pen' | 'eraser' | 'text'} tool The tool selected in the toolbar.
* @property {() => 'pen' | 'eraser' | 'text' | 'hide'} tool The tool selected in the toolbar.
* @property {() => boolean} canWrite False while there is no page to write on.
* @property {() => void} beginStroke
* @property {(points: number[]) => void} addPoints
* @property {() => void} endStroke
* @property {(x0: number, y0: number, x1: number, y1: number) => void} eraseAlong
* @property {(x0: number, y0: number, x1: number, y1: number) => {strokes: number, texts: number}} eraseAlong
* Erases one's own strokes and text blocks along the movement and says how many.
* @property {(x: number, y: number, reach: number) => void} [eraseEnded]
* The eraser went down at (x, y) and has come up again, having moved
* no farther than `reach` from there.
* @property {(x0: number, y0: number, x1: number, y1: number) => void} placeText
* With the text tool, the pointer went down at (x0, y0) and up at (x1, y1).
* @property {(x0: number, y0: number, x1: number) => void} [previewText]
* With the text tool, the pointer is down at (x0, y0) and now at x1.
* @property {(x0: number, y0: number, x1: number, y1: number) => void} placeRect
* With the text or Hide tool, the pointer went down at (x0, y0) and up
* at (x1, y1): a tap, or a rectangle dragged open.
* @property {(x0: number, y0: number, x1: number, y1: number) => void} [previewRect]
* With the text or Hide tool, the pointer is down at (x0, y0) and now at (x1, y1).
* @property {(clientX: number, clientY: number) => boolean} [beginHold]
* The pointer has rested on the page for HOLD_MS, with any tool: a
* hold. True if something under it took the hold (an image to move
* or scale); the stroke or text placement begun is then abandoned
* here, and the pointer's movement goes to holdMove. With the
* eraser, erasing waits until the pointer moves or is lifted, so that
* a hold erases nothing (Andy, 2026-10-08: every tool can move).
* @property {(clientX: number, clientY: number) => void} [holdMove]
* @property {() => void} [holdEnd]
* @property {(clientX: number, clientY: number) => void} [beginZoom]
* The pointer went down with Ctrl (or Command) held: a zoom drag.
* @property {(pixelsUp: number) => void} [zoomTo]
* The zoom drag has moved this far up the screen (negative: down).
* @property {() => void} [endZoom]
* @property {(report: PenReport) => void} [onReport]
*/
/**
* A zoom drag shorter than this, in screen pixels, zooms nothing, so that
* a tap with Ctrl held is not a zoom.
*/
const ZOOM_DEAD_ZONE = 4;
/**
* A press that rests this long becomes a hold, which moves or scales the
* image under it (Andy's teacher, 2026-10-08; 600 ms so that a careful
* start of a stroke is not one). Moving farther than HOLD_SLACK_PX
* before then makes it a stroke after all.
*/
export const HOLD_MS = 600;
export const HOLD_SLACK_PX = 6;
/**
* What the input device is delivering, for judging pen support.
* @typedef {object} PenReport
@@ -44,6 +75,13 @@ const NOMINAL_PRESSURE = 0.4;
* @property {number} pointsPerSecond
*/
/** @param {EventTarget | null} target @returns {string} The element, for the trace. */
function describeTarget(target) {
if (!(target instanceof Element)) return 'nothing';
const name = target.tagName.toLowerCase();
return target.id ? `${name}#${target.id}` : (target.className ? `${name}.${target.className}` : name);
}
/**
* @param {HTMLElement} surface The element that receives the pointer.
* @param {PenTarget} target
@@ -53,6 +91,28 @@ export function attachPenInput(surface, target) {
let activePointer = null;
let erasing = false;
let placingText = false;
let zooming = false;
let zoomStartY = 0;
/** @type {ReturnType<typeof setTimeout> | null} */
let holdTimer = null;
let holding = false;
let pressX = 0;
let pressY = 0;
/** @type {PointerEvent | null} The eraser's press, not yet erased with. */
let eraseWaiting = null;
/** The eraser's press erases now, if it was waiting. */
function eraseFromPress() {
if (!eraseWaiting) return;
const press = eraseWaiting;
eraseWaiting = null;
erase(press);
}
function cancelHoldTimer() {
if (holdTimer !== null) clearTimeout(holdTimer);
holdTimer = null;
}
let textStartX = 0;
let textStartY = 0;
let eraseStartX = 0;
@@ -63,6 +123,9 @@ export function attachPenInput(surface, target) {
let lastY = NaN;
let strokeStart = 0;
let strokePoints = 0;
/** What the eraser removed since its press, for the trace. */
let erasedStrokes = 0;
let erasedTexts = 0;
/**
* @param {PointerEvent} event
@@ -103,35 +166,77 @@ export function attachPenInput(surface, target) {
function erase(event) {
for (const e of allEvents(event)) {
const [x, y] = target.toPage(e.clientX, e.clientY);
target.eraseAlong(Number.isNaN(lastX) ? x : lastX, Number.isNaN(lastY) ? y : lastY, x, y);
const removed = target.eraseAlong(Number.isNaN(lastX) ? x : lastX, Number.isNaN(lastY) ? y : lastY, x, y);
erasedStrokes += removed.strokes;
erasedTexts += removed.texts;
eraseReach = Math.max(eraseReach, Math.hypot(x - eraseStartX, y - eraseStartY));
lastX = x;
lastY = y;
}
}
/** @param {PointerEvent} event @returns {string} The press, for the trace. */
function describe(event) {
const where = event.target === surface ? '' : ` on ${describeTarget(event.target)}`;
return `${event.pointerType} ${event.pointerId} buttons ${event.buttons} pressure ${event.pressure.toFixed(2)}${where}`;
}
surface.addEventListener('pointerdown', (event) => {
if (activePointer !== null || !target.canWrite() || event.target !== surface) return;
/** @param {string} why */
const refuse = (why) => trace(`press ${describe(event)} refused: ${why}`);
if (activePointer !== null) return refuse(`pointer ${activePointer} still active`);
if (event.target !== surface) return refuse('not on the page');
if (event.pointerType === 'pen') penSeen = true;
// Once a pen has been used, a touch is taken to be the resting hand.
if (event.pointerType === 'touch' && penSeen) return;
if (event.pointerType === 'mouse' && event.button !== 0) return;
if (event.pointerType === 'touch' && penSeen) return refuse('a touch after a pen: the resting hand');
if (event.pointerType === 'mouse' && event.button !== 0) return refuse('not the main button');
// With Ctrl (Command on a Mac) held, a drag up or down zooms and
// draws nothing (Andy, 2026-10-08).
zooming = (event.ctrlKey || event.metaKey) && target.beginZoom !== undefined;
if (!zooming && !target.canWrite()) return refuse('cannot write now');
event.preventDefault();
surface.setPointerCapture(event.pointerId);
activePointer = event.pointerId;
trace(`press ${describe(event)}, ${target.tool()}${zooming ? ', zooming' : ''}`);
if (zooming) {
zoomStartY = event.clientY;
target.beginZoom?.(event.clientX, event.clientY);
return;
}
const tool = target.tool();
erasing = tool === 'eraser' || (event.buttons & ERASER_BUTTON) !== 0;
placingText = tool === 'text' && !erasing;
placingText = (tool === 'text' || tool === 'hide') && !erasing;
lastX = NaN;
lastY = NaN;
strokeStart = performance.now();
strokePoints = 0;
erasedStrokes = 0;
erasedTexts = 0;
holding = false;
eraseWaiting = null;
// Resting the pointer, with any tool, may become a hold on an image.
if (target.beginHold) {
pressX = event.clientX;
pressY = event.clientY;
holdTimer = setTimeout(() => {
holdTimer = null;
if (activePointer === null) return;
if (target.beginHold?.(pressX, pressY)) {
holding = true;
placingText = false;
eraseWaiting = null;
} else {
eraseFromPress();
}
}, HOLD_MS);
}
if (placingText) {
[textStartX, textStartY] = target.toPage(event.clientX, event.clientY);
} else if (erasing) {
[eraseStartX, eraseStartY] = target.toPage(event.clientX, event.clientY);
eraseReach = 0;
erase(event);
if (target.beginHold) eraseWaiting = event;
else erase(event);
} else {
target.beginStroke();
draw(event);
@@ -140,23 +245,53 @@ export function attachPenInput(surface, target) {
surface.addEventListener('pointermove', (event) => {
if (event.pointerId !== activePointer) return;
if (placingText) {
const [x] = target.toPage(event.clientX, event.clientY);
target.previewText?.(textStartX, textStartY, x);
if (holding) {
target.holdMove?.(event.clientX, event.clientY);
return;
}
if (erasing) erase(event);
else draw(event);
if (holdTimer !== null && Math.hypot(event.clientX - pressX, event.clientY - pressY) > HOLD_SLACK_PX) {
cancelHoldTimer();
eraseFromPress();
}
if (zooming) {
const up = zoomStartY - event.clientY;
const beyond = Math.max(0, Math.abs(up) - ZOOM_DEAD_ZONE);
target.zoomTo?.(Math.sign(up) * beyond);
return;
}
if (placingText) {
const [x, y] = target.toPage(event.clientX, event.clientY);
target.previewRect?.(textStartX, textStartY, x, y);
return;
}
if (erasing) {
if (!eraseWaiting) erase(event);
} else {
draw(event);
}
});
/** @param {PointerEvent} event */
function finish(event) {
if (event.pointerId !== activePointer) return;
if (event.pointerId !== activePointer) {
trace(`${event.type} ${event.pointerType} ${event.pointerId}, not the active pointer`);
return;
}
trace(`${event.type} ${event.pointerType} ${event.pointerId}, ` +
(erasing ? `erased ${erasedStrokes} strokes and ${erasedTexts} texts` : `${strokePoints} points`));
activePointer = null;
if (placingText) {
cancelHoldTimer();
if (holding) {
holding = false;
target.holdEnd?.();
} else if (zooming) {
zooming = false;
target.endZoom?.();
} else if (placingText) {
const [x, y] = target.toPage(event.clientX, event.clientY);
if (event.type === 'pointerup') target.placeText(textStartX, textStartY, x, y);
if (event.type === 'pointerup') target.placeRect(textStartX, textStartY, x, y);
} else if (erasing) {
eraseFromPress();
if (event.type === 'pointerup') target.eraseEnded?.(eraseStartX, eraseStartY, eraseReach);
} else {
target.endStroke();
@@ -164,7 +299,25 @@ export function attachPenInput(surface, target) {
}
surface.addEventListener('pointerup', finish);
surface.addEventListener('pointercancel', finish);
surface.addEventListener('lostpointercapture', (event) => {
if (event.pointerId === activePointer) trace(`capture of pointer ${event.pointerId} lost while active`);
});
for (const type of ['touchstart', 'touchend', 'touchcancel']) {
surface.addEventListener(type, (event) => {
const touches = [...(/** @type {TouchEvent} */ (event).changedTouches)]
.map((touch) => /** @type {{touchType?: string}} */ (touch).touchType ?? 'touch');
trace(`${type} ${touches.join(' ')}${event.target === surface ? '' : ` on ${describeTarget(event.target)}`}`);
});
}
// The side button of a pen asks for a context menu.
surface.addEventListener('contextmenu', (event) => event.preventDefault());
// Safari on iOS: cancelling pointerdown leaves the touch's own
// behaviour (selection, the magnifier, the callout) to WebKit; only a
// cancelled touchstart stops it. The text field is a child of the
// surface and keeps its touches, or it could not be tapped into.
surface.addEventListener('touchstart', (event) => {
if (event.target === surface) event.preventDefault();
}, { passive: false });
}
+388
View File
@@ -0,0 +1,388 @@
// @ts-check
/**
* A QR code of a text, so that the invitation can be read by the camera
* of a phone or a tablet (Andy, 2026-10-10: there is no good way to send
* a link from a Linux computer to an iPad). Written for Kokkoja since
* the client uses no packages: byte mode, any version from 1 to 40,
* error correction level M, the mask chosen by the standard's penalty
* rules (ISO/IEC 18004). `tests/test_qr_code.py` compares every version
* and level with Nayuki's reference encoder, mask choice included.
*/
/** Error correction levels, as the format bits name them. */
const LEVELS = { L: 1, M: 0, Q: 3, H: 2 };
/** @typedef {keyof typeof LEVELS} Level */
/** Error correction codewords per block, by level then version (index 0 unused). */
const ECC_PER_BLOCK = {
L: [0, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
M: [0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
Q: [0, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
H: [0, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
};
/** Number of blocks, by level then version. */
const BLOCKS = {
L: [0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25],
M: [0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49],
Q: [0, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],
H: [0, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81],
};
/** @param {number} version @returns {number} Modules not taken by function patterns. */
function rawDataModules(version) {
let result = (16 * version + 128) * version + 64;
if (version >= 2) {
const aligns = Math.floor(version / 7) + 2;
result -= (25 * aligns - 10) * aligns - 55;
if (version >= 7) result -= 36;
}
return result;
}
/** @param {number} version @param {Level} level @returns {number} Data codewords. */
function dataCodewords(version, level) {
return Math.floor(rawDataModules(version) / 8) - ECC_PER_BLOCK[level][version] * BLOCKS[level][version];
}
/** @param {number} version @returns {number} Bits of the byte count in byte mode. */
function countBits(version) {
return version < 10 ? 8 : 16;
}
/**
* @param {number} byteCount
* @param {Level} level
* @returns {number} The smallest version holding that many bytes.
*/
function versionFor(byteCount, level) {
for (let version = 1; version <= 40; version++) {
if (4 + countBits(version) + byteCount * 8 <= dataCodewords(version, level) * 8) return version;
}
throw new Error('Too long for a QR code');
}
/** @param {number} version @returns {number[]} Centres of the alignment patterns, one axis. */
function alignmentPositions(version) {
if (version === 1) return [];
const count = Math.floor(version / 7) + 2;
const size = version * 4 + 17;
const step = Math.floor((version * 8 + count * 3 + 5) / (count * 4 - 4)) * 2;
const result = [6];
for (let position = size - 7; result.length < count; position -= step) result.splice(1, 0, position);
return result;
}
/* Reed-Solomon over GF(2^8) with the standard's polynomial. */
/** @param {number} x @param {number} y @returns {number} */
function multiply(x, y) {
let z = 0;
for (let i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11d);
z ^= ((y >>> i) & 1) * x;
}
return z;
}
/** @param {number} degree @returns {number[]} The generator polynomial's coefficients, highest first after the leading 1. */
function generator(degree) {
let result = Array(degree - 1).fill(0).concat([1]);
let root = 1;
for (let i = 0; i < degree; i++) {
result = result.map((coefficient, j) => multiply(coefficient, root) ^ (j + 1 < result.length ? result[j + 1] : 0));
root = multiply(root, 2);
}
return result;
}
/** @param {number[]} data @param {number[]} divisor @returns {number[]} The remainder. */
function remainder(data, divisor) {
const result = Array(divisor.length).fill(0);
for (const byte of data) {
const factor = byte ^ /** @type {number} */ (result.shift());
result.push(0);
divisor.forEach((coefficient, i) => { result[i] ^= multiply(coefficient, factor); });
}
return result;
}
/**
* @param {Uint8Array} bytes
* @param {number} version
* @param {Level} level
* @returns {number[]} All codewords, data and error correction interleaved.
*/
function codewords(bytes, version, level) {
/** @type {number[]} */
const bits = [];
/** @param {number} value @param {number} count */
const append = (value, count) => { for (let i = count - 1; i >= 0; i--) bits.push((value >>> i) & 1); };
append(4, 4);
append(bytes.length, countBits(version));
for (const byte of bytes) append(byte, 8);
const capacity = dataCodewords(version, level) * 8;
append(0, Math.min(4, capacity - bits.length));
append(0, (8 - bits.length % 8) % 8);
for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) append(pad, 8);
/** @type {number[]} */
const data = [];
for (let i = 0; i < bits.length; i += 8) data.push(parseInt(bits.slice(i, i + 8).join(''), 2));
const blockCount = BLOCKS[level][version];
const eccCount = ECC_PER_BLOCK[level][version];
const total = Math.floor(rawDataModules(version) / 8);
const shortBlocks = blockCount - total % blockCount;
const shortLength = Math.floor(total / blockCount);
const divisor = generator(eccCount);
/** @type {number[][]} */
const blocks = [];
for (let i = 0, start = 0; i < blockCount; i++) {
const length = shortLength - eccCount + (i < shortBlocks ? 0 : 1);
const block = data.slice(start, start + length);
start += length;
const ecc = remainder(block, divisor);
if (i < shortBlocks) block.push(0); // A placeholder, skipped below.
blocks.push(block.concat(ecc));
}
/** @type {number[]} */
const result = [];
for (let i = 0; i < blocks[0].length; i++) {
blocks.forEach((block, j) => {
if (i !== shortLength - eccCount || j >= shortBlocks) result.push(block[i]);
});
}
return result;
}
/**
* The modules of a QR code: `modules[y][x]` is true where dark.
* @typedef {boolean[][]} Modules
*/
/**
* @param {Uint8Array} bytes
* @param {Level} level
* @returns {Modules}
*/
function encodeBytes(bytes, level) {
const version = versionFor(bytes.length, level);
const size = version * 4 + 17;
/** @type {Modules} */
const modules = Array.from({ length: size }, () => Array(size).fill(false));
/** @type {boolean[][]} Where function patterns are, not to be masked. */
const reserved = Array.from({ length: size }, () => Array(size).fill(false));
/** @param {number} x @param {number} y @param {boolean} dark */
const setFunction = (x, y, dark) => { modules[y][x] = dark; reserved[y][x] = true; };
// Timing patterns, finders with their separators, alignment patterns.
for (let i = 0; i < size; i++) {
setFunction(6, i, i % 2 === 0);
setFunction(i, 6, i % 2 === 0);
}
/** @param {number} cx @param {number} cy */
const finder = (cx, cy) => {
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= size || y >= size) continue;
const distance = Math.max(Math.abs(dx), Math.abs(dy));
setFunction(x, y, distance !== 2 && distance !== 4);
}
}
};
finder(3, 3);
finder(size - 4, 3);
finder(3, size - 4);
const aligns = alignmentPositions(version);
aligns.forEach((cx, i) => {
aligns.forEach((cy, j) => {
const onFinder = (i === 0 && j === 0) || (i === 0 && j === aligns.length - 1) ||
(i === aligns.length - 1 && j === 0);
if (onFinder) return;
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
setFunction(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
}
}
});
});
/** @param {number} mask */
const drawFormat = (mask) => {
const data = (LEVELS[level] << 3) | mask;
let rem = data;
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
const bits = ((data << 10) | rem) ^ 0x5412;
/** @param {number} i @returns {boolean} */
const bit = (i) => ((bits >>> i) & 1) !== 0;
for (let i = 0; i <= 5; i++) setFunction(8, i, bit(i));
setFunction(8, 7, bit(6));
setFunction(8, 8, bit(7));
setFunction(7, 8, bit(8));
for (let i = 9; i < 15; i++) setFunction(14 - i, 8, bit(i));
for (let i = 0; i < 8; i++) setFunction(size - 1 - i, 8, bit(i));
for (let i = 8; i < 15; i++) setFunction(8, size - 15 + i, bit(i));
setFunction(8, size - 8, true);
};
drawFormat(0); // Reserves the places; redrawn with the chosen mask.
if (version >= 7) {
let rem = version;
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
const bits = (version << 12) | rem;
for (let i = 0; i < 18; i++) {
const dark = ((bits >>> i) & 1) !== 0;
const a = size - 11 + (i % 3);
const b = Math.floor(i / 3);
setFunction(a, b, dark);
setFunction(b, a, dark);
}
}
// The codewords, in the zigzag the standard prescribes.
const words = codewords(bytes, version, level);
let i = 0;
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5;
for (let vertical = 0; vertical < size; vertical++) {
for (let j = 0; j < 2; j++) {
const x = right - j;
const upward = ((right + 1) & 2) === 0;
const y = upward ? size - 1 - vertical : vertical;
if (reserved[y][x] || i >= words.length * 8) continue;
modules[y][x] = ((words[i >>> 3] >>> (7 - (i & 7))) & 1) !== 0;
i++;
}
}
}
// The mask with the least penalty.
/** @type {((x: number, y: number) => boolean)[]} */
const masks = [
(x, y) => (x + y) % 2 === 0,
(x, y) => y % 2 === 0,
(x, y) => x % 3 === 0,
(x, y) => (x + y) % 3 === 0,
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
(x, y) => (x * y) % 2 + (x * y) % 3 === 0,
(x, y) => ((x * y) % 2 + (x * y) % 3) % 2 === 0,
(x, y) => ((x + y) % 2 + (x * y) % 3) % 2 === 0,
];
/** @param {number} mask */
const applyMask = (mask) => {
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (!reserved[y][x] && masks[mask](x, y)) modules[y][x] = !modules[y][x];
}
}
};
let best = 0;
let least = Infinity;
for (let mask = 0; mask < 8; mask++) {
applyMask(mask);
drawFormat(mask);
const score = penalty(modules);
if (score < least) {
least = score;
best = mask;
}
applyMask(mask);
}
applyMask(best);
drawFormat(best);
return modules;
}
/**
* The standard's four penalty rules, as Nayuki's reference encoder reads
* them: a finder-like pattern is a run of dark, light, dark, light, dark
* in the ratio 1:1:3:1:1 at any scale, with light four times its unit
* or more on a side (the edge counting as light), scored once a side.
* The tests compare the mask chosen with that encoder's.
* @param {Modules} modules
* @returns {number}
*/
function penalty(modules) {
const size = modules.length;
let score = 0;
/** @param {boolean[]} line */
const scoreLine = (line) => {
/** The lengths of the latest runs, the latest first. */
const runs = [0, 0, 0, 0, 0, 0, 0];
/** @param {number} length */
const addRun = (length) => {
if (runs[0] === 0) length += size; // The edge is light without end.
runs.pop();
runs.unshift(length);
};
/** Finder-like patterns ending with the light run just added. */
const patterns = () => {
const n = runs[1];
const core = n > 0 && runs[2] === n && runs[3] === n * 3 && runs[4] === n && runs[5] === n;
return (core && runs[0] >= n * 4 && runs[6] >= n ? 1 : 0) +
(core && runs[6] >= n * 4 && runs[0] >= n ? 1 : 0);
};
let color = false;
let run = 0;
for (const dark of line) {
if (dark === color) {
run++;
if (run === 5) score += 3;
else if (run > 5) score += 1;
continue;
}
addRun(run);
if (!color) score += patterns() * 40;
color = dark;
run = 1;
}
if (color) {
addRun(run);
run = 0;
}
addRun(run + size);
score += patterns() * 40;
};
for (let y = 0; y < size; y++) scoreLine(modules[y]);
for (let x = 0; x < size; x++) scoreLine(modules.map((row) => row[x]));
let dark = 0;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (modules[y][x]) dark++;
if (x + 1 < size && y + 1 < size) {
const a = modules[y][x];
if (modules[y][x + 1] === a && modules[y + 1][x] === a && modules[y + 1][x + 1] === a) score += 3;
}
}
}
const total = size * size;
const steps = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
return score + steps * 10;
}
/**
* @param {string} text
* @param {Level} [level]
* @returns {Modules}
*/
export function qrModules(text, level = 'M') {
return encodeBytes(new TextEncoder().encode(text), level);
}
/** The light border the standard requires, in modules. */
export const QUIET_ZONE = 4;
/**
* @param {Modules} modules
* @returns {string} An SVG path of the dark modules, one unit a module,
* for a viewBox of `-QUIET_ZONE -QUIET_ZONE size+2*QUIET_ZONE
* size+2*QUIET_ZONE`.
*/
export function qrPath(modules) {
/** @type {string[]} */
const parts = [];
modules.forEach((row, y) => {
row.forEach((dark, x) => { if (dark) parts.push(`M${x} ${y}h1v1h-1z`); });
});
return parts.join('');
}
+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
+79
View File
@@ -0,0 +1,79 @@
// @ts-check
/**
* No selection of text that the stylesheet declares unselectable.
*
* Safari on iOS and iPadOS sometimes selected the save status at the
* toolbar's right end when a stroke began with an Apple Pencil on Andy's
* iPad (2026-10-10), no hand on the glass: WebKit may take a Pencil drag
* for a selection gesture, and the selection snaps to the nearest text.
* The prefixed -webkit-user-select: none made it rarer, not gone, so
* WebKit's gesture path does not fully respect the property. Two
* guards, both inert where the browser already does the right thing:
* a selection that is about to start in unselectable text is refused
* (selectstart), and one that WebKit makes there anyway is cleared the
* moment it appears (selectionchange). Each is logged, in the console
* once per place and in the record every time, as evidence of which
* path the browser took. Selections inside fields
* (the invitation, a text block being typed) are left alone: a field's
* own selection reaches document.getSelection() in some browsers as a
* range on the field's parent, which may be unselectable.
*/
import { log, trace } from './log.js';
/**
* @param {Node | null | undefined} node
* @returns {boolean} Whether the stylesheet forbids selecting it.
*/
function unselectable(node) {
const element = node instanceof Element ? node : node?.parentElement;
if (!element) return false;
const style = getComputedStyle(element);
const value = style.getPropertyValue('-webkit-user-select') ||
style.getPropertyValue('user-select');
return value === 'none';
}
/**
* @param {Node | null | undefined} node
* @returns {string} The element, for the log.
*/
function describe(node) {
const element = node instanceof Element ? node : node?.parentElement;
if (!element) return 'nothing';
const name = element.tagName.toLowerCase();
if (element.id) return `${name}#${element.id}`;
return element.className ? `${name}.${element.className}` : name;
}
/** Whether the selection belongs to a focused text field. */
function inField() {
const active = document.activeElement;
return active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement ||
(active instanceof HTMLElement && active.isContentEditable);
}
export function guardSelection() {
/** @type {Set<string>} */
const logged = new Set();
/** @param {string} what */
function once(what) {
if (logged.has(what)) return trace(what);
logged.add(what);
log(what);
}
document.addEventListener('selectstart', (event) => {
if (inField() || !(event.target instanceof Node) || !unselectable(event.target)) return;
event.preventDefault();
once(`Refused a selection starting in unselectable ${describe(event.target)}`);
});
document.addEventListener('selectionchange', () => {
const selection = document.getSelection();
if (!selection || selection.isCollapsed || inField()) return;
if (!unselectable(selection.anchorNode) && !unselectable(selection.focusNode)) return;
const where = describe(selection.anchorNode);
selection.removeAllRanges();
once(`Cleared a selection the browser made in unselectable ${where}`);
});
}
+330 -57
View File
@@ -13,7 +13,8 @@ import {
} from './crypto.js';
import {
MAX_STROKE_POINTS, POINT_SIZE, Page, appendPoints, cleanName, cleanPageDescriptor,
cleanStroke, cleanText, placeImage, pointCount, validId, validPoints,
cleanStroke, cleanText, placeImage, pointCount, sheetHolding, validId, validPoints,
MAX_PAGE_HIDES,
} from './page_model.js';
import { RelayConnection } from './relay_connection.js';
@@ -49,6 +50,9 @@ import { RelayConnection } from './relay_connection.js';
* image was added or taken back, not if writing reached an edge.
* @property {() => void} onPermissionsChanged
* Who may start pages changed.
* @property {(command: 'fit' | 'sheet') => void} onViewCommand
* The host pressed "Fit" or "A4": everyone's view follows (Andy,
* 2026-10-08), each in their own window.
* @property {() => void} onParticipantsChanged
* @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus
*/
@@ -71,6 +75,19 @@ const SNAPSHOT_CHUNK_CHARACTERS = 100000;
/** An image is sent in pieces of this many bytes. */
const IMAGE_CHUNK_BYTES = 96 * 1024;
/** While an image is dragged by hand, its new place is sent this often. */
const PAGE_SEND_INTERVAL_MS = 40;
/**
* The points of one's own stroke are sent at most this often: the first
* at once, the rest gathered (Andy, 2026-10-10, after the load test:
* one message per pointer event was 60 to 120 messages a second from
* each writer, and the relay's limit is the message rate). The delay
* others see is at most this, under what Andy felt as no lag. The
* writer's own screen is not delayed.
*/
export const POINT_SEND_INTERVAL_MS = 25;
export class Session {
/**
* @param {SessionOptions} options
@@ -82,37 +99,14 @@ export class Session {
this.identity = options.identity;
this.hostId = options.hostId;
this.name = cleanName(options.name) || 'Unnamed';
/**
* The image we have just added, which we may take back until anything
* else changes on the page.
* @type {PageImage | null}
*/
this.removableImage = null;
// Every event that reports a change to the page ends that.
/** @type {SessionEvents} */
this.events = {
...events,
onLayerReplaced: (layerId) => {
this.removableImage = null;
events.onLayerReplaced(layerId);
},
onStrokeProgress: (layerId, stroke) => {
this.removableImage = null;
events.onStrokeProgress(layerId, stroke);
},
onTextChanged: (layerId, block) => {
this.removableImage = null;
events.onTextChanged(layerId, block);
},
onPageReplaced: (oldPage, newPage) => {
this.removableImage = null;
events.onPageReplaced(oldPage, newPage);
},
onImagesChanged: () => {
this.removableImage = null;
events.onImagesChanged();
},
};
/** @type {ReturnType<typeof setTimeout> | null} A page descriptor waiting to be sent. */
this.pageSendTimer = null;
/** @type {{id: string, at: number, points: number[]} | null} Points of one's own stroke not yet sent. */
this.pendingPoints = null;
/** @type {ReturnType<typeof setTimeout> | null} */
this.pointSendTimer = null;
this.lastPointSend = 0;
this.events = events;
this.isHost = this.identity.id === this.hostId;
/** @type {Set<string>} Guests the host allows to start pages. */
@@ -216,6 +210,15 @@ export class Session {
return this.isHost || this.permitted.has(this.identity.id);
}
/**
* The host's "Fit" or "A4" goes to everyone. Only the host's does.
* @param {'fit' | 'sheet'} command
*/
sendViewCommand(command) {
if (!this.isHost) return;
this.send(0, { type: 'view', command });
}
/**
* Allow a guest to start pages, or stop allowing it. Only the host may.
* @param {string} id
@@ -258,48 +261,233 @@ export class Session {
* @param {string} prepared.hash
* @param {number} prepared.naturalWidth In page units.
* @param {number} prepared.naturalHeight
* @param {boolean} [shrink] Whether an image wider than the sheet is
* scaled down to it; otherwise it keeps its size and widens the sheet.
* @returns {boolean} False if the page has no room for it.
*/
addImage(prepared, shrink = true) {
addImage(prepared) {
const page = this.page;
if (!this.mayMakePages() || page.number === 0) return false;
const place = placeImage(prepared.naturalWidth, prepared.naturalHeight, page.imagesBottom(), shrink);
const place = placeImage(page, prepared.naturalWidth, prepared.naturalHeight);
if (!page.hasRoomForImage(prepared.data.length, place.y + place.height)) return false;
/** @type {PageImage} */
const image = {
type: prepared.type, size: prepared.data.length, hash: prepared.hash, data: prepared.data,
x: place.x, y: place.y, width: place.width, height: place.height,
x: place.x, y: place.y, width: place.width, height: place.height, by: this.identity.id,
};
page.images.push(image);
page.imagesRevision += 1;
page.baseWidth = Math.max(page.baseWidth, place.pageWidth);
page.baseHeight = Math.max(page.baseHeight, place.pageHeight);
page.baseLeft = place.sheet.left;
page.baseTop = place.sheet.top;
page.baseWidth = place.sheet.width;
page.baseHeight = place.sheet.height;
this.send(0, { type: 'page', page: page.descriptor() });
this.sendImage(0, image);
this.events.onImagesChanged();
this.removableImage = image;
this.extentChanged(true);
return true;
}
// Hide rectangles: page objects like images, for those who may make pages.
/**
* Hide part of the page for everyone.
* @param {import('./page_model.js').Rectangle} rect
* @returns {boolean} Whether a Hide rectangle was made.
*/
addHide(rect) {
const page = this.page;
if (!this.mayMakePages() || page.number === 0 || page.hides.length >= MAX_PAGE_HIDES) return false;
page.hides.push({
id: randomId(), x: Math.round(rect.left), y: Math.round(rect.top),
width: Math.round(rect.width), height: Math.round(rect.height),
});
this.sheetAround(rect);
page.imagesRevision += 1;
this.sendPageNow();
this.events.onImagesChanged();
this.extentChanged(true);
return true;
}
/**
* Take back the image we have just added: the remedy for having chosen
* the wrong file. It works only until anything else changes on the
* page (Andy, 2026-10-05), so nothing can have been written on the image,
* and since it is the lowest image, no other image is affected.
* @returns {boolean} Whether an image was taken back.
* Give a Hide rectangle a new rectangle: moved or resized by hand.
* @param {number} index
* @param {import('./page_model.js').Rectangle} rect
*/
removeAddedImage() {
placeHideByHand(index, rect) {
const page = this.page;
const image = this.removableImage;
if (!image || page.images.at(-1) !== image) return false;
page.images.pop();
const hide = page.hides[index];
if (!this.mayMakePages() || !hide) return;
hide.x = Math.round(rect.left);
hide.y = Math.round(rect.top);
hide.width = Math.round(rect.width);
hide.height = Math.round(rect.height);
this.sheetAround(rect);
this.imagesChangedByHand();
}
/**
* Remove a Hide rectangle: the eraser tapped on it.
* @param {number} index
* @returns {boolean}
*/
removeHide(index) {
const page = this.page;
if (!this.mayMakePages() || !page.hides[index]) return false;
page.hides.splice(index, 1);
page.imagesRevision += 1;
this.sendPageNow();
this.events.onImagesChanged();
this.extentChanged(true);
return true;
}
/**
* Let the sheet hold a rectangle with the image margin around it.
* @param {import('./page_model.js').Rectangle} rect
*/
sheetAround(rect) {
const page = this.page;
const sheet = sheetHolding(page, rect);
page.baseLeft = sheet.left;
page.baseTop = sheet.top;
page.baseWidth = sheet.width;
page.baseHeight = sheet.height;
}
/**
* Bring an image to the front, among the images.
* @param {number} index
* @returns {number} Its index now.
*/
raiseImage(index) {
const page = this.page;
if (!this.mayMakePages() || index < 0 || index >= page.images.length) return index;
if (index !== page.images.length - 1) {
const [image] = page.images.splice(index, 1);
page.images.push(image);
this.imagesChangedByHand();
}
return page.images.length - 1;
}
/**
* Give an image a new rectangle: moved or scaled by hand (Andy's
* teacher, 2026-10-08). Writing stays where it is. The image comes
* to the front, the sheet grows to hold it, and everyone sees it move.
* @param {number} index
* @param {import('./page_model.js').Rectangle} rect
* @returns {number} The image's index now.
*/
placeImageByHand(index, rect) {
const page = this.page;
if (!this.mayMakePages() || index < 0 || index >= page.images.length) return index;
index = this.raiseImage(index);
const image = page.images[index];
image.x = Math.round(rect.left);
image.y = Math.round(rect.top);
image.width = Math.round(rect.width);
image.height = Math.round(rect.height);
this.sheetAround({ left: image.x, top: image.y, width: image.width, height: image.height });
this.imagesChangedByHand();
return index;
}
/** An image was raised, moved or scaled: tell everyone, soon. */
imagesChangedByHand() {
const page = this.page;
page.imagesRevision += 1;
this.sendPageSoon();
this.events.onImagesChanged();
this.extentChanged(true);
}
/**
* Send the page descriptor, at most every few tens of milliseconds
* while an image is being dragged; sendPageNow() at the end.
*/
sendPageSoon() {
if (this.pageSendTimer !== null) return;
this.pageSendTimer = setTimeout(() => {
this.pageSendTimer = null;
this.send(0, { type: 'page', page: this.page.descriptor() });
}, PAGE_SEND_INTERVAL_MS);
}
sendPageNow() {
if (this.pageSendTimer !== null) {
clearTimeout(this.pageSendTimer);
this.pageSendTimer = null;
}
this.send(0, { type: 'page', page: this.page.descriptor() });
}
/**
* Make the sheet wider, for more room to write (Andy, 2026-10-08:
* "Widen" enlarges the page rather than zooming). Everyone's page
* widens; it is a change to the page, so it needs the right to make
* pages, and it ends the chance to take back an image.
* @param {number} width The page's new width, if wider than now.
* @returns {boolean} Whether the page widened.
*/
widenPage(width) {
const page = this.page;
const added = Math.round(width) - page.width;
if (!this.mayMakePages() || page.number === 0 || added <= 0) return false;
// Equally on both sides of the PAGE as it is, writing included (the
// sheet may lie inside a page that writing has grown; measured from
// the sheet, the page came out short, Andy, 2026-10-08): what is on
// the page stays where it is seen. The same whole number of units on
// each side, since the page is centred on the screen: an odd growth
// split 1199 and 1200 moved the page's centre half a unit, a fraction
// of a pixel that showed as a one-pixel move on Olion (2026-10-09).
const half = Math.floor(added / 2);
if (half === 0) return false;
page.baseLeft = page.left - half;
page.baseWidth = page.width + 2 * half;
page.imagesRevision += 1;
page.baseWidth = page.sheetWidth();
page.baseHeight = page.sheetHeight();
this.send(0, { type: 'page', page: page.descriptor() });
this.extentChanged(true);
return true;
}
/**
* Make the sheet the smallest that holds everything on the page with
* the margin around it ("Shrink", Andy, 2026-10-08). A change to the
* page like "Widen".
* @returns {boolean} Whether the sheet changed.
*/
shrinkPage() {
const page = this.page;
if (!this.mayMakePages() || page.number === 0) return false;
const sheet = page.contentSheet();
if (!sheet || (sheet.left === page.baseLeft && sheet.top === page.baseTop &&
sheet.width === page.baseWidth && sheet.height === page.baseHeight)) return false;
page.baseLeft = sheet.left;
page.baseTop = sheet.top;
page.baseWidth = sheet.width;
page.baseHeight = sheet.height;
page.imagesRevision += 1;
this.send(0, { type: 'page', page: page.descriptor() });
this.extentChanged(true);
return true;
}
/**
* Remove an image: the eraser tapped on it (Andy, 2026-10-08: "the user
* has decided that the image should be deleted"). Allowed to whoever
* may make pages, and to whoever added the image. Writing on it stays
* where it is; the sheet keeps its size ("Shrink" trims it).
* @param {number} index
* @returns {boolean} Whether it was removed.
*/
removeImage(index) {
const page = this.page;
const image = page.images[index];
if (!image || !(this.mayMakePages() || image.by === this.identity.id)) return false;
page.images.splice(index, 1);
page.imagesRevision += 1;
this.imageTransfers.delete(image.hash);
this.sendPageNow();
this.events.onImagesChanged();
this.extentChanged(true);
return true;
@@ -314,7 +502,10 @@ export class Session {
loadPage(kept) {
if (!this.mayMakePages()) return;
const page = new Page(kept.baseWidth, kept.baseHeight, randomId(), this.page.number + 1);
page.baseLeft = kept.baseLeft;
page.baseTop = kept.baseTop;
page.images = kept.images.map((image) => ({ ...image }));
page.hides = kept.hides.map((hide) => ({ ...hide }));
page.imagesRevision = kept.imagesRevision;
for (const [id, layer] of kept.layers) {
const copy = page.layer(id, layer.name);
@@ -460,6 +651,38 @@ export class Session {
this.pageSourcePeer = peer;
}
/**
* Whether a page descriptor from a participant without the right to
* make pages is one they may still send: our page, the sheet as it is,
* and the images ours with only images they added left out (Andy,
* 2026-10-08: whoever added an image may remove it, right or no right).
* @param {any} received
* @param {string} id The sender.
* @returns {boolean}
*/
removesOnlyOwnImages(received, id) {
const descriptor = cleanPageDescriptor(received);
const page = this.page;
if (!descriptor || descriptor.id !== page.id || descriptor.left !== page.baseLeft ||
descriptor.top !== page.baseTop || descriptor.width !== page.baseWidth ||
descriptor.height !== page.baseHeight ||
JSON.stringify(descriptor.hides) !== JSON.stringify(page.hides)) return false;
/** @param {Omit<PageImage, 'data'>} image */
const key = (image) => `${image.hash} ${image.x} ${image.y} ${image.width} ${image.height} ${image.by}`;
let at = 0;
let removed = 0;
for (const ours of page.images) {
if (at < descriptor.images.length && key(descriptor.images[at]) === key(ours)) {
at += 1;
} else if (ours.by === id) {
removed += 1;
} else {
return false;
}
}
return at === descriptor.images.length && removed > 0;
}
/**
* Another participant's list of the images on the page we are on. The
* list with the higher revision is the later one. Of two different
@@ -470,11 +693,13 @@ export class Session {
*/
considerImages(descriptor) {
const page = this.page;
/** @param {Omit<PageImage, 'data'>[]} images */
const listed = (images) => images.map((image) =>
`${image.hash} ${image.x} ${image.y} ${image.width} ${image.height}`).join(',');
const ours = listed(page.images);
const theirs = listed(descriptor.images);
/** @param {import('./page_model.js').PageDescriptor} d */
const listed = (d) => d.images.map((image) =>
`${image.hash} ${image.x} ${image.y} ${image.width} ${image.height}`).join(',') +
'|' + d.hides.map((hide) => `${hide.id} ${hide.x} ${hide.y} ${hide.width} ${hide.height}`).join(',') +
`|${d.left} ${d.top} ${d.width} ${d.height}`;
const ours = listed(page.descriptor());
const theirs = listed(descriptor);
if (descriptor.revision < page.imagesRevision ||
(descriptor.revision === page.imagesRevision && theirs <= ours)) return;
const held = new Map();
@@ -482,7 +707,10 @@ export class Session {
if (image.data) held.set(image.hash, image.data);
}
page.images = descriptor.images.map((image) => ({ ...image, data: held.get(image.hash) ?? null }));
page.hides = descriptor.hides.map((hide) => ({ ...hide }));
page.imagesRevision = descriptor.revision;
page.baseLeft = descriptor.left;
page.baseTop = descriptor.top;
page.baseWidth = descriptor.width;
page.baseHeight = descriptor.height;
// Bytes on their way for an image that is no longer wanted are dropped,
@@ -541,16 +769,54 @@ export class Session {
const at = pointCount(stroke);
appendPoints(stroke, at, points);
this.page.includeStroke(stroke, at);
this.send(0, { type: 'stroke_points', id: stroke.id, at, points });
this.queuePoints(stroke.id, at, points);
this.events.onStrokeProgress(this.identity.id, stroke);
this.extentChanged();
}
/**
* Points to send: at once if none went in the last interval, else with
* the next sending, which the timer makes when the interval is up.
* @param {string} id
* @param {number} at
* @param {number[]} points
*/
queuePoints(id, at, points) {
if (this.pendingPoints && this.pendingPoints.id === id) {
this.pendingPoints.points.push(...points);
} else {
this.sendPendingPoints();
this.pendingPoints = { id, at, points: points.slice() };
}
const since = performance.now() - this.lastPointSend;
if (since >= POINT_SEND_INTERVAL_MS) {
this.sendPendingPoints();
} else if (this.pointSendTimer === null) {
this.pointSendTimer = setTimeout(() => {
this.pointSendTimer = null;
this.sendPendingPoints();
}, POINT_SEND_INTERVAL_MS - since);
}
}
sendPendingPoints() {
if (this.pointSendTimer !== null) {
clearTimeout(this.pointSendTimer);
this.pointSendTimer = null;
}
const pending = this.pendingPoints;
if (!pending) return;
this.pendingPoints = null;
this.lastPointSend = performance.now();
this.send(0, { type: 'stroke_points', id: pending.id, at: pending.at, points: pending.points });
}
endStroke() {
const stroke = this.currentStroke;
if (!stroke) return;
this.currentStroke = null;
stroke.done = true;
this.sendPendingPoints();
this.send(0, { type: 'stroke_end', id: stroke.id });
this.events.onStrokeProgress(this.identity.id, stroke);
}
@@ -756,12 +1022,19 @@ export class Session {
if (!id) return;
if (message.type === 'page') {
if (id === this.hostId || this.permitted.has(id)) this.considerPage(message.page, true, peer);
else if (this.removesOnlyOwnImages(message.page, id)) this.considerPage(message.page, false, peer);
return;
}
if (message.type === 'permissions') {
if (id === this.hostId) this.receivedPermissions(message.ids);
return;
}
if (message.type === 'view') {
if (id === this.hostId && (message.command === 'fit' || message.command === 'sheet')) {
this.events.onViewCommand(message.command);
}
return;
}
if (message.type === 'image') {
this.receivedImagePiece(message);
return;
+164 -83
View File
@@ -1,15 +1,19 @@
// @ts-check
/**
* Editing a text block: a real text field laid over the page, so that
* umlauts, dead keys and selection work as they do everywhere, with
* handles for moving the block and changing its width.
* umlauts, dead keys and selection work as they do everywhere. Moving a
* block and resizing a text rectangle are done by a hold of the pen, as
* for images (main.js), not by handles here (2026-10-08).
*
* While a block is being edited the renderer leaves it out, and the field
* shows it; everyone else sees it drawn, updated at every keystroke.
*/
import { randomId } from './crypto.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js';
import { textBounds } from './page_model.js';
import { TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js';
import { HOLD_MS, HOLD_SLACK_PX } from './pen_input.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, baselineOf, fontMetrics, layoutText } from './text_layout.js';
/** @typedef {import('./page_model.js').TextBlock} TextBlock */
/** @typedef {import('./page_model.js').TextFont} TextFont */
@@ -18,6 +22,7 @@ import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js';
/** A block without a width is shown at least this wide, in page units. */
const LEAST_FIELD_WIDTH = 300;
export class TextEditor {
/**
* @param {HTMLElement} pageElement
@@ -25,6 +30,13 @@ export class TextEditor {
* @param {object} target
* @param {(block: TextBlock) => void} target.setText The block changed.
* @param {(id: string) => void} target.removeText The block was left empty.
* @param {(clientX: number, clientY: number) => boolean} [target.beginHold]
* The pen rested on the field for HOLD_MS: a hold, as on the page
* (Andy, 2026-10-09: the box being typed in can be moved without
* stopping). Then the pen's movement goes to holdMove, and holdEnd
* when it lifts.
* @param {(clientX: number, clientY: number) => void} [target.holdMove]
* @param {() => void} [target.holdEnd]
*/
constructor(pageElement, renderer, target) {
this.pageElement = pageElement;
@@ -38,6 +50,7 @@ export class TextEditor {
this.field.spellcheck = false;
this.field.hidden = true;
this.field.addEventListener('input', () => this.changed());
this.watchForHold();
this.field.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
@@ -45,95 +58,111 @@ export class TextEditor {
}
});
this.corner = this.makeHandle('text_handle corner', 'Move the text');
this.edge = this.makeHandle('text_edge', 'Move the text');
this.end = this.makeHandle('text_handle end', 'Change the width');
/** Shows the top edge while it is being dragged out. */
/** Free text's corner mark while it is being edited (the renderer leaves the block out then). */
this.mark = document.createElement('div');
this.mark.className = 'text_mark';
this.mark.hidden = true;
/** Shows a rectangle while it is being dragged open. */
this.preview = document.createElement('div');
this.preview.className = 'text_preview';
this.preview.className = 'rect_preview';
this.preview.hidden = true;
this.pageElement.append(this.field, this.edge, this.corner, this.end, this.preview);
this.pageElement.append(this.field, this.mark, this.preview);
}
/**
* Show where the top edge of a new block would run, from (x0, y0) to
* (x1, y0), while the pointer is dragging.
* A press that rests on the field becomes a hold that moves the block,
* as a hold on the page would; a press that moves at once selects text
* as in any field.
*/
watchForHold() {
const field = this.field;
/** @type {ReturnType<typeof setTimeout> | null} */
let timer = null;
let holding = false;
let pointer = -1;
let pressX = 0;
let pressY = 0;
const cancel = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
};
field.addEventListener('pointerdown', (event) => {
if (!this.target.beginHold || (event.pointerType === 'mouse' && event.button !== 0)) return;
pointer = event.pointerId;
pressX = event.clientX;
pressY = event.clientY;
cancel();
timer = setTimeout(() => {
timer = null;
if (!this.target.beginHold?.(pressX, pressY)) return;
holding = true;
field.setPointerCapture(pointer);
}, HOLD_MS);
});
field.addEventListener('pointermove', (event) => {
if (event.pointerId !== pointer) return;
if (holding) {
event.preventDefault();
this.target.holdMove?.(event.clientX, event.clientY);
} else if (timer !== null && Math.hypot(event.clientX - pressX, event.clientY - pressY) > HOLD_SLACK_PX) {
cancel();
}
});
const finish = (/** @type {PointerEvent} */ event) => {
if (event.pointerId !== pointer) return;
cancel();
pointer = -1;
if (holding) {
holding = false;
this.target.holdEnd?.();
field.focus();
}
};
field.addEventListener('pointerup', finish);
field.addEventListener('pointercancel', finish);
}
/**
* Show the rectangle being dragged open, from (x0, y0) to (x1, y1).
* @param {number} x0
* @param {number} y0
* @param {number} x1
* @param {number} y1
*/
showPreview(x0, y0, x1) {
showPreview(x0, y0, x1, y1) {
const scale = this.renderer.cssPixelsPerUnit;
const page = this.renderer.page;
this.preview.hidden = false;
this.preview.style.left = `${(Math.min(x0, x1) - page.left) * scale}px`;
this.preview.style.top = `${(y0 - page.top) * scale}px`;
this.preview.style.top = `${(Math.min(y0, y1) - page.top) * scale}px`;
this.preview.style.width = `${Math.abs(x1 - x0) * scale}px`;
this.preview.style.height = `${Math.abs(y1 - y0) * scale}px`;
}
hidePreview() {
this.preview.hidden = true;
}
/**
* @param {string} className
* @param {string} help
* @returns {HTMLElement}
*/
makeHandle(className, help) {
const handle = document.createElement('div');
handle.className = className;
handle.hidden = true;
handle.setAttribute('data-help', help);
const widening = className.includes('end');
handle.addEventListener('pointerdown', (event) => {
if (!this.block) return;
event.preventDefault();
event.stopPropagation();
handle.setPointerCapture(event.pointerId);
const [startX, startY] = this.renderer.pageFromClient(event.clientX, event.clientY);
const { x, y, width } = this.block;
const move = (/** @type {PointerEvent} */ e) => {
if (!this.block) return;
const [nowX, nowY] = this.renderer.pageFromClient(e.clientX, e.clientY);
if (widening) {
this.block.width = Math.max(100, Math.round((width ?? 0) + nowX - startX));
} else {
this.block.x = Math.round(x + nowX - startX);
this.block.y = Math.round(y + nowY - startY);
}
this.changed();
};
const finish = () => {
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', finish);
handle.removeEventListener('pointercancel', finish);
this.field.focus();
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', finish);
handle.addEventListener('pointercancel', finish);
});
return handle;
}
/** @returns {boolean} */
get editing() {
return this.block !== null;
}
/**
* Start a new block.
* Start a new block: free text, or a text rectangle with a width and a
* height.
* @param {number} x
* @param {number} y
* @param {number | null} width
* @param {number | null} height
* @param {{color: string, size: number, font: TextFont}} style
*/
begin(x, y, width, style) {
begin(x, y, width, height, style) {
this.stop();
/** @type {TextBlock} */
const block = {
id: randomId(), x: Math.round(x), y: Math.round(y), width, text: '', lines: [''],
id: randomId(), x: Math.round(x), y: Math.round(y), width,
height: width === null ? null : height, text: '', lines: [''],
size: style.size, font: style.font, color: style.color, boxWidth: 0, boxHeight: 0,
};
Object.assign(block, layoutText('', width, style.size, style.font));
@@ -163,6 +192,8 @@ export class TextEditor {
this.layout();
this.target.setText(block); // Redrawn without this block.
this.field.focus();
// The cursor at the end of the text (Andy, 2026-10-09).
this.field.setSelectionRange(block.text.length, block.text.length);
}
/** Finish editing. An empty block is removed. */
@@ -172,58 +203,108 @@ export class TextEditor {
this.block = null;
this.renderer.editingText = null;
this.field.hidden = true;
this.mark.hidden = true;
this.field.value = '';
for (const handle of [this.corner, this.edge, this.end]) handle.hidden = true;
if (block.text.trim() === '') this.target.removeText(block.id);
else this.target.setText(block);
}
/**
* The page was replaced under the editor: leave off without sending
* anything, since the block already reached the page it belonged to at
* every keystroke, and that page is being kept. Finishing normally
* would put the block on the new page.
* @returns {TextBlock | null} The block that was being edited.
*/
discard() {
const block = this.block;
if (!block) return null;
this.block = null;
this.renderer.editingText = null;
this.field.hidden = true;
this.mark.hidden = true;
this.field.value = '';
return block;
}
/** The text, position or width changed: lay it out and tell the page. */
changed() {
const block = this.block;
if (!block) return;
block.text = this.field.value;
Object.assign(block, layoutText(block.text, block.width, block.size, block.font));
// A text rectangle grows downward by lines when the text needs more.
if (block.height !== null && block.boxHeight > block.height) block.height = Math.ceil(block.boxHeight);
this.layout();
this.target.setText(block);
}
/** Place the field and the handles over the block, at the present zoom. */
/** Place the field over the block, at the present zoom. */
layout() {
const block = this.block;
if (!block) return;
const scale = this.renderer.cssPixelsPerUnit;
const page = this.renderer.page;
const left = (block.x - page.left) * scale;
const top = (block.y - page.top) * scale;
// Everything on whole device pixels, placed as the canvas places the
// drawn block (renderer.js drawText): the same pixels in both, so that
// nothing moves when editing ends (Andy, 2026-10-09).
const dpr = window.devicePixelRatio || 1;
/** @param {number} px CSS pixels. @returns {number} The nearest whole device pixel, in CSS pixels. */
const whole = (px) => Math.round(px * dpr) / dpr;
const left = whole((block.x - page.left) * scale);
const top = whole((block.y - page.top) * scale);
const isRect = block.height !== null;
const fieldWidth = (block.width ?? Math.max(LEAST_FIELD_WIDTH, block.boxWidth + block.size)) * scale;
const style = this.field.style;
this.field.classList.toggle('rect', isRect);
style.left = `${left}px`;
style.top = `${top}px`;
// A little wider than the block, so that the field never wraps a line
// earlier than the layout did.
style.width = `${fieldWidth - block.size * LEFT_GAP * scale + 2}px`;
style.paddingTop = `${block.size * TOP_GAP * scale}px`;
style.paddingLeft = `${block.size * LEFT_GAP * scale}px`;
// The text's left edge and its first baseline, where the canvas draws
// them; the lines a whole number of pixels apart.
const textLeft = whole((block.x + block.size * LEFT_GAP - page.left) * scale);
const firstBaseline = whole((block.y + baselineOf(block.size, block.font, 0) - page.top) * scale);
const lineHeight = whole(block.size * LINE_HEIGHT * scale);
const { ascent, descent } = fontMetrics(block.size, block.font);
// In a line box the baseline lies half the leading and the ascent down.
const baselineInLine = (lineHeight - (ascent + descent) * scale) / 2 + ascent * scale;
// A box's field is exactly the box, on whole pixels, and draws the
// frame as an outline inside its edge, which takes no room, at the
// width the canvas draws it.
const frame = Math.max(1, Math.round(TEXT_FRAME_WIDTH * scale * dpr)) / dpr;
style.outlineWidth = isRect ? `${frame}px` : '0';
style.outlineOffset = `${-frame}px`;
const padTop = firstBaseline - top - baselineInLine;
style.paddingTop = `${padTop}px`;
style.paddingLeft = `${textLeft - left}px`;
// Room below the last line, or the field scrolls by a few pixels to
// keep the cursor in view and the text jumps.
const slack = (LINE_HEIGHT - 1) / 2 * block.size * scale + 1;
style.paddingBottom = `${slack}px`;
style.font = `${block.size * scale}px ${block.font}`;
style.lineHeight = String(LINE_HEIGHT);
style.lineHeight = `${lineHeight}px`;
style.color = block.color;
style.whiteSpace = block.width === null ? 'pre' : 'pre-wrap';
// Exactly as tall as the lines: no spare line below the text.
style.height = `${block.lines.length * block.size * LINE_HEIGHT * scale}px`;
this.corner.hidden = false;
this.corner.style.left = `${left}px`;
this.corner.style.top = `${top}px`;
const hasWidth = block.width !== null;
this.edge.hidden = !hasWidth;
this.end.hidden = !hasWidth;
if (hasWidth) {
this.edge.style.left = `${left}px`;
this.edge.style.top = `${top}px`;
this.edge.style.width = `${fieldWidth}px`;
this.end.style.left = `${left + fieldWidth}px`;
this.end.style.top = `${top}px`;
if (isRect) {
const bounds = textBounds(block);
// The canvas strokes the far lines on the pixel at the edge itself,
// so the field reaches one pixel past it.
style.boxSizing = 'border-box';
style.width = `${whole((bounds.left + bounds.width - page.left) * scale) - left + 1 / dpr}px`;
style.height = `${Math.max(block.lines.length * lineHeight + padTop + slack,
whole((bounds.top + bounds.height - page.top) * scale) - top + 1 / dpr)}px`;
} else {
style.boxSizing = 'content-box';
// A little wider than the block, so that the field never wraps a
// line earlier than the layout did.
style.width = `${fieldWidth - (textLeft - left) + 2}px`;
style.height = `${block.lines.length * lineHeight}px`;
}
this.field.scrollTop = 0;
this.mark.hidden = isRect;
this.mark.style.left = `${left}px`;
this.mark.style.top = `${top}px`;
this.mark.style.width = `${whole(TEXT_MARK * scale)}px`;
this.mark.style.height = `${whole(TEXT_MARK * scale)}px`;
this.mark.style.borderWidth = `${Math.max(1, Math.round(TEXT_FRAME_WIDTH * scale * dpr)) / dpr}px`;
}
}
+39 -2
View File
@@ -14,9 +14,10 @@ export const LINE_HEIGHT = 1.25;
/**
* Room between a block's top edge and its first line, as a multiple of
* the font size (Andy, 2026-10-03: the text should not sit on the edge).
* the font size (Andy, 2026-10-03: the text should not sit on the edge;
* halved from 0.3 on 2026-10-09).
*/
export const TOP_GAP = 0.3;
export const TOP_GAP = 0.15;
/**
* Room between a block's left edge (its corner square) and the text, as a
@@ -51,6 +52,42 @@ export function textWidth(text, size, family) {
return measurer.measureText(text).width;
}
/**
* Where the baseline of line `i` of a block lies below the block's top, in
* page units, placed as a browser places text in a line box: the gap, then
* half the leading (the line height less the font's own ascent and
* descent), then the ascent. The text field and the canvas then agree
* to the pixel (Andy, 2026-10-09: the text moved when editing ended).
* @param {number} size
* @param {string} family
* @param {number} i
* @returns {number}
*/
export function baselineOf(size, family, i) {
const { ascent, descent } = fontMetrics(size, family);
const halfLeading = (size * LINE_HEIGHT - ascent - descent) / 2;
return size * (TOP_GAP + i * LINE_HEIGHT) + halfLeading + ascent;
}
/**
* The font's ascent and descent, in page units, as the browser lays
* lines out with them.
* @param {number} size
* @param {string} family
* @returns {{ascent: number, descent: number}}
*/
export function fontMetrics(size, family) {
if (!measurer) {
measurer = /** @type {CanvasRenderingContext2D} */ (document.createElement('canvas').getContext('2d'));
}
measurer.font = fontOf(size, family);
const metrics = measurer.measureText('Mg');
return {
ascent: metrics.fontBoundingBoxAscent ?? size * 0.8,
descent: metrics.fontBoundingBoxDescent ?? size * 0.2,
};
}
/**
* Break one paragraph into lines no wider than `width`. Breaks come at
* spaces; a word wider than the width is broken where it must be.
+10 -2
View File
@@ -9,7 +9,7 @@
* are covered too, since the listeners are on the document.
*/
const DELAY_MS = 1000;
const DELAY_MS = 2000;
const GAP_PX = 6;
/** @type {HTMLElement | null} */
@@ -66,8 +66,16 @@ export function enableTooltips() {
timer = setTimeout(() => { timer = null; show(element); }, DELAY_MS);
});
document.addEventListener('pointerleave', hide);
document.addEventListener('pointerdown', hide);
// Focus that comes from a click brings no tooltip: the user is already
// acting on the element, and a list it opens would be covered (Andy,
// 2026-10-07, the notebook list). Focus from the keyboard does.
let pressedAt = -Infinity;
document.addEventListener('pointerdown', () => {
pressedAt = performance.now();
hide();
});
document.addEventListener('focusin', (event) => {
if (performance.now() - pressedAt < 500) return;
const element = helpElement(event.target);
if (element) show(element);
});