// @ts-check /** * The Kokkoja client: the start screen, and the session screen that * connects the pen, the page and the other participants. */ import { deriveSession, formatInvitation, formatInvitationLink, loadIdentity, newIdentity, newSecret, parseInvitation, saveIdentity, } from './crypto.js'; import { NotebookWriter, keepCurrentPage, keepIdentity, listKeptPages, listNotebooks, notebooksInBrowser, readCurrentPage, readIdentity, readPage, rememberToken, useBrowserStorage, } from './notebook.js'; import { download, pickImageFile } from './browser_files.js'; import { 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, ZOOM_STEP } from './renderer.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'; const COLORS = ['#1c1c1e', '#1f4fd8', '#d42a2a', '#1c8a3c']; /** Everyone begins in black (Andy, 2026-10-03; the host began in red before). */ const DEFAULT_COLOR = COLORS[0]; /** Nominal line widths in page units (0.1 mm). */ const WIDTHS = [4, 7, 12]; /** 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; /** * 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'; const DEFAULT_NOTEBOOK = '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)); } /** * 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) ?? ''; input('notebook').value = localStorage.getItem(NOTEBOOK_KEY) ?? DEFAULT_NOTEBOOK; const pending = invitationInAddress(); if (pending) input('invitation_entry').value = pending; // From a web site, a guest who has the Kokkoja program installed may // prefer it, for a notebook of real files. const installed = /** @type {HTMLAnchorElement} */ (element('open_installed')); installed.hidden = !(pending && notebooksInBrowser()); installed.href = `${INSTALLED_ADDRESS}#join=${encodeURIComponent(pending)}`; showNotebooks().catch((error) => { element('notebook_note').textContent = String(error.message ?? error); element('notebook_note').classList.add('problem'); }); } /** Offer the existing notebooks and say where notebooks are kept. */ async function showNotebooks() { const { directory, notebooks } = await listNotebooks(); const choices = element('notebook_choices'); choices.replaceChildren(); for (const notebook of notebooks) { const option = document.createElement('option'); option.value = notebook; choices.append(option); } 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)); } /** @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 entered. */ function enteredNotebook() { const notebook = input('notebook').value.trim() || 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; const isHost = identity.id === invitation.hostId; let tool = /** @type {'pen' | 'eraser' | 'text'} */ ('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() { element('page_number').textContent = session.page.number > 0 ? `Page ${session.page.number}` : 'Waiting for the host’s page'; } 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) => { textEditor?.stop(); // 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: () => { renderer.showImages(); writer.writeNow(); }, onPageResized: (byImages) => { // An image added or taken back does not rescale what is in view. if (byImages) renderer.holdScale(); renderer.resize(); textEditor?.layout(); writer.changed(); }, onPermissionsChanged: () => { showHostTools(); showParticipants(session, renderer); }, onParticipantsChanged: () => { syncLayers(); showParticipants(session, renderer); }, onStatus: (status, detail) => { const text = { connecting: 'Connecting…', connected: 'Connected', failed: detail ?? 'Disconnected' }; element('status').textContent = text[status]; element('status').classList.toggle('problem', status !== 'connected'); }, }); const writer = new NotebookWriter( notebook, () => (session.page.number > 0 ? fileOf(session.page) : null), (message, problem) => { element('save_status').textContent = message; element('save_status').classList.toggle('problem', problem); }); element('save_status').textContent = savedPage ? 'Continued from the notebook' : ''; 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)); textEditor = new TextEditor(pageElement, renderer, { setText: (block) => session.setText(block), removeText: (id) => session.removeTexts([id]), }); attachPenInput(pageElement, { toPage: (clientX, clientY) => renderer.pageFromClient(clientX, clientY), tool: () => tool, canWrite: () => session.page.number > 0, beginStroke: () => { textEditor?.stop(); renderer.holdFit = true; session.beginStroke(color, width); }, addPoints: (points) => session.addPoints(points), endStroke: () => { session.endStroke(); renderer.holdFit = false; if (renderer.zoom === 'fit') renderer.resize(); }, eraseAlong: (x0, y0, x1, y1) => { textEditor?.stop(); const radius = renderer.pageLength(ERASER_RADIUS); session.erase(strokesTouched(session.ownLayer, x0, y0, x1, y1, radius)); session.removeTexts(textsTouched(session.ownLayer, x0, y0, x1, y1, radius)); }, eraseEnded: (x, y, reach) => { // A tap of the eraser on the image we have just added takes it back. const image = session.removableImage; if (image && reach < TAP_DISTANCE && x >= image.x && x <= image.x + image.width && y >= image.y && y <= image.y + image.height) { session.removeAddedImage(); } }, previewText: (x0, y0, x1) => { if (Math.abs(x1 - x0) >= TAP_DISTANCE) textEditor?.showPreview(x0, y0, x1); else textEditor?.hidePreview(); }, placeText: (x0, y0, x1, y1) => { if (!textEditor) return; textEditor.hidePreview(); const editing = textEditor.block; textEditor.stop(); const own = textAt(session.ownLayer, x0, y0); if (Math.hypot(x1 - x0, y1 - y0) < TAP_DISTANCE) { // A tap: into our own text to change it, or a new block here. if (own && own !== editing) textEditor.edit(own); else if (!own) textEditor.begin(x0, y0, null, { color, size: textSize, font: textFont }); } else { // A drag along the top edge: a new block that wraps at its width. textEditor.begin(Math.min(x0, x1), y0, Math.max(100, Math.round(Math.abs(x1 - x0))), { color, size: textSize, font: textFont }); } }, onReport: (report) => { element('pen_report').textContent = `Input: ${report.pointerType}, pressure ${report.pressure.toFixed(2)}, ` + `${report.pointsPerSecond} points per second`; }, }); buildToolbar({ color, width, textSize, textFont, setTool: (value) => { if (value !== 'text') textEditor?.stop(); tool = value; // Only the chosen tool's settings show: the pen's colours and line // widths, the text's colours, size and font, nothing for the eraser. element('colors').hidden = value === 'eraser'; element('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. const showZoom = () => { element('zoom_fit').setAttribute('aria-pressed', String(renderer.zoom === 'fit')); }; // A reloaded tab shows the page as it was last seen in it (Andy, // 2026-10-05: the least surprise): the zoom and the place scrolled to // are remembered in the tab, for the page they belong to. 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 = () => { showZoom(); textEditor?.layout(); viewChanged(); }; element('zoom_fit').addEventListener('click', () => renderer.setZoom('fit')); element('zoom_in').addEventListener('click', () => renderer.zoomBy(ZOOM_STEP)); element('zoom_out').addEventListener('click', () => renderer.zoomBy(1 / ZOOM_STEP)); showZoom(); 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(); } showHostTools(); element('new_page').addEventListener('click', () => session.newPage()); /** * Add an image file to the page, below the images already on it. * @param {Blob} file * @param {boolean} [shrink] Whether an image wider than the sheet is * scaled down to it. */ async function loadImage(file, shrink = true) { if (!session.mayMakePages() || session.page.number === 0) return; try { if (!session.addImage(await prepareImage(file), shrink)) { throw new Error('The page has no room for another image.'); } } catch (error) { element('save_status').textContent = error instanceof Error && error.message ? error.message : 'That file is not an image.'; element('save_status').classList.add('problem'); } } const chooser = new NotebookChooser((chosenNotebook, pageName, text) => { const kept = pageFromSvg(text); if (!kept) { element('save_status').textContent = `${pageName} is not a Kokkoja page.`; element('save_status').classList.add('problem'); return; } session.loadPage(kept.page); }); element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook); }); // Image files are chosen, and pictures of the page exported, through a // chooser of our own, which begins where the notebooks are. const fileChooser = new FileChooser(); element('load_image').addEventListener('click', async () => { if (notebooksInBrowser()) { // No local program to look through the files: the browser's own chooser. const file = await pickImageFile(); if (file) loadImage(file); return; } const file = await fileChooser.chooseImage(); if (file) loadImage(file, fileChooser.shrinksWideImages()); }); /** * 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 })); element('save_status').textContent = 'Exported to your downloads'; element('save_status').classList.remove('problem'); 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; element('save_status').textContent = `Exported to ${saved}`; element('save_status').classList.remove('problem'); } element('export_page').addEventListener('click', () => { exportPage(); }); // The notebook: its name, and keeping the page. const notebookField = input('session_notebook'); notebookField.value = notebook; showNotebooks().catch(() => {}); notebookField.addEventListener('change', () => { const chosen = notebookField.value.trim(); if (!chosen || chosen === writer.notebook) { notebookField.value = writer.notebook; return; } sessionStorage.setItem(notebookKey, chosen); localStorage.setItem(NOTEBOOK_KEY, chosen); writer.changeNotebook(chosen, (text) => { const saved = pageFromSvg(text); return saved !== null && saved.session === roomId && saved.page.id === session.page.id; }).then(() => showNotebooks()).catch(() => {}); }); element('keep_page').addEventListener('click', async () => { const kept = await writer.keepPage(); // In the browser the notebook is out of reach of the user's own // programs: a kept page is also downloaded, which is how a page gets out. if (kept && notebooksInBrowser()) { const text = await readPage(writer.notebook, kept); if (text !== null) download(kept, new Blob([text], { type: 'image/svg+xml' })); } }); element('copy_invitation').addEventListener('click', async () => { const field = input('invitation'); field.select(); try { await navigator.clipboard.writeText(field.value); element('copy_invitation').textContent = 'Copied'; setTimeout(() => { element('copy_invitation').textContent = 'Copy'; }, 1500); } catch { // The field is selected; the user can copy it with the keyboard. } }); if (notebooksInBrowser()) { element('keep_page').setAttribute('data-help', 'Keep a dated copy of the page as it is now in this browser’s notebook, and download it. ' + 'The page is also kept by itself whenever it is about to be replaced.'); element('export_page').setAttribute('data-help', 'Download a picture of the page, as it is shown here, as a PNG file.'); } session.start(); // For looking at the session from the browser's console during development. Object.assign(window, { kokkoja: { session, renderer, writer, loadImage, textEditor, notebook: { listNotebooks, listKeptPages, readPage, readCurrentPage, notebooksInBrowser }, } }); } /** * Mark one button of a group as chosen. * @param {Element} group * @param {Element} chosen */ function press(group, chosen) { for (const button of group.querySelectorAll('button')) { button.setAttribute('aria-pressed', String(button === chosen)); } } /** * @param {object} options * @param {string} options.color Initial color. * @param {number} options.width Initial width. * @param {number} options.textSize * @param {import('./page_model.js').TextFont} options.textFont * @param {(tool: 'pen' | 'eraser' | 'text') => 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', () => { press(/** @type {Element} */ (button.parentElement), button); const chosen = button.getAttribute('data-tool'); options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : '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); }; for (const color of PALETTE) { const button = document.createElement('button'); button.type = 'button'; button.className = 'swatch'; button.style.setProperty('--swatch', color); button.setAttribute('aria-label', `Color ${color}`); button.addEventListener('click', () => choose(color)); palette.append(button); } more.addEventListener('click', () => { if (!palette.hidden) { palette.hidden = true; return; } 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) { const allow = document.createElement('button'); allow.type = 'button'; allow.className = 'allow'; allow.textContent = 'pages'; allow.setAttribute('data-help', 'Let this participant start new pages and load images.'); const allowed = session.permitted.has(participant.id); allow.setAttribute('aria-pressed', String(allowed)); allow.addEventListener('click', () => session.setPermitted(participant.id, !allowed)); item.append(allow); } 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'); 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(); 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); }