// @ts-check /** * The Kokkoja client: the start screen, and the session screen that * connects the pen, the page and the other participants. */ import { deriveSession, formatInvitation, formatInvitationLink, loadIdentity, newIdentity, newSecret, parseInvitation, saveIdentity, } from './crypto.js'; import { NotebookWriter, discardNotebook, discardPage, keepCurrentPage, keepIdentity, listKeptPages, listNotebooks, notebooksInBrowser, readCurrentPage, readIdentity, readPage, rememberToken, useBrowserStorage, } from './notebook.js'; import { download, pickImageFile } from './browser_files.js'; import { DEFAULT_NOTEBOOK, NotebookPicker } from './notebook_picker.js'; import { pageFromSvg, pageToSvg } from './page_file.js'; import { hashOf } from './crypto.js'; import { imageResolution } from './image_info.js'; import { LEAST_TEXT_POINTS, MAX_IMAGE_BYTES, MOST_TEXT_POINTS, 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} */ 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} */ const standing = new Map(); let passing = { text: '', problem: false }; /** @type {ReturnType | 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[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} */ 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' | 'arrange'} */ ('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 | 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); if (layerId === session.identity.id) refreshArrangeCues(); writer.changed(); }, onPageReplaced: (oldPage, newPage) => { log(`Page ${newPage.number} (${newPage.images.length} images) replaces page ${oldPage.number}`); // A block being typed stays on the page being left, where every // keystroke already put it; finishing the edit now would move it // to the new page (seen by Andy, 2026-10-06). An empty one is // dropped from the old page before that page is kept. const abandoned = textEditor?.discard() ?? null; if (abandoned && abandoned.text.trim() === '') { oldPage.layers.get(session.identity.id)?.texts.delete(abandoned.id); } // Nothing is lost: the page being left is kept in the notebook. writer.pageReplaced(oldPage.number > 0 ? fileOf(oldPage) : null); renderer.setPage(newPage); layerOrder = ''; syncLayers(); showParticipants(session, renderer); showPageNumber(); refreshArrangeCues(); }, onImagesChanged: () => { log(`Page ${session.page.number}: ${session.page.images.length} images, ${session.page.hides.length} hides`); renderer.showImages(); renderer.drawHides(); refreshArrangeCues(); writer.writeNow(); }, onPageResized: () => { // The view keeps its scale; the page extends beyond the window. renderer.resize(); textEditor?.layout(); writer.changed(); }, 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); refreshArrangeCues(); }, 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; } /** * With the Arrange tool chosen, mark what a press can move or resize: * the images and Hides (for the host and co-hosts) and one's own text * rectangles; free text can be moved but has no corners. Called * whenever any of them, or the right, may have changed. */ function refreshArrangeCues() { if (tool !== 'arrange') { renderer.setArrangeCues([]); return; } const page = session.page; /** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide'}[]} */ const cues = []; if (session.mayMakePages()) { for (const im of page.images) cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height } }); } for (const block of session.ownLayer.texts.values()) { if (block.height !== null) cues.push({ kind: 'text', rect: textBounds(block) }); } if (session.mayMakePages()) { for (const h of page.hides) cues.push({ kind: 'hide', rect: { left: h.x, top: h.y, width: h.width, height: h.height } }); } renderer.setArrangeCues(cues); } /** @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); refreshArrangeCues(); }, holdEnd: () => { if (!held) return; const r = currentHeldRect(held); if (r) log(`${held.kind} placed at ${r.left / 100}, ${r.top / 100} cm, ${r.width / 100} by ${r.height / 100} cm`); if (held.kind !== 'text') session.sendPageNow(); if (held.kind === 'text') session.endTextEdit(held.id, 'hold'); if (held.kind === 'hide') session.endHideEdit(); held = null; renderer.showGrip(null); refreshArrangeCues(); }, eraseAlong: (x0, y0, x1, y1) => { textEditor?.stop(); session.beginErasing(); const radius = renderer.pageLength(ERASER_RADIUS); const strokes = strokesTouched(session.ownLayer, x0, y0, x1, y1, radius); const texts = textsTouched(session.ownLayer, x0, y0, x1, y1, radius); session.erase(strokes); session.removeTexts(texts); return { strokes: strokes.length, texts: texts.length }; }, eraseEnded: (x, y, reach) => { // What the movement erased is one gesture to undo. const erased = session.endErasing(); if (reach >= TAP_DISTANCE) { lastEmptyTap = 0; return; } // A tap of the eraser on a Hide rectangle or an image removes it // (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, Hide or Arrange. element('colors').hidden = value === 'eraser' || value === 'hide' || value === 'arrange'; element('widths').hidden = value !== 'pen'; element('text_style').hidden = value !== 'text'; refreshArrangeCues(); }, 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'); }); // "Expand" makes the sheet itself fill the window at the present zoom, // in both directions; nothing on it moves (Andy, 2026-10-08 for the // width, both ways 2026-10-11). element('zoom_expand').addEventListener('click', () => { const room = renderer.available(); const expanded = session.expandPage(room.width / renderer.cssPixelsPerUnit, room.height / renderer.cssPixelsPerUnit); if (expanded) { log(`Page expanded to ${session.page.baseWidth / 100} by ${session.page.baseHeight / 100} cm`); } }); element('zoom_shrink').addEventListener('click', () => { if (session.shrinkPage()) { log(`Page shrunk to ${session.page.baseWidth / 100} by ${session.page.baseHeight / 100} cm`); } }); try { const seen = JSON.parse(sessionStorage.getItem(viewKey) ?? 'null'); if (seen && seen.page === session.page.id && (seen.zoom === 'fit' || seen.zoom > 0) && Number.isFinite(seen.x) && Number.isFinite(seen.y)) { renderer.showView(seen); } } catch { // Nothing usable was remembered: the page is shown as a new one is. } element('desk').addEventListener('scroll', viewChanged, { passive: true }); window.addEventListener('pagehide', saveView); // The host drives: the host starts new pages, and may let a guest do so. function showHostTools() { // New and Image are the host's; Notebooks, in the same group so that // nothing moves for the host, is everyone's (the chooser reserves // loading). for (const id of ['new_page', 'load_image']) element(id).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_expand').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); }, () => { // A deleted notebook leaves the toolbar's list. showNotebooks(sessionPicker).catch(() => {}); }); element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook, session.mayMakePages()); }); // Image files are chosen, and pictures of the page exported, through a // chooser of our own, which begins where the notebooks are. const fileChooser = new FileChooser(); element('load_image').addEventListener('click', async () => { if (notebooksInBrowser()) { // No local program to look through the files: the browser's own chooser. const file = await pickImageFile(); if (file) loadImage(file); return; } const file = await fileChooser.chooseImage(); if (file) loadImage(file); }); /** * Save a picture of the page as it is shown here (layers hidden on this * screen are left out) where the user chooses. */ async function exportPage() { const page = session.page; if (page.number === 0) return; textEditor?.stop(); // Anything still waiting to be saved is saved first, so that its // "Saved" does not take the place of "Exported" a moment later. await writer.writeNow(); const now = new Date(); const day = [now.getFullYear(), now.getMonth() + 1, now.getDate()] .map((part) => String(part).padStart(2, '0')).join('_'); if (notebooksInBrowser()) { download(`${day}_page_${page.number}.png`, await pageToPng(page, { session: roomId, hostId: session.hostId, hidden: renderer.hidden })); log(`Exported page ${page.number} to the downloads`); passingNotice('Exported to your downloads'); return; } const saved = await fileChooser.chooseWhereToSave( 'Export', `${day}_page_${page.number}.png`, async (directory, name, replace) => writePicture( directory, name, await pageToPng(page, { session: roomId, hostId: session.hostId, hidden: renderer.hidden }), replace)); if (saved === null) return; log(`Exported page ${page.number} to ${saved}`); passingNotice(`Exported to ${saved}`); } element('export_page').addEventListener('click', () => { exportPage(); }); // The notebook: choosing one, making one, and keeping the page. const sessionPicker = new NotebookPicker( select('session_notebook'), input('session_notebook_name'), (message) => { if (message) passingNotice(message, true); }, (chosen) => { if (chosen === writer.notebook) return; sessionStorage.setItem(notebookKey, chosen); localStorage.setItem(NOTEBOOK_KEY, chosen); writer.changeNotebook(chosen, (text) => { const saved = pageFromSvg(text); return saved !== null && saved.session === roomId && saved.page.id === session.page.id; }).then(() => showNotebooks(sessionPicker)).catch(() => {}); }); sessionPicker.fill([], notebook); sessionPicker.current = notebook; showNotebooks(sessionPicker).catch(() => {}); element('keep_page').addEventListener('click', async () => { const { kept, new: made } = await writer.keepPage(); log(kept ? `${made ? 'Kept' : 'Already kept'} as ${kept}` : 'Nothing to keep'); // In the browser the notebook is out of reach of the user's own // programs: a page kept just now is also downloaded, which is how a // page gets out. One already kept was downloaded then, and is not // again (Andy, 2026-10-06: a second press should do nothing new). if (kept && made && notebooksInBrowser()) { const text = await readPage(writer.notebook, kept); if (text !== null) download(kept, new Blob([text], { type: 'image/svg+xml' })); } }); const sessionDialog = /** @type {HTMLDialogElement} */ (element('session_dialog')); element('session_button').addEventListener('click', () => { showLog(); sessionDialog.showModal(); }); element('session_close').addEventListener('click', () => sessionDialog.close()); // The log in the Session window, kept current while the window is open. const logView = element('event_log'); function showLog() { logView.textContent = logText(); logView.scrollTop = logView.scrollHeight; } watchLog(() => { if (sessionDialog.open) showLog(); }); element('copy_log').addEventListener('click', async () => { try { await navigator.clipboard.writeText(logText()); element('copy_log').textContent = 'Copied'; setTimeout(() => { element('copy_log').textContent = 'Copy'; }, 1500); } catch { // The text is selectable; the user can copy it by hand. } }); element('copy_invitation').addEventListener('click', async () => { const field = input('invitation'); field.select(); try { await navigator.clipboard.writeText(field.value); element('copy_invitation').textContent = 'Copied'; setTimeout(() => { element('copy_invitation').textContent = 'Copy'; }, 1500); } catch { // The field is selected; the user can copy it with the keyboard. } }); if (notebooksInBrowser()) { element('keep_page').setAttribute('data-help', 'Keep a dated copy of the page as it is now in this browser’s notebook, and download it. ' + 'The page is also kept by itself whenever it is about to be replaced.'); element('export_page').setAttribute('data-help', 'Download a picture of the page, as it is shown here, as a PNG file.'); } session.start(); // For looking at the session from the browser's console during development. Object.assign(window, { kokkoja: { session, renderer, writer, loadImage, textEditor, logText, notebook: { listNotebooks, listKeptPages, readPage, readCurrentPage, notebooksInBrowser, discardNotebook, discardPage }, } }); } /** * Mark one button of a group as chosen. * @param {Element} group * @param {Element} chosen */ function press(group, chosen) { for (const button of group.querySelectorAll('button')) { button.setAttribute('aria-pressed', String(button === chosen)); } } /** * @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' | 'arrange') => 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' : chosen === 'arrange' ? 'arrange' : '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, expand, 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); }