Kokkoja: first public assembly

Assembled from the development repository by tools/make_release.sh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-06 15:58:02 +02:00
commit 95c80d04c9
39 files changed
+9905

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
// @ts-check
/**
* Files without the local program: when Kokkoja is opened from a web
* site, an image file comes from the browser's own file chooser, and
* anything to be saved is a download.
*/
/**
* Let the user choose an image file.
* @returns {Promise<File | null>} Null if none was chosen.
*/
export function pickImageFile() {
return new Promise((resolve) => {
const picker = document.createElement('input');
picker.type = 'file';
picker.accept = 'image/*';
picker.addEventListener('change', () => resolve(picker.files?.[0] ?? null));
picker.addEventListener('cancel', () => resolve(null));
picker.click();
});
}
/**
* Give the user a file, through the browser's downloads.
* @param {string} name
* @param {Blob} content
*/
export function download(name, content) {
const address = URL.createObjectURL(content);
const link = document.createElement('a');
link.href = address;
link.download = name;
document.body.append(link);
link.click();
link.remove();
// The browser has begun the download; the address can go a moment later.
setTimeout(() => URL.revokeObjectURL(address), 10000);
}
+154
View File
@@ -0,0 +1,154 @@
// @ts-check
/**
* Notebooks kept in the browser, for Kokkoja opened from a web site with
* no local program on the computer.
*
* The notebooks follow the same rules as the directories the local program
* keeps (`notebook.js`): a notebook holds `Current.svg`, the page being
* written, and dated kept pages such as `2026_10_02_01.svg`. They live in
* the browser's IndexedDB for this site, which survives closing the
* browser but belongs to this browser on this computer alone; the way to
* take a page out is to download it. The identity is kept in the
* browser's localStorage in the same way.
*/
const DATABASE = 'kokkoja';
const PAGES = 'pages';
const IDENTITY_KEY = 'kokkoja_identity';
const CURRENT_PAGE = 'Current.svg';
const KEPT_PAGE = /^(\d{4}_\d{2}_\d{2})_(\d{2,})\.svg$/;
/**
* @typedef {object} StoredPage
* @property {string} notebook
* @property {string} name
* @property {string} text
* @property {number} changed When the page was last written, in milliseconds.
*/
/** @type {Promise<IDBDatabase> | null} */
let opening = null;
/** @returns {Promise<IDBDatabase>} */
function database() {
opening ??= new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE, 1);
request.onupgradeneeded = () => {
const store = request.result.createObjectStore(PAGES, { keyPath: ['notebook', 'name'] });
store.createIndex('notebook', 'notebook');
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('The browser’s storage cannot be opened.'));
});
return opening;
}
/**
* @template T
* @param {IDBRequest<T>} request
* @returns {Promise<T>}
*/
function result(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('The browser’s storage failed.'));
});
}
/**
* Run work in one transaction on the pages.
* @template T
* @param {IDBTransactionMode} mode
* @param {(pages: IDBObjectStore) => Promise<T>} work
* @returns {Promise<T>}
*/
async function transaction(mode, work) {
const db = await database();
const tx = db.transaction(PAGES, mode);
const done = new Promise((resolve, reject) => {
tx.oncomplete = () => resolve(undefined);
tx.onerror = () => reject(tx.error ?? new Error('The browser’s storage failed.'));
tx.onabort = () => reject(tx.error ?? new Error('The browser’s storage failed.'));
});
const value = await work(tx.objectStore(PAGES));
await done;
return value;
}
/**
* @param {IDBObjectStore} pages
* @param {string} notebook
* @returns {Promise<StoredPage[]>} The notebook's pages, by name.
*/
async function pagesOf(pages, notebook) {
const found = /** @type {StoredPage[]} */ (await result(pages.index('notebook').getAll(notebook)));
return found.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
}
/**
* @param {number} time Milliseconds.
* @returns {string} The day, as 2026_10_02.
*/
function dayOf(time) {
const date = new Date(time);
return [date.getFullYear(), date.getMonth() + 1, date.getDate()]
.map((part) => String(part).padStart(2, '0')).join('_');
}
/** @type {import('./notebook.js').Backend} */
export const backend = {
async readIdentity() {
return localStorage.getItem(IDENTITY_KEY);
},
async keepIdentity(text) {
if (localStorage.getItem(IDENTITY_KEY) !== null) return false;
localStorage.setItem(IDENTITY_KEY, text);
return true;
},
async listNotebooks() {
const names = await transaction('readonly', async (pages) => {
const keys = /** @type {[string, string][]} */ (await result(pages.getAllKeys()));
return [...new Set(keys.map(([notebook]) => notebook))].sort();
});
return { directory: '', notebooks: names };
},
async listPages(notebook) {
return transaction('readonly', async (pages) => (await pagesOf(pages, notebook)).map((page) => page.name));
},
async readPage(notebook, pageName) {
const page = /** @type {StoredPage | undefined} */ (await transaction(
'readonly', (pages) => result(pages.get([notebook, pageName]))));
return page ? page.text : null;
},
async writePage(notebook, pageName, text) {
await transaction('readwrite', async (pages) => {
await result(pages.put({ notebook, name: pageName, text, changed: Date.now() }));
});
},
// The same rules as keep_page() in kokkoja.py: nothing is kept of a
// blank page or of one already kept; the name is the day the page was
// last changed and a serial number.
async keepPage(notebook) {
return transaction('readwrite', async (pages) => {
const current = /** @type {StoredPage | undefined} */ (await result(pages.get([notebook, CURRENT_PAGE])));
if (!current || !(current.text.includes('data-stroke=') || current.text.includes('<image'))) {
return { kept: null, new: false };
}
const kept = (await pagesOf(pages, notebook)).filter((page) => KEPT_PAGE.test(page.name));
const same = kept.find((page) => page.text === current.text);
if (same) return { kept: same.name, new: false };
const day = dayOf(current.changed);
const serials = kept.map((page) => /** @type {RegExpExecArray} */ (KEPT_PAGE.exec(page.name)))
.filter((match) => match[1] === day).map((match) => Number(match[2]));
const name = `${day}_${String(Math.max(0, ...serials) + 1).padStart(2, '0')}.svg`;
await result(pages.put({ notebook, name, text: current.text, changed: current.changed }));
return { kept: name, new: true };
});
},
};
+132
View File
@@ -0,0 +1,132 @@
// @ts-check
/**
* The notebook chooser: the "Notebook" button's dialog, which shows the
* notebooks and the pages kept in them, and loads the chosen page.
*/
import { listKeptPages, listNotebooks, readPage } from './notebook.js';
/**
* @param {string} id
* @returns {HTMLElement}
*/
function element(id) {
return /** @type {HTMLElement} */ (document.getElementById(id));
}
/**
* @param {string} pageName
* @returns {string} "2026_10_02_01.svg" shown as "2026-10-02 (1)".
*/
function pageTitle(pageName) {
const match = /^(\d{4})_(\d{2})_(\d{2})_(\d+)\.svg$/.exec(pageName);
return match ? `${match[1]}-${match[2]}-${match[3]} (${Number(match[4])})` : pageName;
}
export class NotebookChooser {
/**
* @param {(notebook: string, pageName: string, text: string) => void} onChosen
* Called with the chosen page file.
*/
constructor(onChosen) {
this.onChosen = onChosen;
this.dialog = /** @type {HTMLDialogElement} */ (element('notebook_chooser'));
this.notebook = '';
/** Counts openings, so that pictures fetched for an earlier one are dropped. */
this.opening = 0;
/** @type {string[]} */
this.pictureAddresses = [];
element('chooser_close').addEventListener('click', () => this.dialog.close());
this.dialog.addEventListener('close', () => this.forgetPictures());
}
/**
* Show the notebooks, or the pages of the only notebook.
* @param {string} [notebook] Open on this notebook.
*/
async open(notebook) {
this.opening += 1;
this.dialog.showModal();
element('chooser_message').textContent = '';
element('chooser_pages').replaceChildren(); // Nothing stale while the lists load.
try {
const { notebooks } = await listNotebooks();
this.showNotebooks(notebooks);
const chosen = notebook && notebooks.includes(notebook) ? notebook
: notebooks.length === 1 ? notebooks[0] : '';
await this.showPages(chosen);
} catch (error) {
element('chooser_message').textContent = error instanceof Error ? error.message : String(error);
}
}
/** @param {string[]} notebooks */
showNotebooks(notebooks) {
const list = element('chooser_notebooks');
list.replaceChildren();
list.hidden = notebooks.length <= 1;
for (const notebook of notebooks) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = notebook;
button.addEventListener('click', () => { this.showPages(notebook); });
list.append(button);
}
}
/** @param {string} notebook */
async showPages(notebook) {
this.notebook = notebook;
for (const button of element('chooser_notebooks').querySelectorAll('button')) {
button.setAttribute('aria-pressed', String(button.textContent === notebook));
}
const grid = element('chooser_pages');
grid.replaceChildren();
this.forgetPictures();
if (!notebook) {
element('chooser_message').textContent = 'Choose a notebook.';
return;
}
const opening = this.opening += 1;
const pages = await listKeptPages(notebook);
if (opening !== this.opening) return;
element('chooser_message').textContent = pages.length ? '' : 'This notebook has no kept pages.';
/** @type {Map<string, HTMLImageElement>} */
const pictures = new Map();
for (const pageName of pages) {
const card = document.createElement('button');
card.type = 'button';
card.className = 'page_card';
const picture = document.createElement('img');
picture.alt = '';
pictures.set(pageName, picture);
const title = document.createElement('span');
title.textContent = pageTitle(pageName);
card.append(picture, title);
card.addEventListener('click', async () => {
const text = await readPage(notebook, pageName);
if (text === null) return;
this.dialog.close();
this.onChosen(notebook, pageName, text);
});
grid.append(card);
}
// The pictures are the page files themselves, fetched one by one.
for (const [pageName, picture] of pictures) {
const text = await readPage(notebook, pageName);
if (opening !== this.opening) return;
if (text === null) continue;
const address = URL.createObjectURL(new Blob([text], { type: 'image/svg+xml' }));
this.pictureAddresses.push(address);
picture.src = address;
}
}
forgetPictures() {
for (const address of this.pictureAddresses) URL.revokeObjectURL(address);
this.pictureAddresses = [];
}
}
+278
View File
@@ -0,0 +1,278 @@
// @ts-check
/**
* Cryptography for a session, using only the browser's Web Crypto API.
*
* An invitation carries a random *secret*. From it every participant
* derives the *room id* (which the relay sees) and the *message key* (which
* the relay never sees). Each participant also has a signing key pair; the
* participant's id is a hash of the public key, so nobody else can claim it.
*
* See notes/protocol.md.
*/
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const SECRET_PATTERN = /^[A-Za-z0-9_-]{22}$/;
const SIGNATURE = { name: 'ECDSA', hash: 'SHA-256' };
const SIGNING_KEY = { name: 'ECDSA', namedCurve: 'P-256' };
/**
* @param {Uint8Array} bytes
* @returns {string}
*/
export function toBase64Url(bytes) {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/**
* @param {string} text
* @returns {Uint8Array<ArrayBuffer>}
*/
export function fromBase64Url(text) {
const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/'));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
/**
* Ordinary base64, as used in a `data:` address. Works on large arrays.
* @param {Uint8Array} bytes
* @returns {string}
*/
export function toBase64(bytes) {
let binary = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
}
/**
* @param {string} text Ordinary base64.
* @returns {Uint8Array<ArrayBuffer>}
*/
export function fromBase64(text) {
const binary = atob(text);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
/**
* @param {Uint8Array<ArrayBuffer>} bytes
* @returns {Promise<string>} SHA-256 of the bytes, base64url.
*/
export async function hashOf(bytes) {
return toBase64Url(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)));
}
/**
* A random identifier, for strokes and the like.
* @param {number} [byteCount]
* @returns {string}
*/
export function randomId(byteCount = 9) {
return toBase64Url(crypto.getRandomValues(new Uint8Array(byteCount)));
}
/**
* @typedef {object} Invitation
* @property {string} relay Address of the relay, ws:// or wss://.
* @property {string} secret The session secret.
* @property {string} hostId Participant id of the host.
*/
/**
* @param {Invitation} invitation
* @returns {string}
*/
export function formatInvitation({ relay, secret, hostId }) {
return `${relay}#${secret}.${hostId}`;
}
/**
* An invitation as a link to a Kokkoja web site, which a guest can open
* and be in the session with nothing installed. The invitation itself is
* after the '#', which browsers never send to the site.
* @param {string} site The site's address, such as https://kokkoja.example/.
* @param {string} invitation From formatInvitation().
* @returns {string}
*/
export function formatInvitationLink(site, invitation) {
// Only the '#' needs hiding; the rest stays readable.
return `${site.replace(/\/*$/, '/')}#join=${invitation.replace('#', '%23')}`;
}
/**
* The invitation in a piece of text: the invitation itself, or a link
* that carries one.
* @param {string} text
* @returns {string} The invitation, or the text as it was.
*/
export function unwrapInvitation(text) {
text = text.trim();
const link = /^https?:\/\/[^#\s]+#(.*)$/.exec(text);
if (!link) return text;
return new URLSearchParams(link[1]).get('join')?.trim() ?? text;
}
/**
* @param {string} text An invitation, or a link that carries one.
* @returns {Invitation | null} Null if the text is not an invitation.
*/
export function parseInvitation(text) {
const match = /^(wss?:\/\/[^#\s]+)#([A-Za-z0-9_-]{22})\.([A-Za-z0-9_-]{22})$/.exec(unwrapInvitation(text));
if (!match) return null;
return { relay: match[1].replace(/\/+$/, ''), secret: match[2], hostId: match[3] };
}
/** @returns {string} */
export function newSecret() {
return toBase64Url(crypto.getRandomValues(new Uint8Array(16)));
}
/**
* Derive the room id and the message key from the session secret.
* @param {string} secret
* @returns {Promise<{roomId: string, key: CryptoKey}>}
*/
export async function deriveSession(secret) {
if (!SECRET_PATTERN.test(secret)) throw new Error('bad session secret');
const material = await crypto.subtle.importKey(
'raw', fromBase64Url(secret), 'HKDF', false, ['deriveBits', 'deriveKey']);
const salt = new Uint8Array(32);
const roomBits = await crypto.subtle.deriveBits(
{ name: 'HKDF', hash: 'SHA-256', salt, info: encoder.encode('kokkoja 1 room') },
material, 128);
const key = await crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt, info: encoder.encode('kokkoja 1 key') },
material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
return { roomId: toBase64Url(new Uint8Array(roomBits)), key };
}
/**
* Encrypt a message. The result is a 12-byte nonce followed by the
* ciphertext.
* @param {CryptoKey} key
* @param {object} message
* @returns {Promise<Uint8Array>}
*/
export async function encrypt(key, message) {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: nonce }, key, encoder.encode(JSON.stringify(message)));
const result = new Uint8Array(12 + ciphertext.byteLength);
result.set(nonce);
result.set(new Uint8Array(ciphertext), 12);
return result;
}
/**
* Decrypt a message. Rejects if the message was not made with this key.
* @param {CryptoKey} key
* @param {Uint8Array<ArrayBuffer>} bytes
* @returns {Promise<any>}
*/
export async function decrypt(key, bytes) {
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: bytes.subarray(0, 12) }, key, bytes.subarray(12));
return JSON.parse(decoder.decode(plaintext));
}
/**
* A participant's identity: a signing key pair and the id derived from it.
* @typedef {object} Identity
* @property {string} id First 16 bytes of SHA-256 of the public key.
* @property {string} publicKey Raw public key, base64url.
* @property {CryptoKey} privateKey
*/
/**
* @param {Uint8Array<ArrayBuffer>} publicKeyBytes
* @returns {Promise<string>}
*/
async function idOfPublicKey(publicKeyBytes) {
const digest = await crypto.subtle.digest('SHA-256', publicKeyBytes);
return toBase64Url(new Uint8Array(digest, 0, 16));
}
/** @returns {Promise<Identity>} */
export async function newIdentity() {
const pair = await crypto.subtle.generateKey(SIGNING_KEY, true, ['sign', 'verify']);
const publicKeyBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));
return {
id: await idOfPublicKey(publicKeyBytes),
publicKey: toBase64Url(publicKeyBytes),
privateKey: pair.privateKey,
};
}
/**
* @param {Identity} identity
* @returns {Promise<string>} Text that loadIdentity() accepts.
*/
export async function saveIdentity(identity) {
const jwk = await crypto.subtle.exportKey('jwk', identity.privateKey);
return JSON.stringify({ publicKey: identity.publicKey, privateKey: jwk });
}
/**
* @param {string} text
* @returns {Promise<Identity>}
*/
export async function loadIdentity(text) {
const stored = JSON.parse(text);
const privateKey = await crypto.subtle.importKey(
'jwk', stored.privateKey, SIGNING_KEY, true, ['sign']);
return {
id: await idOfPublicKey(fromBase64Url(stored.publicKey)),
publicKey: stored.publicKey,
privateKey,
};
}
/**
* The statement a participant signs to claim a relay connection: "the
* holder of this key is peer number `peer` in room `roomId`".
* @param {string} roomId
* @param {number} peer
*/
function claimText(roomId, peer) {
return encoder.encode(`kokkoja 1 hello ${roomId} ${peer}`);
}
/**
* @param {Identity} identity
* @param {string} roomId
* @param {number} peer
* @returns {Promise<string>} The signature, base64url.
*/
export async function signClaim(identity, roomId, peer) {
const signature = await crypto.subtle.sign(SIGNATURE, identity.privateKey, claimText(roomId, peer));
return toBase64Url(new Uint8Array(signature));
}
/**
* Check a claim made by another participant.
* @param {string} publicKey Raw public key, base64url.
* @param {string} signature From signClaim(), base64url.
* @param {string} roomId
* @param {number} peer
* @returns {Promise<string | null>} The participant id, or null if the
* claim is not valid.
*/
export async function verifyClaim(publicKey, signature, roomId, peer) {
try {
const publicKeyBytes = fromBase64Url(publicKey);
const key = await crypto.subtle.importKey('raw', publicKeyBytes, SIGNING_KEY, false, ['verify']);
const valid = await crypto.subtle.verify(
SIGNATURE, key, fromBase64Url(signature), claimText(roomId, peer));
return valid ? await idOfPublicKey(publicKeyBytes) : null;
} catch {
return null;
}
}
+312
View File
@@ -0,0 +1,312 @@
// @ts-check
/**
* The file chooser: Kokkoja's own dialog for choosing an image file
* ("Image") and for choosing where to export a picture of the page
* ("Export"). The browser's file chooser cannot be told where to begin
* and cannot choose a place to write to; this one begins where the
* notebooks are, remembers where it was last used, and reaches the whole
* file system through the local program.
*/
import { listFiles, readFile } from './files.js';
/** Where the chooser was last used, for each of its two purposes. */
const DIRECTORY_KEYS = { image: 'kokkoja_image_directory', export: 'kokkoja_export_directory' };
/** Whether an image wider than the page is shrunk to fit it: 'no' if not. */
const SHRINK_KEY = 'kokkoja_image_shrink';
/** The longer side of the small pictures of image files, in pixels. */
const SMALL_PICTURE_SIDE = 240;
/** No small picture is made of a file larger than this. */
const SMALL_PICTURE_BYTES = 32 << 20;
/**
* @param {string} id
* @returns {HTMLElement}
*/
function element(id) {
return /** @type {HTMLElement} */ (document.getElementById(id));
}
export class FileChooser {
constructor() {
this.dialog = /** @type {HTMLDialogElement} */ (element('file_chooser'));
this.directoryField = /** @type {HTMLInputElement} */ (element('files_directory'));
this.nameField = /** @type {HTMLInputElement} */ (element('files_name'));
this.shrinkBox = /** @type {HTMLInputElement} */ (element('files_shrink'));
this.shrinkBox.checked = localStorage.getItem(SHRINK_KEY) !== 'no';
this.shrinkBox.addEventListener('change', () => {
localStorage.setItem(SHRINK_KEY, this.shrinkBox.checked ? 'yes' : 'no');
});
/** @type {'image' | 'export'} */
this.purpose = 'image';
/** @type {import('./files.js').DirectoryListing | null} */
this.listing = null;
/** Counts listings, so that work begun for an earlier one is dropped. */
this.showing = 0;
/** @type {((result: any) => void) | null} Ends the present use. */
this.finish = null;
/** @type {((directory: string, name: string, replace: boolean) => Promise<string | null>) | null} */
this.save = null;
/** The name that a second press of the button may replace. */
this.mayReplace = '';
element('files_close').addEventListener('click', () => this.done(null));
// Escape closes the dialog by itself. The browser reports a close a
// moment after it happens; if the chooser is open again by then, the
// report is about the earlier use and must not end this one.
this.dialog.addEventListener('close', () => {
if (!this.dialog.open) this.done(null);
});
element('files_up').addEventListener('click', () => {
if (this.listing?.parent) this.show(this.listing.parent);
});
this.directoryField.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
this.show(this.directoryField.value.trim());
});
this.nameField.addEventListener('input', () => this.forgetReplacing());
this.nameField.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
this.saveHere();
});
element('files_save_here').addEventListener('click', () => this.saveHere());
}
/**
* Let the user choose an image file.
* @returns {Promise<Blob | null>} The file, or null if none was chosen.
*/
chooseImage() {
return this.open('image', 'Image');
}
/**
* @returns {boolean} Whether the user wants an image wider than the page
* shrunk to fit it (the other choice keeps its size and widens the page).
*/
shrinksWideImages() {
return this.shrinkBox.checked;
}
/**
* Let the user choose a directory and a name, and save there.
* @param {string} title
* @param {string} name The name to suggest.
* @param {(directory: string, name: string, replace: boolean) => Promise<string | null>} save
* Writes the file and returns its full path, or null if a file of
* that name exists and `replace` is false.
* @returns {Promise<string | null>} The full path written, or null.
*/
chooseWhereToSave(title, name, save) {
const result = this.open('export', title);
this.save = save;
this.nameField.value = name;
return result;
}
/**
* @param {'image' | 'export'} purpose
* @param {string} title
* @returns {Promise<any>}
*/
open(purpose, title) {
if (this.dialog.open) return Promise.resolve(null);
this.finish?.(null);
this.purpose = purpose;
element('files_title').textContent = title;
element('files_save').hidden = purpose !== 'export';
element('files_shrink_line').hidden = purpose !== 'image';
element('files_directories').replaceChildren();
element('files_entries').replaceChildren();
this.dialog.showModal();
const result = new Promise((resolve) => { this.finish = resolve; });
// Where it was last used; if that is gone, where the notebooks are.
this.show(localStorage.getItem(DIRECTORY_KEYS[purpose]) ?? '').then((shown) => {
if (!shown && this.finish) this.show('');
});
return result;
}
/** @param {any} result */
done(result) {
const finish = this.finish;
this.finish = null;
this.save = null;
this.showing += 1;
if (this.dialog.open) this.dialog.close();
finish?.(result);
}
/** @param {string} message */
say(message) {
element('files_message').textContent = message;
}
forgetReplacing() {
this.mayReplace = '';
element('files_save_here').textContent = 'Export';
}
/**
* Show a directory.
* @param {string} directory A full path, or '' for where the notebooks are.
* @returns {Promise<boolean>} Whether it could be shown.
*/
async show(directory) {
const showing = this.showing += 1;
let listing;
try {
listing = await listFiles(directory);
} catch (error) {
if (showing === this.showing) this.say(error instanceof Error ? error.message : String(error));
return false;
}
if (showing !== this.showing) return true;
this.listing = listing;
this.directoryField.value = listing.directory;
/** @type {HTMLButtonElement} */ (element('files_up')).disabled = listing.parent === null;
localStorage.setItem(DIRECTORY_KEYS[this.purpose], listing.directory);
this.forgetReplacing();
// The directories go down the left, one to a line, and the files
// beside them (Andy, 2026-10-05), so that neither pushes the other out
// of view.
const entries = element('files_entries');
entries.replaceChildren();
const directories = element('files_directories');
directories.replaceChildren();
for (const one of listing.directories) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = one.name;
button.addEventListener('click', () => this.show(one.path));
directories.append(button);
}
const files = this.purpose === 'image' ? listing.files
: listing.files.filter((file) => file.name.toLowerCase().endsWith('.png'));
if (this.purpose === 'image') this.showImages(files, entries, showing);
else this.showExisting(files, entries);
this.say(this.purpose === 'image' && files.length === 0 ? 'No image files here.' : '');
return true;
}
/**
* The image files of a directory, each with a small picture, made one
* after another as the files come into view.
* @param {import('./files.js').DirectoryListing['files']} files
* @param {HTMLElement} entries
* @param {number} showing
*/
showImages(files, entries, showing) {
const grid = document.createElement('div');
grid.className = 'files_images';
/** @type {Map<Element, {path: string, picture: HTMLCanvasElement}>} */
const waiting = new Map();
/** @type {Element[]} Cards in view, awaiting their pictures. */
const queue = [];
let drawing = false;
const drawNext = async () => {
if (drawing) return;
drawing = true;
for (let card = queue.shift(); card && showing === this.showing; card = queue.shift()) {
const entry = waiting.get(card);
waiting.delete(card);
if (!entry) continue;
try {
const bitmap = await createImageBitmap(await readFile(entry.path));
const scale = Math.min(1, SMALL_PICTURE_SIDE / Math.max(bitmap.width, bitmap.height));
entry.picture.width = Math.max(1, Math.round(bitmap.width * scale));
entry.picture.height = Math.max(1, Math.round(bitmap.height * scale));
entry.picture.getContext('2d')?.drawImage(bitmap, 0, 0, entry.picture.width, entry.picture.height);
bitmap.close();
} catch {
// Not an image the browser can read: the card keeps its name only.
}
}
drawing = false;
};
const inView = new IntersectionObserver((changes) => {
for (const change of changes) {
if (!change.isIntersecting || !waiting.has(change.target)) continue;
inView.unobserve(change.target);
queue.push(change.target);
}
drawNext();
}, { root: entries });
for (const file of files) {
const card = document.createElement('button');
card.type = 'button';
card.className = 'file_card';
const picture = document.createElement('canvas');
picture.width = picture.height = 1;
const name = document.createElement('span');
name.textContent = file.name;
card.append(picture, name);
card.addEventListener('click', async () => {
try {
this.done(await readFile(file.path));
} catch (error) {
this.say(error instanceof Error ? error.message : String(error));
}
});
grid.append(card);
if (file.size <= SMALL_PICTURE_BYTES) {
waiting.set(card, { path: file.path, picture });
inView.observe(card);
}
}
if (files.length) entries.append(grid);
}
/**
* The PNG files already in a directory, by name: choosing one takes its
* name, to replace it.
* @param {import('./files.js').DirectoryListing['files']} files
* @param {HTMLElement} entries
*/
showExisting(files, entries) {
const list = document.createElement('div');
list.className = 'files_existing';
for (const file of files) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = file.name;
button.addEventListener('click', () => {
this.nameField.value = file.name;
this.forgetReplacing();
});
list.append(button);
}
if (files.length) entries.append(list);
}
/** Save into the directory shown, under the name in the name field. */
async saveHere() {
const save = this.save;
const directory = this.listing?.directory;
let name = this.nameField.value.trim();
if (!save || !directory || !name) return;
if (!name.toLowerCase().endsWith('.png')) name += '.png';
this.nameField.value = name;
try {
const saved = await save(directory, name, this.mayReplace === name);
if (saved !== null) {
this.done(saved);
return;
}
// No system dialog: a second press of the button replaces the file.
this.mayReplace = name;
element('files_save_here').textContent = 'Replace';
this.say(`${name} exists here.`);
} catch (error) {
this.say(error instanceof Error ? error.message : String(error));
}
}
}
+67
View File
@@ -0,0 +1,67 @@
// @ts-check
/**
* Files outside the notebooks: image files to put on a page, and the place
* a picture of a page is exported to. A web page cannot look through the
* user's directories or write where the user chooses, so it asks the local
* program (kokkoja.py), as it does for the notebooks.
*/
import { askLocalProgram } from './notebook.js';
/**
* @typedef {object} DirectoryListing
* @property {string} directory Its full path.
* @property {string | null} parent Null at the top of the file system.
* @property {{name: string, path: string}[]} directories
* @property {{name: string, path: string, size: number}[]} files Image files only.
*/
/**
* @param {Response} response
* @returns {Promise<string>} What the local program says went wrong.
*/
async function reason(response) {
try {
return String((await response.json()).error);
} catch {
return `error ${response.status}`;
}
}
/**
* @param {string} [directory] A full path; without one, where the notebooks are.
* @returns {Promise<DirectoryListing>}
*/
export async function listFiles(directory = '') {
const response = await askLocalProgram(`api/files?directory=${encodeURIComponent(directory)}`);
if (!response.ok) throw new Error(`${directory}: ${await reason(response)}`);
return response.json();
}
/**
* @param {string} path The full path of an image file, from a listing.
* @returns {Promise<Blob>}
*/
export async function readFile(path) {
const response = await askLocalProgram(`api/file?path=${encodeURIComponent(path)}`);
if (!response.ok) throw new Error(`${path}: ${await reason(response)}`);
return response.blob();
}
/**
* Write a PNG picture into a directory.
* @param {string} directory Its full path.
* @param {string} name Ending in .png.
* @param {Blob} picture
* @param {boolean} replace Whether a file of that name may be replaced.
* @returns {Promise<string | null>} The full path written, or null if a
* file of that name exists and may not be replaced.
*/
export async function writePicture(directory, name, picture, replace) {
const query = `directory=${encodeURIComponent(directory)}&name=${encodeURIComponent(name)}` +
(replace ? '&replace=1' : '');
const response = await askLocalProgram(`api/file?${query}`, 'PUT', picture);
if (response.status === 409) return null;
if (!response.ok) throw new Error(await reason(response));
return (await response.json()).saved;
}
+121
View File
@@ -0,0 +1,121 @@
// @ts-check
/**
* Reads the resolution an image file records about itself, which gives the
* image its natural size on the page. Scanners write it; most other
* programs do not.
*
* Understood: the PNG `pHYs` chunk, the JPEG JFIF header, and the JPEG EXIF
* resolution tags. Anything else gives null.
*/
/** Resolutions outside this range are mistakes, not information. */
const LEAST_DPI = 30;
const MOST_DPI = 2400;
/**
* @param {number | null} dpi
* @returns {number | null}
*/
function plausible(dpi) {
return dpi !== null && dpi >= LEAST_DPI && dpi <= MOST_DPI ? dpi : null;
}
/**
* @param {DataView} view
* @returns {number | null} Dots per inch from the pHYs chunk, if any.
*/
function pngResolution(view) {
let at = 8; // After the signature.
while (at + 12 <= view.byteLength) {
const length = view.getUint32(at);
const type = String.fromCharCode(
view.getUint8(at + 4), view.getUint8(at + 5), view.getUint8(at + 6), view.getUint8(at + 7));
if (type === 'pHYs' && length === 9 && at + 8 + 9 <= view.byteLength) {
const perMetre = view.getUint32(at + 8);
const unitIsMetre = view.getUint8(at + 16) === 1;
return unitIsMetre ? perMetre * 0.0254 : null;
}
if (type === 'IDAT' || type === 'IEND') return null;
at += 12 + length;
}
return null;
}
/**
* @param {DataView} view
* @param {number} start Where the TIFF header of the EXIF data begins.
* @returns {number | null} Dots per inch from XResolution and ResolutionUnit.
*/
function exifResolution(view, start) {
const little = view.getUint16(start) === 0x4949;
/** @param {number} at */
const u16 = (at) => view.getUint16(at, little);
/** @param {number} at */
const u32 = (at) => view.getUint32(at, little);
if (u16(start + 2) !== 42) return null;
const ifd = start + u32(start + 4);
if (ifd + 2 > view.byteLength) return null;
const count = u16(ifd);
let resolution = null;
let unit = 2; // Inches unless said otherwise.
for (let i = 0; i < count; i++) {
const entry = ifd + 2 + i * 12;
if (entry + 12 > view.byteLength) return null;
const tag = u16(entry);
if (tag === 0x011a) { // XResolution, a rational at the given offset.
const at = start + u32(entry + 8);
if (at + 8 > view.byteLength) return null;
const denominator = u32(at + 4);
resolution = denominator ? u32(at) / denominator : null;
} else if (tag === 0x0128) { // ResolutionUnit: 2 inches, 3 centimetres.
unit = u16(entry + 8);
}
}
if (resolution === null) return null;
return unit === 3 ? resolution * 2.54 : resolution;
}
/**
* @param {DataView} view
* @returns {number | null} Dots per inch from the JFIF or EXIF header, if any.
*/
function jpegResolution(view) {
let at = 2; // After the SOI marker.
let fromJfif = null;
while (at + 4 <= view.byteLength && view.getUint8(at) === 0xff) {
const marker = view.getUint8(at + 1);
if (marker === 0xda) break; // Start of scan: no more headers.
const length = view.getUint16(at + 2);
const body = at + 4;
if (marker === 0xe0 && length >= 16 && body + 12 <= view.byteLength &&
view.getUint32(body) === 0x4a464946) { // "JFIF"
const units = view.getUint8(body + 7);
const density = view.getUint16(body + 8);
if (units === 1) fromJfif = density;
else if (units === 2) fromJfif = density * 2.54;
}
if (marker === 0xe1 && body + 6 <= view.byteLength &&
view.getUint32(body) === 0x45786966) { // "Exif"
const fromExif = plausible(exifResolution(view, body + 6));
if (fromExif !== null) return fromExif;
}
at = body + length - 2;
}
return fromJfif;
}
/**
* @param {Uint8Array<ArrayBuffer>} bytes An image file.
* @returns {number | null} The resolution the file records, in dots per
* inch, or null if it records none that can be believed.
*/
export function imageResolution(bytes) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
try {
if (bytes.length > 8 && view.getUint32(0) === 0x89504e47) return plausible(pngResolution(view));
if (bytes.length > 4 && view.getUint16(0) === 0xffd8) return plausible(jpegResolution(view));
} catch {
// A truncated or malformed header: no resolution.
}
return null;
}
+958
View File
@@ -0,0 +1,958 @@
// @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<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) ?? '';
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<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;
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);
}
+333
View File
@@ -0,0 +1,333 @@
// @ts-check
/**
* Notebooks: reading and writing page files.
*
* A notebook is a directory on the user's computer. The page being written
* is the file `Current.svg` in it, rewritten as the page changes. A page is
* *kept* by copying `Current.svg` to a dated file such as
* `2026_10_02_01.svg`. That happens when the user asks, and by itself
* whenever `Current.svg` is about to be given over to a different page, so
* that no page is lost by forgetting to keep it.
*
* The browser cannot reach those directories itself, so it asks the local
* program (kokkoja.py), proving with a token that it is the user's own
* browser. When Kokkoja is opened from a web site instead, with no local
* program, the notebooks are kept in the browser's own storage
* (`browser_notebook.js`) under the same names and rules; the user gets
* pages out of them as downloads.
*/
import * as inBrowser from './browser_notebook.js';
const TOKEN_KEY = 'kokkoja_token';
export const CURRENT_PAGE = 'Current.svg';
/** Write this long after the latest change... */
const QUIET_MS = 400;
/** ...but never leave a change unwritten for longer than this. */
const LONGEST_WAIT_MS = 2000;
/** After a write fails, try again this much later. */
const RETRY_MS = 3000;
/**
* What keeps the notebooks: the local program's files, or the browser.
* @typedef {object} Backend
* @property {() => Promise<string | null>} readIdentity
* @property {(text: string) => Promise<boolean>} keepIdentity
* @property {() => Promise<{directory: string, notebooks: string[]}>} listNotebooks
* The directory is '' when the notebooks are in the browser.
* @property {(notebook: string) => Promise<string[]>} listPages Every page file, Current.svg too.
* @property {(notebook: string, pageName: string) => Promise<string | null>} readPage
* @property {(notebook: string, pageName: string, text: string) => Promise<void>} writePage
* @property {(notebook: string) => Promise<{kept: string | null, new: boolean}>} keepPage
*/
/**
* Keep the token for later visits. The local program gives it in the
* address it opens.
* @param {string} token
*/
export function rememberToken(token) {
localStorage.setItem(TOKEN_KEY, token);
}
/**
* Ask the local program for something, with the token that proves we are
* the user's own browser.
* @param {string} path
* @param {string} [method]
* @param {BodyInit} [body]
* @returns {Promise<Response>}
*/
export async function askLocalProgram(path, method = 'GET', body = undefined) {
const response = await fetch(path, {
method, body,
headers: { Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY) ?? ''}` },
});
if (response.status === 401) {
throw new Error('Notebooks are locked: open Kokkoja from the address that ' +
'the Kokkoja program shows when it starts.');
}
return response;
}
/**
* @param {string} method
* @param {string[]} names Notebook name, then page name; either may be absent.
* @param {string} [body]
* @returns {Promise<Response>}
*/
function request(method, names, body) {
return askLocalProgram(['api', 'notebooks', ...names.map(encodeURIComponent)].join('/'), method, body);
}
/** The notebooks as the local program keeps them: directories of files. */
const inFiles = /** @type {Backend} */ ({
async readIdentity() {
const response = await askLocalProgram('api/identity');
return response.ok ? response.text() : null;
},
async keepIdentity(text) {
return (await askLocalProgram('api/identity', 'PUT', text)).ok;
},
async listNotebooks() {
const response = await request('GET', []);
if (!response.ok) throw new Error('Notebooks are not available.');
return response.json();
},
async listPages(notebook) {
const response = await request('GET', [notebook]);
if (!response.ok) throw new Error(`The notebook “${notebook}” cannot be read.`);
return (await response.json()).pages;
},
async readPage(notebook, pageName) {
const response = await request('GET', [notebook, pageName]);
return response.ok ? response.text() : null;
},
async writePage(notebook, pageName, text) {
const response = await request('PUT', [notebook, pageName], text);
if (!response.ok) throw new Error(`The page could not be saved (${response.status}).`);
},
async keepPage(notebook) {
const response = await request('POST', [notebook, 'keep']);
if (!response.ok) throw new Error(`The page could not be kept (${response.status}).`);
return response.json();
},
});
let backend = inFiles;
/** Keep the notebooks in this browser: there is no local program. */
export function useBrowserStorage() {
backend = inBrowser.backend;
}
/** @returns {boolean} Whether the notebooks are in this browser. */
export function notebooksInBrowser() {
return backend === inBrowser.backend;
}
/**
* The user's identity as it is kept, or null if none is kept yet.
* @returns {Promise<string | null>} Text that loadIdentity() accepts.
*/
export function readIdentity() {
return backend.readIdentity();
}
/**
* Give the user's identity to be kept. Only the first identity is kept;
* the result says whether this one was.
* @param {string} text From saveIdentity().
* @returns {Promise<boolean>}
*/
export function keepIdentity(text) {
return backend.keepIdentity(text);
}
/**
* @returns {Promise<{directory: string, notebooks: string[]}>}
*/
export function listNotebooks() {
return backend.listNotebooks();
}
/**
* @param {string} notebook
* @returns {Promise<string[]>} The names of the pages kept in the
* notebook, in order; not the page being written.
*/
export async function listKeptPages(notebook) {
const pages = await backend.listPages(notebook);
return pages.filter((name) => name !== CURRENT_PAGE);
}
/**
* @param {string} notebook
* @param {string} pageName
* @returns {Promise<string | null>} The page file, or null if there is none.
*/
export function readPage(notebook, pageName) {
return backend.readPage(notebook, pageName);
}
/**
* @param {string} notebook
* @returns {Promise<string | null>} The notebook's current page file, or
* null if it has none.
*/
export function readCurrentPage(notebook) {
return backend.readPage(notebook, CURRENT_PAGE);
}
/**
* Keep a copy of the notebook's current page, unless it is blank or an
* identical page is already kept.
* @param {string} notebook
* @returns {Promise<{kept: string | null, new: boolean}>} The name of the
* kept page, or null if there was nothing to keep, and whether the copy
* was made just now.
*/
export function keepCurrentPage(notebook) {
return backend.keepPage(notebook);
}
/**
* Writes the session's page to the current notebook. There is no save
* command: the page is written whenever it changes.
*
* Everything that touches the notebook's files goes through one queue, so
* that writing and keeping happen in the sequence in which they were asked
* for.
*/
export class NotebookWriter {
/**
* @param {string} notebook
* @param {() => string | null} content Produces the file of the present
* page, or null if there is no page to save yet.
* @param {(message: string, problem: boolean) => void} onStatus
*/
constructor(notebook, content, onStatus) {
this.notebook = notebook;
this.content = content;
this.onStatus = onStatus;
/** @type {number | null} */
this.timer = null;
/** Time of the oldest change not yet written, or 0. */
this.waitingSince = 0;
/** Time of the latest write. */
this.lastWrite = 0;
this.queue = Promise.resolve();
// A change that was waiting is written when the tab is hidden or shown.
document.addEventListener('visibilitychange', () => {
if (this.waitingSince) this.writeNow();
});
}
/**
* Add work to the queue. A failure is reported and does not stop later
* work.
* @param {() => Promise<void>} work
* @returns {Promise<void>}
*/
enqueue(work) {
this.queue = this.queue.then(work).catch((error) => {
this.onStatus(error instanceof Error ? error.message : String(error), true);
// The page is still in memory: try to write it again shortly.
if (this.timer === null) this.timer = setTimeout(() => this.writeNow(), RETRY_MS);
});
return this.queue;
}
/** @param {string} text */
put(text) {
return backend.writePage(this.notebook, CURRENT_PAGE, text);
}
/** The page changed: write it soon. */
changed() {
const now = performance.now();
if (!this.waitingSince) this.waitingSince = now;
if (this.timer !== null) clearTimeout(this.timer);
// Browsers delay the timers of a tab that is not in view, perhaps for a
// long time. There, write at once instead of waiting for a pause.
if (document.visibilityState === 'hidden' && now - this.lastWrite >= QUIET_MS) {
this.writeNow();
return;
}
const delay = Math.max(0, Math.min(QUIET_MS, this.waitingSince + LONGEST_WAIT_MS - now));
this.timer = setTimeout(() => this.writeNow(), delay);
}
/**
* Write the present page without waiting.
* @returns {Promise<void>}
*/
writeNow() {
if (this.timer !== null) clearTimeout(this.timer);
this.timer = null;
this.waitingSince = 0;
this.lastWrite = performance.now();
return this.enqueue(async () => {
const text = this.content();
if (text === null) return;
await this.put(text);
this.onStatus(`Saved at ${new Date().toLocaleTimeString()}`, false);
});
}
/**
* The user asked to keep the page as it is now.
* @returns {Promise<string | null>} The name of the kept page, or null
* if there was nothing to keep.
*/
keepPage() {
this.writeNow();
/** @type {string | null} */
let kept = null;
return this.enqueue(async () => {
const result = await keepCurrentPage(this.notebook);
kept = result.kept;
this.onStatus(
result.kept === null ? 'The page is blank: nothing to keep'
: result.new ? `Kept as ${result.kept}` : `Already kept as ${result.kept}`,
false);
}).then(() => kept);
}
/**
* The session moved to another page. Keep the old one, then begin
* writing the new one.
* @param {string | null} oldText The file of the old page, or null if it
* was never a page of ours to save.
* @returns {Promise<void>}
*/
pageReplaced(oldText) {
if (this.timer !== null) clearTimeout(this.timer);
this.timer = null;
this.waitingSince = 0;
this.enqueue(async () => {
if (oldText !== null) await this.put(oldText);
await keepCurrentPage(this.notebook);
});
return this.writeNow();
}
/**
* Change to another notebook. Whatever page that notebook was left with
* is kept, unless it is this very page.
* @param {string} notebook
* @param {(text: string) => boolean} isPresentPage
* Whether a page file holds the page we are on.
* @returns {Promise<void>}
*/
changeNotebook(notebook, isPresentPage) {
this.writeNow();
this.enqueue(async () => {
this.notebook = notebook;
const text = await readCurrentPage(notebook);
if (text !== null && !isPresentPage(text)) await keepCurrentPage(notebook);
});
return this.writeNow();
}
}
+262
View File
@@ -0,0 +1,262 @@
// @ts-check
/**
* The page file: one self-contained SVG file per page.
*
* The file serves two readers. Any web browser, or Inkscape, shows the page
* from the ordinary SVG shapes. Kokkoja reads the page back exactly from
* `data-` attributes on the same elements, so removing a layer or a stroke
* from the file removes both what is shown and what Kokkoja reads.
*
* <svg data-kokkoja="1" data-session=".." data-page=".." data-number="3"
* viewBox="0 0 2100 2970" ...>
* <rect .../> the paper
* <image data-hash=".." href="data:image/png;base64,..."/> one for each image
* <g data-layer="<participant id>" data-name="Andy">
* <g data-stroke="<stroke id>" data-width="7" data-points="x y p ..."
* stroke="#1f4fd8" ...>
* <path d="M... Q..." stroke-width="6.9"/> what is shown
* </g>
* <g data-text="<text id>" data-x=".." data-y=".." data-width=".." ...>
* <desc>the text as typed</desc>
* <text ...><tspan>one line as shown</tspan>...</text>
* </g>
* </g>
* </svg>
*
* This is an experiment (see CLAUDE.md, "Not yet verified").
*/
import { fromBase64, toBase64 } from './crypto.js';
import {
Page, POINT_SIZE, cleanImage, cleanName, cleanPageDescriptor, cleanStroke, cleanText,
pointCount, validId,
} from './page_model.js';
import { partAt } from './stroke_geometry.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP } from './text_layout.js';
/** @typedef {import('./page_model.js').Stroke} Stroke */
const FORMAT_VERSION = '1';
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
/**
* The base64 form of each image, so that it is computed once and not at
* every save.
* @type {WeakMap<Uint8Array, string>}
*/
const imageText = new WeakMap();
/**
* @param {import('./page_model.js').PageImage} image
* @returns {string} The element that shows the image, or '' if its bytes
* have not arrived.
*/
function imageElement(image) {
if (!image.data) return '';
let text = imageText.get(image.data);
if (text === undefined) {
text = toBase64(image.data);
imageText.set(image.data, text);
}
return `<image data-hash="${image.hash}" x="${image.x}" y="${image.y}"` +
` width="${image.width}" height="${image.height}"` +
` preserveAspectRatio="none" href="data:${image.type};base64,${text}"/>`;
}
/**
* @param {string} text
* @returns {string} The text, safe inside a double-quoted XML attribute.
*/
function escaped(text) {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* @param {string} text
* @returns {string} The text, safe as XML content.
*/
function escapedContent(text) {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/**
* The elements that show and record a text block.
* @param {import('./page_model.js').TextBlock} block
* @returns {string}
*/
function textElements(block) {
const lines = block.lines.map((line, i) =>
`<tspan x="${number(block.x + block.size * LEFT_GAP)}"` +
` y="${number(block.y + block.size * (TOP_GAP + i * LINE_HEIGHT))}">${escapedContent(line)}</tspan>`);
return `<g data-text="${escaped(block.id)}" data-x="${block.x}" data-y="${block.y}"` +
` data-width="${block.width ?? ''}" data-size="${block.size}" data-font="${block.font}"` +
` data-box="${block.boxWidth} ${block.boxHeight}">` +
`<desc>${escapedContent(block.text)}</desc>` +
`<text font-family="${block.font}" font-size="${block.size}" fill="${block.color}"` +
` dominant-baseline="text-before-edge" xml:space="preserve">${lines.join('')}</text></g>`;
}
/**
* @param {number} value
* @returns {string} The number with at most two decimals.
*/
function number(value) {
return String(Math.round(value * 100) / 100);
}
/**
* The shapes that show a stroke. Consecutive parts of the same width (to
* 0.05 mm, far finer than the eye or a printer resolves) are joined into
* one path, since each part begins where the one before it ended. The
* exact pressures are in `data-points`, so nothing is lost by this.
* @param {Stroke} stroke
* @returns {string}
*/
function strokeShapes(stroke) {
const count = pointCount(stroke);
if (count === 0) return '';
if (count === 1) {
const dot = partAt(stroke, 0);
return `<circle cx="${number(dot.x)}" cy="${number(dot.y)}" r="${number(dot.width / 2)}"` +
` fill="${stroke.color}" stroke="none"/>`;
}
let shapes = '';
let path = '';
let pathWidth = '';
for (let k = 0; k < count; k++) {
const part = partAt(stroke, k);
const width = (Math.round(part.width * 2) / 2).toString();
if (width !== pathWidth) {
if (path) shapes += `<path d="${path}" stroke-width="${pathWidth}"/>`;
path = `M${number(part.fromX)} ${number(part.fromY)}`;
pathWidth = width;
}
path += `Q${number(part.x)} ${number(part.y)} ${number(part.toX)} ${number(part.toY)}`;
}
return shapes + `<path d="${path}" stroke-width="${pathWidth}"/>`;
}
/**
* @param {Page} page
* @param {object} options
* @param {string} options.session The session the page belongs to (its
* room id), so that returning to the session continues the page.
* @param {string} options.hostId
* @param {Set<string>} [options.hidden] Layers to leave out: for a picture
* of the page as someone shows it, never for the page file itself.
* @returns {string} The page file.
*/
export function pageToSvg(page, { session, hostId, hidden }) {
const lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
`<svg xmlns="${SVG_NAMESPACE}" data-kokkoja="${FORMAT_VERSION}"` +
` data-session="${escaped(session)}" data-host="${escaped(hostId)}"` +
` data-page="${escaped(page.id)}" data-number="${page.number}"` +
` data-base="${page.baseWidth} ${page.baseHeight}" data-revision="${page.imagesRevision}"` +
` viewBox="${page.left} ${page.top} ${page.width} ${page.height}"` +
` width="${page.width / 100}cm" height="${page.height / 100}cm">`,
`<title>Kokkoja page ${page.number}</title>`,
`<rect x="${page.left}" y="${page.top}" width="${page.width}" height="${page.height}"` +
' fill="#ffffff"/>',
];
for (const image of page.images) {
const shown = imageElement(image);
if (shown) lines.push(shown);
}
for (const id of page.layerOrder(hostId)) {
const layer = page.layers.get(id);
if (!layer || layer.isEmpty() || hidden?.has(id)) continue;
lines.push(`<g data-layer="${escaped(layer.id)}" data-name="${escaped(layer.name)}">`);
for (const stroke of layer.strokes.values()) {
if (pointCount(stroke) === 0) continue;
lines.push(
`<g data-stroke="${escaped(stroke.id)}" data-width="${stroke.width}"` +
` data-points="${stroke.points.join(' ')}" stroke="${stroke.color}" fill="none"` +
` stroke-linecap="round" stroke-linejoin="round">${strokeShapes(stroke)}</g>`);
}
for (const block of layer.texts.values()) lines.push(textElements(block));
lines.push('</g>');
}
lines.push('</svg>', '');
return lines.join('\n');
}
/**
* Read a page file.
* @param {string} text
* @returns {{page: Page, hostId: string, session: string} | null} Null if
* the text is not a Kokkoja page.
*/
export function pageFromSvg(text) {
const root = new DOMParser().parseFromString(text, 'image/svg+xml').documentElement;
if (root.localName !== 'svg' || root.getAttribute('data-kokkoja') !== FORMAT_VERSION) return null;
const box = (root.getAttribute('viewBox') ?? '').split(/\s+/).map(Number);
if (box.length !== 4 || !(box[2] > 0) || !(box[3] > 0)) return null;
const base = (root.getAttribute('data-base') ?? '').split(/\s+/).map(Number);
const descriptor = cleanPageDescriptor({
id: root.getAttribute('data-page'), number: Number(root.getAttribute('data-number')),
width: base.length === 2 ? base[0] : box[2], height: base.length === 2 ? base[1] : box[3],
revision: Number(root.getAttribute('data-revision') ?? 0),
});
if (!descriptor) return null;
const page = new Page(descriptor.width, descriptor.height, descriptor.id, descriptor.number);
page.imagesRevision = descriptor.revision;
/** @type {Map<string, Uint8Array<ArrayBuffer>>} An image shown twice is held once. */
const held = new Map();
for (const shown of root.querySelectorAll('image[data-hash]')) {
const address = /^data:(image\/png|image\/jpeg);base64,(.*)$/s.exec(shown.getAttribute('href') ?? '');
if (!address) continue;
const hash = shown.getAttribute('data-hash') ?? '';
const data = held.get(hash) ?? fromBase64(address[2]);
const image = cleanImage({
type: address[1], size: data.length, hash,
x: Number(shown.getAttribute('x')), y: Number(shown.getAttribute('y')),
width: Number(shown.getAttribute('width')), height: Number(shown.getAttribute('height')),
});
if (!image) continue;
held.set(hash, data);
page.images.push({ ...image, data });
}
for (const group of root.querySelectorAll('g[data-layer]')) {
const layerId = group.getAttribute('data-layer');
if (!validId(layerId)) continue;
const layer = page.layer(layerId, cleanName(group.getAttribute('data-name')));
for (const element of group.querySelectorAll('g[data-stroke]')) {
const points = (element.getAttribute('data-points') ?? '').split(/\s+/).map(Number);
const stroke = cleanStroke({
id: element.getAttribute('data-stroke'),
color: element.getAttribute('stroke'),
width: Number(element.getAttribute('data-width')),
points,
done: true,
});
if (stroke && points.length >= POINT_SIZE) layer.strokes.set(stroke.id, stroke);
}
for (const element of group.querySelectorAll('g[data-text]')) {
const widthText = element.getAttribute('data-width') ?? '';
const box = (element.getAttribute('data-box') ?? '').split(' ').map(Number);
const block = cleanText({
id: element.getAttribute('data-text'),
x: Number(element.getAttribute('data-x')), y: Number(element.getAttribute('data-y')),
width: widthText === '' ? null : Number(widthText),
size: Number(element.getAttribute('data-size')),
font: element.getAttribute('data-font') ?? 'sans-serif',
color: element.querySelector('text')?.getAttribute('fill'),
text: element.querySelector('desc')?.textContent ?? '',
lines: [...element.querySelectorAll('tspan')].map((span) => span.textContent ?? ''),
boxWidth: box[0], boxHeight: box[1],
});
if (block) layer.texts.set(block.id, block);
}
}
page.recomputeStrokeBounds();
page.updateExtent();
return {
page,
hostId: root.getAttribute('data-host') ?? '',
session: root.getAttribute('data-session') ?? '',
};
}
+629
View File
@@ -0,0 +1,629 @@
// @ts-check
/**
* The page: a stack of layers, one per participant, each holding that
* participant's strokes. Coordinates are in page units of 0.1 mm.
*
* This module holds data only. It does not draw and does not talk to the
* network.
*/
/** A blank page is A4. */
export const BLANK_PAGE_WIDTH = 2100;
export const BLANK_PAGE_HEIGHT = 2970;
/**
* The page grows to keep this much room around everything written on it,
* in steps of the same amount so that it does not shift with every pen
* point. 8 mm, Andy's choice (2026-10-03; 15 mm before).
*/
export const WRITING_MARGIN = 80;
/**
* An image is placed this far inside the sheet's edge. 8 mm, Andy's
* choice; it is deliberately a separate setting from WRITING_MARGIN.
*/
export const IMAGE_MARGIN = 80;
/**
* @typedef {object} Stroke
* @property {string} id
* @property {string} color CSS color.
* @property {number} width Nominal width in page units.
* @property {number[]} points x, y, pressure for each point, in sequence.
* Pressure is 0 to 1.
* @property {boolean} done False while the stroke is being drawn.
*/
/** Numbers per point in Stroke.points. */
export const POINT_SIZE = 3;
/**
* Typed text on a layer. Its top-left corner is (x, y); with a width it
* wraps there, without one it breaks lines only at the writer's Enter.
* @typedef {object} TextBlock
* @property {string} id
* @property {number} x
* @property {number} y
* @property {number | null} width Page units, or null for no wrapping.
* @property {string} text As typed, with the writer's line breaks.
* @property {string[]} lines As shown, laid out by the writer's client.
* @property {number} size Font size in page units.
* @property {TextFont} font One of the families every computer has.
* @property {string} color
* @property {number} boxWidth The rectangle the lines take up.
* @property {number} boxHeight
*/
/**
* The font families a block may use: the generic ones, so that the page
* file looks right on any computer.
* @typedef {'sans-serif' | 'serif' | 'monospace'} TextFont
*/
export const TEXT_FONTS = /** @type {const} */ (['sans-serif', 'serif', 'monospace']);
/** Page units (0.1 mm) in a typographic point. */
export const UNITS_PER_POINT = 254 / 72;
/** Text sizes in points that may be chosen. */
export const LEAST_TEXT_POINTS = 6;
export const MOST_TEXT_POINTS = 100;
/** The size to begin with: 14 points (Andy, 2026-10-03). */
export const TEXT_SIZE = Math.round(14 * UNITS_PER_POINT * 10) / 10;
const MAX_TEXT_CHARACTERS = 20000;
const MAX_TEXT_LINES = 2000;
/**
* @param {Stroke} stroke
* @returns {number}
*/
export function pointCount(stroke) {
return stroke.points.length / POINT_SIZE;
}
/**
* Add points to a stroke. `at` is the number of points that preceded these
* when they were sent, so that points received twice are added only once.
* @param {Stroke} stroke
* @param {number} at
* @param {number[]} points
*/
export function appendPoints(stroke, at, points) {
const overlap = Math.max(0, pointCount(stroke) - at);
for (let i = overlap * POINT_SIZE; i < points.length; i++) stroke.points.push(points[i]);
}
/**
* Round a coordinate or a pressure for storage and sending.
* @param {number} value
* @param {number} scale 10 for coordinates (0.01 mm), 1000 for pressure.
*/
export function rounded(value, scale) {
return Math.round(value * scale) / scale;
}
// Checking data that comes from outside: other participants and page files.
export const MAX_NAME_LENGTH = 40;
const MAX_ID_LENGTH = 40;
const MAX_STROKE_WIDTH = 200;
const COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
/** A longer stroke is split, so that one stroke always fits in a message. */
export const MAX_STROKE_POINTS = 20000;
/**
* @param {unknown} name
* @returns {string}
*/
export function cleanName(name) {
return typeof name === 'string' ? name.trim().slice(0, MAX_NAME_LENGTH) : '';
}
/**
* @param {unknown} id
* @returns {id is string}
*/
export function validId(id) {
return typeof id === 'string' && id.length > 0 && id.length <= MAX_ID_LENGTH;
}
/**
* @param {unknown} points
* @returns {points is number[]}
*/
export function validPoints(points) {
return Array.isArray(points) && points.length % POINT_SIZE === 0 &&
points.length <= MAX_STROKE_POINTS * POINT_SIZE && points.every(Number.isFinite);
}
/**
* Check a stroke that came from outside.
* @param {any} received
* @returns {Stroke | null} A stroke that is safe to keep, or null.
*/
export function cleanStroke(received) {
if (!received || !validId(received.id) || !COLOR_PATTERN.test(received.color) ||
!(received.width > 0 && received.width <= MAX_STROKE_WIDTH)) return null;
const points = received.points ?? [];
if (!validPoints(points)) return null;
return {
id: received.id, color: received.color, width: received.width,
points: points.slice(), done: received.done !== false,
};
}
/**
* Check a text block that came from outside.
* @param {any} received
* @returns {TextBlock | null}
*/
export function cleanText(received) {
if (!received || !validId(received.id) || !COLOR_PATTERN.test(received.color) ||
typeof received.text !== 'string' || received.text.length > MAX_TEXT_CHARACTERS ||
!Array.isArray(received.lines) || received.lines.length > MAX_TEXT_LINES ||
!received.lines.every((/** @type {unknown} */ line) => typeof line === 'string') ||
![received.x, received.y].every((v) => Number.isFinite(v) && Math.abs(v) <= 10 * MAX_PAGE_SIZE) ||
(received.width !== null && !(received.width >= 10 && received.width <= MAX_PAGE_SIZE)) ||
!(received.size >= 10 && received.size <= 400) ||
!TEXT_FONTS.includes(received.font) ||
!(received.boxWidth >= 0 && received.boxWidth <= MAX_PAGE_SIZE) ||
!(received.boxHeight >= 0 && received.boxHeight <= 10 * MAX_PAGE_SIZE)) return null;
return {
id: received.id, x: received.x, y: received.y, width: received.width,
text: received.text, lines: received.lines.slice(), size: received.size, font: received.font,
color: received.color, boxWidth: received.boxWidth, boxHeight: received.boxHeight,
};
}
export class Layer {
/**
* @param {string} id The participant's id.
* @param {string} name The participant's display name.
*/
constructor(id, name) {
this.id = id;
this.name = name;
/** @type {Map<string, Stroke>} In drawing sequence. */
this.strokes = new Map();
/** @type {Map<string, TextBlock>} In the sequence of making. */
this.texts = new Map();
}
/** @returns {boolean} */
isEmpty() {
return this.strokes.size === 0 && this.texts.size === 0;
}
}
/**
* An image on a page, usually a scanned exercise, and where it lies on the
* page. A page may hold several, one below the other.
* @typedef {object} PageImage
* @property {'image/png' | 'image/jpeg'} type
* @property {number} size Bytes.
* @property {string} hash SHA-256 of the bytes, base64url.
* @property {number} x Its rectangle on the page, in page units.
* @property {number} y
* @property {number} width
* @property {number} height
* @property {Uint8Array<ArrayBuffer> | null} data Null until the bytes
* have arrived from another participant.
*/
/**
* What a descriptor says about an image: everything but the bytes.
* @param {PageImage} image
* @returns {Omit<PageImage, 'data'>}
*/
function withoutData(image) {
const { data, ...rest } = image;
return rest;
}
/**
* What identifies a page and gives its size. The host sends it to the
* others when the page changes.
* @typedef {object} PageDescriptor
* @property {string} id
* @property {number} number Counts the pages of a session from 1.
* @property {number} width
* @property {number} height
* @property {Omit<PageImage, 'data'>[]} images From the top of the page down.
* @property {number} revision Counts the changes made to `images`, so
* that of two lists the later one can be told.
*/
/**
* A rectangle in page units.
* @typedef {{left: number, top: number, width: number, height: number}} Rectangle
*/
const MAX_PAGE_SIZE = 20000;
/** An image larger than this is made smaller before it is shared. */
export const MAX_IMAGE_BYTES = 4 << 20;
/**
* A page holds at most this many images, and this many bytes of them,
* because the page file holds them all and is rewritten at every save.
*/
export const MAX_PAGE_IMAGES = 12;
export const MAX_PAGE_IMAGE_BYTES = 24 << 20;
/**
* Check the description of a page's image that came from outside.
* @param {any} received
* @returns {PageImage | null}
*/
export function cleanImage(received) {
if (!received || (received.type !== 'image/png' && received.type !== 'image/jpeg') ||
!Number.isInteger(received.size) || received.size < 1 || received.size > MAX_IMAGE_BYTES ||
typeof received.hash !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(received.hash) ||
![received.x, received.y].every((v) => Number.isFinite(v) && Math.abs(v) <= MAX_PAGE_SIZE) ||
![received.width, received.height].every((v) => v >= 1 && v <= MAX_PAGE_SIZE)) return null;
return {
type: received.type, size: received.size, hash: received.hash,
x: received.x, y: received.y, width: received.width, height: received.height, data: null,
};
}
/**
* Where an image of a given natural size goes on a page: centred across
* the A4 sheet, inside the margin (Andy, 2026-10-03: his teacher's scans
* are often partial and wider than they are tall). The first image lies
* at the top of the sheet; each later one lies below the lowest image
* already there, the margin apart, so that a page can be made of a series
* of images (Andy, 2026-10-05).
*
* An image wider than the sheet's width inside the margin is scaled down
* to it, or, if `shrink` is false, keeps its natural size: it then begins
* at the left margin and the sheet is widened to hold it (Andy,
* 2026-10-05, as a choice to try beside the scaling).
* @param {number} naturalWidth In page units.
* @param {number} naturalHeight
* @param {number} [below] The bottom edge of the lowest image already on
* the page; 0 if there is none.
* @param {boolean} [shrink]
* @returns {{x: number, y: number, width: number, height: number,
* pageWidth: number, pageHeight: number}}
* The image's rectangle, and how large the sheet must be to hold it.
*/
export function placeImage(naturalWidth, naturalHeight, below = 0, shrink = true) {
const room = BLANK_PAGE_WIDTH - 2 * IMAGE_MARGIN;
// At its natural size an image may still be no wider than a page can be.
const scale = Math.min(1, (shrink ? room : MAX_PAGE_SIZE - 2 * IMAGE_MARGIN) / naturalWidth);
const width = Math.round(naturalWidth * scale);
const height = Math.round(naturalHeight * scale);
const x = width <= room ? Math.round((BLANK_PAGE_WIDTH - width) / 2) : IMAGE_MARGIN;
const y = Math.round(below) + IMAGE_MARGIN;
return {
x, y, width, height,
pageWidth: Math.max(BLANK_PAGE_WIDTH, x + width + IMAGE_MARGIN),
pageHeight: Math.max(BLANK_PAGE_HEIGHT, y + height + IMAGE_MARGIN),
};
}
/**
* Check a page descriptor that came from outside.
* @param {any} received
* @returns {PageDescriptor | null}
*/
export function cleanPageDescriptor(received) {
if (!received || !validId(received.id) || !Number.isInteger(received.number) ||
received.number < 1 ||
!(received.width >= 100 && received.width <= MAX_PAGE_SIZE) ||
!(received.height >= 100 && received.height <= MAX_PAGE_SIZE)) return null;
const listed = received.images ?? [];
if (!Array.isArray(listed) || listed.length > MAX_PAGE_IMAGES) return null;
const images = [];
for (const one of listed) {
const image = cleanImage(one);
if (!image) return null;
images.push(withoutData(image));
}
return {
id: received.id, number: received.number, width: received.width, height: received.height,
images,
revision: Number.isInteger(received.revision) && received.revision >= 0 ? received.revision : 0,
};
}
export class Page {
/**
* @param {number} [width]
* @param {number} [height]
* @param {string} [id] Empty for the placeholder a guest shows
* before the host's page arrives.
* @param {number} [number] 0 for the placeholder.
*/
constructor(width = BLANK_PAGE_WIDTH, height = BLANK_PAGE_HEIGHT, id = '', number = 0) {
/**
* The size of the sheet before anything is written on it: A4, or taller
* if its images need the room.
*/
this.baseWidth = width;
this.baseHeight = height;
this.id = id;
this.number = number;
/** @type {PageImage[]} From the top of the page down. */
this.images = [];
/** Counts the changes made to `images`. */
this.imagesRevision = 0;
/** The page's present rectangle. See updateExtent(). */
this.left = 0;
this.top = 0;
this.width = width;
this.height = height;
/** @type {Rectangle | null} Around every stroke, or null if none. */
this.strokeBounds = null;
/** @type {Map<string, Layer>} */
this.layers = new Map();
}
/** @returns {PageDescriptor} */
descriptor() {
return {
id: this.id, number: this.number, width: this.baseWidth, height: this.baseHeight,
images: this.images.map(withoutData),
revision: this.imagesRevision,
};
}
/** @returns {number} The bottom edge of the lowest image; 0 if there is none. */
imagesBottom() {
return Math.max(0, ...this.images.map((image) => image.y + image.height));
}
/** @returns {number} How tall the sheet must be to hold its images: A4 at least. */
sheetHeight() {
return Math.max(BLANK_PAGE_HEIGHT, this.imagesBottom() + IMAGE_MARGIN);
}
/** @returns {number} How wide the sheet must be to hold its images: A4 at least. */
sheetWidth() {
return Math.max(BLANK_PAGE_WIDTH,
...this.images.map((image) => image.x + image.width + IMAGE_MARGIN));
}
/** @returns {boolean} Whether its images have made the sheet larger than A4. */
isLargerThanSheet() {
return this.baseWidth > BLANK_PAGE_WIDTH || this.baseHeight > BLANK_PAGE_HEIGHT;
}
/**
* Whether one more image may be added, given what the page holds.
* @param {number} size Bytes of the image.
* @param {number} bottom Where its bottom edge would lie.
* @returns {boolean}
*/
hasRoomForImage(size, bottom) {
const bytes = this.images.reduce((sum, image) => sum + image.size, 0);
return this.images.length < MAX_PAGE_IMAGES && bytes + size <= MAX_PAGE_IMAGE_BYTES &&
bottom + IMAGE_MARGIN <= MAX_PAGE_SIZE;
}
/**
* The rectangle the page has before anything is written on it: the
* sheet, which holds the images if there are any.
* @returns {Rectangle}
*/
baseExtent() {
return { left: 0, top: 0, width: this.baseWidth, height: this.baseHeight };
}
/**
* Widen the record of where strokes lie to include these points.
* @param {Stroke} stroke
* @param {number} [from] Index of the first point not yet included.
*/
includeStroke(stroke, from = 0) {
const p = stroke.points;
const reach = stroke.width * 2; // The drawn width at full pressure, and a little more.
let b = this.strokeBounds;
for (let i = from * POINT_SIZE; i < p.length; i += POINT_SIZE) {
const x = p[i];
const y = p[i + 1];
if (!b) {
b = this.strokeBounds = { left: x - reach, top: y - reach, width: 2 * reach, height: 2 * reach };
continue;
}
const right = Math.max(b.left + b.width, x + reach);
const bottom = Math.max(b.top + b.height, y + reach);
b.left = Math.min(b.left, x - reach);
b.top = Math.min(b.top, y - reach);
b.width = right - b.left;
b.height = bottom - b.top;
}
}
/**
* Widen the record of where things lie to include a text block.
* @param {TextBlock} block
*/
includeText(block) {
const corners = [block.x, block.y, 0, block.x + block.boxWidth, block.y + block.boxHeight, 0];
this.includeStroke({ id: '', color: '', width: 0, points: corners, done: true });
}
/** Find where strokes and text lie again, after some were removed or replaced. */
recomputeStrokeBounds() {
this.strokeBounds = null;
for (const layer of this.layers.values()) {
for (const stroke of layer.strokes.values()) this.includeStroke(stroke);
for (const block of layer.texts.values()) this.includeText(block);
}
}
/**
* Set the page's rectangle from what is on it: the base rectangle,
* widened to hold every stroke with the margin around it, in steps of
* the margin. The same contents give the same rectangle on every
* participant's computer.
* @returns {boolean} Whether the rectangle changed.
*/
updateExtent() {
const base = this.baseExtent();
let { left, top } = base;
let right = base.left + base.width;
let bottom = base.top + base.height;
const b = this.strokeBounds;
if (b) {
const step = WRITING_MARGIN;
left = Math.min(left, Math.floor((b.left - WRITING_MARGIN) / step) * step);
top = Math.min(top, Math.floor((b.top - WRITING_MARGIN) / step) * step);
right = Math.max(right, Math.ceil((b.left + b.width + WRITING_MARGIN) / step) * step);
bottom = Math.max(bottom, Math.ceil((b.top + b.height + WRITING_MARGIN) / step) * step);
}
const changed = left !== this.left || top !== this.top ||
right - left !== this.width || bottom - top !== this.height;
this.left = left;
this.top = top;
this.width = right - left;
this.height = bottom - top;
return changed;
}
/**
* An empty page from its descriptor.
* @param {PageDescriptor} descriptor
* @returns {Page}
*/
static from(descriptor) {
const page = new Page(descriptor.width, descriptor.height, descriptor.id, descriptor.number);
page.images = descriptor.images.map((image) => ({ ...image, data: null }));
page.imagesRevision = descriptor.revision;
page.updateExtent();
return page;
}
/** @returns {boolean} Nothing is written on the page. */
isBlank() {
for (const layer of this.layers.values()) {
if (!layer.isEmpty()) return false;
}
return true;
}
/**
* Layer ids from bottom to top. The host's layer is on top so that
* corrections lie over the writing they correct.
* @param {string} hostId
* @returns {string[]}
*/
layerOrder(hostId) {
return [...this.layers.keys()].sort((a, b) =>
Number(a === hostId) - Number(b === hostId) || a.localeCompare(b));
}
/**
* The layer of a participant, created if it does not exist.
* @param {string} id
* @param {string} [name]
* @returns {Layer}
*/
layer(id, name) {
let layer = this.layers.get(id);
if (!layer) {
layer = new Layer(id, name ?? '');
this.layers.set(id, layer);
} else if (name) {
layer.name = name;
}
return layer;
}
}
/**
* Squared distance from point (px, py) to the segment (ax, ay)-(bx, by).
* @param {number} px
* @param {number} py
* @param {number} ax
* @param {number} ay
* @param {number} bx
* @param {number} by
*/
function distanceSquaredToSegment(px, py, ax, ay, bx, by) {
const dx = bx - ax;
const dy = by - ay;
const lengthSquared = dx * dx + dy * dy;
let t = lengthSquared === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / lengthSquared;
t = Math.max(0, Math.min(1, t));
const cx = ax + t * dx - px;
const cy = ay + t * dy - py;
return cx * cx + cy * cy;
}
/**
* The text blocks of a layer whose rectangle the segment from (x0, y0) to
* (x1, y1) passes through, widened by `radius`.
* @param {Layer} layer
* @param {number} x0
* @param {number} y0
* @param {number} x1
* @param {number} y1
* @param {number} radius
* @returns {string[]} Text block ids.
*/
export function textsTouched(layer, x0, y0, x1, y1, radius) {
const touched = [];
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / radius));
for (const block of layer.texts.values()) {
for (let s = 0; s <= steps; s++) {
const x = x0 + (x1 - x0) * s / steps;
const y = y0 + (y1 - y0) * s / steps;
if (x >= block.x - radius && x <= block.x + block.boxWidth + radius &&
y >= block.y - radius && y <= block.y + block.boxHeight + radius) {
touched.push(block.id);
break;
}
}
}
return touched;
}
/**
* @param {Layer} layer
* @param {number} x
* @param {number} y
* @returns {TextBlock | null} The block of the layer at the point, if any.
*/
export function textAt(layer, x, y) {
for (const block of [...layer.texts.values()].reverse()) {
if (x >= block.x && x <= block.x + block.boxWidth && y >= block.y && y <= block.y + block.boxHeight) {
return block;
}
}
return null;
}
/**
* The strokes of a layer that pass within `radius` of the segment from
* (x0, y0) to (x1, y1): what an eraser moved along that segment touches.
* @param {Layer} layer
* @param {number} x0
* @param {number} y0
* @param {number} x1
* @param {number} y1
* @param {number} radius
* @returns {string[]} Stroke ids.
*/
export function strokesTouched(layer, x0, y0, x1, y1, radius) {
const touched = [];
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / radius));
for (const stroke of layer.strokes.values()) {
const points = stroke.points;
const reach = radius + stroke.width;
const reachSquared = reach * reach;
let hit = false;
for (let s = 0; s <= steps && !hit; s++) {
const ex = x0 + (x1 - x0) * s / steps;
const ey = y0 + (y1 - y0) * s / steps;
for (let i = 0; i < points.length && !hit; i += POINT_SIZE) {
const j = Math.min(i + POINT_SIZE, points.length - POINT_SIZE);
hit = distanceSquaredToSegment(
ex, ey, points[i], points[i + 1], points[j], points[j + 1]) <= reachSquared;
}
}
if (hit) touched.push(stroke.id);
}
return touched;
}
+88
View File
@@ -0,0 +1,88 @@
// @ts-check
/**
* A picture of a page: the page file drawn into a PNG, for use outside
* Kokkoja.
*/
import { pageToSvg } from './page_file.js';
/**
* The resolution of an exported picture, in dots per inch: enough to print
* handwriting cleanly, and an A4 page is then 1654 by 2339 pixels.
*/
export const EXPORT_DPI = 200;
/** A very large page is exported at a lower resolution, to stay within this. */
const MOST_PIXELS_ON_A_SIDE = 10000;
const CRC_TABLE = new Uint32Array(256).map((_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c;
});
/**
* @param {Uint8Array} bytes
* @returns {number} The checksum that ends a PNG chunk.
*/
function crc32(bytes) {
let c = 0xffffffff;
for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
/**
* Record a PNG's resolution in it (a pHYs chunk, placed after the header
* chunk), so that other programs print the page at its true size, and so
* that Kokkoja places it at that size if it is loaded as an image.
* @param {Uint8Array<ArrayBuffer>} png
* @param {number} dpi
* @returns {Uint8Array<ArrayBuffer>}
*/
function withResolution(png, dpi) {
const HEADER_END = 8 + 25; // The signature, then the IHDR chunk.
const chunk = new Uint8Array(21);
const view = new DataView(chunk.buffer);
const perMetre = Math.round(dpi / 0.0254);
view.setUint32(0, 9);
chunk.set([0x70, 0x48, 0x59, 0x73], 4); // "pHYs"
view.setUint32(8, perMetre);
view.setUint32(12, perMetre);
chunk[16] = 1; // The unit is the metre.
view.setUint32(17, crc32(chunk.subarray(4, 17)));
const out = new Uint8Array(png.length + chunk.length);
out.set(png.subarray(0, HEADER_END));
out.set(chunk, HEADER_END);
out.set(png.subarray(HEADER_END), HEADER_END + chunk.length);
return out;
}
/**
* @param {import('./page_model.js').Page} page
* @param {Parameters<typeof pageToSvg>[1]} options As for the page file;
* layers named in `hidden` are left out.
* @returns {Promise<Blob>} A PNG picture of the page.
*/
export async function pageToPng(page, options) {
const scale = Math.min(EXPORT_DPI / 254, MOST_PIXELS_ON_A_SIDE / Math.max(page.width, page.height));
const address = URL.createObjectURL(
new Blob([pageToSvg(page, options)], { type: 'image/svg+xml' }));
try {
const drawing = new Image();
drawing.src = address;
await drawing.decode();
const canvas = document.createElement('canvas');
canvas.width = Math.round(page.width * scale);
canvas.height = Math.round(page.height * scale);
const context = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d'));
context.drawImage(drawing, 0, 0, canvas.width, canvas.height);
/** @type {Blob} */
const blob = await new Promise((resolve, reject) => canvas.toBlob(
(result) => (result ? resolve(result) : reject(new Error('The picture could not be made.'))),
'image/png'));
const png = withResolution(new Uint8Array(await blob.arrayBuffer()), scale * 254);
return new Blob([png], { type: 'image/png' });
} finally {
URL.revokeObjectURL(address);
}
}
+170
View File
@@ -0,0 +1,170 @@
// @ts-check
/**
* Turns pointer events (pen, mouse or finger) on the page into strokes and
* erasures.
*/
import { rounded } from './page_model.js';
/** Bit in PointerEvent.buttons for the eraser end of a pen. */
const ERASER_BUTTON = 32;
/**
* Pressure recorded for a mouse or a finger, which report none. It is the
* pressure at which a stroke is drawn at its nominal width.
*/
const NOMINAL_PRESSURE = 0.4;
/**
* @typedef {object} PenTarget
* @property {(clientX: number, clientY: number) => [number, number]} toPage
* Convert a window position to page units.
* @property {() => 'pen' | 'eraser' | 'text'} tool The tool selected in the toolbar.
* @property {() => boolean} canWrite False while there is no page to write on.
* @property {() => void} beginStroke
* @property {(points: number[]) => void} addPoints
* @property {() => void} endStroke
* @property {(x0: number, y0: number, x1: number, y1: number) => void} eraseAlong
* @property {(x: number, y: number, reach: number) => void} [eraseEnded]
* The eraser went down at (x, y) and has come up again, having moved
* no farther than `reach` from there.
* @property {(x0: number, y0: number, x1: number, y1: number) => void} placeText
* With the text tool, the pointer went down at (x0, y0) and up at (x1, y1).
* @property {(x0: number, y0: number, x1: number) => void} [previewText]
* With the text tool, the pointer is down at (x0, y0) and now at x1.
* @property {(report: PenReport) => void} [onReport]
*/
/**
* What the input device is delivering, for judging pen support.
* @typedef {object} PenReport
* @property {string} pointerType 'pen', 'mouse' or 'touch'.
* @property {number} pressure Latest pressure.
* @property {number} pointsPerSecond
*/
/**
* @param {HTMLElement} surface The element that receives the pointer.
* @param {PenTarget} target
*/
export function attachPenInput(surface, target) {
/** @type {number | null} */
let activePointer = null;
let erasing = false;
let placingText = false;
let textStartX = 0;
let textStartY = 0;
let eraseStartX = 0;
let eraseStartY = 0;
let eraseReach = 0;
let penSeen = false;
let lastX = NaN;
let lastY = NaN;
let strokeStart = 0;
let strokePoints = 0;
/**
* @param {PointerEvent} event
* @returns {PointerEvent[]} Every position the pointer reported since
* the previous event, not only the latest.
*/
function allEvents(event) {
const coalesced = event.getCoalescedEvents ? event.getCoalescedEvents() : [];
return coalesced.length ? coalesced : [event];
}
/** @param {PointerEvent} event */
function draw(event) {
/** @type {number[]} */
const points = [];
let pressure = 0;
for (const e of allEvents(event)) {
const [x, y] = target.toPage(e.clientX, e.clientY);
const px = rounded(x, 10);
const py = rounded(y, 10);
if (px === lastX && py === lastY) continue;
pressure = e.pointerType === 'pen' ? rounded(e.pressure, 1000) : NOMINAL_PRESSURE;
points.push(px, py, pressure);
lastX = px;
lastY = py;
}
if (points.length === 0) return;
target.addPoints(points);
strokePoints += points.length / 3;
const seconds = (performance.now() - strokeStart) / 1000;
target.onReport?.({
pointerType: event.pointerType, pressure,
pointsPerSecond: seconds > 0.2 ? Math.round(strokePoints / seconds) : 0,
});
}
/** @param {PointerEvent} event */
function erase(event) {
for (const e of allEvents(event)) {
const [x, y] = target.toPage(e.clientX, e.clientY);
target.eraseAlong(Number.isNaN(lastX) ? x : lastX, Number.isNaN(lastY) ? y : lastY, x, y);
eraseReach = Math.max(eraseReach, Math.hypot(x - eraseStartX, y - eraseStartY));
lastX = x;
lastY = y;
}
}
surface.addEventListener('pointerdown', (event) => {
if (activePointer !== null || !target.canWrite() || event.target !== surface) return;
if (event.pointerType === 'pen') penSeen = true;
// Once a pen has been used, a touch is taken to be the resting hand.
if (event.pointerType === 'touch' && penSeen) return;
if (event.pointerType === 'mouse' && event.button !== 0) return;
event.preventDefault();
surface.setPointerCapture(event.pointerId);
activePointer = event.pointerId;
const tool = target.tool();
erasing = tool === 'eraser' || (event.buttons & ERASER_BUTTON) !== 0;
placingText = tool === 'text' && !erasing;
lastX = NaN;
lastY = NaN;
strokeStart = performance.now();
strokePoints = 0;
if (placingText) {
[textStartX, textStartY] = target.toPage(event.clientX, event.clientY);
} else if (erasing) {
[eraseStartX, eraseStartY] = target.toPage(event.clientX, event.clientY);
eraseReach = 0;
erase(event);
} else {
target.beginStroke();
draw(event);
}
});
surface.addEventListener('pointermove', (event) => {
if (event.pointerId !== activePointer) return;
if (placingText) {
const [x] = target.toPage(event.clientX, event.clientY);
target.previewText?.(textStartX, textStartY, x);
return;
}
if (erasing) erase(event);
else draw(event);
});
/** @param {PointerEvent} event */
function finish(event) {
if (event.pointerId !== activePointer) return;
activePointer = null;
if (placingText) {
const [x, y] = target.toPage(event.clientX, event.clientY);
if (event.type === 'pointerup') target.placeText(textStartX, textStartY, x, y);
} else if (erasing) {
if (event.type === 'pointerup') target.eraseEnded?.(eraseStartX, eraseStartY, eraseReach);
} else {
target.endStroke();
}
}
surface.addEventListener('pointerup', finish);
surface.addEventListener('pointercancel', finish);
// The side button of a pen asks for a context menu.
surface.addEventListener('contextmenu', (event) => event.preventDefault());
}
+107
View File
@@ -0,0 +1,107 @@
// @ts-check
/**
* The connection to the relay. It knows nothing about pages or encryption:
* it delivers bytes to peers and reports who is present. It reconnects by
* itself when the connection drops.
*/
const RETRY_DELAYS_MS = [500, 1000, 2000, 5000];
/** Close codes after which retrying is pointless (see the relay). */
const FINAL_CLOSE_CODES = new Map([
[4001, 'The session is full.'],
[4002, 'The relay is full.'],
]);
/**
* @typedef {object} RelayHandlers
* @property {(peer: number, peers: number[]) => void} onWelcome
* Connected (or reconnected): our peer number and the peers present.
* @property {(peer: number) => void} onJoin
* @property {(peer: number) => void} onLeave
* @property {(peer: number, body: Uint8Array<ArrayBuffer>) => void} onMessage
* @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus
*/
export class RelayConnection {
/**
* @param {string} relay Address of the relay, ws:// or wss://.
* @param {string} roomId
* @param {RelayHandlers} handlers
*/
constructor(relay, roomId, handlers) {
this.url = `${relay}/room/${roomId}`;
this.handlers = handlers;
/** @type {WebSocket | null} */
this.socket = null;
this.attempt = 0;
this.closed = false;
}
connect() {
if (this.closed) return;
this.handlers.onStatus('connecting');
const socket = new WebSocket(this.url);
socket.binaryType = 'arraybuffer';
this.socket = socket;
socket.onmessage = (event) => {
if (socket !== this.socket) return;
if (typeof event.data === 'string') {
const control = JSON.parse(event.data);
if (control.type === 'welcome') {
this.attempt = 0;
this.handlers.onStatus('connected');
this.handlers.onWelcome(control.peer, control.peers);
} else if (control.type === 'join') {
this.handlers.onJoin(control.peer);
} else if (control.type === 'leave') {
this.handlers.onLeave(control.peer);
}
} else {
const bytes = new Uint8Array(event.data);
if (bytes.length < 4) return;
const peer = new DataView(event.data).getUint32(0);
this.handlers.onMessage(peer, bytes.subarray(4));
}
};
socket.onclose = (event) => {
if (socket !== this.socket) return;
this.socket = null;
if (this.closed) return;
const final = FINAL_CLOSE_CODES.get(event.code);
if (final) {
this.closed = true;
this.handlers.onStatus('failed', final);
return;
}
this.handlers.onStatus('connecting');
const delay = RETRY_DELAYS_MS[Math.min(this.attempt, RETRY_DELAYS_MS.length - 1)];
this.attempt += 1;
setTimeout(() => this.connect(), delay);
};
}
/** @returns {boolean} */
get connected() {
return this.socket !== null && this.socket.readyState === WebSocket.OPEN;
}
/**
* @param {number} destination A peer number, or 0 for everyone else.
* @param {Uint8Array} body
*/
send(destination, body) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
const message = new Uint8Array(4 + body.length);
new DataView(message.buffer).setUint32(0, destination);
message.set(body, 4);
this.socket.send(message);
}
close() {
this.closed = true;
if (this.socket) this.socket.close();
}
}
+535
View File
@@ -0,0 +1,535 @@
// @ts-check
/**
* Draws the page. Each layer has its own canvas, so hiding a layer or
* redrawing it after an erasure does not touch the others.
*
* A stroke is drawn as a chain of parts, one per point (see
* stroke_geometry.js). The part for a point can be drawn only when the
* following point is known, so the newest end of a stroke in progress (its
* *tail*) is drawn on a separate canvas that is cleared and redrawn as the
* stroke grows.
*/
import { BLANK_PAGE_HEIGHT, BLANK_PAGE_WIDTH, POINT_SIZE, pointCount } from './page_model.js';
import { partAt, widthAt } from './stroke_geometry.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, fontOf } from './text_layout.js';
/** @typedef {import('./page_model.js').Page} Page */
/** @typedef {import('./page_model.js').Stroke} Stroke */
/** @typedef {import('./page_model.js').TextBlock} TextBlock */
/**
* A rectangle in page units.
* @typedef {{x0: number, y0: number, x1: number, y1: number}} Region
*/
/**
* One step of zooming, by button or scroll wheel, is this factor (10%,
* Andy's first choice to try, 2026-10-03).
*/
export const ZOOM_STEP = 1.1;
const LEAST_CSS_PIXELS_PER_UNIT = 0.05;
const MOST_CSS_PIXELS_PER_UNIT = 8;
/**
* How the page is scaled on screen: fitted to the window, or a fixed
* number of CSS pixels per page unit.
* @typedef {'fit' | number} Zoom
*/
/**
* Draw a text block.
* @param {CanvasRenderingContext2D} context
* @param {TextBlock} block
*/
function drawText(context, block) {
context.font = fontOf(block.size, block.font);
context.fillStyle = block.color;
context.textBaseline = 'top';
block.lines.forEach((line, i) => {
context.fillText(line, block.x + block.size * LEFT_GAP,
block.y + block.size * (TOP_GAP + i * LINE_HEIGHT));
});
}
/**
* Draw the part of a stroke that belongs to point `k`.
* @param {CanvasRenderingContext2D} context
* @param {Stroke} stroke
* @param {number} k
*/
function drawPart(context, stroke, k) {
const part = partAt(stroke, k);
context.beginPath();
if (part.dot) {
context.arc(part.x, part.y, part.width / 2, 0, 2 * Math.PI);
context.fillStyle = stroke.color;
context.fill();
return;
}
context.moveTo(part.fromX, part.fromY);
context.quadraticCurveTo(part.x, part.y, part.toX, part.toY);
context.strokeStyle = stroke.color;
context.lineWidth = part.width;
context.stroke();
}
export class Renderer {
/**
* @param {HTMLElement} pageElement Element that holds the canvases.
* @param {Page} page
*/
constructor(pageElement, page) {
this.pageElement = pageElement;
this.page = page;
/** @type {Map<string, HTMLCanvasElement>} */
this.canvases = new Map();
/** @type {Set<string>} */
this.hidden = new Set();
/** @type {WeakMap<Stroke, number>} Parts of each stroke already drawn. */
this.drawn = new WeakMap();
/** @type {Map<Stroke, string>} Strokes in progress and their layers. */
this.active = new Map();
/** Region of the tail canvas in use, in page units, or null. */
this.tailRegion = /** @type {Region | null} */ (null);
this.tailCanvas = this.makeCanvas();
this.tailCanvas.classList.add('tails');
this.pageElement.append(this.tailCanvas);
/**
* The elements that show the page's images beneath the layers, one for
* each image whose bytes are here, and the bytes each one shows.
* @type {{element: HTMLImageElement, data: Uint8Array | null}[]}
*/
this.imageElements = [];
/** Canvas pixels per page unit. */
this.scale = 1;
/** The page's left and top as last shown, for keeping the view still. */
this.shownLeft = /** @type {number | null} */ (null);
this.shownTop = /** @type {number | null} */ (null);
/** @type {Zoom} */
this.zoom = 'fit';
/** CSS pixels per page unit as last shown. */
this.cssPixelsPerUnit = 1;
/**
* True while the user is drawing a stroke. At "Fit" the page is not
* refitted then: if it were, writing past an edge would move the edge
* away from the pen with every step of growth.
*/
this.holdFit = false;
/** @type {(() => void) | null} Told when the zoom changes. */
this.onZoomChanged = null;
/**
* True until a page that its images have made larger than a sheet has
* been shown for the first time. Such a page is a series of exercises
* to scroll through (Andy, 2026-10-05), so it is first shown at the
* scale at which one sheet fits the window, not all of it made small.
* (A reloaded tab shows it as it was last seen there: see showView.)
*/
this.sheetFirst = false;
/**
* The text block being edited, if any, which is shown by the editor's
* own text field and so not drawn here.
* @type {string | null}
*/
this.editingText = null;
this.setPage(page);
// Ctrl and the scroll wheel zoom about the pointer, as elsewhere.
this.desk().addEventListener('wheel', (event) => {
if (!event.ctrlKey) return;
event.preventDefault();
this.zoomBy(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, event.clientX, event.clientY);
}, { passive: false });
}
/** @returns {HTMLElement} The scrolling container of the page. */
desk() {
return /** @type {HTMLElement} */ (this.pageElement.parentElement);
}
/**
* Zoom in or out by a factor, from whatever the zoom is now.
* @param {number} factor
* @param {number} [clientX] The point to zoom about; else the middle.
* @param {number} [clientY]
*/
zoomBy(factor, clientX, clientY) {
this.setZoom(this.cssPixelsPerUnit * factor, clientX, clientY);
}
/**
* Change the zoom. With a position given, the page point under it stays
* under it; otherwise the point at the middle of the view stays.
* @param {Zoom} zoom
* @param {number} [clientX]
* @param {number} [clientY]
*/
setZoom(zoom, clientX, clientY) {
if (typeof zoom === 'number') {
zoom = Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT, zoom));
}
const desk = this.desk();
const deskBox = desk.getBoundingClientRect();
const anchorX = clientX ?? deskBox.left + desk.clientWidth / 2;
const anchorY = clientY ?? deskBox.top + desk.clientHeight / 2;
const [pageX, pageY] = this.pageFromClient(anchorX, anchorY);
this.zoom = zoom;
this.sheetFirst = false;
this.resize();
// Scroll so that the anchored page point is back under the anchor.
const box = this.pageElement.getBoundingClientRect();
const nowX = box.left + (pageX - this.page.left) * this.cssPixelsPerUnit;
const nowY = box.top + (pageY - this.page.top) * this.cssPixelsPerUnit;
desk.scrollLeft += nowX - anchorX;
desk.scrollTop += nowY - anchorY;
this.onZoomChanged?.();
}
/**
* Show another page in place of the present one.
* @param {Page} page
*/
setPage(page) {
this.page = page;
this.shownLeft = null;
this.shownTop = null;
this.zoom = 'fit';
this.sheetFirst = page.isLargerThanSheet();
this.onZoomChanged?.();
// A new page is seen from its top.
this.desk().scrollTop = 0;
this.desk().scrollLeft = 0;
for (const canvas of this.canvases.values()) canvas.remove();
this.canvases.clear();
this.hidden.clear();
this.active.clear();
this.showImages();
this.resize();
// Once more after the browser has laid the page out, in case anything
// above (a dialog closing, say) left the measurements stale. Andy saw
// a loaded page's image appear only after pressing Fit (2026-10-03),
// which could not be reproduced.
requestAnimationFrame(() => {
if (this.page === page) this.resize();
});
}
/**
* How the page is being shown, so that it can be shown the same way
* after the browser page is reloaded.
* @returns {{zoom: Zoom, x: number, y: number}} The zoom, and the page
* point at the top left corner of the view.
*/
view() {
const desk = this.desk();
return {
zoom: this.zoom,
x: this.page.left + desk.scrollLeft / this.cssPixelsPerUnit,
y: this.page.top + desk.scrollTop / this.cssPixelsPerUnit,
};
}
/**
* Show the page as view() described it.
* @param {{zoom: Zoom, x: number, y: number}} view
*/
showView(view) {
this.zoom = view.zoom === 'fit' ? 'fit'
: Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT, view.zoom));
this.sheetFirst = false;
this.resize();
const desk = this.desk();
desk.scrollLeft = (view.x - this.page.left) * this.cssPixelsPerUnit;
desk.scrollTop = (view.y - this.page.top) * this.cssPixelsPerUnit;
this.onZoomChanged?.();
}
/**
* Keep the present scale from now on, if the page is being fitted to the
* window. Called when an image is added or taken back: the page then
* extends beyond the window, to be scrolled to, and what is in view
* stays as it is (Andy, 2026-10-05).
*/
holdScale() {
if (this.zoom !== 'fit' || this.shownLeft === null) return;
this.zoom = this.cssPixelsPerUnit;
this.onZoomChanged?.();
}
/** Place the image elements where the images lie on the page. */
layoutImages() {
const page = this.page;
for (const [i, image] of page.images.entries()) {
const style = this.imageElements[i]?.element.style;
if (!style) continue;
style.left = `${(image.x - page.left) / page.width * 100}%`;
style.top = `${(image.y - page.top) / page.height * 100}%`;
style.width = `${image.width / page.width * 100}%`;
style.height = `${image.height / page.height * 100}%`;
}
}
/**
* Show the page's images whose bytes are here. An element that already
* shows the right bytes is left alone, so that adding an image does not
* make the others flicker.
*/
showImages() {
const images = this.page.images;
for (const [i, image] of images.entries()) {
let shown = this.imageElements[i];
if (!shown) {
const element = document.createElement('img');
element.className = 'page_image';
element.alt = '';
element.draggable = false;
element.hidden = true;
// Beneath the layers, in the sequence of the images.
const before = this.imageElements[i - 1]?.element.nextSibling ?? this.pageElement.firstChild;
this.pageElement.insertBefore(element, before);
shown = this.imageElements[i] = { element, data: null };
}
if (shown.data === image.data) continue;
if (shown.element.src) URL.revokeObjectURL(shown.element.src);
shown.data = image.data;
shown.element.hidden = !image.data;
if (image.data) {
shown.element.src = URL.createObjectURL(new Blob([image.data], { type: image.type }));
} else {
shown.element.removeAttribute('src');
}
}
for (const gone of this.imageElements.splice(images.length)) {
if (gone.element.src) URL.revokeObjectURL(gone.element.src);
gone.element.remove();
}
this.layoutImages();
}
makeCanvas() {
const canvas = document.createElement('canvas');
canvas.className = 'layer';
return canvas;
}
/**
* @param {HTMLCanvasElement} canvas
* @returns {CanvasRenderingContext2D}
*/
context(canvas) {
const context = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d'));
context.setTransform(this.scale, 0, 0, this.scale,
-this.page.left * this.scale, -this.page.top * this.scale);
context.lineCap = 'round';
context.lineJoin = 'round';
return context;
}
/** @param {HTMLCanvasElement} canvas */
sizeCanvas(canvas) {
canvas.width = Math.round(this.page.width * this.scale);
canvas.height = Math.round(this.page.height * this.scale);
}
/**
* Match the canvases to the page's rectangle and its displayed size, and
* redraw. Called when the window changes, when the page grows and when
* the zoom changes.
*
* At "Fit", the whole page is shown within the window, and refitted
* when the page grows, except while the user is drawing (see holdFit).
*/
resize() {
const desk = this.desk();
const deskStyle = getComputedStyle(desk);
// offsetWidth, not clientWidth: a fitted page needs no scrollbars, so
// the room they take while zoomed in must not count.
const availableWidth = desk.offsetWidth -
parseFloat(deskStyle.paddingLeft) - parseFloat(deskStyle.paddingRight);
const availableHeight = desk.offsetHeight -
parseFloat(deskStyle.paddingTop) - parseFloat(deskStyle.paddingBottom);
if (!(availableWidth > 0) || !(availableHeight > 0)) return;
const first = this.zoom === 'fit' && this.sheetFirst;
this.sheetFirst = false;
if (first) {
this.zoom = Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT,
1 / Math.max(BLANK_PAGE_WIDTH / availableWidth, BLANK_PAGE_HEIGHT / availableHeight)));
}
let unitsPerCssPixel;
if (this.zoom !== 'fit') unitsPerCssPixel = 1 / this.zoom;
else if (this.holdFit && this.shownLeft !== null) unitsPerCssPixel = 1 / this.cssPixelsPerUnit;
else unitsPerCssPixel = Math.max(this.page.width / availableWidth, this.page.height / availableHeight);
this.cssPixelsPerUnit = 1 / unitsPerCssPixel;
this.pageElement.style.width = `${this.page.width / unitsPerCssPixel}px`;
this.pageElement.style.height = `${this.page.height / unitsPerCssPixel}px`;
this.layoutImages();
// When the page grew to the left or upward, keep what is in view in view.
if (this.shownLeft !== null && this.page.left < this.shownLeft) {
desk.scrollLeft += (this.shownLeft - this.page.left) / unitsPerCssPixel;
}
if (this.shownTop !== null && this.page.top < this.shownTop) {
desk.scrollTop += (this.shownTop - this.page.top) / unitsPerCssPixel;
}
this.shownLeft = this.page.left;
this.shownTop = this.page.top;
this.scale = (window.devicePixelRatio || 1) / unitsPerCssPixel;
this.sizeCanvas(this.tailCanvas);
this.tailRegion = null;
for (const id of this.canvases.keys()) this.redrawLayer(id, true);
this.drawTails();
if (first) this.onZoomChanged?.();
}
/**
* Put the layer canvases in the given sequence, bottom to top, creating
* canvases for new layers.
* @param {string[]} ids
*/
setLayerOrder(ids) {
for (const id of ids) {
let canvas = this.canvases.get(id);
if (!canvas) {
canvas = this.makeCanvas();
this.sizeCanvas(canvas);
this.canvases.set(id, canvas);
}
this.pageElement.insertBefore(canvas, this.tailCanvas);
}
}
/**
* @param {string} id
* @param {boolean} visible
*/
setLayerVisible(id, visible) {
if (visible) this.hidden.delete(id);
else this.hidden.add(id);
const canvas = this.canvases.get(id);
if (canvas) canvas.hidden = !visible;
this.drawTails();
}
/**
* Draw a layer again from the page.
* @param {string} id
* @param {boolean} [resized] The canvas size must change as well.
*/
redrawLayer(id, resized = false) {
const canvas = this.canvases.get(id);
const layer = this.page.layers.get(id);
if (!canvas) return;
if (resized) this.sizeCanvas(canvas);
const context = this.context(canvas);
context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height);
for (const [stroke, layerId] of this.active) {
if (layerId === id) this.active.delete(stroke);
}
if (!layer) return;
for (const stroke of layer.strokes.values()) {
this.drawn.set(stroke, 0);
this.drawNewParts(context, id, stroke);
}
for (const block of layer.texts.values()) {
if (block.id !== this.editingText) drawText(context, block);
}
this.drawTails();
}
/**
* A stroke gained points or was finished: draw what is new.
* @param {string} layerId
* @param {Stroke} stroke
*/
strokeProgress(layerId, stroke) {
const canvas = this.canvases.get(layerId);
if (!canvas) return;
this.drawNewParts(this.context(canvas), layerId, stroke);
this.drawTails();
}
/**
* @param {CanvasRenderingContext2D} context
* @param {string} layerId
* @param {Stroke} stroke
*/
drawNewParts(context, layerId, stroke) {
const count = pointCount(stroke);
// The part for the newest point waits until the stroke is finished or
// another point arrives.
const ready = stroke.done ? count : count - 1;
let k = this.drawn.get(stroke) ?? 0;
for (; k < ready; k++) drawPart(context, stroke, k);
this.drawn.set(stroke, Math.max(k, 0));
if (stroke.done) this.active.delete(stroke);
else this.active.set(stroke, layerId);
}
/** Redraw the tails of the strokes in progress. */
drawTails() {
const context = this.context(this.tailCanvas);
if (this.tailRegion) {
const r = this.tailRegion;
context.clearRect(r.x0, r.y0, r.x1 - r.x0, r.y1 - r.y0);
this.tailRegion = null;
}
for (const [stroke, layerId] of this.active) {
const count = pointCount(stroke);
if (count === 0 || this.hidden.has(layerId)) continue;
const p = stroke.points;
const end = (count - 1) * POINT_SIZE;
const width = widthAt(stroke, count - 1);
context.beginPath();
let startX = p[end];
let startY = p[end + 1];
if (count === 1) {
context.arc(p[end], p[end + 1], width / 2, 0, 2 * Math.PI);
context.fillStyle = stroke.color;
context.fill();
} else {
startX = (p[end - POINT_SIZE] + p[end]) / 2;
startY = (p[end - POINT_SIZE + 1] + p[end + 1]) / 2;
context.moveTo(startX, startY);
context.lineTo(p[end], p[end + 1]);
context.strokeStyle = stroke.color;
context.lineWidth = width;
context.stroke();
}
const margin = width + 2 / this.scale;
/** @type {Region} */
const r = this.tailRegion ?? { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
r.x0 = Math.min(r.x0, startX - margin, p[end] - margin);
r.y0 = Math.min(r.y0, startY - margin, p[end + 1] - margin);
r.x1 = Math.max(r.x1, startX + margin, p[end] + margin);
r.y1 = Math.max(r.y1, startY + margin, p[end + 1] + margin);
this.tailRegion = r;
}
}
/**
* Convert a position in the window to page units.
* @param {number} clientX
* @param {number} clientY
* @returns {[number, number]}
*/
pageFromClient(clientX, clientY) {
const box = this.pageElement.getBoundingClientRect();
return [
this.page.left + (clientX - box.left) * this.page.width / box.width,
this.page.top + (clientY - box.top) * this.page.height / box.height,
];
}
/**
* @param {number} cssPixels
* @returns {number} The same length in page units.
*/
pageLength(cssPixels) {
return cssPixels * this.page.width / this.pageElement.getBoundingClientRect().width;
}
}
+974
View File
@@ -0,0 +1,974 @@
// @ts-check
/**
* A session: the page, the participants, and the messages that keep every
* participant's copy of the page the same.
*
* The rule that makes this simple is that each layer has one writer. A
* participant is the authority on their own layer, so there are no
* conflicting edits to reconcile. See notes/protocol.md for the messages.
*/
import {
decrypt, encrypt, fromBase64, hashOf, randomId, signClaim, toBase64, verifyClaim,
} from './crypto.js';
import {
MAX_STROKE_POINTS, POINT_SIZE, Page, appendPoints, cleanName, cleanPageDescriptor,
cleanStroke, cleanText, placeImage, pointCount, validId, validPoints,
} from './page_model.js';
import { RelayConnection } from './relay_connection.js';
/** @typedef {import('./crypto.js').Identity} Identity */
/** @typedef {import('./page_model.js').Layer} Layer */
/** @typedef {import('./page_model.js').Stroke} Stroke */
/** @typedef {import('./page_model.js').PageImage} PageImage */
/** @typedef {import('./page_model.js').TextBlock} TextBlock */
/**
* @typedef {object} Participant
* @property {string} id
* @property {string} name
* @property {number | null} peer Relay peer number; null when not connected.
* @property {boolean} self
* @property {boolean} host
*/
/**
* @typedef {object} SessionEvents
* @property {(layerId: string) => void} onLayerReplaced
* A layer's contents changed as a whole and must be redrawn.
* @property {(layerId: string, stroke: Stroke) => void} onStrokeProgress
* A stroke gained points or was finished.
* @property {(layerId: string, block: TextBlock) => void} onTextChanged
* A text block was made or changed.
* @property {(oldPage: Page, newPage: Page) => void} onPageReplaced
* The session moved to another page.
* @property {() => void} onImagesChanged
* An image was added to the page, or the bytes of one have arrived.
* @property {(byImages: boolean) => void} onPageResized
* The page grew (or shrank) around its contents; `byImages` if an
* image was added or taken back, not if writing reached an edge.
* @property {() => void} onPermissionsChanged
* Who may start pages changed.
* @property {() => void} onParticipantsChanged
* @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus
*/
/**
* @typedef {object} SessionOptions
* @property {string} relay
* @property {string} roomId
* @property {CryptoKey} key
* @property {Identity} identity
* @property {string} hostId
* @property {string} name Our display name.
* @property {Page} [page] The page as last saved, when returning to
* the session.
*/
/** Approximate size at which a layer snapshot is split into messages. */
const SNAPSHOT_CHUNK_CHARACTERS = 100000;
/** An image is sent in pieces of this many bytes. */
const IMAGE_CHUNK_BYTES = 96 * 1024;
export class Session {
/**
* @param {SessionOptions} options
* @param {SessionEvents} events
*/
constructor(options, events) {
this.roomId = options.roomId;
this.key = options.key;
this.identity = options.identity;
this.hostId = options.hostId;
this.name = cleanName(options.name) || 'Unnamed';
/**
* The image we have just added, which we may take back until anything
* else changes on the page.
* @type {PageImage | null}
*/
this.removableImage = null;
// Every event that reports a change to the page ends that.
/** @type {SessionEvents} */
this.events = {
...events,
onLayerReplaced: (layerId) => {
this.removableImage = null;
events.onLayerReplaced(layerId);
},
onStrokeProgress: (layerId, stroke) => {
this.removableImage = null;
events.onStrokeProgress(layerId, stroke);
},
onTextChanged: (layerId, block) => {
this.removableImage = null;
events.onTextChanged(layerId, block);
},
onPageReplaced: (oldPage, newPage) => {
this.removableImage = null;
events.onPageReplaced(oldPage, newPage);
},
onImagesChanged: () => {
this.removableImage = null;
events.onImagesChanged();
},
};
this.isHost = this.identity.id === this.hostId;
/** @type {Set<string>} Guests the host allows to start pages. */
this.permitted = new Set();
// The host begins a session with page 1. A guest shows a placeholder
// until the host's page arrives.
this.page = options.page ??
(this.isHost ? new Page(undefined, undefined, randomId(), 1) : new Page());
this.ownLayer = this.page.layer(this.identity.id, this.name);
this.page.recomputeStrokeBounds();
this.page.updateExtent();
/** @type {Map<string, Participant>} */
this.participants = new Map();
this.participants.set(this.identity.id, {
id: this.identity.id, name: this.name, peer: null, self: true,
host: this.identity.id === this.hostId,
});
for (const layer of this.page.layers.values()) {
if (layer.id === this.identity.id) continue;
this.participants.set(layer.id, {
id: layer.id, name: layer.name || 'Unnamed', peer: null, self: false,
host: layer.id === this.hostId,
});
}
/** @type {Map<number, string>} Relay peer number to participant id. */
this.peerIds = new Map();
/** @type {Set<number>} Peers the relay says are present. */
this.presentPeers = new Set();
/** @type {Map<number, number>} Last sequence number seen from a peer. */
this.lastSeq = new Map();
/** @type {Map<string, {name: string, strokes: Stroke[], texts: TextBlock[]}>} Snapshots being received. */
this.incoming = new Map();
/**
* The bytes of the page's images received so far, by each image's hash.
* @type {Map<string, {buffer: Uint8Array<ArrayBuffer>, received: number}>}
*/
this.imageTransfers = new Map();
/** @type {Map<string, Set<number>>} Peers waiting for an image from us, by its hash. */
this.imageRequests = new Map();
/**
* The peer whose announcement moved us to the present page. Its
* snapshots of any layer of that page are accepted: when a page is
* loaded from a notebook, that peer sends the writing kept with it.
*/
this.pageSourcePeer = 0;
this.myPeer = 0;
/** The peer asked for the layers of absent participants. */
this.statePeer = 0;
this.stateComplete = true;
/**
* True while our own layer may be completed from another participant's
* copy: on the first connection after the browser page was loaded, when
* the others may hold strokes of ours that we had not yet saved.
*/
this.recoverOwnLayer = false;
this.firstConnection = true;
/** Counts connections, so that work queued for an old one is dropped. */
this.epoch = 0;
this.seq = 0;
this.sendChain = Promise.resolve();
this.receiveChain = Promise.resolve();
/** @type {Stroke | null} The stroke we are drawing. */
this.currentStroke = null;
this.connection = new RelayConnection(options.relay, options.roomId, {
onWelcome: (peer, peers) => this.welcomed(peer, peers),
onJoin: (peer) => { this.presentPeers.add(peer); },
onLeave: (peer) => this.peerLeft(peer),
onMessage: (peer, body) => this.received(peer, body),
onStatus: (status, detail) => {
if (status !== 'connected') this.disconnected();
this.events.onStatus(status, detail);
},
});
}
start() {
this.connection.connect();
}
/** @returns {Participant[]} Ourselves first, then the others by name. */
participantList() {
return [...this.participants.values()].sort((a, b) =>
Number(b.self) - Number(a.self) || a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
}
/** @returns {string[]} Layer ids from bottom to top. */
layerOrder() {
return this.page.layerOrder(this.hostId);
}
// Pages.
/** @returns {boolean} Whether we may start pages: the host, or a guest the host allows. */
mayMakePages() {
return this.isHost || this.permitted.has(this.identity.id);
}
/**
* Allow a guest to start pages, or stop allowing it. Only the host may.
* @param {string} id
* @param {boolean} allowed
*/
setPermitted(id, allowed) {
if (!this.isHost || id === this.identity.id) return;
if (allowed) this.permitted.add(id);
else this.permitted.delete(id);
this.send(0, { type: 'permissions', ids: [...this.permitted] });
this.events.onPermissionsChanged();
}
/**
* The page's rectangle may have changed with its contents.
* @param {boolean} [byImages] Whether its images were what changed.
*/
extentChanged(byImages = false) {
if (this.page.updateExtent()) this.events.onPageResized(byImages);
}
/**
* Start a new blank page for everyone. The host may; so may a guest the
* host allows.
*/
newPage() {
if (!this.mayMakePages()) return;
const page = new Page(undefined, undefined, randomId(), this.page.number + 1);
page.updateExtent();
this.replacePage(page);
this.send(0, { type: 'page', page: page.descriptor() });
}
/**
* Add an image to the page for everyone, below the images already on it.
* The host may; so may a guest the host allows.
* @param {object} prepared
* @param {PageImage['type']} prepared.type
* @param {Uint8Array<ArrayBuffer>} prepared.data
* @param {string} prepared.hash
* @param {number} prepared.naturalWidth In page units.
* @param {number} prepared.naturalHeight
* @param {boolean} [shrink] Whether an image wider than the sheet is
* scaled down to it; otherwise it keeps its size and widens the sheet.
* @returns {boolean} False if the page has no room for it.
*/
addImage(prepared, shrink = true) {
const page = this.page;
if (!this.mayMakePages() || page.number === 0) return false;
const place = placeImage(prepared.naturalWidth, prepared.naturalHeight, page.imagesBottom(), shrink);
if (!page.hasRoomForImage(prepared.data.length, place.y + place.height)) return false;
/** @type {PageImage} */
const image = {
type: prepared.type, size: prepared.data.length, hash: prepared.hash, data: prepared.data,
x: place.x, y: place.y, width: place.width, height: place.height,
};
page.images.push(image);
page.imagesRevision += 1;
page.baseWidth = Math.max(page.baseWidth, place.pageWidth);
page.baseHeight = Math.max(page.baseHeight, place.pageHeight);
this.send(0, { type: 'page', page: page.descriptor() });
this.sendImage(0, image);
this.events.onImagesChanged();
this.removableImage = image;
this.extentChanged(true);
return true;
}
/**
* Take back the image we have just added: the remedy for having chosen
* the wrong file. It works only until anything else changes on the
* page (Andy, 2026-10-05), so nothing can have been written on the image,
* and since it is the lowest image, no other image is affected.
* @returns {boolean} Whether an image was taken back.
*/
removeAddedImage() {
const page = this.page;
const image = this.removableImage;
if (!image || page.images.at(-1) !== image) return false;
page.images.pop();
page.imagesRevision += 1;
page.baseWidth = page.sheetWidth();
page.baseHeight = page.sheetHeight();
this.send(0, { type: 'page', page: page.descriptor() });
this.events.onImagesChanged();
this.extentChanged(true);
return true;
}
/**
* Start a new page for everyone from a page kept in a notebook, with
* everything that was on it. Its layers keep their owners: a layer of
* someone present is theirs to erase again.
* @param {Page} kept As read from the page file.
*/
loadPage(kept) {
if (!this.mayMakePages()) return;
const page = new Page(kept.baseWidth, kept.baseHeight, randomId(), this.page.number + 1);
page.images = kept.images.map((image) => ({ ...image }));
page.imagesRevision = kept.imagesRevision;
for (const [id, layer] of kept.layers) {
const copy = page.layer(id, layer.name);
for (const stroke of layer.strokes.values()) {
copy.strokes.set(stroke.id, { ...stroke, points: stroke.points.slice(), done: true });
}
for (const block of layer.texts.values()) copy.texts.set(block.id, { ...block, lines: block.lines.slice() });
if (!this.participants.has(id)) {
this.participants.set(id, {
id, name: layer.name || 'Unnamed', peer: null, self: false, host: id === this.hostId,
});
}
}
page.recomputeStrokeBounds();
page.updateExtent();
this.replacePage(page);
this.send(0, { type: 'page', page: page.descriptor() });
const sent = new Set();
for (const image of page.images) {
if (!sent.has(image.hash)) this.sendImage(0, image);
sent.add(image.hash);
}
for (const layer of page.layers.values()) {
if (!layer.isEmpty()) this.sendLayer(0, layer);
}
this.events.onParticipantsChanged();
}
// Text on our own layer.
/**
* Make or change a text block of ours. The whole block is sent each
* time, so that it appears on the other screens as it is typed.
* @param {TextBlock} block
*/
setText(block) {
if (this.page.number === 0) return;
this.ownLayer.texts.set(block.id, block);
this.send(0, { type: 'text_set', page: this.page.id, text: block });
this.page.recomputeStrokeBounds();
this.events.onTextChanged(this.identity.id, block);
this.extentChanged();
}
/** @param {string[]} ids Text blocks of our own layer. */
removeTexts(ids) {
const removed = ids.filter((id) => this.ownLayer.texts.delete(id));
if (removed.length === 0) return;
this.send(0, { type: 'text_remove', page: this.page.id, ids: removed });
this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(this.identity.id);
this.extentChanged();
}
/**
* @param {number} destination
* @param {PageImage} image An image of the present page whose bytes we hold.
*/
sendImage(destination, image) {
const data = image.data;
if (!data) return;
for (let at = 0; at < data.length; at += IMAGE_CHUNK_BYTES) {
this.send(destination, {
type: 'image', page: this.page.id, hash: image.hash, at,
data: toBase64(data.subarray(at, at + IMAGE_CHUNK_BYTES)),
});
}
}
/**
* Ask a peer that is on our page for the images whose bytes we lack.
* @param {number} peer
*/
requestImages(peer) {
const asked = new Set();
for (const image of this.page.images) {
if (image.data || asked.has(image.hash)) continue;
asked.add(image.hash);
this.send(peer, { type: 'image_request', page: this.page.id, hash: image.hash });
}
}
/**
* A piece of one of the page's images arrived. Pieces are taken in
* sequence; the finished image must match its hash in the page
* descriptor, so it does not matter which participant sent it.
* @param {any} message
*/
async receivedImagePiece(message) {
const page = this.page;
if (message.page !== page.id || typeof message.data !== 'string') return;
const image = page.images.find((one) => one.hash === message.hash && !one.data);
if (!image) return;
let transfer = this.imageTransfers.get(image.hash);
if (!transfer) {
transfer = { buffer: new Uint8Array(image.size), received: 0 };
this.imageTransfers.set(image.hash, transfer);
}
if (message.at !== transfer.received) return;
const piece = fromBase64(message.data);
if (transfer.received + piece.length > image.size) return;
transfer.buffer.set(piece, transfer.received);
transfer.received += piece.length;
if (transfer.received < image.size) return;
this.imageTransfers.delete(image.hash);
const data = transfer.buffer;
const matches = await hashOf(data) === image.hash;
if (page !== this.page || !matches) return; // A damaged or false image: wait for another copy.
let filled = false;
for (const one of page.images) {
if (one.hash !== image.hash || one.data) continue;
one.data = data;
filled = true;
}
if (!filled) return;
this.events.onImagesChanged();
for (const peer of this.imageRequests.get(image.hash) ?? []) this.sendImage(peer, image);
this.imageRequests.delete(image.hash);
}
/**
* Move to a page that another participant is on, if it is newer than
* ours. Pages are numbered by the host, so the highest number is the
* session's current page; between two pages of the same number, the
* host's is taken. If it is the page we are on, it may have gained
* images.
* @param {unknown} received A page descriptor.
* @param {boolean} fromHost
* @param {number} peer Who told us.
*/
considerPage(received, fromHost, peer) {
const descriptor = cleanPageDescriptor(received);
if (!descriptor) return;
if (descriptor.id === this.page.id) {
this.considerImages(descriptor);
return;
}
const newer = descriptor.number > this.page.number ||
(descriptor.number === this.page.number && fromHost);
if (!newer) return;
this.replacePage(Page.from(descriptor));
this.pageSourcePeer = peer;
}
/**
* Another participant's list of the images on the page we are on. The
* list with the higher revision is the later one. Of two different
* lists of the same revision (two participants changed the images at the
* same moment) everyone takes the same one, and the change that lost
* must be made again.
* @param {import('./page_model.js').PageDescriptor} descriptor
*/
considerImages(descriptor) {
const page = this.page;
/** @param {Omit<PageImage, 'data'>[]} images */
const listed = (images) => images.map((image) =>
`${image.hash} ${image.x} ${image.y} ${image.width} ${image.height}`).join(',');
const ours = listed(page.images);
const theirs = listed(descriptor.images);
if (descriptor.revision < page.imagesRevision ||
(descriptor.revision === page.imagesRevision && theirs <= ours)) return;
const held = new Map();
for (const image of page.images) {
if (image.data) held.set(image.hash, image.data);
}
page.images = descriptor.images.map((image) => ({ ...image, data: held.get(image.hash) ?? null }));
page.imagesRevision = descriptor.revision;
page.baseWidth = descriptor.width;
page.baseHeight = descriptor.height;
// Bytes on their way for an image that is no longer wanted are dropped,
// so that the same image, added again, is received from its beginning.
for (const hash of this.imageTransfers.keys()) {
if (!page.images.some((image) => image.hash === hash && !image.data)) this.imageTransfers.delete(hash);
}
this.events.onImagesChanged();
this.extentChanged(true);
}
/** @param {Page} page */
replacePage(page) {
this.endStroke();
const old = this.page;
this.page = page;
this.ownLayer = page.layer(this.identity.id, this.name);
for (const participant of this.participants.values()) {
if (participant.peer !== null) page.layer(participant.id, participant.name);
}
this.incoming.clear();
this.imageTransfers.clear();
this.imageRequests.clear();
this.pageSourcePeer = 0;
this.events.onPageReplaced(old, page);
}
// Drawing on our own layer.
/**
* @param {string} color
* @param {number} width
* @returns {Stroke}
*/
beginStroke(color, width) {
if (this.currentStroke) this.endStroke();
const stroke = { id: randomId(), color, width, points: [], done: false };
this.ownLayer.strokes.set(stroke.id, stroke);
this.currentStroke = stroke;
this.send(0, {
type: 'stroke_begin', page: this.page.id, stroke: { id: stroke.id, color, width },
});
return stroke;
}
/** @param {number[]} points x, y, pressure for each point. */
addPoints(points) {
let stroke = this.currentStroke;
if (!stroke || points.length === 0) return;
if (pointCount(stroke) + points.length / POINT_SIZE > MAX_STROKE_POINTS) {
const last = stroke.points.slice(-POINT_SIZE);
this.endStroke();
stroke = this.beginStroke(stroke.color, stroke.width);
points = last.concat(points);
}
const at = pointCount(stroke);
appendPoints(stroke, at, points);
this.page.includeStroke(stroke, at);
this.send(0, { type: 'stroke_points', id: stroke.id, at, points });
this.events.onStrokeProgress(this.identity.id, stroke);
this.extentChanged();
}
endStroke() {
const stroke = this.currentStroke;
if (!stroke) return;
this.currentStroke = null;
stroke.done = true;
this.send(0, { type: 'stroke_end', id: stroke.id });
this.events.onStrokeProgress(this.identity.id, stroke);
}
/** @param {string[]} ids Strokes of our own layer. */
erase(ids) {
const erased = ids.filter((id) => this.ownLayer.strokes.delete(id));
if (erased.length === 0) return;
this.send(0, { type: 'erase', page: this.page.id, ids: erased });
this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(this.identity.id);
this.extentChanged();
}
// Sending.
/**
* Queue a message. Messages are encrypted and sent in the sequence in
* which they were queued.
* @param {number} destination A peer number, or 0 for everyone else.
* @param {object | (() => Promise<object>)} message
*/
send(destination, message) {
if (!this.connection.connected) return;
const epoch = this.epoch;
this.sendChain = this.sendChain.then(async () => {
if (epoch !== this.epoch) return;
const body = typeof message === 'function' ? await message() : message;
const bytes = await encrypt(this.key, { ...body, seq: this.seq++ });
if (epoch === this.epoch) this.connection.send(destination, bytes);
}).catch((error) => console.error('Kokkoja: send failed', error));
}
/**
* Send a whole layer. The receiver replaces its copy of the layer.
* @param {number} destination
* @param {Layer} layer
*/
sendLayer(destination, layer) {
this.send(destination, {
type: 'layer_begin', page: this.page.id, layer: layer.id, name: layer.name,
});
/** @type {Stroke[]} */
let chunk = [];
let size = 0;
for (const stroke of layer.strokes.values()) {
chunk.push(stroke);
size += 100 + stroke.points.length * 8;
if (size >= SNAPSHOT_CHUNK_CHARACTERS) {
this.send(destination, { type: 'layer_strokes', layer: layer.id, strokes: chunk });
chunk = [];
size = 0;
}
}
if (chunk.length) this.send(destination, { type: 'layer_strokes', layer: layer.id, strokes: chunk });
if (layer.texts.size) {
this.send(destination, { type: 'layer_texts', layer: layer.id, texts: [...layer.texts.values()] });
}
this.send(destination, { type: 'layer_end', layer: layer.id });
}
/**
* Announce ourselves, with proof that this connection is ours.
* @param {number} destination
* @param {boolean} reply True when answering another participant's hello.
*/
sendHello(destination, reply) {
const peer = this.myPeer;
this.send(destination, async () => ({
type: 'hello', reply, name: this.name, publicKey: this.identity.publicKey,
signature: await signClaim(this.identity, this.roomId, peer),
page: this.page.number > 0 ? this.page.descriptor() : null,
permitted: this.isHost ? [...this.permitted] : undefined,
}));
}
// Connection events.
/**
* @param {number} peer
* @param {number[]} peers
*/
welcomed(peer, peers) {
this.epoch += 1;
this.myPeer = peer;
this.presentPeers = new Set(peers);
const self = this.participants.get(this.identity.id);
if (self) self.peer = peer;
this.recoverOwnLayer = this.firstConnection && peers.length > 0;
this.firstConnection = false;
this.sendHello(0, false);
// While recovering, our layer is sent when the recovery is complete.
if (!this.recoverOwnLayer && !this.ownLayer.isEmpty()) this.sendLayer(0, this.ownLayer);
this.requestState();
this.events.onParticipantsChanged();
}
/** Ask the longest-connected peer for the layers of absent participants. */
requestState() {
this.stateComplete = this.presentPeers.size === 0;
if (this.stateComplete) this.recoverOwnLayer = false;
this.statePeer = this.stateComplete ? 0 : Math.min(...this.presentPeers);
if (this.statePeer) this.send(this.statePeer, { type: 'state_request' });
}
disconnected() {
this.epoch += 1;
this.peerIds.clear();
this.presentPeers.clear();
this.lastSeq.clear();
this.incoming.clear();
for (const participant of this.participants.values()) participant.peer = null;
this.finishStrokesOfAbsent();
this.events.onParticipantsChanged();
}
/** @param {number} peer */
peerLeft(peer) {
this.presentPeers.delete(peer);
this.lastSeq.delete(peer);
const id = this.peerIds.get(peer);
this.peerIds.delete(peer);
const participant = id ? this.participants.get(id) : undefined;
if (participant && participant.peer === peer) participant.peer = null;
this.finishStrokesOfAbsent();
if (peer === this.statePeer && !this.stateComplete) this.requestState();
this.events.onParticipantsChanged();
}
/** A stroke whose writer has gone will get no more points. */
finishStrokesOfAbsent() {
for (const layer of this.page.layers.values()) {
const owner = this.participants.get(layer.id);
if (owner && (owner.self || owner.peer !== null)) continue;
for (const stroke of layer.strokes.values()) {
if (stroke.done) continue;
stroke.done = true;
this.events.onStrokeProgress(layer.id, stroke);
}
}
}
// Receiving.
/**
* @param {number} peer
* @param {Uint8Array<ArrayBuffer>} body
*/
received(peer, body) {
const epoch = this.epoch;
this.receiveChain = this.receiveChain.then(async () => {
if (epoch !== this.epoch) return;
let message;
try {
message = await decrypt(this.key, body);
} catch {
return; // Not made with the session key.
}
if (epoch !== this.epoch || !message || typeof message.seq !== 'number') return;
if (message.seq <= (this.lastSeq.get(peer) ?? -1)) return;
this.lastSeq.set(peer, message.seq);
if (message.type === 'hello') await this.receivedHello(peer, message, epoch);
else this.receivedFromParticipant(peer, message);
}).catch((error) => console.error('Kokkoja: receive failed', error));
}
/**
* @param {number} peer
* @param {any} message
* @param {number} epoch
*/
async receivedHello(peer, message, epoch) {
const id = await verifyClaim(message.publicKey, message.signature, this.roomId, peer);
if (epoch !== this.epoch || !id || id === this.identity.id) return;
const name = cleanName(message.name) || 'Unnamed';
const previous = this.participants.get(id);
if (previous && previous.peer !== null) this.peerIds.delete(previous.peer);
this.peerIds.set(peer, id);
this.participants.set(id, { id, name, peer, self: false, host: id === this.hostId });
if (id === this.hostId) this.receivedPermissions(message.permitted);
this.considerPage(message.page, id === this.hostId, peer);
// This peer is on our page: it can give us the images we lack.
if (message.page?.id === this.page.id) this.requestImages(peer);
this.page.layer(id, name);
this.events.onParticipantsChanged();
if (!message.reply) {
this.sendHello(peer, true);
if (!this.ownLayer.isEmpty()) this.sendLayer(peer, this.ownLayer);
}
}
/**
* A message other than hello. It is accepted only from a peer that has
* proved who it is, and changes only that participant's layer.
* @param {number} peer
* @param {any} message
*/
receivedFromParticipant(peer, message) {
const id = this.peerIds.get(peer);
if (!id) return;
if (message.type === 'page') {
if (id === this.hostId || this.permitted.has(id)) this.considerPage(message.page, true, peer);
return;
}
if (message.type === 'permissions') {
if (id === this.hostId) this.receivedPermissions(message.ids);
return;
}
if (message.type === 'image') {
this.receivedImagePiece(message);
return;
}
if (message.type === 'image_request') {
if (message.page !== this.page.id) return;
const image = this.page.images.find((one) => one.hash === message.hash);
if (!image) return;
if (image.data) {
this.sendImage(peer, image);
} else {
const waiting = this.imageRequests.get(image.hash) ?? new Set();
waiting.add(peer);
this.imageRequests.set(image.hash, waiting);
}
return;
}
const layer = this.page.layer(id);
switch (message.type) {
case 'stroke_begin': {
if (message.page !== this.page.id) break;
const stroke = cleanStroke({ ...message.stroke, points: [], done: false });
if (stroke) layer.strokes.set(stroke.id, stroke);
break;
}
case 'stroke_points': {
const stroke = layer.strokes.get(message.id);
if (!stroke || stroke.done || !validPoints(message.points) ||
!Number.isInteger(message.at) || message.at < 0) break;
const before = pointCount(stroke);
appendPoints(stroke, message.at, message.points);
if (pointCount(stroke) > MAX_STROKE_POINTS) stroke.points.length = MAX_STROKE_POINTS * POINT_SIZE;
this.page.includeStroke(stroke, before);
this.events.onStrokeProgress(id, stroke);
this.extentChanged();
break;
}
case 'stroke_end': {
const stroke = layer.strokes.get(message.id);
if (!stroke || stroke.done) break;
stroke.done = true;
this.events.onStrokeProgress(id, stroke);
break;
}
case 'erase': {
if (message.page !== this.page.id || !Array.isArray(message.ids)) break;
let erased = false;
for (const strokeId of message.ids) erased = layer.strokes.delete(strokeId) || erased;
if (!erased) break;
this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(id);
this.extentChanged();
break;
}
case 'text_set': {
if (message.page !== this.page.id) break;
const block = cleanText(message.text);
if (!block) break;
layer.texts.set(block.id, block);
this.page.recomputeStrokeBounds();
this.events.onTextChanged(id, block);
this.extentChanged();
break;
}
case 'text_remove': {
if (message.page !== this.page.id || !Array.isArray(message.ids)) break;
let removed = false;
for (const textId of message.ids) removed = layer.texts.delete(textId) || removed;
if (!removed) break;
this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(id);
this.extentChanged();
break;
}
case 'layer_begin':
if (message.page === this.page.id && validId(message.layer) &&
this.acceptsLayerFrom(peer, id, message.layer)) {
this.incoming.set(`${peer} ${message.layer}`,
{ name: cleanName(message.name), strokes: [], texts: [] });
}
break;
case 'layer_texts': {
const snapshot = this.incoming.get(`${peer} ${message.layer}`);
if (!snapshot || !Array.isArray(message.texts)) break;
for (const received of message.texts) {
const block = cleanText(received);
if (block) snapshot.texts.push(block);
}
break;
}
case 'layer_strokes': {
const snapshot = this.incoming.get(`${peer} ${message.layer}`);
if (!snapshot || !Array.isArray(message.strokes)) break;
for (const received of message.strokes) {
const stroke = cleanStroke(received);
if (stroke) snapshot.strokes.push(stroke);
}
break;
}
case 'layer_end': {
const key = `${peer} ${message.layer}`;
const snapshot = this.incoming.get(key);
this.incoming.delete(key);
if (snapshot && this.acceptsLayerFrom(peer, id, message.layer)) {
this.applySnapshot(message.layer, snapshot, message.layer === id);
}
break;
}
case 'state_request':
this.sendState(peer, id);
break;
case 'state_end':
if (peer !== this.statePeer) break;
this.stateComplete = true;
if (this.recoverOwnLayer) {
this.recoverOwnLayer = false;
if (!this.ownLayer.isEmpty()) this.sendLayer(0, this.ownLayer);
}
break;
}
}
/**
* The host said who may start pages.
* @param {unknown} ids
*/
receivedPermissions(ids) {
if (!Array.isArray(ids)) return;
const permitted = new Set(ids.filter(validId));
const same = permitted.size === this.permitted.size && [...permitted].every((id) => this.permitted.has(id));
if (same) return;
this.permitted = permitted;
this.events.onPermissionsChanged();
}
/**
* Whether to accept a snapshot of a layer from a peer. A participant may
* always send their own layer. The peer that moved us to this page may
* send any of its layers, our own included while we have nothing on it
* (a page loaded from a notebook comes with its writing). The peer we
* asked for the session state may send the layers of participants who
* are not connected, and our own layer while we are recovering it (the
* browser page was reloaded).
* @param {number} peer
* @param {string} senderId
* @param {string} layerId
*/
acceptsLayerFrom(peer, senderId, layerId) {
if (layerId === senderId) return true;
if (peer === this.pageSourcePeer) {
return layerId !== this.identity.id || this.ownLayer.isEmpty();
}
if (peer !== this.statePeer) return false;
if (layerId === this.identity.id) return this.recoverOwnLayer;
const owner = this.participants.get(layerId);
return !owner || owner.peer === null;
}
/**
* @param {string} layerId
* @param {{name: string, strokes: Stroke[], texts: TextBlock[]}} snapshot
* @param {boolean} fromOwner
*/
applySnapshot(layerId, snapshot, fromOwner) {
const layer = this.page.layer(layerId, snapshot.name);
if (layerId === this.identity.id) {
// Recovering our own layer: keep anything drawn since we joined.
for (const stroke of snapshot.strokes) {
if (!layer.strokes.has(stroke.id)) layer.strokes.set(stroke.id, { ...stroke, done: true });
}
for (const block of snapshot.texts) {
if (!layer.texts.has(block.id)) layer.texts.set(block.id, block);
}
} else {
layer.strokes.clear();
for (const stroke of snapshot.strokes) {
layer.strokes.set(stroke.id, fromOwner ? stroke : { ...stroke, done: true });
}
layer.texts.clear();
for (const block of snapshot.texts) layer.texts.set(block.id, block);
if (!this.participants.has(layerId)) {
this.participants.set(layerId, {
id: layerId, name: snapshot.name || 'Unnamed', peer: null, self: false,
host: layerId === this.hostId,
});
this.events.onParticipantsChanged();
}
}
this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(layerId);
this.extentChanged();
}
/**
* Answer a state request: send the layers that their owners cannot send
* themselves.
* @param {number} peer
* @param {string} requesterId
*/
sendState(peer, requesterId) {
for (const layer of this.page.layers.values()) {
if (layer.id === this.identity.id || layer.isEmpty()) continue;
const owner = this.participants.get(layer.id);
const ownerConnected = owner !== undefined && owner.peer !== null;
if (layer.id === requesterId || !ownerConnected) this.sendLayer(peer, layer);
}
this.send(peer, { type: 'state_end' });
}
}
+63
View File
@@ -0,0 +1,63 @@
// @ts-check
/**
* The shape of a stroke, shared by everything that draws one: the screen
* (renderer.js) and the page file (page_file.js).
*
* A stroke is a chain of *parts*, one per point. The part for a point runs
* from the midpoint before it to the midpoint after it, curving through the
* point, with a width taken from the pen pressure at that point. At the
* ends of the stroke the part runs to the end point itself.
*/
import { POINT_SIZE, pointCount } from './page_model.js';
/** @typedef {import('./page_model.js').Stroke} Stroke */
/**
* @typedef {object} Part
* @property {number} fromX
* @property {number} fromY
* @property {number} x The point itself, the control point of the curve.
* @property {number} y
* @property {number} toX
* @property {number} toY
* @property {number} width
* @property {boolean} dot The stroke is a single point: a filled circle
* of diameter `width` at (x, y).
*/
/**
* The drawn width of a stroke at one of its points. The stroke's nominal
* width is drawn at pressure 0.4, about the pressure of ordinary writing
* (Andy's averaged 0.34 to 0.45 on a Cintiq 16 and rarely passed 0.8). A
* firm press, 0.75, is about 2.6 times as wide.
* @param {Stroke} stroke
* @param {number} index Point index.
*/
export function widthAt(stroke, index) {
const pressure = stroke.points[index * POINT_SIZE + 2];
return stroke.width * (0.36 + 4 * pressure * pressure);
}
/**
* The part of a stroke that belongs to point `k`.
* @param {Stroke} stroke
* @param {number} k
* @returns {Part}
*/
export function partAt(stroke, k) {
const p = stroke.points;
const last = pointCount(stroke) - 1;
const x = p[k * POINT_SIZE];
const y = p[k * POINT_SIZE + 1];
return {
fromX: k === 0 ? x : (p[(k - 1) * POINT_SIZE] + x) / 2,
fromY: k === 0 ? y : (p[(k - 1) * POINT_SIZE + 1] + y) / 2,
x,
y,
toX: k === last ? x : (x + p[(k + 1) * POINT_SIZE]) / 2,
toY: k === last ? y : (y + p[(k + 1) * POINT_SIZE + 1]) / 2,
width: widthAt(stroke, k),
dot: last === 0,
};
}
+229
View File
@@ -0,0 +1,229 @@
// @ts-check
/**
* Editing a text block: a real text field laid over the page, so that
* umlauts, dead keys and selection work as they do everywhere, with
* handles for moving the block and changing its width.
*
* While a block is being edited the renderer leaves it out, and the field
* shows it; everyone else sees it drawn, updated at every keystroke.
*/
import { randomId } from './crypto.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js';
/** @typedef {import('./page_model.js').TextBlock} TextBlock */
/** @typedef {import('./page_model.js').TextFont} TextFont */
/** @typedef {import('./renderer.js').Renderer} Renderer */
/** A block without a width is shown at least this wide, in page units. */
const LEAST_FIELD_WIDTH = 300;
export class TextEditor {
/**
* @param {HTMLElement} pageElement
* @param {Renderer} renderer
* @param {object} target
* @param {(block: TextBlock) => void} target.setText The block changed.
* @param {(id: string) => void} target.removeText The block was left empty.
*/
constructor(pageElement, renderer, target) {
this.pageElement = pageElement;
this.renderer = renderer;
this.target = target;
/** @type {TextBlock | null} */
this.block = null;
this.field = document.createElement('textarea');
this.field.className = 'text_field';
this.field.spellcheck = false;
this.field.hidden = true;
this.field.addEventListener('input', () => this.changed());
this.field.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
this.stop();
}
});
this.corner = this.makeHandle('text_handle corner', 'Move the text');
this.edge = this.makeHandle('text_edge', 'Move the text');
this.end = this.makeHandle('text_handle end', 'Change the width');
/** Shows the top edge while it is being dragged out. */
this.preview = document.createElement('div');
this.preview.className = 'text_preview';
this.preview.hidden = true;
this.pageElement.append(this.field, this.edge, this.corner, this.end, this.preview);
}
/**
* Show where the top edge of a new block would run, from (x0, y0) to
* (x1, y0), while the pointer is dragging.
* @param {number} x0
* @param {number} y0
* @param {number} x1
*/
showPreview(x0, y0, x1) {
const scale = this.renderer.cssPixelsPerUnit;
const page = this.renderer.page;
this.preview.hidden = false;
this.preview.style.left = `${(Math.min(x0, x1) - page.left) * scale}px`;
this.preview.style.top = `${(y0 - page.top) * scale}px`;
this.preview.style.width = `${Math.abs(x1 - x0) * scale}px`;
}
hidePreview() {
this.preview.hidden = true;
}
/**
* @param {string} className
* @param {string} help
* @returns {HTMLElement}
*/
makeHandle(className, help) {
const handle = document.createElement('div');
handle.className = className;
handle.hidden = true;
handle.setAttribute('data-help', help);
const widening = className.includes('end');
handle.addEventListener('pointerdown', (event) => {
if (!this.block) return;
event.preventDefault();
event.stopPropagation();
handle.setPointerCapture(event.pointerId);
const [startX, startY] = this.renderer.pageFromClient(event.clientX, event.clientY);
const { x, y, width } = this.block;
const move = (/** @type {PointerEvent} */ e) => {
if (!this.block) return;
const [nowX, nowY] = this.renderer.pageFromClient(e.clientX, e.clientY);
if (widening) {
this.block.width = Math.max(100, Math.round((width ?? 0) + nowX - startX));
} else {
this.block.x = Math.round(x + nowX - startX);
this.block.y = Math.round(y + nowY - startY);
}
this.changed();
};
const finish = () => {
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', finish);
handle.removeEventListener('pointercancel', finish);
this.field.focus();
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', finish);
handle.addEventListener('pointercancel', finish);
});
return handle;
}
/** @returns {boolean} */
get editing() {
return this.block !== null;
}
/**
* Start a new block.
* @param {number} x
* @param {number} y
* @param {number | null} width
* @param {{color: string, size: number, font: TextFont}} style
*/
begin(x, y, width, style) {
this.stop();
/** @type {TextBlock} */
const block = {
id: randomId(), x: Math.round(x), y: Math.round(y), width, text: '', lines: [''],
size: style.size, font: style.font, color: style.color, boxWidth: 0, boxHeight: 0,
};
Object.assign(block, layoutText('', width, style.size, style.font));
this.edit(block);
}
/**
* Give the block being edited another colour, size or font.
* @param {Partial<{color: string, size: number, font: TextFont}>} style
*/
restyle(style) {
if (!this.block) return;
Object.assign(this.block, style);
this.changed();
}
/**
* Edit a block of ours.
* @param {TextBlock} block
*/
edit(block) {
this.stop();
this.block = block;
this.renderer.editingText = block.id;
this.field.value = block.text;
this.field.hidden = false;
this.layout();
this.target.setText(block); // Redrawn without this block.
this.field.focus();
}
/** Finish editing. An empty block is removed. */
stop() {
const block = this.block;
if (!block) return;
this.block = null;
this.renderer.editingText = null;
this.field.hidden = true;
this.field.value = '';
for (const handle of [this.corner, this.edge, this.end]) handle.hidden = true;
if (block.text.trim() === '') this.target.removeText(block.id);
else this.target.setText(block);
}
/** The text, position or width changed: lay it out and tell the page. */
changed() {
const block = this.block;
if (!block) return;
block.text = this.field.value;
Object.assign(block, layoutText(block.text, block.width, block.size, block.font));
this.layout();
this.target.setText(block);
}
/** Place the field and the handles over the block, at the present zoom. */
layout() {
const block = this.block;
if (!block) return;
const scale = this.renderer.cssPixelsPerUnit;
const page = this.renderer.page;
const left = (block.x - page.left) * scale;
const top = (block.y - page.top) * scale;
const fieldWidth = (block.width ?? Math.max(LEAST_FIELD_WIDTH, block.boxWidth + block.size)) * scale;
const style = this.field.style;
style.left = `${left}px`;
style.top = `${top}px`;
// A little wider than the block, so that the field never wraps a line
// earlier than the layout did.
style.width = `${fieldWidth - block.size * LEFT_GAP * scale + 2}px`;
style.paddingTop = `${block.size * TOP_GAP * scale}px`;
style.paddingLeft = `${block.size * LEFT_GAP * scale}px`;
style.font = `${block.size * scale}px ${block.font}`;
style.lineHeight = String(LINE_HEIGHT);
style.color = block.color;
style.whiteSpace = block.width === null ? 'pre' : 'pre-wrap';
// Exactly as tall as the lines: no spare line below the text.
style.height = `${block.lines.length * block.size * LINE_HEIGHT * scale}px`;
this.corner.hidden = false;
this.corner.style.left = `${left}px`;
this.corner.style.top = `${top}px`;
const hasWidth = block.width !== null;
this.edge.hidden = !hasWidth;
this.end.hidden = !hasWidth;
if (hasWidth) {
this.edge.style.left = `${left}px`;
this.edge.style.top = `${top}px`;
this.edge.style.width = `${fieldWidth}px`;
this.end.style.left = `${left + fieldWidth}px`;
this.end.style.top = `${top}px`;
}
}
}
+111
View File
@@ -0,0 +1,111 @@
// @ts-check
/**
* Lays out the text of a text block into lines, measured in page units.
*
* The writer's client does this and sends the lines along with the text,
* so that everyone shows the same line breaks even where fonts differ a
* little. A block with a width wraps at it; a block without one breaks
* lines only where the writer pressed Enter.
*/
/** Line height as a multiple of the font size. */
export const LINE_HEIGHT = 1.25;
/**
* Room between a block's top edge and its first line, as a multiple of
* the font size (Andy, 2026-10-03: the text should not sit on the edge).
*/
export const TOP_GAP = 0.3;
/**
* Room between a block's left edge (its corner square) and the text, as a
* multiple of the font size: about half a character's width (Andy,
* 2026-10-03).
*/
export const LEFT_GAP = 0.25;
/**
* @param {number} size Font size in page units.
* @param {string} family
* @returns {string} The CSS font for a canvas whose units are page units.
*/
export function fontOf(size, family) {
return `${size}px ${family}`;
}
/** @type {CanvasRenderingContext2D | null} */
let measurer = null;
/**
* @param {string} text
* @param {number} size
* @param {string} family
* @returns {number} The text's width in page units.
*/
export function textWidth(text, size, family) {
if (!measurer) {
measurer = /** @type {CanvasRenderingContext2D} */ (document.createElement('canvas').getContext('2d'));
}
measurer.font = fontOf(size, family);
return measurer.measureText(text).width;
}
/**
* Break one paragraph into lines no wider than `width`. Breaks come at
* spaces; a word wider than the width is broken where it must be.
* @param {string} paragraph
* @param {number} width
* @param {number} size
* @param {string} family
* @returns {string[]}
*/
function wrap(paragraph, width, size, family) {
/** @type {string[]} */
const lines = [];
let line = '';
for (const word of paragraph.split(' ')) {
const tried = line === '' ? word : `${line} ${word}`;
if (textWidth(tried, size, family) <= width ||
line === '' && textWidth(word, size, family) <= width) {
line = tried;
continue;
}
if (line !== '') lines.push(line);
// The word alone: fits, or is broken by characters.
line = '';
for (const character of word) {
if (line !== '' && textWidth(line + character, size, family) > width) {
lines.push(line);
line = '';
}
line += character;
}
}
lines.push(line);
return lines;
}
/**
* @param {string} text What the writer typed, with its own line breaks.
* @param {number | null} width Wrap at this width, or null for no wrapping.
* @param {number} size
* @param {string} family
* @returns {{lines: string[], boxWidth: number, boxHeight: number}}
* The lines as shown, and the rectangle they take up.
*/
export function layoutText(text, width, size, family) {
/** @type {string[]} */
const lines = [];
const gap = size * LEFT_GAP;
for (const paragraph of text.split('\n')) {
if (width === null) lines.push(paragraph);
else lines.push(...wrap(paragraph, Math.max(size, width - gap), size, family));
}
const widest = Math.max(0, ...lines.map((line) => textWidth(line, size, family)));
return {
lines,
boxWidth: width ?? Math.ceil(widest + gap),
boxHeight: Math.ceil(size * TOP_GAP + lines.length * size * LINE_HEIGHT),
};
}
+77
View File
@@ -0,0 +1,77 @@
// @ts-check
/**
* Tooltips: a full explanation of a button, shown below it after the
* pointer (or a pen hovering above the screen) has rested on it for a
* moment. The buttons themselves carry terse labels; this is where the
* explanation lives.
*
* Any element with a `data-help` attribute gets one. Elements added later
* are covered too, since the listeners are on the document.
*/
const DELAY_MS = 1000;
const GAP_PX = 6;
/** @type {HTMLElement | null} */
let tooltip = null;
/** @type {number | null} */
let timer = null;
/** @type {Element | null} */
let shownFor = null;
/**
* @param {EventTarget | null} target
* @returns {HTMLElement | null} The element with help under the pointer.
*/
function helpElement(target) {
return target instanceof Element ? target.closest('[data-help]') : null;
}
/** @param {HTMLElement} element */
function show(element) {
if (!tooltip) {
tooltip = document.createElement('div');
tooltip.className = 'tooltip';
tooltip.setAttribute('role', 'tooltip');
document.body.append(tooltip);
}
tooltip.textContent = element.getAttribute('data-help');
tooltip.hidden = false;
shownFor = element;
// Below the element, aligned with its left edge, kept within the window.
const box = element.getBoundingClientRect();
const width = tooltip.offsetWidth;
const left = Math.max(GAP_PX, Math.min(box.left, window.innerWidth - width - GAP_PX));
tooltip.style.left = `${left}px`;
tooltip.style.top = `${box.bottom + GAP_PX}px`;
}
function hide() {
if (timer !== null) clearTimeout(timer);
timer = null;
shownFor = null;
if (tooltip) tooltip.hidden = true;
}
export function enableTooltips() {
document.addEventListener('pointerover', (event) => {
const element = helpElement(event.target);
if (!element) {
hide();
return;
}
if (element === shownFor) return;
hide();
timer = setTimeout(() => { timer = null; show(element); }, DELAY_MS);
});
document.addEventListener('pointerleave', hide);
document.addEventListener('pointerdown', hide);
document.addEventListener('focusin', (event) => {
const element = helpElement(event.target);
if (element) show(element);
});
document.addEventListener('focusout', hide);
// Typing means the user's attention is on the keys, not the tooltip.
document.addEventListener('keydown', hide);
}