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,151 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kokkoja</title>
|
||||
<link rel="stylesheet" href="kokkoja.css">
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<main id="start" hidden>
|
||||
<h1>Kokkoja</h1>
|
||||
<label for="name">Your name, as others will see it</label>
|
||||
<input id="name" type="text" maxlength="40" autocomplete="off">
|
||||
|
||||
<label for="notebook">Notebook for the pages of this session</label>
|
||||
<input id="notebook" type="text" maxlength="100" autocomplete="off" list="notebook_choices">
|
||||
<datalist id="notebook_choices"></datalist>
|
||||
<p id="notebook_note" class="note notebook_directory"></p>
|
||||
|
||||
<section>
|
||||
<h2>Start a session</h2>
|
||||
<p>You will be given an invitation to send to the others.</p>
|
||||
<button id="start_session" type="button">Start a session</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Join a session</h2>
|
||||
<label for="invitation_entry">Invitation</label>
|
||||
<input id="invitation_entry" type="text" autocomplete="off" spellcheck="false">
|
||||
<button id="join_session" type="button">Join</button>
|
||||
<p class="note"><a id="open_installed" href="#" hidden>Join with the Kokkoja program installed on this computer instead</a></p>
|
||||
</section>
|
||||
|
||||
<p id="start_message" class="message" role="alert"></p>
|
||||
</main>
|
||||
|
||||
<div id="session" hidden>
|
||||
<header class="toolbar">
|
||||
<div class="group" role="radiogroup" aria-label="Tool">
|
||||
<button type="button" data-tool="pen" aria-pressed="true"
|
||||
data-help="Write on the page. Only your own writing can be erased.">Pen</button>
|
||||
<button type="button" data-tool="text" aria-pressed="false"
|
||||
data-help="Type on the page. Tap where the text should begin, or drag along its top edge to set the width at which it wraps. Tap your own text to change or move it.">Text</button>
|
||||
<button type="button" data-tool="eraser" aria-pressed="false"
|
||||
data-help="Erase your own strokes and text by moving over them.">Eraser</button>
|
||||
</div>
|
||||
<div class="group" id="colors" role="radiogroup" aria-label="Color">
|
||||
<button id="more_colors" type="button" class="swatch more"
|
||||
data-help="Choose another color. It is added here.">+</button>
|
||||
</div>
|
||||
<div id="color_palette" class="palette" hidden></div>
|
||||
<div class="group" id="widths" role="radiogroup" aria-label="Line width"></div>
|
||||
<div class="group" id="text_style" hidden>
|
||||
<select id="text_font" aria-label="Font" data-help="The font: a sans-serif, a serif or a monospaced one, as this computer has them.">
|
||||
<option value="sans-serif" style="font-family: sans-serif">Sans</option>
|
||||
<option value="serif" style="font-family: serif">Serif</option>
|
||||
<option value="monospace" style="font-family: monospace">Mono</option>
|
||||
</select>
|
||||
<input id="text_size" type="number" min="6" max="100" step="1" value="14"
|
||||
aria-label="Text size in points" data-help="Text size in points.">
|
||||
</div>
|
||||
<div class="toolbar_right">
|
||||
<div class="group" role="radiogroup" aria-label="Zoom">
|
||||
<button id="zoom_fit" type="button" aria-pressed="true"
|
||||
data-help="Show the whole page in the window. Ctrl and the scroll wheel zoom in and out.">Fit</button>
|
||||
<button id="zoom_in" type="button" data-help="Zoom in by a step. Ctrl and the scroll wheel do the same about the pointer.">+</button>
|
||||
<button id="zoom_out" type="button" data-help="Zoom out by a step.">−</button>
|
||||
</div>
|
||||
<div class="group" id="host_tools" hidden>
|
||||
<button id="new_page" type="button"
|
||||
data-help="Start a new blank page for everyone. The current page is kept in each notebook.">New</button>
|
||||
<button id="load_image" type="button"
|
||||
data-help="Put an image file, such as a scanned exercise, on the page for everyone. It goes below the images already there. The view keeps its scale; scroll down to a new image. For a page of its own, press New first. An image you have just added can be taken back with a tap of the eraser, until anything else is done on the page.">Image</button>
|
||||
<button id="load_notebook_page" type="button"
|
||||
data-help="Start a new page for everyone from a page kept in a notebook, with everything on it. The current page is kept in each notebook.">Notebook</button>
|
||||
</div>
|
||||
<span id="page_number" class="page_number"></span>
|
||||
<span id="status" class="status"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="workspace">
|
||||
<div id="desk" class="desk">
|
||||
<div id="page" class="page"></div>
|
||||
</div>
|
||||
|
||||
<aside class="panel">
|
||||
<h2>Participants</h2>
|
||||
<ul id="participants"></ul>
|
||||
|
||||
<h2>Invitation</h2>
|
||||
<input id="invitation" type="text" readonly spellcheck="false">
|
||||
<button id="copy_invitation" type="button"
|
||||
data-help="Copy the invitation, to send to the others by any means.">Copy</button>
|
||||
|
||||
<h2><label for="session_notebook">Current notebook</label></h2>
|
||||
<input id="session_notebook" type="text" maxlength="100" autocomplete="off"
|
||||
list="notebook_choices">
|
||||
<button id="keep_page" type="button"
|
||||
data-help="Keep a dated copy of the page as it is now. The page is also kept by itself whenever it is about to be replaced.">Keep page</button>
|
||||
<button id="export_page" type="button"
|
||||
data-help="Save a picture of the page, as it is shown here, in a PNG file at a place you choose.">Export</button>
|
||||
<p id="save_status" class="note"></p>
|
||||
<p class="note notebook_directory"></p>
|
||||
|
||||
<p id="pen_report" class="report"></p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="notebook_chooser" class="chooser">
|
||||
<header>
|
||||
<h2>Notebooks</h2>
|
||||
<button id="chooser_close" type="button" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div class="chooser_body">
|
||||
<nav id="chooser_notebooks" class="chooser_notebooks"></nav>
|
||||
<div id="chooser_pages" class="chooser_pages"></div>
|
||||
</div>
|
||||
<p id="chooser_message" class="note"></p>
|
||||
</dialog>
|
||||
|
||||
<dialog id="file_chooser" class="chooser">
|
||||
<header>
|
||||
<h2 id="files_title">Image</h2>
|
||||
<button id="files_close" type="button" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div class="files_place">
|
||||
<button id="files_up" type="button">Up</button>
|
||||
<input id="files_directory" type="text" spellcheck="false" autocomplete="off"
|
||||
aria-label="Directory">
|
||||
</div>
|
||||
<div class="files_body">
|
||||
<nav id="files_directories" class="files_directories"></nav>
|
||||
<div id="files_entries" class="files_entries"></div>
|
||||
</div>
|
||||
<label id="files_shrink_line" class="files_shrink">
|
||||
<input id="files_shrink" type="checkbox" checked> Shrink an image wider than the page to fit it
|
||||
</label>
|
||||
<div id="files_save" class="files_save" hidden>
|
||||
<input id="files_name" type="text" spellcheck="false" autocomplete="off"
|
||||
aria-label="File name">
|
||||
<button id="files_save_here" type="button">Export</button>
|
||||
</div>
|
||||
<p id="files_message" class="note"></p>
|
||||
</dialog>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
:root {
|
||||
--ink: #1c1c1e;
|
||||
--muted: #6b6b70;
|
||||
--line: #d4d4d8;
|
||||
--panel: #f6f6f7;
|
||||
--desk: #e4e4e7;
|
||||
--accent: #1f4fd8;
|
||||
--text_edge: #ababab; /* The top edge of a text block being edited: 67% grey. */
|
||||
--text_handle: #ababab; /* Its squares: the same. */
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body { height: 100%; margin: 0; }
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
button, input { font: inherit; color: inherit; }
|
||||
|
||||
button {
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover { border-color: var(--muted); }
|
||||
|
||||
button[aria-pressed="true"] {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 1.4rem 0 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Start screen */
|
||||
|
||||
#start {
|
||||
max-width: 28rem;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1.5rem;
|
||||
}
|
||||
|
||||
#start h1 { margin: 0 0 1.5rem; font-size: 1.6rem; }
|
||||
#start label { display: block; margin: 1rem 0 0.3rem; }
|
||||
#start section { margin-top: 1.5rem; }
|
||||
#start section p { margin: 0 0 0.6rem; color: var(--muted); }
|
||||
#start section button { margin-top: 0.6rem; }
|
||||
|
||||
.message { min-height: 1.4em; margin-top: 1.5rem; color: #b42318; }
|
||||
|
||||
.note { margin: 0.3rem 0 0; font-size: 0.85rem; color: var(--muted); overflow-wrap: anywhere; }
|
||||
.note.problem { color: #b42318; }
|
||||
.file { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
/* Session */
|
||||
|
||||
#session {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.group { display: flex; align-items: center; gap: 0.35rem; }
|
||||
|
||||
.swatch {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--swatch);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
/* The same height as the buttons, so the toolbar does not change height. */
|
||||
.toolbar button:not(.swatch), #text_size, #text_font {
|
||||
box-sizing: border-box;
|
||||
height: 2rem;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
#text_size, #text_font {
|
||||
margin: 0;
|
||||
padding-left: 0.4rem;
|
||||
padding-right: 0.4rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#text_size { width: 3.6rem; }
|
||||
#text_font { padding-right: 0.3rem; }
|
||||
|
||||
/* The palette that "+" opens: no system dialog, which crashes Firefox on
|
||||
Linux (Hollis, 2026-10-04). */
|
||||
.palette {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1.5rem);
|
||||
gap: 0.3rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
.palette .swatch { width: 1.5rem; height: 1.5rem; }
|
||||
|
||||
.swatch.more {
|
||||
--swatch: white;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.width_choice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.4rem;
|
||||
height: 1.9rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.width_choice span {
|
||||
display: block;
|
||||
width: 1.4rem;
|
||||
border-radius: 99px;
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
#zoom_in, #zoom_out {
|
||||
width: 2.2rem;
|
||||
padding: 0.1rem 0;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* What concerns the page and the view, not the tool: it never moves. */
|
||||
.toolbar_right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.page_number { color: var(--muted); }
|
||||
.status { min-width: 6.5rem; text-align: right; color: var(--muted); }
|
||||
.status.problem { color: #b42318; }
|
||||
|
||||
.workspace {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.desk {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
padding: 1.25rem;
|
||||
background: var(--desk);
|
||||
}
|
||||
|
||||
.page {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 0.25);
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.page canvas, .page_image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Text being edited */
|
||||
|
||||
.text_field {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
box-sizing: content-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.text_handle {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin: -5px 0 0 -5px;
|
||||
border: 1px solid white;
|
||||
border-radius: 2px;
|
||||
background: var(--text_handle);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.text_handle.end { cursor: ew-resize; }
|
||||
|
||||
.text_edge {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
height: 0;
|
||||
margin-top: -1px;
|
||||
border-top: 2px solid var(--text_edge);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.text_edge::before, .text_edge::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 8px;
|
||||
border-left: 2px solid var(--text_edge);
|
||||
}
|
||||
|
||||
.text_edge::before { left: 0; }
|
||||
.text_edge::after { right: 0; }
|
||||
|
||||
/* The top edge of a new block, while it is being dragged out. */
|
||||
.text_preview {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
height: 0;
|
||||
margin-top: -1px;
|
||||
border-top: 2px solid var(--text_edge);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.panel {
|
||||
width: 16rem;
|
||||
padding: 0 1rem 1rem;
|
||||
overflow: auto;
|
||||
border-left: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.panel ul { margin: 0; padding: 0; list-style: none; }
|
||||
|
||||
.panel li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.panel li.absent { color: var(--muted); }
|
||||
.panel li label { flex: 1; }
|
||||
.panel li .role { font-size: 0.8rem; color: var(--muted); }
|
||||
.panel li .allow { order: 1; padding: 0.1rem 0.4rem; font-size: 0.75rem; }
|
||||
|
||||
.panel button { margin-top: 0.4rem; }
|
||||
|
||||
/* The notebook chooser */
|
||||
|
||||
.chooser {
|
||||
width: min(56rem, 90vw);
|
||||
height: min(36rem, 85vh);
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 30px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.chooser::backdrop { background: rgb(0 0 0 / 0.3); }
|
||||
|
||||
.chooser header { display: flex; align-items: center; }
|
||||
.chooser header h2 { flex: 1; margin: 0; }
|
||||
.chooser header button { width: 2rem; padding: 0.2rem 0; font-size: 1.1rem; line-height: 1; }
|
||||
|
||||
.chooser_body {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
height: calc(100% - 4rem);
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.chooser_notebooks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
width: 12rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chooser_notebooks button { text-align: left; }
|
||||
|
||||
.chooser_pages {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
align-content: start;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.page_card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.page_card img {
|
||||
width: 100%;
|
||||
aspect-ratio: 210 / 297;
|
||||
object-fit: contain;
|
||||
background: var(--desk);
|
||||
}
|
||||
|
||||
.page_card span { font-size: 0.8rem; }
|
||||
|
||||
/* The file chooser */
|
||||
|
||||
#file_chooser[open] { display: flex; flex-direction: column; }
|
||||
|
||||
.files_place, .files_save {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.files_place input, .files_save input { flex: 1; min-width: 0; }
|
||||
|
||||
/* Directories down the left, one to a line; the files beside them. Each
|
||||
scrolls by itself, as in the notebook chooser. */
|
||||
.files_body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.files_directories {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
width: 12rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.files_directories button { text-align: left; overflow-wrap: anywhere; }
|
||||
|
||||
.files_entries {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.files_shrink { display: block; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
|
||||
.files_existing {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.files_images {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.file_card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.file_card canvas {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: contain;
|
||||
background: var(--desk);
|
||||
}
|
||||
|
||||
.file_card span { font-size: 0.8rem; overflow-wrap: anywhere; }
|
||||
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
max-width: 20rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
background: var(--ink);
|
||||
color: white;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.25);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.report {
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
Reference in new issue
Block a user