Files
kokkoja-rel/client/js/main.js
T
ack 4e3a281d65 Undo, and the toolbar in three groups
Undo takes back one's own latest mark, by the button, Ctrl+Z or two
quick taps of the eraser; the toolbar's drawing tools, the page and
view, and the session stand in three groups that do not move when the
tool changes; the buttons are a little smaller.
2026-10-11 13:17:22 +02:00

1507 lines
57 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, 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, TEXT_FONTS, TEXT_SIZE,
UNITS_PER_POINT, strokesTouched, textAt, textsTouched,
} from './page_model.js';
import { attachPenInput } from './pen_input.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';
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';
/**
* 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';
/**
* 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 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') ?? '';
}
/**
* 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 : '');
const isHost = identity.id === invitation.hostId;
let tool = /** @type {'pen' | 'eraser' | 'text' | 'hide'} */ ('pen');
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 host’s page' : '', 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);
writer.changed();
},
onStrokeProgress: (layerId, stroke) => {
syncLayers();
renderer.strokeProgress(layerId, stroke);
if (stroke.done) writer.changed();
},
onTextChanged: (layerId) => {
syncLayers();
renderer.redrawLayer(layerId);
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);
}
// 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();
},
onImagesChanged: () => {
log(`Page ${session.page.number}: ${session.page.images.length} images, ${session.page.hides.length} hides`);
renderer.showImages();
renderer.drawHides();
writer.writeNow();
},
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);
},
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 keeps the width of its widest state, the pen's
// colours and widths, so that the page-and-view group, centred between
// it and the session group, stays put when the tool changes (Andy,
// 2026-10-11). The floor rises if the group ever grows, as when a
// colour is added.
// The settings (colours, widths, font and size) keep their widest
// room likewise, so that Eraser, Hide and Undo, which stand after them
// since 2026-10-11, do not jump when a tool shows fewer settings.
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 is finished first, so that the undo is of it.
const undo = () => {
textEditor?.stop();
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();
}
});
// 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;
/** 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;
}
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();
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 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);
session.beginHideEdit(h.id);
} 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);
session.beginTextEdit(block.id, 'hold');
} 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(false);
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();
if (held.kind === 'text') session.endTextEdit(held.id, 'hold');
if (held.kind === 'hide') session.endHideEdit();
held = null;
renderer.showGrip(null);
},
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
// (the right to make pages, or having added the image).
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) => {
if (Math.abs(x1 - x0) >= TAP_DISTANCE || Math.abs(y1 - y0) >= TAP_DISTANCE) {
textEditor?.showPreview(x0, y0, x1, y1);
} else {
textEditor?.hidePreview();
}
},
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 (!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,
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' || value === 'hide';
element('widths').hidden = value !== 'pen';
element('text_style').hidden = value !== 'text';
},
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();
};
// 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) &&
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);
// 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', () => {
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.mayMakePages() || 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);
});
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,
notebook: { listNotebooks, listKeptPages, readPage, readCurrentPage, notebooksInBrowser },
} });
}
/**
* 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));
}
}
/**
* @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 {(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
* @param {(font: import('./page_model.js').TextFont) => void} options.setTextFont
*/
function buildToolbar(options) {
for (const button of document.querySelectorAll('[data-tool]')) {
button.addEventListener('click', () => {
// The tool buttons stand in two groups since 2026-10-11 (Pen and
// Text before the colours they use, Eraser and Hide 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));
}
const chosen = button.getAttribute('data-tool');
options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : chosen === 'hide' ? 'hide' : 'pen');
});
}
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);
}
// "+" 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));
}
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);
}
};
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`;
palette.hidden = false;
});
document.addEventListener('pointerdown', (event) => {
if (!palette.hidden && !palette.contains(/** @type {Node} */ (event.target)) && event.target !== more) {
palette.hidden = true;
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') palette.hidden = true;
});
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);
if (session.isHost && !participant.self) {
// 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.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');
const visible = document.createElement('input');
visible.type = 'checkbox';
visible.checked = !renderer.hidden.has(participant.id);
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 (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');
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);
}