Files
kokkoja-rel/client/js/main.js
T

1509 lines
58 KiB
JavaScript
Raw Normal View History

2026-10-06 15:58:02 +02:00
// @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,
2026-10-06 15:58:02 +02:00
readCurrentPage, readIdentity, readPage, rememberToken, useBrowserStorage,
} from './notebook.js';
import { download, pickImageFile } from './browser_files.js';
import { DEFAULT_NOTEBOOK, NotebookPicker } from './notebook_picker.js';
2026-10-06 15:58:02 +02:00
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';
2026-10-06 15:58:02 +02:00
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';
2026-10-11 13:17:22 +02:00
import { guardSelection, inField } from './selection_guard.js';
import { QUIET_ZONE, qrModules, qrPath } from './qr_code.js';
2026-10-06 15:58:02 +02:00
/**
* 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'];
2026-10-06 15:58:02 +02:00
/** 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';
2026-10-06 15:58:02 +02:00
/** 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;
2026-10-11 13:17:22 +02:00
/**
* 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;
2026-10-06 15:58:02 +02:00
/**
* 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; },
() => {});
2026-10-06 15:58:02 +02:00
/**
* 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;
2026-10-06 15:58:02 +02:00
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);
2026-10-06 15:58:02 +02:00
// 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) => {
2026-10-06 15:58:02 +02:00
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) {
2026-10-06 15:58:02 +02:00
const { directory, notebooks } = await listNotebooks();
const current = picker.value;
picker.fill(notebooks, current);
picker.current = current;
2026-10-06 15:58:02 +02:00
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);
}
2026-10-06 15:58:02 +02:00
/** @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. */
2026-10-06 15:58:02 +02:00
function enteredNotebook() {
const notebook = startPicker.value || DEFAULT_NOTEBOOK;
2026-10-06 15:58:02 +02:00
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 : '');
2026-10-06 15:58:02 +02:00
const isHost = identity.id === invitation.hostId;
let tool = /** @type {'pen' | 'eraser' | 'text' | 'hide'} */ ('pen');
2026-10-06 15:58:02 +02:00
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);
2026-10-06 15:58:02 +02:00
}
/** @type {ReturnType<typeof setTimeout> | undefined} */
let connectionNoticeTimer;
2026-10-06 15:58:02 +02:00
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);
}
2026-10-06 15:58:02 +02:00
// 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`);
2026-10-06 15:58:02 +02:00
renderer.showImages();
renderer.drawHides();
2026-10-06 15:58:02 +02:00
writer.writeNow();
},
onPageResized: () => {
// The view keeps its scale; the page extends beyond the window.
2026-10-06 15:58:02 +02:00
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();
},
2026-10-06 15:58:02 +02:00
onPermissionsChanged: () => {
log(`May start pages: ${session.mayMakePages() ? 'yes' : 'no'}` +
(session.isHost ? `; guests allowed: ${session.permitted.size}` : ''));
2026-10-06 15:58:02 +02:00
showHostTools();
showParticipants(session, renderer);
},
onParticipantsChanged: () => {
syncLayers();
showParticipants(session, renderer);
},
2026-10-11 13:17:22 +02:00
onUndoChanged: () => {
/** @type {HTMLButtonElement} */ (element('undo')).disabled = !session.canUndo();
},
2026-10-06 15:58:02 +02:00
onStatus: (status, detail) => {
const text = { connecting: 'Connecting…', connected: 'Connected', failed: detail ?? 'Disconnected' };
log(`Relay: ${text[status]}`);
2026-10-06 15:58:02 +02:00
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);
2026-10-06 15:58:02 +02:00
},
});
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),
2026-10-06 15:58:02 +02:00
});
showSaveState(savedPage ? 'Continued from the notebook' : '', false);
2026-10-06 15:58:02 +02:00
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();
2026-10-11 13:17:22 +02:00
// 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);
}
2026-10-06 15:58:02 +02:00
textEditor = new TextEditor(pageElement, renderer, {
setText: (block) => session.setText(block),
removeText: (id) => session.removeTexts([id]),
2026-10-11 13:17:22 +02:00
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?.(),
});
2026-10-11 13:17:22 +02:00
// 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;
2026-10-11 13:17:22 +02:00
/** 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,
2026-10-06 15:58:02 +02:00
});
/**
* @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 = {
2026-10-06 15:58:02 +02:00
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);
2026-10-06 15:58:02 +02:00
},
endStroke: () => {
strokeInProgress = null;
2026-10-06 15:58:02 +02:00
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);
2026-10-11 13:17:22 +02:00
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);
2026-10-11 13:17:22 +02:00
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;
2026-10-11 13:17:22 +02:00
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();
2026-10-11 13:17:22 +02:00
if (held.kind === 'text') session.endTextEdit(held.id, 'hold');
if (held.kind === 'hide') session.endHideEdit();
held = null;
renderer.showGrip(null);
2026-10-06 15:58:02 +02:00
},
eraseAlong: (x0, y0, x1, y1) => {
textEditor?.stop();
2026-10-11 13:17:22 +02:00
session.beginErasing();
2026-10-06 15:58:02 +02:00
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 };
2026-10-06 15:58:02 +02:00
},
eraseEnded: (x, y, reach) => {
2026-10-11 13:17:22 +02:00
// 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).
2026-10-11 13:17:22 +02:00
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;
2026-10-06 15:58:02 +02:00
}
2026-10-11 13:17:22 +02:00
// 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;
}
2026-10-06 15:58:02 +02:00
},
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();
}
2026-10-06 15:58:02 +02:00
},
placeRect: (x0, y0, x1, y1) => {
2026-10-06 15:58:02 +02:00
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;
}
2026-10-06 15:58:02 +02:00
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).
2026-10-06 15:58:02 +02:00
if (own && own !== editing) textEditor.edit(own);
else if (!own && !editing) textEditor.begin(x0, y0, null, null, { color, size: textSize, font: textFont });
2026-10-06 15:58:02 +02:00
} 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),
2026-10-06 15:58:02 +02:00
{ 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`);
2026-10-06 15:58:02 +02:00
},
};
attachPenInput(pageElement, penTarget);
2026-10-06 15:58:02 +02:00
buildToolbar({
color, width, textSize, textFont,
setTool: (value) => {
if (value !== 'text') textEditor?.stop();
if (value !== tool) trace(`Tool: ${value}`);
2026-10-06 15:58:02 +02:00
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';
2026-10-06 15:58:02 +02:00
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`);
}
});
2026-10-06 15:58:02 +02:00
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();
2026-10-06 15:58:02 +02:00
}
showHostTools();
element('new_page').addEventListener('click', () => {
log('New page asked for');
session.newPage();
});
2026-10-06 15:58:02 +02:00
/**
* 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.
2026-10-06 15:58:02 +02:00
* @param {Blob} file
*/
async function loadImage(file) {
2026-10-06 15:58:02 +02:00
if (!session.mayMakePages() || session.page.number === 0) return;
try {
if (!session.addImage(await prepareImage(file))) {
2026-10-06 15:58:02 +02:00
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);
2026-10-06 15:58:02 +02:00
}
}
const chooser = new NotebookChooser((chosenNotebook, pageName, text) => {
const kept = pageFromSvg(text);
if (!kept) {
passingNotice(`${pageName} is not a Kokkoja page.`, true);
2026-10-06 15:58:02 +02:00
return;
}
log(`Notebook page loaded: ${chosenNotebook} / ${pageName}`);
2026-10-06 15:58:02 +02:00
session.loadPage(kept.page);
}, () => {
// A deleted notebook leaves the toolbar's list.
showNotebooks(sessionPicker).catch(() => {});
2026-10-06 15:58:02 +02:00
});
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);
2026-10-06 15:58:02 +02:00
});
/**
* 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');
2026-10-06 15:58:02 +02:00
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}`);
2026-10-06 15:58:02 +02:00
}
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(() => {});
2026-10-06 15:58:02 +02:00
element('keep_page').addEventListener('click', async () => {
const { kept, new: made } = await writer.keepPage();
log(kept ? `${made ? 'Kept' : 'Already kept'} as ${kept}` : 'Nothing to keep');
2026-10-06 15:58:02 +02:00
// 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()) {
2026-10-06 15:58:02 +02:00
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.
}
});
2026-10-06 15:58:02 +02:00
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, discardNotebook, discardPage },
2026-10-06 15:58:02 +02:00
} });
}
/**
* 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
2026-10-06 15:58:02 +02:00
* @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', () => {
2026-10-11 13:17:22 +02:00
// 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));
}
2026-10-06 15:58:02 +02:00
const chosen = button.getAttribute('data-tool');
options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : chosen === 'hide' ? 'hide' : 'pen');
2026-10-06 15:58:02 +02:00
});
}
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);
}
};
2026-10-06 15:58:02 +02:00
more.addEventListener('click', () => {
if (!palette.hidden) {
palette.hidden = true;
return;
}
showPalette();
2026-10-06 15:58:02 +02:00
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';
2026-10-06 15:58:02 +02:00
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);
2026-10-06 15:58:02 +02:00
}
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');
2026-10-06 15:58:02 +02:00
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}`);
2026-10-06 15:58:02 +02:00
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);
}