Files
kokkoja-rel/client/js/main.js
T
ackandClaude Fable 5.1 60ad747e32 Everyone has the same buttons; the view is personal
The host's rights and the co-host are gone: anyone in a session may
start a new page, put an image on the page, load a kept page, hide,
expand and shrink, and move or remove images and Hides; the page left
is kept in every notebook.  Fit and Normal scale act on your own screen
only.  A session goes on without whoever started it, and a newcomer to
an empty room starts page 1 with New.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 20:22:13 +02:00

2111 lines
83 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @ts-check
/**
* The Kokkoja client: the start screen, and the session screen that
* connects the pen, the page and the other participants.
*/
import {
deriveSession, formatInvitation, formatInvitationLink, loadIdentity, newIdentity, newSecret,
parseInvitation, saveIdentity,
} from './crypto.js';
import {
NotebookWriter, discardNotebook, discardPage, keepCurrentPage, keepIdentity, listKeptPages, listNotebooks, notebooksInBrowser,
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';
import {
LEAST_TEXT_POINTS, MAX_IMAGE_BYTES, MOST_TEXT_POINTS, POINT_SIZE, TEXT_FONTS, TEXT_SIZE,
UNITS_PER_POINT, rounded, strokeBox, strokesTouched, textAt, textsTouched,
} from './page_model.js';
import { HOLD_MS, NOMINAL_PRESSURE, attachPenInput } from './pen_input.js';
import { Renderer } from './renderer.js';
import { LEAST_IMAGE_SIDE, textBounds, rectAt } from './page_model.js';
import { inkExtent } from './stroke_geometry.js';
import {
SHAPE_KEY, SHAPE_NAMES, draggedPoints, isFilled, isTapped, lineInProgressPoints, movedPoints,
shapeNamed, straightPoints, vertexPoints,
} from './shapes.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';
import { writePicture } from './files.js';
import { pageToPng } from './page_picture.js';
import { TextEditor } from './text_editor.js';
import { enableTooltips } from './tooltip.js';
import { guardSelection, inField } from './selection_guard.js';
import { QUIET_ZONE, qrModules, qrPath } from './qr_code.js';
import { manualList, manualText } from './manual.js';
/**
* 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); 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;
/** With the text tool or the eraser, a press that moves less than this (3 mm) is a tap. */
const TAP_DISTANCE = 30;
/**
* Two taps of the eraser within this, both finding nothing to erase, undo
* the latest gesture (Andy, 2026-10-11). A tap that erases something is
* never half of a double tap, so the eraser's own taps are unaffected.
*/
const DOUBLE_TAP_MS = 400;
/**
* An image file is shared as it is if it is no larger than this, in bytes
* and in pixels along its longer side. Otherwise it is made smaller and
* stored as JPEG, so that it travels quickly and the page file stays small.
*/
const IMAGE_AS_IS_BYTES = 1500000;
const IMAGE_LONGEST_SIDE = 2400;
const NAME_KEY = 'kokkoja_name';
/** Colours the user added from the palette, remembered in this browser. */
const COLORS_KEY = 'kokkoja_colors';
/**
* The palette behind "+": eight hues in three shades, and greys. Our own,
* not the browser's colour dialog, which crashes Firefox on Linux.
*/
const PALETTE = [
'#e53935', '#fb8c00', '#fdd835', '#43a047', '#00acc1', '#1e88e5', '#8e24aa', '#d81b60',
'#b71c1c', '#e65100', '#f9a825', '#1b5e20', '#006064', '#0d47a1', '#4a148c', '#880e4f',
'#ffcdd2', '#ffe0b2', '#fff9c4', '#c8e6c9', '#b2ebf2', '#bbdefb', '#e1bee7', '#f8bbd0',
'#ffffff', '#bdbdbd', '#757575', '#424242', '#8d6e63', '#5d4037', '#3e2723', '#000000',
];
const NOTEBOOK_KEY = 'kokkoja_notebook';
/** The Shape tool's fill colour, remembered in this browser; 'none' for none. */
const FILL_KEY = 'kokkoja_fill';
/** @typedef {import('./pen_input.js').Tool} Tool */
/** @typedef {import('./shapes.js').Shape} Shape */
/**
* Where the installed Kokkoja program serves the client, for a guest who
* opened an invitation link on a web site but has the program too.
*/
const INSTALLED_ADDRESS = 'http://localhost:8760/';
/**
* How this client was served (`config.json`): by the local program, which
* keeps the notebooks as files ("files"), or by a web site, where the
* notebooks are kept in the browser ("browser"). The relay is the one for
* sessions started here; the site, if any, is a Kokkoja web site that an
* invitation can be a link to.
* @typedef {object} Config
* @property {string} relay
* @property {'files' | 'browser'} storage
* @property {string} site
*/
/** @type {Config} */
let config = { relay: '', storage: 'files', site: '' };
/**
* @param {string} id
* @returns {HTMLElement}
*/
function element(id) {
return /** @type {HTMLElement} */ (document.getElementById(id));
}
/**
* @param {string} id
* @returns {HTMLInputElement}
*/
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
* browser and are the same from any browser on this computer; the first
* time, they are made here and given to it.
* @returns {Promise<import('./crypto.js').Identity>}
*/
async function ownIdentity() {
const kept = await readIdentity();
if (kept !== null) return loadIdentity(kept);
const identity = await newIdentity();
if (await keepIdentity(await saveIdentity(identity))) return identity;
// Another tab got there first: use what it kept.
const other = await readIdentity();
return other !== null ? loadIdentity(other) : identity;
}
// Start screen.
/** @param {string} [message] */
function showStart(message = '') {
element('session').hidden = true;
element('start').hidden = false;
element('start_message').textContent = message;
input('name').value = localStorage.getItem(NAME_KEY) ?? '';
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(startPicker).catch((error) => {
element('notebook_note').textContent = String(error.message ?? error);
element('notebook_note').classList.add('problem');
});
}
/**
* 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 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.';
note.classList.remove('problem');
}
}
/**
* The part of the page address after '#', as named values. The local
* program puts the token there; a join link puts the invitation there.
* Browsers do not send this part of an address to any server.
*/
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 others 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') ?? '';
}
/**
* Set the page address for a session, or for none.
* @param {string} [invitation]
*/
function setAddress(invitation) {
history.replaceState(null, '', invitation
? `#join=${encodeURIComponent(invitation)}` : location.pathname);
}
/** @returns {string | null} The name entered, or null after complaining. */
function enteredName() {
const name = input('name').value.trim();
if (!name) {
element('start_message').textContent = 'Please enter your name.';
input('name').focus();
return null;
}
localStorage.setItem(NAME_KEY, name);
return name;
}
/** @returns {string} The notebook chosen. */
function enteredNotebook() {
const notebook = startPicker.value || DEFAULT_NOTEBOOK;
localStorage.setItem(NOTEBOOK_KEY, notebook);
return notebook;
}
async function startSession() {
const name = enteredName();
if (!name) return;
const identity = await ownIdentity();
const secret = newSecret();
const invitation = formatInvitation({ relay: config.relay, secret, hostId: identity.id });
await enterSession(invitation, name, enteredNotebook());
}
async function joinSession() {
const name = enteredName();
if (!name) return;
const invitation = input('invitation_entry').value.trim();
if (!parseInvitation(invitation)) {
element('start_message').textContent = 'That is not a Kokkoja invitation.';
return;
}
await enterSession(invitation, name, enteredNotebook());
}
// Session screen.
/**
* Prepare an image file to be put on the page. The image's natural size
* comes from the resolution the file records, if any, and otherwise from
* taking a pixel as 0.1 mm (Andy's decision, 2026-10-03); where it goes on
* the page is decided when it is added. An image of more pixels or bytes
* than is useful is made smaller here, which does not change its natural
* size.
* @param {Blob} file
* @returns {Promise<Parameters<Session['addImage']>[0]>}
*/
async function prepareImage(file) {
const original = new Uint8Array(await file.arrayBuffer());
const bitmap = await createImageBitmap(file);
const dpi = imageResolution(original);
const unitsPerPixel = dpi === null ? 1 : 254 / dpi;
let blob = file;
const longest = Math.max(bitmap.width, bitmap.height);
const asIs = (file.type === 'image/png' || file.type === 'image/jpeg') &&
file.size <= IMAGE_AS_IS_BYTES && longest <= IMAGE_LONGEST_SIDE;
if (!asIs) {
const scale = Math.min(1, IMAGE_LONGEST_SIDE / longest);
const canvas = document.createElement('canvas');
canvas.width = Math.round(bitmap.width * scale);
canvas.height = Math.round(bitmap.height * scale);
const context = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d'));
context.fillStyle = '#ffffff';
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
for (const quality of [0.9, 0.75, 0.5]) {
blob = await new Promise((resolve, reject) => canvas.toBlob(
(result) => (result ? resolve(result) : reject(new Error('The image could not be prepared.'))),
'image/jpeg', quality));
if (blob.size <= MAX_IMAGE_BYTES) break;
}
}
if (blob.size > MAX_IMAGE_BYTES) throw new Error('The image is too large.');
const data = blob === file ? original : new Uint8Array(await blob.arrayBuffer());
const type = blob.type === 'image/png' ? 'image/png' : 'image/jpeg';
return {
type, data, hash: await hashOf(data),
naturalWidth: bitmap.width * unitsPerPixel, naturalHeight: bitmap.height * unitsPerPixel,
};
}
/**
* The page to begin a session with, from the notebook's current page file.
* If that file holds a page of this very session (the browser page was
* reloaded, or we left and came back), the session continues from it. If
* it holds a page of another session, that page is kept first, because the
* file is about to be given over to this session.
* @param {string} notebook
* @param {string} roomId
* @returns {Promise<import('./page_model.js').Page | undefined>}
*/
async function pageToContinue(notebook, roomId) {
const text = await readCurrentPage(notebook);
if (text === null) return undefined;
const saved = pageFromSvg(text);
if (saved && saved.session === roomId) return saved.page;
await keepCurrentPage(notebook);
return undefined;
}
/** True once this browser page has entered a session. */
let entered = false;
/**
* Where a browser tab remembers the notebook it chose for a session.
* @param {string} roomId
*/
function notebookKeyFor(roomId) {
return `kokkoja_notebook_${roomId}`;
}
/**
* @param {string} invitationText
* @param {string} name
* @param {string} notebook
*/
async function enterSession(invitationText, name, notebook) {
const invitation = parseInvitation(invitationText);
if (!invitation) {
showStart('That is not a Kokkoja invitation.');
return;
}
if (entered) return;
entered = true;
const { roomId, key } = await deriveSession(invitation.secret);
const identity = await ownIdentity();
// A browser tab stays with the notebook it chose for a session.
const notebookKey = notebookKeyFor(roomId);
notebook = sessionStorage.getItem(notebookKey) ?? notebook;
sessionStorage.setItem(notebookKey, notebook);
const savedPage = await pageToContinue(notebook, roomId);
setAddress(invitationText);
element('start').hidden = true;
element('session').hidden = false;
// The invitation is shown as a link to the web site when there is one,
// so that a guest can open it and be in the session with nothing
// 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 : '');
let tool = /** @type {Tool} */ ('pen');
/** The Shape tool's current shape, remembered in this browser (Andy, 2026-10-14). */
let shape = shapeNamed(localStorage.getItem(SHAPE_KEY));
/** The colour a shape is filled with, or null (Andy, 2026-10-14); separate from the line's colour. */
let fill = /^#[0-9a-f]{6}$/.exec(localStorage.getItem(FILL_KEY) ?? '')?.[0] ?? null;
let color = DEFAULT_COLOR;
let width = WIDTHS[1];
let textSize = TEXT_SIZE;
let textFont = /** @type {import('./page_model.js').TextFont} */ ('sans-serif');
/** @type {Renderer} */
let renderer;
let layerOrder = '';
function syncLayers() {
const order = session.layerOrder();
if (order.join(' ') === layerOrder) return;
layerOrder = order.join(' ');
renderer.setLayerOrder(order);
}
/**
* @param {import('./page_model.js').Page} page
* @returns {string} The page file.
*/
const fileOf = (page) => pageToSvg(page, { session: roomId, hostId: invitation.hostId });
function showPageNumber() {
const waiting = session.page.number === 0;
element('page_number').textContent = waiting ? 'No page yet' : `Page ${session.page.number}`;
standingNotice('page', waiting ? 'Waiting for the others’ page; New starts one' : '', false);
}
/** @type {ReturnType<typeof setTimeout> | undefined} */
let connectionNoticeTimer;
const session = new Session(
{
relay: invitation.relay, roomId, key, identity, hostId: invitation.hostId, name,
page: savedPage,
},
{
onLayerReplaced: (layerId) => {
syncLayers();
renderer.redrawLayer(layerId);
if (layerId === session.identity.id) refreshArrangeCues();
writer.changed();
},
onStrokeProgress: (layerId, stroke) => {
syncLayers();
renderer.strokeProgress(layerId, stroke);
if (stroke.done) {
if (stroke.shape && layerId === session.identity.id) refreshArrangeCues();
writer.changed();
}
},
onTextChanged: (layerId) => {
syncLayers();
renderer.redrawLayer(layerId);
if (layerId === session.identity.id) refreshArrangeCues();
writer.changed();
},
onPageReplaced: (oldPage, newPage) => {
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);
}
// A line being made likewise stays on the page being left (the
// session has ended its stroke).
lineInProgress = null;
renderer.hideShapePreview();
// Nothing is lost: the page being left is kept in the notebook.
writer.pageReplaced(oldPage.number > 0 ? fileOf(oldPage) : null);
renderer.setPage(newPage);
layerOrder = '';
syncLayers();
showParticipants(session, renderer);
showPageNumber();
refreshArrangeCues();
},
onImagesChanged: () => {
log(`Page ${session.page.number}: ${session.page.images.length} images, ${session.page.hides.length} hides`);
renderer.showImages();
renderer.drawHides();
refreshArrangeCues();
writer.writeNow();
},
onPageResized: () => {
// The view keeps its scale; the page extends beyond the window.
renderer.resize();
textEditor?.layout();
writer.changed();
},
onParticipantsChanged: () => {
syncLayers();
showParticipants(session, renderer);
},
onUndoChanged: () => {
/** @type {HTMLButtonElement} */ (element('undo')).disabled = !session.canUndo();
},
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),
{
onSaved: (time) => {
showSaveState(`Saved at ${time}`, false);
standingNotice('saving', '');
},
onProblem: (message) => {
showSaveState(message, true);
standingNotice('saving', message);
},
onKept: (message) => passingNotice(message),
});
showSaveState(savedPage ? 'Continued from the notebook' : '', false);
const pageElement = element('page');
renderer = new Renderer(pageElement, session.page);
/** @type {TextEditor | null} */
let textEditor = null;
syncLayers();
for (const layerId of session.page.layers.keys()) renderer.redrawLayer(layerId);
showParticipants(session, renderer);
showPageNumber();
new ResizeObserver(() => {
renderer.resize();
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();
// The drawing group and the settings (colours, widths, font and size)
// keep the width of their widest state, so that Eraser, Hide and Undo,
// which stand after the settings since 2026-10-11, do not jump when a
// tool shows fewer settings (the drawing group's floor also kept the
// middle group still while there was one, until 2026-10-14). The
// floor rises if a group ever grows, as when a colour is added.
for (const part of [element('drawing'), element('settings')]) {
let widest = 0;
new ResizeObserver(() => {
const width = part.getBoundingClientRect().width;
if (width > widest) {
widest = width;
part.style.minWidth = `${width}px`;
}
}).observe(part);
}
textEditor = new TextEditor(pageElement, renderer, {
setText: (block) => session.setText(block),
removeText: (id) => session.removeTexts([id]),
beginEdit: (id) => session.beginTextEdit(id, 'editor'),
endEdit: (id) => session.endTextEdit(id, 'editor'),
// 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?.(),
});
// Undo: one's own latest gesture, then the one before (Andy,
// 2026-10-11): the button, Ctrl+Z (Command+Z on a Mac) outside a field,
// and two quick taps of the eraser on empty paper (eraseEnded below).
// A block being typed, or a line being made, is finished first, so that
// the undo is of it.
const undo = () => {
textEditor?.stop();
finishLine();
const what = session.undo();
if (what) log(`Undid ${what}`);
};
element('undo').addEventListener('click', undo);
document.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey &&
event.key.toLowerCase() === 'z' && !inField()) {
event.preventDefault();
undo();
}
// A line being made: Escape finishes it, Backspace takes back its
// last point.
if (lineInProgress && !inField() && (event.key === 'Escape' || event.key === 'Backspace')) {
event.preventDefault();
if (event.key === 'Escape') finishLine();
else removeLastVertex();
}
});
// 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 or
* a shape 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' | 'shape' | 'image'} kind
* @property {number} index Of the Hide rectangle or image.
* @property {string} id Of the text block or the shape's stroke.
* @property {[number, number] | null} corner
* @property {[number, number]} fixed
* @property {number} aspect
* @property {number} offsetX
* @property {number} offsetY
*/
/** @type {Held | null} */
let held = null;
/** When the eraser last tapped empty paper, by performance.now(); 0 if not lately. */
let lastEmptyTap = 0;
/** @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;
}
if (h.kind === 'shape') {
const stroke = session.ownLayer.strokes.get(h.id);
return stroke ? strokeBox(stroke) : null;
}
const block = session.ownLayer.texts.get(h.id);
return block ? textBounds(block) : null;
}
/**
* The grip around the held thing: for a stroke, around its ink rather
* than its points (Andy, 2026-10-14).
* @param {Held} h
*/
function showHeldGrip(h) {
if (h.kind === 'shape') {
const stroke = session.ownLayer.strokes.get(h.id);
renderer.showGrip(stroke ? inkExtent(stroke) : null, h.corner, true);
} else {
renderer.showGrip(currentHeldRect(h), h.corner, heldHasCorners(h));
}
}
/**
* @param {Held} h
* @returns {boolean} Whether the held thing has corners to resize by:
* everything but free text.
*/
const heldHasCorners = (h) => h.kind !== 'text' || session.ownLayer.texts.get(h.id)?.height !== null;
/** @returns {import('./page_model.js').Stroke[]} Our shapes, in drawing order. */
const ownShapes = () => [...session.ownLayer.strokes.values()].filter((stroke) => stroke.shape !== null && stroke.done);
/**
* With the Arrange tool chosen, mark everything a press can move or
* resize with a blue box and corners (Andy, 2026-10-14): the images
* and Hides, one's own text blocks and shapes; free text, which has
* no width to resize, gets the box without the corners. The one being
* held is left out, the green grip standing in its place until the
* pointer lifts. Called whenever the images, the Hides, one's own
* text or shapes, or the hold may have changed.
*/
function refreshArrangeCues() {
if (tool !== 'arrange') {
renderer.setArrangeCues([]);
return;
}
/** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]} */
const cues = [];
const page = session.page;
page.images.forEach((im, index) => {
if (held?.kind === 'image' && held.index === index) return;
cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height }, corners: true });
});
page.hides.forEach((h, index) => {
if (held?.kind === 'hide' && held.index === index) return;
cues.push({ kind: 'hide', rect: { left: h.x, top: h.y, width: h.width, height: h.height }, corners: true });
});
for (const block of session.ownLayer.texts.values()) {
if (held?.kind === 'text' && held.id === block.id) continue;
cues.push({ kind: 'text', rect: textBounds(block), corners: block.height !== null });
}
for (const stroke of ownShapes()) {
if (held?.kind === 'shape' && held.id === stroke.id) continue;
const rect = inkExtent(stroke);
if (rect) cues.push({ kind: 'shape', rect, corners: true });
}
// The handwritten stroke under the hovering pointer, if any.
const hovered = hoveredStroke && session.ownLayer.strokes.get(hoveredStroke);
if (hovered && !(held?.kind === 'shape' && held.id === hovered.id)) {
const rect = inkExtent(hovered);
if (rect) cues.push({ kind: 'stroke', rect, corners: true });
}
renderer.setArrangeCues(cues);
}
/**
* Handwriting under Arrange (Andy, 2026-10-14): a page may hold hundreds
* of strokes, so they are not all boxed at once; the one whose ink the
* pointer hovers over gets the box, kept while the pointer stays within
* it (so that its corners can be reached), and a press takes hold of a
* stroke by its ink, or by the shown box's corner, under Arrange only:
* the hold with another tool leaves handwriting alone, since resting
* the pen on one's own writing is part of writing.
* @type {string | null} The id of the stroke whose box is shown.
*/
let hoveredStroke = null;
/**
* The frontmost handwritten stroke of ours (not a shape, which has its
* cue always) within the eraser's reach of the point, by its ink.
* @param {number} x
* @param {number} y
* @returns {import('./page_model.js').Stroke | null}
*/
function handwritingAt(x, y) {
const layer = session.ownLayer;
const ids = strokesTouched(layer, x, y, x, y, renderer.pageLength(ERASER_RADIUS));
for (let i = ids.length - 1; i >= 0; i--) {
const stroke = layer.strokes.get(ids[i]);
if (stroke && stroke.done && stroke.shape === null) return stroke;
}
return null;
}
/**
* Under Arrange, the pointer hovering at this point: the stroke under
* it gets the box, or the shown box stays while the pointer is within
* it or near its corners.
* @param {number} x
* @param {number} y
*/
function hoverHandwriting(x, y) {
const shown = hoveredStroke && session.ownLayer.strokes.get(hoveredStroke);
const box = shown ? inkExtent(shown) : null;
const reach = renderer.cornerReach();
const within = box && x >= box.left - reach && x <= box.left + box.width + reach && y >= box.top - reach && y <= box.top + box.height + reach;
const under = handwritingAt(x, y);
const next = under ? under.id : within ? hoveredStroke : null;
if (next === hoveredStroke) return;
hoveredStroke = next;
refreshArrangeCues();
}
// The Shape tool (Andy, 2026-10-14; shapes.js): a rectangle, an ellipse
// or an arrow is dragged open and becomes one stroke when the pointer
// lifts, a preview showing meanwhile; a line is made of taps, one
// vertex each, its stroke growing for everyone with each tap, a rubber
// band from the last vertex following a hovering pointer, until a tap
// on the last vertex (finished open) or on the first (closed), Escape,
// or a change of tool or shape.
/** @type {{stroke: import('./page_model.js').Stroke, vertices: [number, number][]} | null} */
let lineInProgress = null;
/** The line being made is complete. */
function finishLine() {
const line = lineInProgress;
if (!line) return;
lineInProgress = null;
renderer.hideShapePreview();
if (session.currentStroke === line.stroke) session.endStroke();
log(`Line of ${line.vertices.length} points`);
}
/**
* Backspace while a line is made: its last vertex goes. A stroke in
* progress cannot lose points by any message, so the stroke is erased
* and begun again with the vertices left.
*/
function removeLastVertex() {
const line = lineInProgress;
if (!line) return;
line.vertices.pop();
if (session.currentStroke === line.stroke) session.endStroke(false);
session.erase([line.stroke.id]);
if (line.vertices.length === 0) {
lineInProgress = null;
renderer.hideShapePreview();
return;
}
line.stroke = session.beginStroke(color, width, { fill, kind: 'line' });
session.addPoints(lineInProgressPoints(line.vertices));
}
/**
* Whether a shape's points all lie where the page need not grow, or
* Extend is on. With Extend off a shape is drawn whole or not at all,
* since dropping its points beyond the edge, as a stroke's are, would
* deform it.
* @param {number[]} points
*/
function pageHolds(points) {
if (extend) return true;
for (let i = 0; i < points.length; i += POINT_SIZE) {
if (!session.page.holdsWriting(points[i], points[i + 1], width)) return false;
}
return true;
}
/**
* The pointer went down at (x0, y0) and up at (x1, y1) with the Shape
* tool.
* @param {number} x0
* @param {number} y0
* @param {number} x1
* @param {number} y1
*/
function placeShape(x0, y0, x1, y1) {
renderer.hideShapePreview();
const dragged = Math.hypot(x1 - x0, y1 - y0) >= TAP_DISTANCE;
/** @param {number} v */
const r = (v) => rounded(v, 10);
if (!isTapped(shape)) {
if (!dragged) return;
const points = draggedPoints(shape, r(x0), r(y0), r(x1), r(y1), width);
if (!pageHolds(points)) {
trace('shape refused: it would reach past the page with Extend off');
return;
}
textEditor?.stop();
session.beginStroke(color, width, { filled: isFilled(shape), fill: isFilled(shape) ? null : fill, kind: shape });
session.addPoints(points);
session.endStroke();
log(`${SHAPE_NAMES[shape]} drawn${fill && !isFilled(shape) ? `, filled ${fill}` : ''}`);
return;
}
// A line: each press and release gives a vertex, the release point.
const vx = r(x1);
const vy = r(y1);
if (lineInProgress && session.currentStroke !== lineInProgress.stroke) lineInProgress = null;
const line = lineInProgress;
if (line) {
const reach = renderer.cornerReach();
const [fx, fy] = line.vertices[0];
const [lx, ly] = line.vertices[line.vertices.length - 1];
if (line.vertices.length >= 2 && Math.hypot(vx - fx, vy - fy) <= reach) {
session.addPoints([fx, fy, NOMINAL_PRESSURE]); // Closed: back to the first point.
finishLine();
} else if (Math.hypot(vx - lx, vy - ly) <= reach) {
finishLine();
} else if (pageHolds([vx, vy, NOMINAL_PRESSURE])) {
line.vertices.push([vx, vy]);
session.addPoints(vertexPoints(vx, vy));
} else {
trace('line point refused: beyond the page with Extend off');
}
return;
}
// The first vertex; a drag gives the first two.
/** @type {[number, number][]} */
const vertices = dragged ? [[r(x0), r(y0)], [vx, vy]] : [[vx, vy]];
const points = lineInProgressPoints(vertices);
if (!pageHolds(points)) {
trace('line refused: beyond the page with Extend off');
return;
}
textEditor?.stop();
const stroke = session.beginStroke(color, width, { fill, kind: 'line' });
lineInProgress = { stroke, vertices };
session.addPoints(points);
}
/**
* The implied rectangle under a point, for the hover cue (Andy,
* 2026-10-14: text rectangles and Hides show their corners only then,
* like free text): the frontmost Hide, else the frontmost text block
* among the layers shown, with its kind, which decides the cue's form.
* @param {number} x
* @param {number} y
* @returns {{rect: import('./page_model.js').Rectangle, kind: 'hide' | 'rect' | 'free'} | null}
*/
function impliedRectAt(x, y) {
/** @param {import('./page_model.js').Rectangle} b */
const holds = (b) => x >= b.left && x <= b.left + b.width && y >= b.top && y <= b.top + b.height;
for (const h of [...session.page.hides].reverse()) {
const b = { left: h.x, top: h.y, width: h.width, height: h.height };
if (holds(b)) return { rect: b, kind: 'hide' };
}
for (const id of session.layerOrder().reverse()) {
const layer = session.page.layers.get(id);
if (!layer || renderer.hidden.has(id)) continue;
for (const block of [...layer.texts.values()].reverse()) {
const b = textBounds(block);
if (holds(b)) return { rect: b, kind: block.height === null ? 'free' : 'rect' };
}
}
return null;
}
/**
* What a press of the eraser at this point would remove, for the cue
* shown while the pointer hovers (Andy, 2026-10-14): the rectangles
* around the strokes and text blocks of ours within the eraser's reach,
* whole, as the eraser takes them; or, where there is none, the Hide or
* image a tap would remove, by the same rules as eraseEnded.
* @param {number} x
* @param {number} y
* @returns {import('./page_model.js').Rectangle[]}
*/
function eraseTargetsAt(x, y) {
const layer = session.ownLayer;
const radius = renderer.pageLength(ERASER_RADIUS);
/** @type {import('./page_model.js').Rectangle[]} */
const rects = [];
for (const id of strokesTouched(layer, x, y, x, y, radius)) {
const stroke = layer.strokes.get(id);
const rect = stroke && inkExtent(stroke);
if (rect) rects.push(rect);
}
for (const id of textsTouched(layer, x, y, x, y, radius)) {
const block = layer.texts.get(id);
if (block) rects.push(textBounds(block));
}
if (rects.length) return rects;
const page = session.page;
const hide = page.hideAt(x, y, 0);
if (hide) {
const h = page.hides[hide.index];
return [{ left: h.x, top: h.y, width: h.width, height: h.height }];
}
const found = page.imageAt(x, y, 0);
const image = found && page.images[found.index];
if (image) {
return [{ left: image.x, top: image.y, width: image.width, height: image.height }];
}
return [];
}
/** @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();
strokeInProgress = session.beginStroke(color, width);
},
addPoints: (points) => {
const inside = withinPage(points);
if (inside.length) session.addPoints(inside);
},
endStroke: () => {
strokeInProgress = null;
session.endStroke();
},
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, an image, 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 = page.hideAt(x, y, reach);
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);
session.beginHideEdit(h.id);
} else {
const blocks = [...session.ownLayer.texts.values()];
const at = rectAt(blocks.map(textBounds), x, y, reach);
// Handwriting, under Arrange only: the shown box's corner, or a
// stroke by its ink.
/** @type {{stroke: import('./page_model.js').Stroke, corner: [number, number] | null} | null} */
let writing = null;
if (!at && tool === 'arrange') {
const shown = hoveredStroke && session.ownLayer.strokes.get(hoveredStroke);
const shownBox = shown ? inkExtent(shown) : null;
const cornerAt = shownBox ? rectAt([shownBox], x, y, reach) : null;
if (shown && cornerAt?.corner) writing = { stroke: shown, corner: cornerAt.corner };
else {
const under = handwritingAt(x, y);
if (under) writing = { stroke: under, corner: null };
}
}
const shapes = at || writing ? [] : ownShapes();
const shapeAt = at || writing ? null : rectAt(shapes.map((s) => strokeBox(s) ?? { left: 0, top: 0, width: 0, height: 0 }), 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);
session.beginTextEdit(block.id, 'hold');
} else if (writing) {
const box = /** @type {import('./page_model.js').Rectangle} */ (strokeBox(writing.stroke));
if (session.beginShapeEdit(writing.stroke.id)) found = hold('shape', 0, writing.stroke.id, box, writing.corner, x, y);
} else if (shapeAt) {
// A shape of ours (Andy, 2026-10-14): moved, or resized from a
// corner, by the rectangle around its points.
const stroke = shapes[shapeAt.index];
const box = /** @type {import('./page_model.js').Rectangle} */ (strokeBox(stroke));
if (session.beginShapeEdit(stroke.id)) found = hold('shape', shapeAt.index, stroke.id, box, shapeAt.corner, x, y);
} else {
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(false);
session.erase([begun.id]);
}
textEditor?.hidePreview();
held = found;
showHeldGrip(found);
refreshArrangeCues(); // The held thing's blue cue gives way to the grip.
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;
// An image keeps its shape when resized, and so does a circle.
const keepShape = held.kind === 'image' || (held.kind === 'shape' && session.ownLayer.strokes.get(held.id)?.shape === 'circle');
const rect = heldRect(held, now, x, y, keepShape);
if (held.kind === 'image') {
held.index = session.placeImageByHand(held.index, rect);
} else if (held.kind === 'hide') {
session.placeHideByHand(held.index, rect);
} else if (held.kind === 'shape') {
const stroke = session.ownLayer.strokes.get(held.id);
if (!stroke) return;
session.setShapePoints(held.id, movedPoints(stroke, now, {
left: Math.round(rect.left), top: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height),
}));
} 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();
}
showHeldGrip(held);
refreshArrangeCues();
},
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 === 'image' || held.kind === 'hide') session.sendPageNow();
if (held.kind === 'text') session.endTextEdit(held.id, 'hold');
if (held.kind === 'shape') session.endShapeEdit();
if (held.kind === 'hide') session.endHideEdit();
held = null;
renderer.showGrip(null);
refreshArrangeCues();
},
eraseAlong: (x0, y0, x1, y1) => {
textEditor?.stop();
session.beginErasing();
const radius = renderer.pageLength(ERASER_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) => {
// What the movement erased is one gesture to undo.
const erased = session.endErasing();
if (reach >= TAP_DISTANCE) {
lastEmptyTap = 0;
return;
}
// A tap of the eraser on a Hide rectangle or an image removes it,
// for everyone (by anyone since 2026-10-18).
let took = erased.strokes + erased.texts > 0;
if (!took) {
const hide = session.page.hideAt(x, y, 0);
if (hide && session.removeHide(hide.index)) {
log(`Hide ${hide.index + 1} removed`);
took = true;
} else {
const found = session.page.imageAt(x, y, 0);
if (found && session.removeImage(found.index)) {
log(`Image ${found.index + 1} removed`);
took = true;
}
}
}
if (took) {
lastEmptyTap = 0;
return;
}
// A tap on empty paper: the second within DOUBLE_TAP_MS undoes.
const now = performance.now();
if (now - lastEmptyTap <= DOUBLE_TAP_MS) {
lastEmptyTap = 0;
undo();
} else {
lastEmptyTap = now;
}
},
previewRect: (x0, y0, x1, y1) => {
const dragged = Math.hypot(x1 - x0, y1 - y0) >= TAP_DISTANCE;
if (tool === 'shape') {
// The shape as it will be, in the pen's colour and width; for a
// line, the side the press is adding.
if (isTapped(shape)) {
/** @type {[number, number]} */
const from = lineInProgress ? lineInProgress.vertices[lineInProgress.vertices.length - 1] : [x0, y0];
if (lineInProgress || dragged) renderer.showShapePreview(straightPoints([from, [x1, y1]]), color, width, false);
} else if (dragged) {
renderer.showShapePreview(draggedPoints(shape, x0, y0, x1, y1, width), color, width, isFilled(shape));
} else {
renderer.hideShapePreview();
}
return;
}
if (dragged) {
textEditor?.showPreview(x0, y0, x1, y1);
} else {
textEditor?.hidePreview();
}
},
// A hovering pointer: the rubber band of a line being made, and the
// corners of free text under it (Andy, 2026-10-14: free text is used
// constantly, so its extent shows only then).
hover: (clientX, clientY) => {
const [x, y] = renderer.pageFromClient(clientX, clientY);
if (lineInProgress && tool === 'shape') {
const last = lineInProgress.vertices[lineInProgress.vertices.length - 1];
renderer.showShapePreview(straightPoints([last, [x, y]]), color, width, false);
}
// With the Eraser, the red box says what matters, so the blue cue
// of an implied rectangle stays away (the two had shown together).
const implied = tool === 'eraser' ? null : impliedRectAt(x, y);
renderer.showHoverCue(implied?.rect ?? null, implied?.kind);
renderer.setEraseCues(tool === 'eraser' ? eraseTargetsAt(x, y) : []);
if (tool === 'arrange') hoverHandwriting(x, y);
},
hoverEnded: (left = false) => {
renderer.showHoverCue(null);
renderer.setEraseCues([]);
if (lineInProgress) renderer.hideShapePreview();
// The handwriting's box stays through a press (the pointer is still
// there) and goes when the pointer leaves the page.
if (left && hoveredStroke) {
hoveredStroke = null;
refreshArrangeCues();
}
},
placeRect: (x0, y0, x1, y1) => {
if (tool === 'shape') {
placeShape(x0, y0, x1, y1);
return;
}
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 (!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 && !editing) textEditor.begin(x0, y0, null, null, { color, size: textSize, font: textFont });
} else {
// 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) => {
// 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, shape, fill,
setTool: (value) => {
if (value !== 'text') textEditor?.stop();
if (value !== 'shape') finishLine();
if (value !== tool) trace(`Tool: ${value}`);
tool = value;
// Only the chosen tool's settings show: the pen's and the shapes'
// colours and line widths, the text's colours, size and font,
// nothing for the eraser, Hide or Arrange.
element('colors').hidden = value === 'eraser' || value === 'hide' || value === 'arrange';
element('widths').hidden = value !== 'pen' && value !== 'shape';
element('text_style').hidden = value !== 'text';
if (value !== 'arrange') hoveredStroke = null;
refreshArrangeCues();
if (value !== 'eraser') renderer.setEraseCues([]);
},
setShape: (value) => {
finishLine();
if (value !== shape) trace(`Shape: ${value}`);
shape = value;
localStorage.setItem(SHAPE_KEY, value);
},
setFill: (value) => {
finishLine();
fill = value;
localStorage.setItem(FILL_KEY, value ?? 'none');
trace(`Fill: ${value ?? 'none'}`);
},
setColor: (value) => {
color = value;
textEditor?.restyle({ color });
},
setWidth: (value) => { width = value; },
setTextSize: (value) => {
textSize = value;
textEditor?.restyle({ size: value });
},
setTextFont: (value) => {
textFont = value;
textEditor?.restyle({ font: value });
},
});
// Zoom is each person's own; it changes nothing on the page.
// 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.
const viewKey = `kokkoja_view_${roomId}`;
const saveView = () => {
if (session.page.number === 0) return;
sessionStorage.setItem(viewKey, JSON.stringify({ page: session.page.id, ...renderer.view() }));
};
let viewTimer = 0;
const viewChanged = () => {
clearTimeout(viewTimer);
viewTimer = setTimeout(saveView, 300);
};
renderer.onZoomChanged = () => {
textEditor?.layout();
viewChanged();
};
// Fit and Normal scale ("A4" until 2026-10-14) are each person's own
// (2026-10-18; the host's reached everyone from 2026-10-08 until then).
// The page and view commands live in the Page menu (Andy, 2026-10-14:
// seldom used, and the one row is precious).
attachPopup(element('page_menu_button'), element('page_menu'));
// Help (Andy, 2026-10-15): one click shows the manual, the tooltips of
// every command gathered into one window (Ctrl-click until then); two
// clicks within DOUBLE_TAP_MS show the note about the documentation
// site. Since one click opens a modal window, it must wait the
// interval out to be told from the first of two; the shape button's
// double click needs no such wait, its single click having chosen the
// tool already. Two clicks are counted here rather than by the
// browser's dblclick, which a pen or a touch screen does not always
// send.
const manualDialog = /** @type {HTMLDialogElement} */ (element('manual_dialog'));
const helpNote = attachPopup(element('help'), element('help_popup'), undefined, false);
const showManual = () => {
helpNote.close();
const body = element('manual_text');
body.replaceChildren(manualList(document));
manualDialog.showModal();
body.focus();
};
/** @type {ReturnType<typeof setTimeout> | null} */
let helpClick = null;
element('help').addEventListener('click', () => {
if (helpClick !== null) {
clearTimeout(helpClick);
helpClick = null;
helpNote.open();
} else {
helpClick = setTimeout(() => {
helpClick = null;
showManual();
}, DOUBLE_TAP_MS);
}
});
element('manual_close').addEventListener('click', () => manualDialog.close());
element('copy_manual').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(manualText(document));
element('copy_manual').textContent = 'Copied';
setTimeout(() => { element('copy_manual').textContent = 'Copy'; }, 1500);
} catch (error) {
log(`The manual could not be copied: ${error}`);
}
});
element('zoom_fit').addEventListener('click', () => renderer.fit());
element('zoom_sheet').addEventListener('click', () => renderer.sheetScale());
// "Expand" makes the sheet itself fill the window at the present zoom,
// in both directions; nothing on it moves (Andy, 2026-10-08 for the
// width, both ways 2026-10-11).
element('zoom_expand').addEventListener('click', () => {
const room = renderer.available();
const expanded = session.expandPage(room.width / renderer.cssPixelsPerUnit,
room.height / renderer.cssPixelsPerUnit);
if (expanded) {
log(`Page expanded to ${session.page.baseWidth / 100} by ${session.page.baseHeight / 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) &&
Number.isFinite(seen.x) && Number.isFinite(seen.y)) {
renderer.showView(seen);
}
} catch {
// Nothing usable was remembered: the page is shown as a new one is.
}
element('desk').addEventListener('scroll', viewChanged, { passive: true });
window.addEventListener('pagehide', saveView);
// Everyone has every button (Andy and his teacher, 2026-10-18; until
// then New, Image, Hide, Expand and Shrink were the host's and the
// co-hosts').
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, at
* its natural size; moving and scaling it is done by hand afterwards.
* @param {Blob} file
*/
async function loadImage(file) {
if (session.page.number === 0) return;
try {
if (!session.addImage(await prepareImage(file))) {
throw new Error('The page has no room for another image.');
}
} catch (error) {
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) {
passingNotice(`${pageName} is not a Kokkoja page.`, true);
return;
}
log(`Notebook page loaded: ${chosenNotebook} / ${pageName}`);
session.loadPage(kept.page);
}, () => {
// A deleted notebook leaves the toolbar's list.
showNotebooks(sessionPicker).catch(() => {});
});
element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook); });
// Image files are chosen, and pictures of the page exported, through a
// chooser of our own, which begins where the notebooks are.
const fileChooser = new FileChooser();
element('load_image').addEventListener('click', async () => {
if (notebooksInBrowser()) {
// No local program to look through the files: the browser's own chooser.
const file = await pickImageFile();
if (file) loadImage(file);
return;
}
const file = await fileChooser.chooseImage();
if (file) loadImage(file);
});
/**
* Save a picture of the page as it is shown here (layers hidden on this
* screen are left out) where the user chooses.
*/
async function exportPage() {
const page = session.page;
if (page.number === 0) return;
textEditor?.stop();
// Anything still waiting to be saved is saved first, so that its
// "Saved" does not take the place of "Exported" a moment later.
await writer.writeNow();
const now = new Date();
const day = [now.getFullYear(), now.getMonth() + 1, now.getDate()]
.map((part) => String(part).padStart(2, '0')).join('_');
if (notebooksInBrowser()) {
download(`${day}_page_${page.number}.png`,
await pageToPng(page, { session: roomId, hostId: session.hostId, hidden: renderer.hidden }));
log(`Exported page ${page.number} to the downloads`);
passingNotice('Exported to your downloads');
return;
}
const saved = await fileChooser.chooseWhereToSave(
'Export', `${day}_page_${page.number}.png`,
async (directory, name, replace) => writePicture(
directory, name,
await pageToPng(page, { session: roomId, hostId: session.hostId, hidden: renderer.hidden }),
replace));
if (saved === null) return;
log(`Exported page ${page.number} to ${saved}`);
passingNotice(`Exported to ${saved}`);
}
element('export_page').addEventListener('click', () => { exportPage(); });
// 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, 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 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();
try {
await navigator.clipboard.writeText(field.value);
element('copy_invitation').textContent = 'Copied';
setTimeout(() => { element('copy_invitation').textContent = 'Copy'; }, 1500);
} catch {
// The field is selected; the user can copy it with the keyboard.
}
});
if (notebooksInBrowser()) {
element('keep_page').setAttribute('data-help',
'Keep a dated copy of the page as it is now in this browser’s notebook, and download it. ' +
'The page is also kept by itself whenever it is about to be replaced.');
element('export_page').setAttribute('data-help',
'Download a picture of the page, as it is shown here, as a PNG file.');
}
session.start();
// For looking at the session from the browser's console during development.
Object.assign(window, { kokkoja: {
session, renderer, writer, loadImage, textEditor, logText,
manualText: () => manualText(document),
notebook: { listNotebooks, listKeptPages, readPage, readCurrentPage, notebooksInBrowser, discardNotebook, discardPage },
} });
}
/**
* Mark one button of a group as chosen.
* @param {Element} group
* @param {Element} chosen
*/
function press(group, chosen) {
for (const button of group.querySelectorAll('button')) {
button.setAttribute('aria-pressed', String(button === chosen));
}
}
/** The closers of every popup, so that opening one closes the others. @type {Set<() => void>} */
const popupClosers = new Set();
/**
* A popup of Kokkoja's own below a toolbar button (the colour palette,
* the Page menu, the Help note, the shapes; no system menu or dialog,
* 2026-10-14). The button's press opens it below the button, left edges
* aligned, or right edges when it would run past the window; a press
* anywhere else, Escape, or a press on an item marked `.menu_item`
* closes it.
* @param {HTMLElement} button
* @param {HTMLElement} popup
* @param {() => void} [onOpen] Fill the popup before it shows.
* @param {boolean} [byClick] Whether the button's click opens and closes
* it; the Shape button opens its menu by a hold instead.
* @returns {{open: () => void, close: () => void}}
*/
export function attachPopup(button, popup, onOpen, byClick = true) {
const close = () => {
popup.hidden = true;
button.setAttribute('aria-expanded', 'false');
};
popupClosers.add(close);
const open = () => {
// One popup at a time (a keyboard's Enter opens without the press
// that would close the other).
for (const other of popupClosers) other();
onOpen?.();
const box = button.getBoundingClientRect();
// Measured at the window's left edge: placed near the right edge, an
// absolute box shrinks to the room left and its text wraps.
popup.style.left = '0px';
popup.style.top = `${box.bottom + 6}px`;
popup.hidden = false;
button.setAttribute('aria-expanded', 'true');
const width = popup.getBoundingClientRect().width;
const fits = box.left + width <= window.innerWidth - 8;
popup.style.left = `${fits ? box.left : Math.max(8, box.right - width)}px`;
};
if (byClick) {
button.addEventListener('click', () => {
if (popup.hidden) open();
else close();
});
}
popup.addEventListener('click', (event) => {
const item = /** @type {Element} */ (event.target).closest('.menu_item');
// A label's click reaches here twice, once as the input's; close on the second.
if (item && !(event.target instanceof HTMLLabelElement)) close();
});
document.addEventListener('pointerdown', (event) => {
const target = /** @type {Node} */ (event.target);
if (!popup.hidden && !popup.contains(target) && !button.contains(target)) close();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !popup.hidden) close();
});
return { open, close };
}
/**
* @param {object} options
* @param {string} options.color Initial color.
* @param {number} options.width Initial width.
* @param {number} options.textSize
* @param {import('./page_model.js').TextFont} options.textFont
* @param {Shape} options.shape The Shape tool's shape to begin with.
* @param {string | null} options.fill Its fill colour, or none.
* @param {(tool: Tool) => void} options.setTool
* @param {(shape: Shape) => void} options.setShape
* @param {(fill: string | null) => void} options.setFill
* @param {(color: string) => void} options.setColor
* @param {(width: number) => void} options.setWidth
* @param {(size: number) => void} options.setTextSize
* @param {(font: import('./page_model.js').TextFont) => void} options.setTextFont
*/
function buildToolbar(options) {
const shapeButton = element('shape_tool');
/** The Shape button's hold has just opened the menu: the click that follows chooses nothing. */
let shapeMenuOpened = false;
/** @param {Element} button */
const chooseTool = (button) => {
// The tool buttons stand in two groups since 2026-10-11 (Pen, Text
// and the shapes before the colours they use, Hide, Arrange and
// Eraser after), so the pressed one is marked among all of them, not
// among siblings.
for (const other of document.querySelectorAll('[data-tool]')) {
other.setAttribute('aria-pressed', String(other === button));
}
options.setTool(/** @type {Tool} */ (button.getAttribute('data-tool')));
};
for (const button of document.querySelectorAll('[data-tool]')) {
button.addEventListener('click', () => {
if (button === shapeButton && shapeMenuOpened) {
shapeMenuOpened = false;
return;
}
chooseTool(button);
});
}
// The Shape button names the current shape, Rectangle to begin with,
// and a quick press chooses the tool; a press held for HOLD_MS, a
// press with the right button (a pen's barrel button) or a double
// click (Andy, 2026-10-14) opens the menu of shapes, and the shape
// chosen there is the button's from then on and the tool is chosen
// (Andy, 2026-10-14: one button for any number of shapes). The menu
// marks the current shape, and ends with the fill colours. The button
// keeps the width of its widest name, so that nothing in the toolbar
// moves when the shape changes.
const shapeMenu = element('shape_menu');
const shapePopup = attachPopup(shapeButton, shapeMenu, undefined, false);
let widest = 0;
for (const name of Object.values(SHAPE_NAMES)) {
shapeButton.textContent = name;
widest = Math.max(widest, shapeButton.getBoundingClientRect().width);
}
shapeButton.style.minWidth = `${Math.ceil(widest)}px`;
/** @param {Shape} shape */
const showShape = (shape) => {
shapeButton.textContent = SHAPE_NAMES[shape];
for (const item of shapeMenu.querySelectorAll('[data-shape]')) {
item.setAttribute('aria-checked', String(item.getAttribute('data-shape') === shape));
}
};
showShape(options.shape);
/** @type {ReturnType<typeof setTimeout> | null} */
let shapeHold = null;
const cancelShapeHold = () => {
if (shapeHold !== null) clearTimeout(shapeHold);
shapeHold = null;
};
const openShapeMenu = () => {
shapeMenuOpened = true;
shapePopup.open();
};
shapeButton.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
shapeMenuOpened = false;
cancelShapeHold();
shapeHold = setTimeout(() => {
shapeHold = null;
openShapeMenu();
}, HOLD_MS);
});
for (const type of ['pointerup', 'pointercancel', 'pointerleave']) shapeButton.addEventListener(type, cancelShapeHold);
shapeButton.addEventListener('contextmenu', (event) => {
event.preventDefault();
cancelShapeHold();
openShapeMenu();
shapeMenuOpened = false; // No click follows a secondary press.
});
// A double click, told from two clicks within DOUBLE_TAP_MS rather than
// from the browser's dblclick event, which a pen or a touch screen does
// not always send; both clicks have chosen the tool already.
let lastShapeClick = -Infinity;
shapeButton.addEventListener('click', () => {
const now = performance.now();
if (now - lastShapeClick <= DOUBLE_TAP_MS) {
lastShapeClick = -Infinity;
openShapeMenu();
shapeMenuOpened = false;
} else {
lastShapeClick = now;
}
});
// The fill colours: the toolbar's colours, those added to it, and
// "none", a state of their own (Andy, 2026-10-14). A press keeps the
// menu open.
const fills = element('shape_fill');
/** @param {string | null} value */
const addFill = (value) => {
const button = document.createElement('button');
button.type = 'button';
button.className = `swatch${value === null ? ' none' : ''}`;
if (value !== null) button.style.setProperty('--swatch', value);
button.setAttribute('aria-label', value === null ? 'No fill' : `Fill ${value}`);
button.setAttribute('data-help', value === null ? 'Draw the shapes unfilled.' : 'Fill the shapes with this color.');
button.setAttribute('aria-pressed', String(value === options.fill));
button.addEventListener('click', () => {
press(fills, button);
options.setFill(value);
});
fills.append(button);
};
addFill(null);
for (const value of COLORS) addFill(value);
for (const item of shapeMenu.querySelectorAll('[data-shape]')) {
item.addEventListener('click', () => {
const chosen = shapeNamed(item.getAttribute('data-shape'));
options.setShape(chosen);
showShape(chosen);
chooseTool(shapeButton);
});
}
const colors = element('colors');
const palette = element('color_palette');
const more = element('more_colors');
/** @param {string} color */
const addSwatch = (color) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'swatch';
button.style.setProperty('--swatch', color);
button.setAttribute('aria-label', `Color ${color}`);
button.setAttribute('data-help', 'Write in this color.');
button.setAttribute('aria-pressed', String(color === options.color));
button.addEventListener('click', () => {
press(colors, button);
options.setColor(color);
});
colors.insertBefore(button, more);
return button;
};
/** @type {string[]} */
let added = [];
try {
added = JSON.parse(localStorage.getItem(COLORS_KEY) ?? '[]');
} catch {
added = [];
}
for (const color of COLORS) addSwatch(color);
for (const color of added) {
if (/^#[0-9a-f]{6}$/.test(color)) {
addSwatch(color);
addFill(color);
}
}
// "+" opens a palette below it; a chosen colour becomes a swatch before
// the "+" and is the colour from then on.
/** @param {string} color */
const choose = (color) => {
palette.hidden = true;
const existing = [...colors.querySelectorAll('.swatch')]
.find((swatch) => swatch.getAttribute('aria-label') === `Color ${color}`);
const button = existing ?? addSwatch(color);
if (!existing) {
added.push(color);
localStorage.setItem(COLORS_KEY, JSON.stringify(added));
addFill(color); // The shapes' fills follow the toolbar's colours.
}
press(colors, button);
options.setColor(color);
};
// 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);
}
};
attachPopup(more, palette, showPalette);
const widths = element('widths');
for (const width of WIDTHS) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'width_choice';
button.setAttribute('aria-label', `Line width ${width / 10} mm`);
button.setAttribute('data-help', `Write with a ${width / 10} mm line. Pressing harder widens it.`);
button.setAttribute('aria-pressed', String(width === options.width));
const line = document.createElement('span');
line.style.height = `${Math.max(1, Math.round(width / 2.5))}px`;
button.append(line);
button.addEventListener('click', () => {
press(widths, button);
options.setWidth(width);
});
widths.append(button);
}
// The text size is a number of points; any size works, since the fonts
// scale. The font is one of the three generic families.
const size = input('text_size');
size.value = String(Math.round(options.textSize / UNITS_PER_POINT));
const applySize = () => {
const points = Math.max(LEAST_TEXT_POINTS, Math.min(MOST_TEXT_POINTS, Number(size.value) || 0));
size.value = String(points);
options.setTextSize(Math.round(points * UNITS_PER_POINT * 10) / 10);
};
size.addEventListener('change', applySize);
size.addEventListener('input', () => {
if (Number(size.value) >= LEAST_TEXT_POINTS) applySize();
});
const font = /** @type {HTMLSelectElement} */ (document.getElementById('text_font'));
font.value = options.textFont;
font.style.fontFamily = options.textFont;
font.addEventListener('change', () => {
const chosen = TEXT_FONTS.find((family) => family === font.value) ?? 'sans-serif';
font.style.fontFamily = chosen;
options.setTextFont(chosen);
});
}
/**
* @param {Session} session
* @param {Renderer} renderer
*/
function showParticipants(session, renderer) {
const list = element('participants');
list.replaceChildren();
for (const participant of session.participantList()) {
const item = document.createElement('li');
const present = participant.self || participant.peer !== null;
item.classList.toggle('absent', !present);
const label = document.createElement('label');
const visible = document.createElement('input');
visible.type = 'checkbox';
visible.checked = !renderer.hidden.has(participant.id);
visible.setAttribute('aria-label', 'Shown');
visible.setAttribute('data-help', 'Show or hide this participant’s writing on your screen only.');
visible.addEventListener('change', () => renderer.setLayerVisible(participant.id, visible.checked));
label.append(visible, ` ${participant.name}`);
item.append(label);
const roles = [];
if (participant.self) roles.push('you');
if (!present) roles.push('away');
if (roles.length) {
const role = document.createElement('span');
role.className = 'role';
role.textContent = roles.join(', ');
item.append(role);
}
list.append(item);
}
}
// Startup.
/** @param {any} error */
function failedToEnter(error) {
entered = false;
showStart(String(error?.message ?? error));
}
element('start_session').addEventListener('click', () => {
startSession().catch(failedToEnter);
});
element('join_session').addEventListener('click', () => {
joinSession().catch(failedToEnter);
});
/**
* Begin where this browser tab left off. A tab that was in a session and
* is reloaded goes straight back into it. A tab that arrives with an
* invitation in its address shows the start screen with the invitation
* filled in, so that the name and the notebook can be chosen.
*/
async function resume() {
const invitationText = invitationInAddress();
const invitation = parseInvitation(invitationText);
const name = localStorage.getItem(NAME_KEY);
if (invitation && name) {
const { roomId } = await deriveSession(invitation.secret);
const notebook = sessionStorage.getItem(notebookKeyFor(roomId));
if (notebook) {
await enterSession(invitationText, name, notebook);
return;
}
}
showStart();
}
enableTooltips();
guardSelection();
log(`Browser: ${navigator.userAgent}; window ${innerWidth}×${innerHeight}, pixel ratio ${devicePixelRatio}`);
const token = addressValues().get('token');
if (token) {
rememberToken(token);
setAddress(invitationInAddress());
}
/** Learn how this client was served, then begin. */
async function startUp() {
const served = await (await fetch('config.json')).json();
config = {
relay: String(served.relay ?? ''),
storage: served.storage === 'browser' ? 'browser' : 'files',
site: String(served.site ?? ''),
};
if (config.storage === 'browser') useBrowserStorage();
await resume();
}
if (!window.isSecureContext) {
showStart('Kokkoja must be opened from this computer (http://localhost) or over HTTPS.');
} else {
startUp().catch(failedToEnter);
}