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:
commit
95c80d04c9
39 files changed
+9905
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {string} The text, safe as XML content.
|
||||
*/
|
||||
function escapedContent(text) {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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') ?? '',
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user