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
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# Editor backups
|
||||||
|
*~
|
||||||
|
|
||||||
|
# PDFs are renderings of the Markdown in notes/
|
||||||
|
*.pdf
|
||||||
|
|
||||||
|
# Python
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
# Development tools installed by npm (see package.json)
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Local Claude settings
|
||||||
|
.claude/settings.local.json
|
||||||
|
tests/downloads_*
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Kokkoja
|
||||||
|
|
||||||
|
Kokkoja lets people at different computers write together on a shared
|
||||||
|
page, by hand or by typing, with every page saved automatically to each
|
||||||
|
person's own notebook. It runs in a web browser beside a video call and
|
||||||
|
replaces the call's screen sharing and annotation. It was made first for
|
||||||
|
teaching a foreign language over a video call, and it serves any work
|
||||||
|
that two or more people would otherwise do over one sheet of paper.
|
||||||
|
|
||||||
|
Kokkoja is free and open-source software.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
* [An introduction](docs/introduction.md): what Kokkoja does and whom it
|
||||||
|
is for.
|
||||||
|
* [A guest's Kokkoja](docs/guest.md): joining a session from an
|
||||||
|
invitation, and the screen.
|
||||||
|
* [A host's Kokkoja](docs/host.md): installing the Kokkoja program,
|
||||||
|
starting a session, inviting guests, driving the session.
|
||||||
|
* [How Kokkoja works](docs/implementation.md): the parts, the encryption,
|
||||||
|
the page file, running a server.
|
||||||
|
|
||||||
|
## In short
|
||||||
|
|
||||||
|
A guest needs nothing but a web browser: open the invitation link, enter
|
||||||
|
a name, join.
|
||||||
|
|
||||||
|
A host needs Python 3 and this directory:
|
||||||
|
|
||||||
|
python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay
|
||||||
|
|
||||||
|
opens Kokkoja in the browser; *Start a session* gives an invitation to
|
||||||
|
send to the guests. Notebooks are directories in `Kokkoja` in the
|
||||||
|
Documents directory.
|
||||||
|
|
||||||
|
## What is here
|
||||||
|
|
||||||
|
* `kokkoja.py`: the local program, which serves the client to your
|
||||||
|
browser and keeps your notebooks as files. Python standard library
|
||||||
|
only.
|
||||||
|
* `client/`: the client, plain JavaScript modules run by the browser as
|
||||||
|
written.
|
||||||
|
* `relay/`: the relay server, Python with the `websockets` library.
|
||||||
|
* `deploy/`: how a Kokkoja server is set up (relay plus web site).
|
||||||
|
* `docs/`: the documentation above.
|
||||||
|
* `tests/`: the tests (`python3 -m unittest discover tests`; the
|
||||||
|
end-to-end test needs Firefox and geckodriver).
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# The Kokkoja server
|
||||||
|
|
||||||
|
A Kokkoja *server* is one machine that does two things for a group of
|
||||||
|
users: it runs the relay, which passes the participants' encrypted
|
||||||
|
messages, and it serves the client as a web site, so that a guest can
|
||||||
|
join by opening a link with nothing installed. Neither stores anything.
|
||||||
|
The first one is Andy's VPS at `kokkoja.andykopra.com`.
|
||||||
|
|
||||||
|
| On the server | What it is |
|
||||||
|
|----------------------------|-------------------------------------------------|
|
||||||
|
| `/opt/kokkoja/relay/` | the relay's code (`relay/` in this repository) |
|
||||||
|
| `/opt/kokkoja/venv/` | Python with the `websockets` library |
|
||||||
|
| `/var/www/kokkoja/` | the client (`client/`) and its `config.json` |
|
||||||
|
| `kokkoja-relay.service` | the relay, run as the system user `kokkoja`, on 127.0.0.1:8770 |
|
||||||
|
| nginx site `kokkoja` | `https://<site>/` serves the client; `wss://<site>/relay` reaches the relay |
|
||||||
|
|
||||||
|
The client's `config.json` on the site says
|
||||||
|
`{"relay": "wss://kokkoja.andykopra.com/relay", "storage": "browser",
|
||||||
|
"site": "https://kokkoja.andykopra.com/"}`: the notebooks of anyone using
|
||||||
|
the site are kept in their browser. A host with the local program uses
|
||||||
|
the server with `python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay`;
|
||||||
|
the invitation is then a link to the site.
|
||||||
|
|
||||||
|
## Setting up or updating
|
||||||
|
|
||||||
|
From the project directory on a development machine:
|
||||||
|
|
||||||
|
deploy/deploy.sh
|
||||||
|
|
||||||
|
copies the relay, the client and the files in this directory to the
|
||||||
|
server and runs `setup_vps.sh` there, which makes or updates everything
|
||||||
|
in the table and restarts the relay. It can be run again at any time.
|
||||||
|
`KOKKOJA_SERVER` names another ssh destination than
|
||||||
|
`root@vps313847.iceservers.net`.
|
||||||
|
|
||||||
|
Steps a person does, once:
|
||||||
|
|
||||||
|
1. DNS: an A record `kokkoja.andykopra.com` pointing at the VPS
|
||||||
|
(82.221.141.89), in cPanel's Zone Editor, since andykopra.com is on
|
||||||
|
the orangewebsite.com nameservers. Check with
|
||||||
|
`host kokkoja.andykopra.com buffy.ns.orangewebsite.com`.
|
||||||
|
|
||||||
|
2. TLS, after the name resolves and `deploy.sh` has run once:
|
||||||
|
|
||||||
|
ssh root@vps313847.iceservers.net certbot --nginx -d kokkoja.andykopra.com
|
||||||
|
|
||||||
|
certbot adds the TLS listener to the nginx site and turns the plain
|
||||||
|
HTTP block into a redirect. Afterwards `setup_vps.sh` leaves the
|
||||||
|
installed nginx site alone, so a later change to `nginx-site.conf`
|
||||||
|
must be carried over by hand.
|
||||||
|
|
||||||
|
3. The VPS sentinel sees a new service, a new user, a new nginx site and
|
||||||
|
a new listening socket. After deploying, as root on the VPS:
|
||||||
|
|
||||||
|
vps-sentinel --accept
|
||||||
|
|
||||||
|
Checks: `https://kokkoja.andykopra.com/config.json` shows the
|
||||||
|
configuration above; `systemctl status kokkoja-relay` is active;
|
||||||
|
`journalctl -u kokkoja-relay -n 20` shows it listening.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
The relay sees only ciphertext and room ids and keeps nothing. It runs
|
||||||
|
as a user of its own under systemd's sandboxing (read-only system, no
|
||||||
|
home directories, no new privileges). The site sends a Content Security
|
||||||
|
Policy that lets the client load nothing from anywhere else and forbids
|
||||||
|
framing. What the site cannot protect against is itself: whoever
|
||||||
|
controls the server could serve a changed client to browser-only
|
||||||
|
guests. Participants running the local program are not exposed to that.
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy the Kokkoja server: copy the relay, the client and the server
|
||||||
|
# files to the VPS, then run deploy/setup_vps.sh there. Run from the
|
||||||
|
# project directory on a development machine.
|
||||||
|
#
|
||||||
|
# deploy/deploy.sh
|
||||||
|
#
|
||||||
|
# KOKKOJA_SERVER names the ssh destination (default root@vps313847.iceservers.net).
|
||||||
|
# The site's config.json is written here: the relay and site addresses
|
||||||
|
# below are the server's public names.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
server=${KOKKOJA_SERVER:-root@vps313847.iceservers.net}
|
||||||
|
site=https://kokkoja.andykopra.com/
|
||||||
|
relay=wss://kokkoja.andykopra.com/relay
|
||||||
|
|
||||||
|
staging=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$staging"' EXIT
|
||||||
|
mkdir -p "$staging/relay" "$staging/client" "$staging/deploy"
|
||||||
|
cp relay/kokkoja_relay.py relay/requirements.txt "$staging/relay/"
|
||||||
|
rsync -a --exclude '__pycache__' client/ "$staging/client/"
|
||||||
|
printf '{"relay": "%s", "storage": "browser", "site": "%s"}\n' "$relay" "$site" > "$staging/client/config.json"
|
||||||
|
cp deploy/kokkoja-relay.service deploy/nginx-site.conf deploy/setup_vps.sh "$staging/deploy/"
|
||||||
|
|
||||||
|
ssh "$server" 'mkdir -p /opt/kokkoja/incoming'
|
||||||
|
rsync -a --delete "$staging/" "$server:/opt/kokkoja/incoming/"
|
||||||
|
ssh "$server" 'bash /opt/kokkoja/incoming/deploy/setup_vps.sh'
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# The Kokkoja relay as a system service. Installed by deploy/setup_vps.sh
|
||||||
|
# as /etc/systemd/system/kokkoja-relay.service.
|
||||||
|
#
|
||||||
|
# The relay listens on this computer only; nginx terminates TLS and
|
||||||
|
# forwards wss://<site>/relay/... to it (deploy/nginx-site.conf).
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Kokkoja relay
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=kokkoja
|
||||||
|
Group=kokkoja
|
||||||
|
ExecStart=/opt/kokkoja/venv/bin/python /opt/kokkoja/relay/kokkoja_relay.py --host 127.0.0.1 --port 8770
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
# The relay needs nothing on the system but its own code.
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
PrivateDevices=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6
|
||||||
|
MemoryDenyWriteExecute=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# nginx server block for a Kokkoja web site: the client's files at
|
||||||
|
# https://kokkoja.andykopra.com/ and the relay at wss://kokkoja.andykopra.com/relay.
|
||||||
|
# Installed by deploy/setup_vps.sh as /etc/nginx/sites-available/kokkoja.
|
||||||
|
#
|
||||||
|
# This is the plain HTTP form. certbot --nginx -d kokkoja.andykopra.com
|
||||||
|
# adds the TLS listener and turns this block into a redirect.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name kokkoja.andykopra.com;
|
||||||
|
|
||||||
|
root /var/www/kokkoja;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# The relay. The client connects to <relay address>/room/<room id>,
|
||||||
|
# so /relay/room/<id> here becomes /room/<id> at the relay.
|
||||||
|
location /relay/ {
|
||||||
|
proxy_pass http://127.0.0.1:8770/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
# The relay pings every 20 seconds; a quiet lesson must not be cut off.
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
proxy_send_timeout 1h;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# The client is a few small files that change with every deployment:
|
||||||
|
# browsers ask whether they have changed rather than keeping old ones.
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
# The client loads nothing from anywhere else, and may not be framed.
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' wss://kokkoja.andykopra.com; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
}
|
||||||
Executable
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Set up, or bring up to date, the Kokkoja server on this machine. Run as
|
||||||
|
# root ON THE SERVER, after deploy/deploy.sh has copied the files to
|
||||||
|
# /opt/kokkoja/incoming. Safe to run again: every step checks first.
|
||||||
|
#
|
||||||
|
# What it makes:
|
||||||
|
# /opt/kokkoja/relay/ the relay's code
|
||||||
|
# /opt/kokkoja/venv/ Python with the websockets library
|
||||||
|
# /var/www/kokkoja/ the client, with config.json
|
||||||
|
# kokkoja-relay.service the relay, as the system user "kokkoja"
|
||||||
|
# nginx site "kokkoja" the web site and the relay behind TLS
|
||||||
|
#
|
||||||
|
# TLS is the one step left to a person, once the name resolves to this
|
||||||
|
# machine (see deploy/README.md):
|
||||||
|
# certbot --nginx -d kokkoja.andykopra.com
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
incoming=/opt/kokkoja/incoming
|
||||||
|
test -d "$incoming" || { echo "nothing in $incoming: run deploy/deploy.sh first" >&2; exit 1; }
|
||||||
|
|
||||||
|
if ! id kokkoja >/dev/null 2>&1; then
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin kokkoja
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Python with the websockets library, in a virtual environment of its own.
|
||||||
|
if ! python3 -c 'import ensurepip' >/dev/null 2>&1; then
|
||||||
|
apt-get install -y python3-venv
|
||||||
|
fi
|
||||||
|
if [ ! -x /opt/kokkoja/venv/bin/python ]; then
|
||||||
|
python3 -m venv /opt/kokkoja/venv
|
||||||
|
fi
|
||||||
|
/opt/kokkoja/venv/bin/pip install --quiet --upgrade pip
|
||||||
|
/opt/kokkoja/venv/bin/pip install --quiet -r "$incoming/relay/requirements.txt"
|
||||||
|
|
||||||
|
# The relay's code, and the client.
|
||||||
|
rm -rf /opt/kokkoja/relay
|
||||||
|
cp -r "$incoming/relay" /opt/kokkoja/relay
|
||||||
|
chmod -R a+rX /opt/kokkoja/relay
|
||||||
|
mkdir -p /var/www/kokkoja
|
||||||
|
rsync -a --delete "$incoming/client/" /var/www/kokkoja/
|
||||||
|
chmod -R a+rX /var/www/kokkoja
|
||||||
|
|
||||||
|
# The service.
|
||||||
|
install -m 644 "$incoming/deploy/kokkoja-relay.service" /etc/systemd/system/kokkoja-relay.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --quiet kokkoja-relay.service
|
||||||
|
systemctl restart kokkoja-relay.service
|
||||||
|
|
||||||
|
# The nginx site. Once certbot has edited the installed copy, it is not
|
||||||
|
# replaced: certbot's additions would be lost.
|
||||||
|
if [ ! -e /etc/nginx/sites-available/kokkoja ]; then
|
||||||
|
install -m 644 "$incoming/deploy/nginx-site.conf" /etc/nginx/sites-available/kokkoja
|
||||||
|
elif ! grep -q "managed by Certbot" /etc/nginx/sites-available/kokkoja; then
|
||||||
|
install -m 644 "$incoming/deploy/nginx-site.conf" /etc/nginx/sites-available/kokkoja
|
||||||
|
else
|
||||||
|
echo "nginx site left as certbot made it; compare with deploy/nginx-site.conf by hand"
|
||||||
|
fi
|
||||||
|
ln -sf ../sites-available/kokkoja /etc/nginx/sites-enabled/kokkoja
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
|
||||||
|
systemctl --no-pager --lines=3 status kokkoja-relay.service || true
|
||||||
|
echo "Kokkoja server is set up."
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
# A guest's Kokkoja
|
||||||
|
|
||||||
|
This part describes Kokkoja as a guest sees it: someone invited to a
|
||||||
|
session by its host. In a lesson, the guest is the student. The host's
|
||||||
|
screen is the same with a few more buttons, described in
|
||||||
|
[A host's Kokkoja](host.md).
|
||||||
|
|
||||||
|
## The invitation
|
||||||
|
|
||||||
|
The host sends you an invitation by whatever means you both use: email, a
|
||||||
|
chat message, a text message. It is a link of this shape:
|
||||||
|
|
||||||
|
https://kokkoja.andykopra.com/#join=wss://kokkoja.andykopra.com/relay%23uQ5c__CGB4FvN64Qiv3jGA.aS_1O51Umk1YHXwgqBZANw
|
||||||
|
|
||||||
|
The part after `join=` is the invitation itself, which the host's Kokkoja
|
||||||
|
may also give you on its own, without the link around it. Both forms
|
||||||
|
work wherever an invitation is asked for. The invitation carries the key
|
||||||
|
that protects the session, so pass it on only to people who belong in the
|
||||||
|
session, and treat it as you would a meeting link. An invitation is good
|
||||||
|
for one session: the host's next session has a new one.
|
||||||
|
|
||||||
|
## Joining
|
||||||
|
|
||||||
|
Open the link in a web browser. Firefox, Brave and Chrome are known to
|
||||||
|
work; other browsers built on the same engines very probably do. You see
|
||||||
|
Kokkoja's start screen with the invitation already filled in. Enter the
|
||||||
|
name the others should see and press **Join**. That is all: nothing is
|
||||||
|
installed, and there is no account to make.
|
||||||
|
|
||||||
|
Your name is remembered by the browser for next time. So is a second
|
||||||
|
field, **Notebook for the pages of this session**, which you can leave as
|
||||||
|
it is for now; what it does is explained under "Your notebook" below.
|
||||||
|
|
||||||
|
If you have the Kokkoja program installed on your computer (see
|
||||||
|
[A host's Kokkoja](host.md) for what that is and why you might want it),
|
||||||
|
the start screen also offers to open the invitation in it instead. If
|
||||||
|
you have not, ignore that line.
|
||||||
|
|
||||||
|
If the host has not started yet, or has left, you are still in the
|
||||||
|
session. The page is shared among whoever is present, and the host's
|
||||||
|
place in it waits for the host to return.
|
||||||
|
|
||||||
|
## The screen
|
||||||
|
|
||||||
|
Across the top is the toolbar. On the left are the tools and their
|
||||||
|
settings; on the right, the zoom and, for the host, the buttons that
|
||||||
|
start pages. Below it, the page fills most of the window, with a panel on
|
||||||
|
the right. Every button explains itself: rest the pointer on it for a
|
||||||
|
second and its explanation appears beneath it.
|
||||||
|
|
||||||
|
### The page
|
||||||
|
|
||||||
|
The page is an A4 sheet, shown so that it fits the window. Write
|
||||||
|
anywhere on it. When your writing comes near an edge, the sheet grows in
|
||||||
|
that direction, in small steps, so there is always room, and the view
|
||||||
|
keeps its scale and extends beyond the window; scroll to reach the rest.
|
||||||
|
An image the host puts on the page lies at the top, or below the images
|
||||||
|
already there.
|
||||||
|
|
||||||
|
The page as a whole is shared: everyone sees the same marks in the same
|
||||||
|
places. Your view of it is your own: how far it is zoomed and where it
|
||||||
|
is scrolled to concern nobody else.
|
||||||
|
|
||||||
|
### Pen
|
||||||
|
|
||||||
|
**Pen** is chosen when you arrive. Draw with a pen on a tablet, a mouse,
|
||||||
|
or a finger on a touch screen. With a pen, pressing harder makes the
|
||||||
|
line wider. The colours are to the right of the tools: black, blue, red
|
||||||
|
and green, and **+** opens a palette of more. A colour chosen from the
|
||||||
|
palette joins the row of colours and stays there in this browser. The
|
||||||
|
three lines to the right of the colours are the line widths: 0.4, 0.7
|
||||||
|
and 1.2 mm at ordinary pressure.
|
||||||
|
|
||||||
|
If a pen is in use, a hand resting on a touch screen is ignored.
|
||||||
|
|
||||||
|
### Text
|
||||||
|
|
||||||
|
**Text** types on the page. Tap where the text should begin and type; a
|
||||||
|
text field appears there and the text is laid out on the page as you
|
||||||
|
type. Enter begins a new line. Press Escape, or tap somewhere else, to
|
||||||
|
finish; a block with nothing in it disappears.
|
||||||
|
|
||||||
|
A block made by a tap does not wrap: its lines are as long as you type
|
||||||
|
them, and the page grows to hold them. To make the text wrap at a chosen
|
||||||
|
width, drag along where its top edge should run instead of tapping: the
|
||||||
|
block begins at the left end of the drag and wraps at the right end.
|
||||||
|
|
||||||
|
Tap your own text to change it. While a block of yours is being edited
|
||||||
|
it is drawn grey, and three handles appear: the one at its top-left
|
||||||
|
corner and the line along its top edge move it, the one at the right end
|
||||||
|
of that line changes its width. You cannot change anyone else's text.
|
||||||
|
|
||||||
|
With the Text tool, the row of colours stays and the line widths give way
|
||||||
|
to the font, one of **Sans**, **Serif** and **Mono**, and the size in
|
||||||
|
points, 14 unless you change it. Both apply to the block you are typing
|
||||||
|
and to the next ones.
|
||||||
|
|
||||||
|
### Eraser
|
||||||
|
|
||||||
|
**Eraser** removes your own writing. Move it across a stroke and the
|
||||||
|
whole stroke goes; across a text block and the block goes. It removes
|
||||||
|
nothing of anyone else's, and nobody else's eraser can touch yours. An
|
||||||
|
erased stroke is gone from the page for everyone.
|
||||||
|
|
||||||
|
### Zoom
|
||||||
|
|
||||||
|
**Fit** shows the whole page in the window, and keeps doing so as the
|
||||||
|
page grows by writing, except while you are in the middle of a stroke of
|
||||||
|
your own. **+** and **−** zoom in and out by a tenth. Holding Ctrl and
|
||||||
|
turning the scroll wheel zooms about the pointer. When the page is
|
||||||
|
larger than the window, scroll it as you would any page. When the host
|
||||||
|
adds an image, the view keeps its scale rather than shrinking to fit; a
|
||||||
|
new image at the bottom of a long page is reached by scrolling.
|
||||||
|
|
||||||
|
If you reload the browser tab, it comes back to the page as you last saw
|
||||||
|
it: same zoom, same place.
|
||||||
|
|
||||||
|
### The panel
|
||||||
|
|
||||||
|
**Participants** lists everyone in the session, with *you* and *host*
|
||||||
|
marked, and *away* beside anyone who has left or lost their connection;
|
||||||
|
an absent participant's writing stays on the page. The box in front of
|
||||||
|
each name shows or hides that participant's writing on your screen alone.
|
||||||
|
Hiding your teacher's corrections to try an exercise again, or hiding
|
||||||
|
your own answers to see the exercise as it was, changes nothing for
|
||||||
|
anyone else.
|
||||||
|
|
||||||
|
**Invitation** shows the invitation of this session, with a button to
|
||||||
|
copy it, in case someone else should be invited.
|
||||||
|
|
||||||
|
**Current notebook**, **Keep page** and **Export** concern your notebook,
|
||||||
|
below. Under them, a line reports what was last saved, and whether
|
||||||
|
anything went wrong.
|
||||||
|
|
||||||
|
The last line of the panel reports what your pen or mouse is delivering:
|
||||||
|
the kind of device, the pressure, and how many positions per second it
|
||||||
|
sends. It is there for curiosity and for finding faults.
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
At the right end of the toolbar, **Page 3** is the number of the page you
|
||||||
|
are on, counted from the start of the session, and beside it *Connected*,
|
||||||
|
*Connecting…* or *Disconnected* says how you stand with the relay. If
|
||||||
|
the connection drops, Kokkoja reconnects by itself; what you wrote while
|
||||||
|
disconnected is sent when it returns. Until the host has started a
|
||||||
|
page, the toolbar says *Waiting for the host's page*.
|
||||||
|
|
||||||
|
## Your notebook
|
||||||
|
|
||||||
|
Every page of the session is saved for you, as it changes, without your
|
||||||
|
doing anything. Where it is saved depends on how you joined.
|
||||||
|
|
||||||
|
### In the browser
|
||||||
|
|
||||||
|
When you joined by opening the link, your notebook is kept inside the
|
||||||
|
browser, on the computer you are using. It stays there between sessions,
|
||||||
|
but it belongs to that browser: another browser, or another computer,
|
||||||
|
has its own. The page you are on is saved there continuously. When the
|
||||||
|
session moves to another page, the page you are leaving is kept under a
|
||||||
|
dated name, such as `2026_10_06_01.svg`, the date being the day it was
|
||||||
|
last changed and the number counting the pages kept that day.
|
||||||
|
|
||||||
|
Two buttons bring pages out of the browser:
|
||||||
|
|
||||||
|
* **Keep page** keeps a dated copy of the page as it is now, and
|
||||||
|
downloads it, so you find it among your browser's downloads. Pressing
|
||||||
|
it again before anything has changed makes no second copy.
|
||||||
|
* **Export** downloads a picture of the page as you see it, as a PNG
|
||||||
|
file; writing you have hidden is left out. Its name is the date and
|
||||||
|
the page number.
|
||||||
|
|
||||||
|
A page file is an SVG file. Any web browser displays it, and vector
|
||||||
|
drawing programs such as Inkscape open it. Kokkoja itself can load one
|
||||||
|
again, writing and all, when someone with the right to start pages
|
||||||
|
chooses it (see "When the host gives you more" below, and the host's
|
||||||
|
part).
|
||||||
|
|
||||||
|
The notebook field on the start screen names the notebook within the
|
||||||
|
browser's storage. Giving each course or each colleague a notebook of
|
||||||
|
their own keeps their pages apart; the name proposed is simply
|
||||||
|
*Notebook*. **Current notebook** in the panel shows the notebook in use
|
||||||
|
and lets you change it during the session.
|
||||||
|
|
||||||
|
### On your computer
|
||||||
|
|
||||||
|
If you would rather have your pages as files in a directory on your own
|
||||||
|
computer, where you can open, copy and back them up as you do any file,
|
||||||
|
install the Kokkoja program. It is small and needs only Python, which
|
||||||
|
Linux and macOS have or can quickly get; [A host's Kokkoja](host.md)
|
||||||
|
tells how, since every host needs it. Then join from the program's own
|
||||||
|
start screen, pasting the invitation, or follow the start screen's offer
|
||||||
|
to open the invitation in the installed program.
|
||||||
|
|
||||||
|
With the program, a notebook is a directory in `Kokkoja` in your
|
||||||
|
Documents directory. The page you are on is `Current.svg` there, and
|
||||||
|
kept pages lie beside it under their dated names. **Keep page** makes
|
||||||
|
the dated copy; **Export** opens Kokkoja's own file chooser, in which you
|
||||||
|
pick a directory and a name for the PNG file.
|
||||||
|
|
||||||
|
The program also keeps your identity, so that you are the same
|
||||||
|
participant in every session and from any browser on that computer, and
|
||||||
|
writing from an earlier lesson is still yours when its page is loaded
|
||||||
|
again. In the browser, your identity belongs to that browser.
|
||||||
|
|
||||||
|
## Leaving and returning
|
||||||
|
|
||||||
|
Close the tab and you have left. Nothing needs saving. If you come back
|
||||||
|
to the same session by opening the invitation again, your writing is
|
||||||
|
still on the page, from the others' copies and from your notebook, and
|
||||||
|
it is still yours to erase or change. A tab that was in a session and is
|
||||||
|
reloaded goes straight back in.
|
||||||
|
|
||||||
|
## When the host gives you more
|
||||||
|
|
||||||
|
Normally only the host puts images on the page, starts new pages, or
|
||||||
|
loads pages from a notebook. The host can give you the same right, for
|
||||||
|
instance in a discussion among colleagues where everyone brings
|
||||||
|
material. When that happens, three more buttons appear at the right of
|
||||||
|
your toolbar:
|
||||||
|
|
||||||
|
* **New** starts a new blank page for everyone. The page being left is
|
||||||
|
kept in each person's notebook.
|
||||||
|
* **Image** puts an image file on the page for everyone, below any
|
||||||
|
images already there. Your browser's file chooser asks for the file
|
||||||
|
(with the Kokkoja program, Kokkoja's own chooser does, beginning where
|
||||||
|
the notebooks are, with a choice whether an image wider than the page
|
||||||
|
is shrunk to fit it). A PNG or JPEG file keeps its exact pixels unless
|
||||||
|
it is very large; other formats and very large images are converted
|
||||||
|
and reduced before they are shared. An image is placed at its natural
|
||||||
|
size, centred, 8 mm from the top or from the image above; one wider
|
||||||
|
than the sheet is scaled down to fit between the margins. To put an
|
||||||
|
image on a page of its own, press **New** first.
|
||||||
|
* **Notebook** starts a new page for everyone from a page kept in one of
|
||||||
|
your notebooks, with everything that was written on it. A chooser
|
||||||
|
shows your notebooks and small pictures of their kept pages. The kept
|
||||||
|
page itself is not changed: the session works on a copy.
|
||||||
|
|
||||||
|
An image cannot be moved or removed once anyone has written on it or
|
||||||
|
added anything after it, since the marks on it would make no sense
|
||||||
|
without it. The one exception is a mistake caught at once: the person
|
||||||
|
who added an image can take it back with a tap of the eraser on it, as
|
||||||
|
long as nothing else has happened on the page since.
|
||||||
|
|
||||||
|
The right stays with you until the host withdraws it or the session ends.
|
||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
# A host's Kokkoja
|
||||||
|
|
||||||
|
The host starts a session, invites the guests and drives the session:
|
||||||
|
puts images on the page, starts new pages, loads pages from a notebook,
|
||||||
|
and decides which guests may do the same. In a lesson, the teacher is
|
||||||
|
the host. Everything in [A guest's Kokkoja](guest.md) applies to the host
|
||||||
|
too; this part describes what is added, and how to install the Kokkoja
|
||||||
|
program, which a host needs.
|
||||||
|
|
||||||
|
## Installing the Kokkoja program
|
||||||
|
|
||||||
|
A guest can join from a web site with nothing installed. A host runs the
|
||||||
|
Kokkoja program on their own computer. It is a small Python program with
|
||||||
|
two jobs: it shows Kokkoja in your web browser, and it reads and writes
|
||||||
|
your notebooks as ordinary directories and files. It listens only to
|
||||||
|
your own computer and talks to nothing else but the relay you name.
|
||||||
|
|
||||||
|
You need:
|
||||||
|
|
||||||
|
* **Linux or macOS.** Windows has not been tried yet.
|
||||||
|
* **Python 3.** Linux has it. On macOS, open Terminal and type
|
||||||
|
`python3 --version`; if Python is not installed, macOS offers to
|
||||||
|
install the command line developer tools, which include it. Any Python
|
||||||
|
from 3.9 on will do.
|
||||||
|
* **A web browser.** Firefox, Brave or Chrome; other browsers built on
|
||||||
|
the same engines very probably work.
|
||||||
|
* **A pen tablet**, if you want to write by hand. A mouse works for
|
||||||
|
pointing and for rough marks.
|
||||||
|
|
||||||
|
Get Kokkoja from its repository, either by downloading it as a zip file
|
||||||
|
and unpacking it, or with git:
|
||||||
|
|
||||||
|
git clone https://git.andykopra.com/ack/kokkoja-rel.git kokkoja
|
||||||
|
|
||||||
|
Nothing else is installed and nothing is changed on your system. To
|
||||||
|
update later, download again or run `git pull` in that directory. To
|
||||||
|
remove Kokkoja, delete the directory; your notebooks are elsewhere and
|
||||||
|
stay.
|
||||||
|
|
||||||
|
## Starting Kokkoja
|
||||||
|
|
||||||
|
Open a terminal in the Kokkoja directory and run
|
||||||
|
|
||||||
|
python3 kokkoja.py --relay wss://kokkoja.andykopra.com/relay
|
||||||
|
|
||||||
|
The program prints an address beginning `http://localhost:8760/` and
|
||||||
|
opens your browser there. Leave the terminal open: the program must run
|
||||||
|
as long as you are in a session. Ctrl-C stops it.
|
||||||
|
|
||||||
|
The address the program prints contains a secret token. Your browser
|
||||||
|
remembers it, so afterwards `http://localhost:8760/` alone brings Kokkoja
|
||||||
|
back; but if the browser ever says that the notebooks are locked, open
|
||||||
|
the full address the program printed. The token is what stops any other
|
||||||
|
web page you might have open from reading your notebooks.
|
||||||
|
|
||||||
|
The relay address names the *Kokkoja server* you use. A server does two
|
||||||
|
things: it passes the participants' messages, encrypted so that it
|
||||||
|
cannot read them, and it serves the web site that your guests join from.
|
||||||
|
The address above is Andy's server; whoever runs a server for you gives
|
||||||
|
you its address. If you give the program no relay, it expects one on
|
||||||
|
your own computer, which is useful only for trying Kokkoja alone.
|
||||||
|
|
||||||
|
Other options, all optional:
|
||||||
|
|
||||||
|
* `--notebooks DIRECTORY` keeps the notebooks somewhere other than
|
||||||
|
`Kokkoja` in your Documents directory.
|
||||||
|
* `--port NUMBER` uses another port than 8760, if that one is taken.
|
||||||
|
* `--no-browser` does not open a browser; open the printed address
|
||||||
|
yourself.
|
||||||
|
* `--site ADDRESS` names the web site that invitations link to. You
|
||||||
|
need it only if your server's web site is not at the relay's host.
|
||||||
|
|
||||||
|
## Starting a session
|
||||||
|
|
||||||
|
On the start screen, enter your name and the notebook for this session,
|
||||||
|
and press **Start a session**. You are on page 1, a blank sheet, and the
|
||||||
|
panel on the right shows the invitation.
|
||||||
|
|
||||||
|
### The notebook
|
||||||
|
|
||||||
|
The notebook you name is a directory under `Kokkoja` in your Documents
|
||||||
|
directory, made when it is first needed, and the start screen offers the
|
||||||
|
names of the notebooks you already have. For teaching, a notebook per
|
||||||
|
student works well: everything a student is given, with everything they
|
||||||
|
and you wrote on it, gathers in that student's notebook, and the next
|
||||||
|
lesson with that student starts by naming it again. Your library of
|
||||||
|
exercises can live in notebooks of its own, such as *Exercises A1*, filled
|
||||||
|
by keeping pages in a session of your own with nobody else present.
|
||||||
|
|
||||||
|
The notebook can be changed during a session, in **Current notebook** in
|
||||||
|
the panel. The page you are on then continues in the new notebook.
|
||||||
|
|
||||||
|
### The invitation
|
||||||
|
|
||||||
|
**Invitation** in the panel shows a link. **Copy** copies it; send it to
|
||||||
|
your guests by email, in a chat message, or however you reach them. A
|
||||||
|
guest opens it, enters a name and is in. A guest with the Kokkoja
|
||||||
|
program installed may paste it into their own program instead. The link
|
||||||
|
carries the secret that protects the session, so send it only to those
|
||||||
|
who should be there; anyone with the link can join and read the page.
|
||||||
|
Each session you start has a new invitation.
|
||||||
|
|
||||||
|
An invitation is good as long as you keep the session open. A guest can
|
||||||
|
join at any time, see everything written so far, and leave and return.
|
||||||
|
|
||||||
|
## Driving the session
|
||||||
|
|
||||||
|
Three buttons at the right of the toolbar are the host's.
|
||||||
|
|
||||||
|
**New** starts a new blank page for everyone. Pages are numbered from 1
|
||||||
|
through the session. The page being left is kept in every participant's
|
||||||
|
notebook, by itself, under a dated name; nobody has to remember to save.
|
||||||
|
|
||||||
|
**Image** puts an image file on the page for everyone. Kokkoja's own
|
||||||
|
file chooser opens, beginning where your notebooks are and remembering
|
||||||
|
where you last chose from; a directory path can also be typed into it.
|
||||||
|
It shows small pictures of the image files it finds. Choose one and it
|
||||||
|
is placed on the page: the first at the top of the sheet, each further
|
||||||
|
one 8 mm below the lowest image there, centred, at its natural size. An
|
||||||
|
image wider than the sheet is scaled down to fit between the margins, so
|
||||||
|
a full-page A4 scan appears at about 92%; the chooser has a setting to
|
||||||
|
turn that off, in which case the sheet widens to the image instead. PNG
|
||||||
|
and JPEG files are shared as they are unless very large; other formats,
|
||||||
|
and images over 2400 pixels on a side or 1.5 MB, are converted to JPEG
|
||||||
|
and reduced first. A page holds up to 12 images.
|
||||||
|
|
||||||
|
For a page that begins with an image, press **New** and then **Image**.
|
||||||
|
For a series of exercises, keep pressing **Image** on the same page: the
|
||||||
|
sheet grows downward and everyone scrolls through them. A new image does
|
||||||
|
not change anyone's zoom; it appears below, to be scrolled to.
|
||||||
|
|
||||||
|
If you chose the wrong file, a tap of the eraser on the image takes it
|
||||||
|
back, as long as nothing else has happened on the page since: no stroke,
|
||||||
|
no text, no erasure, no further image, by anyone. After that an image
|
||||||
|
stays, because the marks made on it would make no sense without it.
|
||||||
|
|
||||||
|
**Notebook** starts a new page for everyone from a page kept in one of
|
||||||
|
your notebooks, with everything that was written on it. A chooser shows
|
||||||
|
your notebooks on the left and small pictures of the chosen notebook's
|
||||||
|
kept pages on the right. The loaded page is a copy: the kept page is
|
||||||
|
not changed by anything that happens next. Each layer on it still
|
||||||
|
belongs to the participant who wrote it, so a student's answers from an
|
||||||
|
earlier lesson are still theirs, and your corrections still yours.
|
||||||
|
|
||||||
|
This is also how you return to an earlier page of the session: the page
|
||||||
|
was kept when you left it, and **Notebook** brings it back as a new page.
|
||||||
|
A lesson might go: **New**, **Image** for the exercise, work, **New**
|
||||||
|
for a point of grammar, work, **Notebook** to return to the exercise.
|
||||||
|
|
||||||
|
## Guests' rights
|
||||||
|
|
||||||
|
In the **Participants** list, each guest's name has a button **pages** in
|
||||||
|
front of it. Pressing it lets that guest start new pages, put images on
|
||||||
|
the page and load pages from their notebook, exactly as you can; the
|
||||||
|
three buttons appear on their toolbar. Pressing it again withdraws the
|
||||||
|
right, which otherwise lasts until the session ends. Guests can never erase or
|
||||||
|
change anyone's writing but their own; that needs no setting.
|
||||||
|
|
||||||
|
## Hiding writing
|
||||||
|
|
||||||
|
The box in front of each participant's name shows or hides that
|
||||||
|
participant's writing on your screen only. Hide a student's answers to
|
||||||
|
see the exercise clean, hide your own corrections to see what the
|
||||||
|
student sees, show them again; the others notice nothing. **Export**
|
||||||
|
leaves hidden writing out of the picture it makes, which is how to make a
|
||||||
|
picture of the exercise with the corrections alone.
|
||||||
|
|
||||||
|
## Keeping and exporting
|
||||||
|
|
||||||
|
The page you are on is written continuously to `Current.svg` in the
|
||||||
|
current notebook, and copied to a dated file whenever the session moves
|
||||||
|
on. **Keep page** makes such a copy now, named for the day and a serial
|
||||||
|
number, such as `2026_10_06_02.svg`; pressing it again before anything
|
||||||
|
has changed makes no second copy. Use it to mark a state worth keeping
|
||||||
|
on its own, or to add a finished page to a notebook of exercises.
|
||||||
|
|
||||||
|
**Export** saves a picture of the page as you see it, as a PNG file at
|
||||||
|
200 dots per inch, in a directory and under a name you choose in
|
||||||
|
Kokkoja's file chooser. Replacing an existing file takes a second press
|
||||||
|
of the button, so a slip does not overwrite anything.
|
||||||
|
|
||||||
|
The notebooks are ordinary directories, and a page is an ordinary SVG
|
||||||
|
file. Open one in a browser or in Inkscape, attach it to an email, keep
|
||||||
|
it with your other documents. Only Kokkoja can load it as a live page
|
||||||
|
again, with each person's writing still theirs.
|
||||||
|
|
||||||
|
## Ending a session
|
||||||
|
|
||||||
|
Close the browser tab, or stop the program with Ctrl-C in its terminal.
|
||||||
|
There is nothing to save: the page is already in your notebook, and in
|
||||||
|
each guest's. Guests who are still connected can go on writing on the
|
||||||
|
page among themselves, but no new page can be started without you or a
|
||||||
|
guest you have given the right, and a guest who arrives after everyone
|
||||||
|
has left finds an empty session. The next lesson is a new session with a
|
||||||
|
new invitation; load the pages you need from the student's notebook.
|
||||||
|
|
||||||
|
## When something goes wrong
|
||||||
|
|
||||||
|
* **The browser says the notebooks are locked.** Open Kokkoja from the
|
||||||
|
full address the program printed when it started, the one with the
|
||||||
|
token.
|
||||||
|
* **"Disconnected" in the toolbar.** Kokkoja reconnects by itself as
|
||||||
|
soon as the relay can be reached, and sends what was written meanwhile.
|
||||||
|
If it does not come back, check your connection; the page is safe in
|
||||||
|
your notebook either way.
|
||||||
|
* **A guest sees "Waiting for the host's page".** You have not started
|
||||||
|
a page yet, or your connection is down. The first page is started when
|
||||||
|
you start the session, so this usually means the latter.
|
||||||
|
* **Pressure does not work.** The last line of the panel reports what
|
||||||
|
the browser receives from your pen. If it says *mouse*, the browser is
|
||||||
|
not seeing the pen as a pen; try the other browser.
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# How Kokkoja works
|
||||||
|
|
||||||
|
This part is for the curious, for anyone who wants to run a Kokkoja
|
||||||
|
server, and for anyone who wants to read or change the code. It
|
||||||
|
describes the parts, what travels between them, what is protected and
|
||||||
|
what is not, and how pages are stored. The code is the final word; it
|
||||||
|
is short and commented.
|
||||||
|
|
||||||
|
## The parts
|
||||||
|
|
||||||
|
Kokkoja is three programs and a few static files.
|
||||||
|
|
||||||
|
* **The client** (`client/`) runs in the web browser and does everything
|
||||||
|
the user sees: the pen, the page, the text, the encryption, the
|
||||||
|
saving. It is plain JavaScript modules, run by the browser as written,
|
||||||
|
with no build step and no third-party packages. Its types are written
|
||||||
|
in JSDoc comments and checked by the TypeScript compiler during
|
||||||
|
development.
|
||||||
|
* **The local program** (`kokkoja.py`) runs on the user's computer. It
|
||||||
|
serves the client to the browser at `http://localhost:8760/`, keeps the
|
||||||
|
user's identity, and reads and writes the notebook directories, which
|
||||||
|
a web page cannot do on its own. It uses nothing but Python's standard
|
||||||
|
library.
|
||||||
|
* **The relay** (`relay/kokkoja_relay.py`) runs on a server that every
|
||||||
|
participant can reach. It passes messages between the members of a
|
||||||
|
session and nothing more: it cannot read them and it stores nothing.
|
||||||
|
It is Python with one library, `websockets`.
|
||||||
|
* **The web site** is the `client/` directory served over HTTPS, with a
|
||||||
|
`config.json` beside it, so that a guest can join without the local
|
||||||
|
program. `deploy/` has the server setup.
|
||||||
|
|
||||||
|
Every participant's computer holds the whole page. Only changes travel:
|
||||||
|
a stroke as it is drawn, a text block as it is typed, an erasure, a new
|
||||||
|
page, an image once. There is no central copy.
|
||||||
|
|
||||||
|
## Why the relay exists
|
||||||
|
|
||||||
|
Two computers behind home routers generally cannot connect to each other
|
||||||
|
directly, so something in between has to pass the messages. The relay is
|
||||||
|
the smallest such thing: a WebSocket server with *rooms*. A client
|
||||||
|
connects to `wss://<server>/relay/room/<room id>`; the relay gives the
|
||||||
|
connection a number, tells the room that a peer has arrived, and from
|
||||||
|
then on sends every message it receives from one member to all the
|
||||||
|
others, stamped with the sender's number. When the last member leaves,
|
||||||
|
the room is gone. It holds at most 32 members per room and 500 rooms,
|
||||||
|
and refuses messages over 1 MB; clients split anything larger.
|
||||||
|
|
||||||
|
## Sessions and encryption
|
||||||
|
|
||||||
|
The invitation is
|
||||||
|
|
||||||
|
<relay address>#<secret>.<host id>
|
||||||
|
|
||||||
|
The *secret* is 16 random bytes, made by the host's browser. From it
|
||||||
|
every client derives two values with HKDF-SHA-256: the *room id*, which
|
||||||
|
names the room at the relay, and the *message key*, a 256-bit AES-GCM
|
||||||
|
key with which every message is encrypted before it is sent and
|
||||||
|
decrypted on arrival. The relay sees the room id, the members' network
|
||||||
|
addresses, and the size and timing of encrypted messages. It cannot
|
||||||
|
compute the key from the room id, so it cannot read anything. Neither
|
||||||
|
can anyone watching the network between the participants and the relay,
|
||||||
|
which is TLS-protected in any case.
|
||||||
|
|
||||||
|
A link invitation wraps the same text:
|
||||||
|
|
||||||
|
https://<site>/#join=<invitation>
|
||||||
|
|
||||||
|
Browsers never send the part of an address after `#` to the site, so the
|
||||||
|
secret stays between the host and the guests.
|
||||||
|
|
||||||
|
## Identity and layers
|
||||||
|
|
||||||
|
Each participant has a signing key pair (ECDSA on P-256), made in the
|
||||||
|
browser the first time and then kept, by the local program in its
|
||||||
|
configuration directory or by the browser in its own storage. The
|
||||||
|
*participant id* is the first 16 bytes of the SHA-256 hash of the public
|
||||||
|
key. When a client joins a room it announces itself with a signed
|
||||||
|
`hello` carrying its name and public key; the signature covers the room
|
||||||
|
id and the peer number the relay stamped on the message, so nobody can
|
||||||
|
replay it elsewhere or claim another peer's connection. Until a peer has
|
||||||
|
sent a valid `hello`, everything else from it is ignored.
|
||||||
|
|
||||||
|
A page is a stack of layers, one per participant, keyed by participant
|
||||||
|
id. A client accepts a change to a layer only from the participant whose
|
||||||
|
id it is, so the rule "nobody can change anyone else's writing" is not a
|
||||||
|
permission that could be misconfigured but a consequence of how the data
|
||||||
|
is organised. Because the id is derived from the key, and the key is
|
||||||
|
kept, a person is the same participant in every session from the same
|
||||||
|
computer, and their writing on a page loaded weeks later is still theirs.
|
||||||
|
|
||||||
|
The host is whoever holds the key whose id is in the invitation. Pages,
|
||||||
|
images and the list of guests allowed to start pages are accepted only
|
||||||
|
from the host (or from an allowed guest, for pages and images).
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
Page coordinates are in units of 0.1 mm. A blank page is an A4 sheet,
|
||||||
|
2100 by 2970 units. Writing within 8 mm of an edge grows the sheet by
|
||||||
|
8 mm on that side. An image is placed at its natural size, taken from
|
||||||
|
the resolution recorded in the file, or 0.1 mm per pixel when there is
|
||||||
|
none, centred across the sheet, 8 mm from the top or from the image
|
||||||
|
above, scaled down if wider than the sheet inside its margins (or, if the
|
||||||
|
person adding it chooses, placed at natural size with the sheet widened
|
||||||
|
to hold it). A page holds at most 12 images and 24 MB of image data.
|
||||||
|
An image file over 2400 pixels on a side or 1.5 MB, or not PNG or JPEG,
|
||||||
|
is redrawn at most 2400 pixels and stored as JPEG before it is shared;
|
||||||
|
smaller PNG and JPEG files are shared byte for byte.
|
||||||
|
|
||||||
|
A stroke is a list of points with pressure, and the line width is a
|
||||||
|
function of pressure: nominal at a pressure of 0.4, about 2.6 times that
|
||||||
|
at 0.75. Points are sent while the stroke is drawn, so the others see it
|
||||||
|
grow. A text block is sent whole at every keystroke, with its line
|
||||||
|
breaks already laid out by the writer's browser, so that everyone shows
|
||||||
|
the same lines.
|
||||||
|
|
||||||
|
Each page has a number, counted from 1 in the session, and a random id.
|
||||||
|
A client that joins late asks the longest-connected member for the
|
||||||
|
layers of absent participants, and receives each present participant's
|
||||||
|
layer from that participant. A client that reconnects after a dropped
|
||||||
|
connection sends its whole layer again, so that nothing drawn meanwhile
|
||||||
|
is lost.
|
||||||
|
|
||||||
|
## The page file
|
||||||
|
|
||||||
|
A notebook is a directory. The page being written is `Current.svg` in
|
||||||
|
it, rewritten a moment after every change (at most every 400 ms while
|
||||||
|
writing continues, at least every 2 s). A *kept* page is a copy of
|
||||||
|
`Current.svg` named `YYYY_MM_DD_NN.svg`, the day being when the page was
|
||||||
|
last changed and NN a serial number within that day. A copy is made when
|
||||||
|
the user asks (**Keep page**), and by the program whenever `Current.svg`
|
||||||
|
is about to be given to another page: when the session moves to a new
|
||||||
|
page, when the notebook is changed, and when a session begins and finds
|
||||||
|
a page of another session in the notebook. Nothing is copied if the
|
||||||
|
page is blank or an identical copy already exists.
|
||||||
|
|
||||||
|
The file is an SVG document that any viewer displays: the images as
|
||||||
|
embedded `<image>` elements, each layer as a group with the participant's
|
||||||
|
name and id, each stroke as a path and each text block as text.
|
||||||
|
Beside the shapes, the same elements carry Kokkoja's own data in
|
||||||
|
attributes, the exact points and pressures, the text and its breaks, so
|
||||||
|
that Kokkoja reads the page back without loss, with each layer still
|
||||||
|
belonging to its participant. The session id and host id are recorded
|
||||||
|
too, which is how a reloaded tab knows that `Current.svg` is its own page
|
||||||
|
to continue from.
|
||||||
|
|
||||||
|
With the local program, the notebooks are in `Kokkoja` in the Documents
|
||||||
|
directory (`--notebooks` chooses another place), the identity and the
|
||||||
|
browser's token in the configuration directory (`~/.config/kokkoja` on
|
||||||
|
Linux, `~/Library/Application Support/kokkoja` on macOS). In the
|
||||||
|
browser, the notebooks are in the site's IndexedDB under the same names
|
||||||
|
and rules, and the identity in its localStorage; a kept page and an
|
||||||
|
exported picture are offered as downloads, since that is the only way a
|
||||||
|
web page can hand a file to its user.
|
||||||
|
|
||||||
|
## The local program and its token
|
||||||
|
|
||||||
|
The local program listens on 127.0.0.1 only. Any web page open in the
|
||||||
|
browser could nevertheless send requests to `localhost:8760`, so every
|
||||||
|
request that touches files must carry a secret token, which the program
|
||||||
|
makes once, keeps in its configuration directory, and passes to the
|
||||||
|
browser in the address it opens (`#token=...`, which the browser keeps
|
||||||
|
in its local storage and never sends to any server). Requests without
|
||||||
|
the token are refused. The program also checks that requests come from
|
||||||
|
its own origin. Outside the notebooks it reads only image files and
|
||||||
|
writes only PNG files, each where the file chooser was pointed.
|
||||||
|
|
||||||
|
## The web site, and whom you trust
|
||||||
|
|
||||||
|
A client served by a web site is a web application like any other: the
|
||||||
|
site operator's code runs in your browser. The end-to-end encryption
|
||||||
|
protects the session from the relay and the network, but a dishonest
|
||||||
|
site could serve a client that gives the key away. Participants who run
|
||||||
|
the local program load the client from their own disk and are not
|
||||||
|
exposed to that; they trust the code they downloaded once. The site
|
||||||
|
sends a Content Security Policy that lets the client load nothing from
|
||||||
|
anywhere else, which protects against the ordinary web attacks but not
|
||||||
|
against the operator. For a lesson this is the same trust one places in
|
||||||
|
any video call service, and considerably less than in one that sees the
|
||||||
|
content; it is stated here so that nobody is surprised.
|
||||||
|
|
||||||
|
## Running a server
|
||||||
|
|
||||||
|
A server is a machine with a public name, TLS and nginx (or any reverse
|
||||||
|
proxy that passes WebSockets). `deploy/README.md` describes the one
|
||||||
|
Kokkoja comes with: the relay as a systemd service bound to localhost,
|
||||||
|
nginx terminating TLS, serving the client's files at `https://<name>/`
|
||||||
|
and forwarding `wss://<name>/relay/` to the relay, and a `config.json`
|
||||||
|
telling the client which relay to use and that notebooks are kept in the
|
||||||
|
browser. A host uses the server with
|
||||||
|
|
||||||
|
python3 kokkoja.py --relay wss://<name>/relay
|
||||||
|
|
||||||
|
and invitations become links to `https://<name>/`. One server serves
|
||||||
|
any number of hosts and sessions at once; the relay's work per session
|
||||||
|
is passing a few kilobytes per second. Since the relay cannot read what
|
||||||
|
it passes, a server run by someone else is a matter of availability, not
|
||||||
|
of privacy; the web site is the part that requires trust, as described
|
||||||
|
above. A teacher who wants no dependence on anyone's server needs a
|
||||||
|
machine of their own that is reachable from the Internet, which a
|
||||||
|
computer behind a home router is not without arrangement.
|
||||||
|
|
||||||
|
## What is not protected yet
|
||||||
|
|
||||||
|
* Only `hello` is signed. A participant working together with a
|
||||||
|
dishonest relay could forge another participant's strokes. Signing
|
||||||
|
every message, or page descriptors with the host's key, would close
|
||||||
|
this.
|
||||||
|
* A participant can announce a page with a higher number than the
|
||||||
|
host's, and the others move to it.
|
||||||
|
* The relay sees who talks to whom, when, and how much.
|
||||||
|
|
||||||
|
## Pen input
|
||||||
|
|
||||||
|
The client uses the browser's pointer events, which deliver every sample
|
||||||
|
a pen produces, with pressure, in Firefox and in Chromium-based browsers
|
||||||
|
on Linux and macOS. The panel's last line shows what arrives. When a
|
||||||
|
pen has been used, touches are ignored, so a hand resting on a touch
|
||||||
|
screen does not draw; this rule has not yet been tested with a device
|
||||||
|
that has both a pen and a touch sensor. Kokkoja opens none of the
|
||||||
|
browser's system dialogs (the colour palette and, with the local
|
||||||
|
program, the file chooser are its own), because one such dialog crashed
|
||||||
|
Firefox on Linux.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
The code is Python and JavaScript only. The tests run with
|
||||||
|
|
||||||
|
.venv/bin/python -m unittest discover tests -v
|
||||||
|
|
||||||
|
where `tests/test_end_to_end.py` drives a headless Firefox through
|
||||||
|
geckodriver with several participants, with and without the local
|
||||||
|
program, and `npm run check` type-checks the client (the TypeScript
|
||||||
|
compiler is the only development dependency). The relay's `--host` and
|
||||||
|
`--port` and the local program's options are listed by `--help`.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Kokkoja: an introduction
|
||||||
|
|
||||||
|
Kokkoja lets people at different computers write together on a shared
|
||||||
|
*page*. Everyone in a session sees the same page. Anyone can write on it
|
||||||
|
by hand or type on it, and what one person writes appears on the others'
|
||||||
|
screens as it is written. A page can begin blank, or it can begin with an
|
||||||
|
image, such as a scanned sheet or a photograph, which everyone then writes
|
||||||
|
on. Every page is saved automatically to a *notebook* on each person's
|
||||||
|
own computer, so that after the session each participant keeps a complete
|
||||||
|
copy of everything that was written.
|
||||||
|
|
||||||
|
Kokkoja runs in a web browser, beside whatever video call you are already
|
||||||
|
using. It replaces the screen sharing and annotation of a video call
|
||||||
|
application with something made for writing together, and it leaves the
|
||||||
|
voice and video to the call.
|
||||||
|
|
||||||
|
## What Kokkoja does
|
||||||
|
|
||||||
|
**A shared page, not a shared screen.** In a video call one person's
|
||||||
|
screen is transmitted as a picture, and annotations float on top of that
|
||||||
|
picture. In Kokkoja every participant's computer holds the page itself:
|
||||||
|
the image, if there is one, and every mark made on it. Only the changes
|
||||||
|
travel over the network. The marks belong to the page, are saved with it,
|
||||||
|
and can be shown or hidden later.
|
||||||
|
|
||||||
|
**Handwriting and typing.** Kokkoja is made for a pen on a drawing
|
||||||
|
tablet, with pressure giving the line its thickness, but a mouse or a
|
||||||
|
finger will do. Typed text is placed anywhere on the page and wraps at a
|
||||||
|
width you choose. Both come in any colour.
|
||||||
|
|
||||||
|
**Everyone writes on their own layer.** Each participant's writing is a
|
||||||
|
layer of its own. You can erase your own writing and nothing else; nobody
|
||||||
|
else can erase yours. Any layer can be hidden on your own screen without
|
||||||
|
affecting anyone else's view.
|
||||||
|
|
||||||
|
**A page that grows.** A page is an A4 sheet. When writing approaches an
|
||||||
|
edge, the sheet grows in that direction, so you are never short of room.
|
||||||
|
An image is placed at its natural size; one wider than the sheet is
|
||||||
|
scaled down to fit, and the sheet grows downward to hold further images.
|
||||||
|
|
||||||
|
**Saved without a save command.** The page you are on is written to your
|
||||||
|
notebook continuously, as it changes. Nothing is lost if a connection
|
||||||
|
drops or a computer crashes. When the session moves to another page, the
|
||||||
|
page being left is kept as a dated file. You can also keep a dated copy
|
||||||
|
of a page at any moment, and export a picture of it as a PNG file.
|
||||||
|
|
||||||
|
**Notebooks are yours.** A notebook is a directory on your own computer
|
||||||
|
and a page is a single file in it, an ordinary SVG file that any
|
||||||
|
image viewer or vector drawing program can open. Nothing is stored on any
|
||||||
|
server, and there are no accounts: you choose the name the others will
|
||||||
|
see, and that is all.
|
||||||
|
|
||||||
|
**Private.** Everything that travels between participants is encrypted
|
||||||
|
on their own computers. The relay server that passes the messages cannot
|
||||||
|
read them. The invitation to a session carries the key, so only those
|
||||||
|
who were invited can read the page.
|
||||||
|
|
||||||
|
**Nothing to install for guests.** A guest joins by opening a link in a
|
||||||
|
web browser. Someone who wants a notebook of real files on their own
|
||||||
|
computer installs the small Kokkoja program instead; that is also how a
|
||||||
|
host runs Kokkoja.
|
||||||
|
|
||||||
|
**Free software.** Kokkoja is free and open-source. Anyone can use it
|
||||||
|
without paying, and nobody depends on a company's service. It runs in
|
||||||
|
Firefox and in browsers built on the Chromium engine, such as Brave and
|
||||||
|
Chrome, on Linux and macOS.
|
||||||
|
|
||||||
|
## Who uses it
|
||||||
|
|
||||||
|
Kokkoja is a general tool for working on a page together at a distance:
|
||||||
|
going through a document with a colleague, sketching a diagram while
|
||||||
|
talking it over, correcting a draft, teaching anything that is done on
|
||||||
|
paper. Wherever two people would otherwise lean over one sheet, Kokkoja
|
||||||
|
puts the sheet between their computers.
|
||||||
|
|
||||||
|
Its ideal use, and the one it was made for first, is teaching a foreign
|
||||||
|
language over a video call. The teacher puts a scanned exercise on the
|
||||||
|
page. The student fills it in by hand or by typing, during the lesson,
|
||||||
|
while the teacher watches, corrects mistakes as they are made, and adds
|
||||||
|
notes in the margin. Each correction is in the teacher's own hand and
|
||||||
|
colour, on the teacher's own layer, where the student cannot change it.
|
||||||
|
When the lesson is over, the student's notebook holds every exercise of
|
||||||
|
the lesson, with the student's answers and the teacher's corrections
|
||||||
|
together on one page, and the teacher's notebook holds the same pages as
|
||||||
|
a record of what the student was given. The teacher's notebooks of
|
||||||
|
exercises become a library: loading a page from one of them puts a fresh
|
||||||
|
copy in front of a new student, and the original is never changed.
|
||||||
|
|
||||||
|
## Hosts and guests
|
||||||
|
|
||||||
|
A session has one *host*, the person who starts it, and any number of
|
||||||
|
*guests*. In a lesson the teacher is the host and the students are the
|
||||||
|
guests. The host sends the guests an invitation, by email, in a chat
|
||||||
|
message, or by any other means. The host *drives*: only the host puts
|
||||||
|
images on the page, starts new pages or loads pages from a notebook, and
|
||||||
|
everyone follows. The host can give any guest the same abilities, for a
|
||||||
|
discussion among colleagues.
|
||||||
|
|
||||||
|
The rest of this documentation describes Kokkoja from the guest's point of
|
||||||
|
view first, since the host sees the same screen and has a few more
|
||||||
|
buttons, then from the host's, and finally explains how it works
|
||||||
|
underneath, for those who want to run their own server or read the code.
|
||||||
|
|
||||||
|
* [A guest's Kokkoja](guest.md)
|
||||||
|
* [A host's Kokkoja](host.md)
|
||||||
|
* [How Kokkoja works](implementation.md)
|
||||||
+496
@@ -0,0 +1,496 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Kokkoja local program.
|
||||||
|
|
||||||
|
Serves the Kokkoja client to a web browser on this computer, and reads
|
||||||
|
and writes the user's notebooks for it. A notebook is a directory; a page
|
||||||
|
is one file in it. It also lets the client's own file chooser look through
|
||||||
|
the user's directories for image files, and writes an exported picture of
|
||||||
|
a page where the user chooses, neither of which a web page can do itself.
|
||||||
|
|
||||||
|
The client must be loaded from this computer (or over HTTPS) because
|
||||||
|
browsers allow their built-in cryptography only there.
|
||||||
|
|
||||||
|
Only the user's own browser may use the notebooks. The program listens on
|
||||||
|
this computer alone, and every notebook request must carry a secret token.
|
||||||
|
The browser gets the token from the address this program opens or prints;
|
||||||
|
another user of the computer, or a web page from elsewhere, does not have it.
|
||||||
|
|
||||||
|
Uses only the Python standard library.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import functools
|
||||||
|
import hmac
|
||||||
|
import http.server
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
CLIENT_DIRECTORY = pathlib.Path(__file__).resolve().parent / "client"
|
||||||
|
|
||||||
|
MAX_PAGE_BYTES = 64 << 20
|
||||||
|
|
||||||
|
# The page being written in a notebook, and the pages kept from it.
|
||||||
|
CURRENT_PAGE = "Current.svg"
|
||||||
|
KEPT_PAGE = re.compile(r"^(\d{4}_\d{2}_\d{2})_(\d{2,})\.svg$")
|
||||||
|
|
||||||
|
# Notebook files are changed by one request at a time.
|
||||||
|
notebook_lock = threading.Lock()
|
||||||
|
|
||||||
|
# A notebook or page name: one path component, not hidden, no control
|
||||||
|
# characters, no characters that a file system might refuse.
|
||||||
|
NAME = re.compile(r'^[^\x00-\x1f/\\:*?"<>|.][^\x00-\x1f/\\:*?"<>|]{0,119}$')
|
||||||
|
|
||||||
|
# The image files the file chooser offers, and what a browser is told they
|
||||||
|
# are. The chooser reads these and nothing else.
|
||||||
|
IMAGE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp"}
|
||||||
|
MAX_IMAGE_FILE_BYTES = 64 << 20
|
||||||
|
|
||||||
|
# An exported page is a PNG file, and nothing else is written outside the
|
||||||
|
# notebooks: the name must end in .png and the bytes must begin like this.
|
||||||
|
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||||
|
|
||||||
|
LOCAL_HOST = re.compile(r"^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$")
|
||||||
|
LOCAL_ORIGIN = re.compile(r"^http://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$")
|
||||||
|
|
||||||
|
|
||||||
|
def config_directory():
|
||||||
|
"""Where Kokkoja keeps its own settings, by each system's convention."""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
base = os.environ.get("APPDATA") or pathlib.Path.home() / "AppData" / "Roaming"
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
base = pathlib.Path.home() / "Library" / "Application Support"
|
||||||
|
else:
|
||||||
|
base = os.environ.get("XDG_CONFIG_HOME") or pathlib.Path.home() / ".config"
|
||||||
|
return pathlib.Path(base) / "kokkoja"
|
||||||
|
|
||||||
|
|
||||||
|
def default_notebooks_directory():
|
||||||
|
"""A Kokkoja directory in the user's documents directory."""
|
||||||
|
documents = pathlib.Path.home() / "Documents"
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
# The documents directory has another name in other languages.
|
||||||
|
try:
|
||||||
|
found = subprocess.run(["xdg-user-dir", "DOCUMENTS"], capture_output=True,
|
||||||
|
text=True, timeout=5).stdout.strip()
|
||||||
|
if found and pathlib.Path(found) != pathlib.Path.home():
|
||||||
|
documents = pathlib.Path(found)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
return documents / "Kokkoja"
|
||||||
|
|
||||||
|
|
||||||
|
def load_token(directory):
|
||||||
|
"""The secret token, made on first use and kept in a private file."""
|
||||||
|
path = directory / "token"
|
||||||
|
try:
|
||||||
|
token = path.read_text().strip()
|
||||||
|
if token:
|
||||||
|
return token
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
token = secrets.token_urlsafe(24)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(descriptor, "w") as file:
|
||||||
|
file.write(token + "\n")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def write_atomically(path, data):
|
||||||
|
"""Replace a file so that it is never seen half written."""
|
||||||
|
temporary = path.with_name("." + path.name + ".writing")
|
||||||
|
with open(temporary, "wb") as file:
|
||||||
|
file.write(data)
|
||||||
|
file.flush()
|
||||||
|
os.fsync(file.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
def keep_page(notebook):
|
||||||
|
"""Keep a copy of the notebook's current page as a dated page.
|
||||||
|
|
||||||
|
The copy is named for the day the page was last changed, with a serial
|
||||||
|
number: 2026_10_02_01.svg. Nothing is copied if the page is blank or
|
||||||
|
if an identical page is already kept.
|
||||||
|
|
||||||
|
Returns (name, new): the name of the kept page, or None if there was
|
||||||
|
nothing to keep, and whether the copy was made just now.
|
||||||
|
"""
|
||||||
|
current = notebook / CURRENT_PAGE
|
||||||
|
try:
|
||||||
|
data = current.read_bytes()
|
||||||
|
changed = current.stat().st_mtime
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None, False
|
||||||
|
if b"data-stroke=" not in data and b"<image" not in data:
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
kept = [entry for entry in notebook.iterdir()
|
||||||
|
if KEPT_PAGE.match(entry.name) and entry.is_file()]
|
||||||
|
for entry in kept:
|
||||||
|
if entry.stat().st_size == len(data) and entry.read_bytes() == data:
|
||||||
|
return entry.name, False
|
||||||
|
|
||||||
|
date = time.strftime("%Y_%m_%d", time.localtime(changed))
|
||||||
|
serials = [int(match.group(2)) for match in map(KEPT_PAGE.match, (e.name for e in kept))
|
||||||
|
if match.group(1) == date]
|
||||||
|
name = f"{date}_{max(serials, default=0) + 1:02d}.svg"
|
||||||
|
write_atomically(notebook / name, data)
|
||||||
|
os.utime(notebook / name, (changed, changed))
|
||||||
|
return name, True
|
||||||
|
|
||||||
|
|
||||||
|
MAX_IDENTITY_BYTES = 4096
|
||||||
|
|
||||||
|
|
||||||
|
def site_of(relay, site):
|
||||||
|
"""The Kokkoja web site that invitations link to, or "".
|
||||||
|
|
||||||
|
Given explicitly, it is used as given. Otherwise it is taken from the
|
||||||
|
relay's address when the relay is a Kokkoja server's, at wss://<host>/relay:
|
||||||
|
such a server serves the client at https://<host>/ as well.
|
||||||
|
"""
|
||||||
|
if site:
|
||||||
|
return site.rstrip("/") + "/"
|
||||||
|
parts = urllib.parse.urlsplit(relay)
|
||||||
|
if parts.scheme == "wss" and parts.path.rstrip("/") == "/relay":
|
||||||
|
return f"https://{parts.netloc}/"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
relay = ""
|
||||||
|
site = ""
|
||||||
|
token = ""
|
||||||
|
notebooks = pathlib.Path()
|
||||||
|
config = pathlib.Path()
|
||||||
|
|
||||||
|
# Replies.
|
||||||
|
|
||||||
|
def send_json(self, value, status=200):
|
||||||
|
self.send_body(json.dumps(value).encode(), "application/json", status)
|
||||||
|
|
||||||
|
def send_body(self, body, content_type, status=200):
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def refuse(self, status, reason):
|
||||||
|
self.send_json({"error": reason}, status)
|
||||||
|
|
||||||
|
def end_headers(self):
|
||||||
|
# The client files change during development; never serve stale ones.
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
def log_message(self, format, *arguments):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Requests.
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
path = self.path.split("?")[0]
|
||||||
|
if path == "/config.json":
|
||||||
|
# How the client was served: by this program, which keeps the
|
||||||
|
# notebooks as files. A web site serves its own config.json
|
||||||
|
# with "storage": "browser".
|
||||||
|
self.send_json({"relay": self.relay, "storage": "files", "site": self.site})
|
||||||
|
elif path == "/api/identity":
|
||||||
|
self.identity_request("GET")
|
||||||
|
elif path == "/api/files":
|
||||||
|
self.files_request()
|
||||||
|
elif path == "/api/file":
|
||||||
|
self.file_request("GET")
|
||||||
|
elif path == "/api" or path.startswith("/api/"):
|
||||||
|
self.notebook_request("GET", path)
|
||||||
|
else:
|
||||||
|
super().do_GET()
|
||||||
|
|
||||||
|
def do_PUT(self):
|
||||||
|
path = self.path.split("?")[0]
|
||||||
|
if path == "/api/identity":
|
||||||
|
self.identity_request("PUT")
|
||||||
|
elif path == "/api/file":
|
||||||
|
self.file_request("PUT")
|
||||||
|
else:
|
||||||
|
self.notebook_request("PUT", path)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self.notebook_request("POST", self.path.split("?")[0])
|
||||||
|
|
||||||
|
def allowed(self):
|
||||||
|
"""Whether a notebook request comes from the user's own browser."""
|
||||||
|
if not LOCAL_HOST.match(self.headers.get("Host", "")):
|
||||||
|
self.refuse(403, "not addressed to this computer")
|
||||||
|
return False
|
||||||
|
origin = self.headers.get("Origin")
|
||||||
|
if origin is not None and not LOCAL_ORIGIN.match(origin):
|
||||||
|
self.refuse(403, "request from another web site")
|
||||||
|
return False
|
||||||
|
given = self.headers.get("Authorization", "")
|
||||||
|
if not hmac.compare_digest(given.encode(), ("Bearer " + self.token).encode()):
|
||||||
|
self.refuse(401, "missing or wrong token")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def identity_request(self, method):
|
||||||
|
"""
|
||||||
|
GET /api/identity the user's identity (signing keys), or 404
|
||||||
|
PUT /api/identity store it, once; a second PUT is refused
|
||||||
|
|
||||||
|
The identity is kept here rather than in the browser so that the
|
||||||
|
user is the same participant in every session, from any browser on
|
||||||
|
this computer, and their writing from an earlier lesson stays
|
||||||
|
theirs. The file is private to the user.
|
||||||
|
"""
|
||||||
|
if not self.allowed():
|
||||||
|
return
|
||||||
|
path = self.config / "identity.json"
|
||||||
|
try:
|
||||||
|
if method == "GET":
|
||||||
|
self.send_body(path.read_bytes(), "application/json")
|
||||||
|
return
|
||||||
|
length = int(self.headers.get("Content-Length", "-1"))
|
||||||
|
if not 0 < length <= MAX_IDENTITY_BYTES:
|
||||||
|
return self.refuse(413, "not an identity")
|
||||||
|
data = self.rfile.read(length)
|
||||||
|
try:
|
||||||
|
json.loads(data)
|
||||||
|
except ValueError:
|
||||||
|
return self.refuse(400, "not an identity")
|
||||||
|
with notebook_lock:
|
||||||
|
if path.exists():
|
||||||
|
return self.refuse(409, "an identity is already kept")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
with os.fdopen(descriptor, "wb") as file:
|
||||||
|
file.write(data)
|
||||||
|
self.send_json({"kept": True})
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.refuse(404, "no identity yet")
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
self.refuse(500, str(error))
|
||||||
|
|
||||||
|
def notebook_request(self, method, path):
|
||||||
|
"""
|
||||||
|
GET /api/notebooks the notebooks
|
||||||
|
GET /api/notebooks/<notebook> the pages of a notebook
|
||||||
|
GET /api/notebooks/<notebook>/<page> a page
|
||||||
|
PUT /api/notebooks/<notebook>/<page> write a page
|
||||||
|
POST /api/notebooks/<notebook>/keep keep a copy of Current.svg
|
||||||
|
"""
|
||||||
|
if not self.allowed():
|
||||||
|
return
|
||||||
|
parts = [urllib.parse.unquote(part) for part in path.split("/")[2:]]
|
||||||
|
if not parts or parts[0] != "notebooks" or len(parts) > 3:
|
||||||
|
return self.refuse(404, "no such request")
|
||||||
|
names = parts[1:]
|
||||||
|
if not all(NAME.match(name) for name in names):
|
||||||
|
return self.refuse(400, "name not allowed")
|
||||||
|
keeping = method == "POST" and len(names) == 2 and names[1] == "keep"
|
||||||
|
if len(names) == 2 and not keeping and not names[1].endswith(".svg"):
|
||||||
|
return self.refuse(400, "a page name ends in .svg")
|
||||||
|
target = self.notebooks.joinpath(*names)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if keeping:
|
||||||
|
with notebook_lock:
|
||||||
|
name, new = keep_page(target.parent) if target.parent.is_dir() else (None, False)
|
||||||
|
self.send_json({"kept": name, "new": new})
|
||||||
|
elif method == "POST":
|
||||||
|
self.refuse(405, "nothing to do")
|
||||||
|
elif method == "PUT":
|
||||||
|
if len(names) != 2:
|
||||||
|
return self.refuse(405, "only a page can be written")
|
||||||
|
length = int(self.headers.get("Content-Length", "-1"))
|
||||||
|
if not 0 <= length <= MAX_PAGE_BYTES:
|
||||||
|
return self.refuse(413, "page too large")
|
||||||
|
data = self.rfile.read(length)
|
||||||
|
with notebook_lock:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
write_atomically(target, data)
|
||||||
|
self.send_json({"saved": names[1]})
|
||||||
|
elif len(names) == 0:
|
||||||
|
self.send_json({"directory": str(self.notebooks),
|
||||||
|
"notebooks": self.listing(self.notebooks, directories=True)})
|
||||||
|
elif len(names) == 1:
|
||||||
|
self.send_json({"pages": self.listing(target, directories=False)})
|
||||||
|
else:
|
||||||
|
self.send_body(target.read_bytes(), "image/svg+xml")
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.refuse(404, "not found")
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
self.refuse(500, str(error))
|
||||||
|
|
||||||
|
def query(self):
|
||||||
|
"""The named values after '?' in the request's address."""
|
||||||
|
values = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query,
|
||||||
|
keep_blank_values=True)
|
||||||
|
return {name: value[-1] for name, value in values.items()}
|
||||||
|
|
||||||
|
def files_request(self):
|
||||||
|
"""
|
||||||
|
GET /api/files?directory=<full path> the directories and the image
|
||||||
|
files in a directory
|
||||||
|
|
||||||
|
Without a directory: the notebooks directory, or the user's home
|
||||||
|
directory if there are no notebooks yet. Hidden entries are left
|
||||||
|
out. Each entry comes with its full path, so that the client never
|
||||||
|
has to put a path together.
|
||||||
|
"""
|
||||||
|
if not self.allowed():
|
||||||
|
return
|
||||||
|
given = self.query().get("directory", "")
|
||||||
|
try:
|
||||||
|
if given:
|
||||||
|
directory = pathlib.Path(given).expanduser()
|
||||||
|
if not directory.is_absolute():
|
||||||
|
return self.refuse(400, "not a full path")
|
||||||
|
else:
|
||||||
|
directory = self.notebooks if self.notebooks.is_dir() else pathlib.Path.home()
|
||||||
|
directory = pathlib.Path(os.path.abspath(directory))
|
||||||
|
directories = []
|
||||||
|
files = []
|
||||||
|
for entry in directory.iterdir():
|
||||||
|
if entry.name.startswith("."):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if entry.is_dir():
|
||||||
|
directories.append({"name": entry.name, "path": str(entry)})
|
||||||
|
elif entry.is_file() and entry.suffix.lower() in IMAGE_TYPES:
|
||||||
|
files.append({"name": entry.name, "path": str(entry),
|
||||||
|
"size": entry.stat().st_size})
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
by_name = lambda entry: (entry["name"].casefold(), entry["name"])
|
||||||
|
self.send_json({
|
||||||
|
"directory": str(directory),
|
||||||
|
"parent": None if directory.parent == directory else str(directory.parent),
|
||||||
|
"directories": sorted(directories, key=by_name),
|
||||||
|
"files": sorted(files, key=by_name)})
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.refuse(404, "no such directory")
|
||||||
|
except NotADirectoryError:
|
||||||
|
self.refuse(400, "not a directory")
|
||||||
|
except PermissionError:
|
||||||
|
self.refuse(403, "this directory may not be read")
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
self.refuse(500, str(error))
|
||||||
|
|
||||||
|
def file_request(self, method):
|
||||||
|
"""
|
||||||
|
GET /api/file?path=<full path> an image file
|
||||||
|
PUT /api/file?directory=<full path>&name=<name>.png[&replace=1]
|
||||||
|
write an exported page
|
||||||
|
|
||||||
|
Only image files are read, by their names. Only PNG files are
|
||||||
|
written, into a directory that exists, and an existing file is
|
||||||
|
replaced only when the request says so (409 otherwise).
|
||||||
|
"""
|
||||||
|
if not self.allowed():
|
||||||
|
return
|
||||||
|
query = self.query()
|
||||||
|
try:
|
||||||
|
if method == "GET":
|
||||||
|
path = pathlib.Path(query.get("path", ""))
|
||||||
|
kind = IMAGE_TYPES.get(path.suffix.lower())
|
||||||
|
if not path.is_absolute() or kind is None:
|
||||||
|
return self.refuse(400, "not an image file")
|
||||||
|
if path.stat().st_size > MAX_IMAGE_FILE_BYTES:
|
||||||
|
return self.refuse(413, "image too large")
|
||||||
|
return self.send_body(path.read_bytes(), kind)
|
||||||
|
directory = pathlib.Path(query.get("directory", ""))
|
||||||
|
name = query.get("name", "")
|
||||||
|
if not directory.is_absolute() or not directory.is_dir():
|
||||||
|
return self.refuse(404, "no such directory")
|
||||||
|
if not NAME.match(name) or not name.lower().endswith(".png"):
|
||||||
|
return self.refuse(400, "the name must end in .png")
|
||||||
|
length = int(self.headers.get("Content-Length", "-1"))
|
||||||
|
if not 0 < length <= MAX_IMAGE_FILE_BYTES:
|
||||||
|
return self.refuse(413, "picture too large")
|
||||||
|
data = self.rfile.read(length)
|
||||||
|
if not data.startswith(PNG_SIGNATURE):
|
||||||
|
return self.refuse(400, "not a PNG picture")
|
||||||
|
target = directory / name
|
||||||
|
with notebook_lock:
|
||||||
|
if target.exists() and query.get("replace") != "1":
|
||||||
|
return self.refuse(409, "exists")
|
||||||
|
write_atomically(target, data)
|
||||||
|
self.send_json({"saved": str(target)})
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.refuse(404, "not found")
|
||||||
|
except PermissionError:
|
||||||
|
self.refuse(403, "not permitted there")
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
self.refuse(500, str(error))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def listing(directory, directories):
|
||||||
|
"""Names of the notebooks in the notebooks directory, or of the
|
||||||
|
pages in a notebook. A notebook that does not exist yet is empty."""
|
||||||
|
if not directory.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
entry.name for entry in directory.iterdir()
|
||||||
|
if NAME.match(entry.name) and
|
||||||
|
(entry.is_dir() if directories else entry.is_file() and entry.name.endswith(".svg")))
|
||||||
|
|
||||||
|
|
||||||
|
def make_server(port, relay, notebooks, token, config, site=""):
|
||||||
|
handler = type("ConfiguredHandler", (Handler,), {
|
||||||
|
"relay": relay.rstrip("/"), "site": site_of(relay, site), "token": token,
|
||||||
|
"notebooks": pathlib.Path(notebooks), "config": pathlib.Path(config)})
|
||||||
|
handler = functools.partial(handler, directory=str(CLIENT_DIRECTORY))
|
||||||
|
return http.server.ThreadingHTTPServer(("127.0.0.1", port), handler)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Kokkoja local program")
|
||||||
|
parser.add_argument("--port", type=int, default=8760)
|
||||||
|
parser.add_argument("--relay", default="ws://127.0.0.1:8770",
|
||||||
|
help="relay used for sessions started here "
|
||||||
|
"(default ws://127.0.0.1:8770)")
|
||||||
|
parser.add_argument("--site", default="",
|
||||||
|
help="Kokkoja web site that invitations link to, so that "
|
||||||
|
"guests can join with nothing installed (default: "
|
||||||
|
"https://<host>/ when the relay is wss://<host>/relay)")
|
||||||
|
parser.add_argument("--notebooks", type=pathlib.Path, default=None,
|
||||||
|
help="directory that holds the notebooks "
|
||||||
|
"(default: Kokkoja in your documents directory)")
|
||||||
|
parser.add_argument("--config", type=pathlib.Path, default=None,
|
||||||
|
help="directory for Kokkoja's own settings "
|
||||||
|
"(default: your system's usual place)")
|
||||||
|
parser.add_argument("--no-browser", action="store_true",
|
||||||
|
help="do not open a browser window")
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
|
||||||
|
notebooks = arguments.notebooks or default_notebooks_directory()
|
||||||
|
config = arguments.config or config_directory()
|
||||||
|
token = load_token(config)
|
||||||
|
server = make_server(arguments.port, arguments.relay, notebooks, token, config, arguments.site)
|
||||||
|
address = f"http://localhost:{arguments.port}/#token={token}"
|
||||||
|
print(f"Kokkoja is at {address}", flush=True)
|
||||||
|
print(f"Notebooks are in {notebooks}", flush=True)
|
||||||
|
print("Ctrl-C to stop.", flush=True)
|
||||||
|
if not arguments.no_browser:
|
||||||
|
webbrowser.open(address)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Kokkoja relay.
|
||||||
|
|
||||||
|
Passes messages between the participants of a session. It stores nothing:
|
||||||
|
no accounts, no pages, no notebooks. Message bodies are encrypted by the
|
||||||
|
clients with a key the relay never sees, so the relay cannot read them.
|
||||||
|
|
||||||
|
The protocol is described in notes/protocol.md. In short:
|
||||||
|
|
||||||
|
* A client connects to ``/room/<room id>``. The relay gives the connection
|
||||||
|
a peer number, unique within the room, and sends ``welcome``.
|
||||||
|
|
||||||
|
* A binary message from a client begins with a 4-byte destination peer
|
||||||
|
number (0 means everyone else in the room). The relay replaces those four
|
||||||
|
bytes with the sender's peer number and forwards the message.
|
||||||
|
|
||||||
|
* The relay sends ``join`` and ``leave`` as peers come and go.
|
||||||
|
|
||||||
|
Requires the ``websockets`` library, version 13 or later.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import http
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
|
||||||
|
from websockets.asyncio.server import broadcast, serve
|
||||||
|
from websockets.exceptions import ConnectionClosed
|
||||||
|
|
||||||
|
ROOM_PATH = re.compile(r"^/room/([A-Za-z0-9_-]{22})$")
|
||||||
|
|
||||||
|
MAX_MESSAGE_BYTES = 1 << 20 # Clients split anything larger.
|
||||||
|
MAX_PEERS_PER_ROOM = 32
|
||||||
|
MAX_ROOMS = 500
|
||||||
|
|
||||||
|
# Close codes in the range reserved for applications.
|
||||||
|
CLOSE_ROOM_FULL = 4001
|
||||||
|
CLOSE_RELAY_FULL = 4002
|
||||||
|
CLOSE_BAD_MESSAGE = 4003
|
||||||
|
|
||||||
|
log = logging.getLogger("kokkoja_relay")
|
||||||
|
|
||||||
|
|
||||||
|
class Room:
|
||||||
|
def __init__(self):
|
||||||
|
self.peers = {} # peer number -> connection
|
||||||
|
self.next_peer = 1 # Peer numbers are never reused in a room.
|
||||||
|
|
||||||
|
|
||||||
|
rooms = {} # room id -> Room
|
||||||
|
|
||||||
|
|
||||||
|
def check_path(connection, request):
|
||||||
|
"""Refuse the WebSocket handshake unless the path names a room."""
|
||||||
|
if not ROOM_PATH.match(request.path):
|
||||||
|
return connection.respond(http.HTTPStatus.NOT_FOUND, "Not a Kokkoja room\n")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def control(message_type, **fields):
|
||||||
|
return json.dumps({"type": message_type, **fields})
|
||||||
|
|
||||||
|
|
||||||
|
async def handle(connection):
|
||||||
|
room_id = ROOM_PATH.match(connection.request.path).group(1)
|
||||||
|
room = rooms.get(room_id)
|
||||||
|
if room is None:
|
||||||
|
if len(rooms) >= MAX_ROOMS:
|
||||||
|
await connection.close(CLOSE_RELAY_FULL, "relay full")
|
||||||
|
return
|
||||||
|
room = rooms[room_id] = Room()
|
||||||
|
if len(room.peers) >= MAX_PEERS_PER_ROOM:
|
||||||
|
await connection.close(CLOSE_ROOM_FULL, "room full")
|
||||||
|
return
|
||||||
|
|
||||||
|
peer = room.next_peer
|
||||||
|
room.next_peer += 1
|
||||||
|
others = sorted(room.peers)
|
||||||
|
room.peers[peer] = connection
|
||||||
|
sender = peer.to_bytes(4, "big")
|
||||||
|
log.info("room %s..: peer %d joined (%d present)", room_id[:4], peer, len(room.peers))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await connection.send(control("welcome", peer=peer, peers=others))
|
||||||
|
broadcast([room.peers[p] for p in others], control("join", peer=peer))
|
||||||
|
async for message in connection:
|
||||||
|
if not isinstance(message, bytes) or len(message) < 4:
|
||||||
|
await connection.close(CLOSE_BAD_MESSAGE, "bad message")
|
||||||
|
break
|
||||||
|
destination = int.from_bytes(message[:4], "big")
|
||||||
|
if destination == 0:
|
||||||
|
targets = [c for p, c in room.peers.items() if p != peer]
|
||||||
|
else:
|
||||||
|
target = room.peers.get(destination)
|
||||||
|
targets = [target] if target is not None and destination != peer else []
|
||||||
|
broadcast(targets, sender + message[4:])
|
||||||
|
except ConnectionClosed:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
del room.peers[peer]
|
||||||
|
if room.peers:
|
||||||
|
broadcast(list(room.peers.values()), control("leave", peer=peer))
|
||||||
|
else:
|
||||||
|
del rooms[room_id]
|
||||||
|
log.info("room %s..: peer %d left (%d present)", room_id[:4], peer, len(room.peers))
|
||||||
|
|
||||||
|
|
||||||
|
async def run(host, port):
|
||||||
|
stop = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
|
def stop_once():
|
||||||
|
if not stop.done():
|
||||||
|
stop.set_result(None)
|
||||||
|
|
||||||
|
for signal_number in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
asyncio.get_running_loop().add_signal_handler(signal_number, stop_once)
|
||||||
|
async with serve(handle, host, port, process_request=check_path,
|
||||||
|
max_size=MAX_MESSAGE_BYTES, ping_interval=20, ping_timeout=20):
|
||||||
|
log.info("relay listening on %s port %d", host, port)
|
||||||
|
await stop
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Kokkoja relay")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1",
|
||||||
|
help="address to listen on (default 127.0.0.1; 0.0.0.0 for all)")
|
||||||
|
parser.add_argument("--port", type=int, default=8770)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
||||||
|
# The library reports every connection as well; our own lines suffice.
|
||||||
|
logging.getLogger("websockets").setLevel(logging.WARNING)
|
||||||
|
asyncio.run(run(arguments.host, arguments.port))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
websockets>=13
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,260 @@
|
|||||||
|
"""Tests of the local program's notebook requests. Run from the project
|
||||||
|
directory:
|
||||||
|
|
||||||
|
.venv/bin/python -m unittest tests.test_local_program -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
||||||
|
import kokkoja # noqa: E402
|
||||||
|
|
||||||
|
TOKEN = "test-token"
|
||||||
|
PAGE = b'<svg xmlns="http://www.w3.org/2000/svg" data-kokkoja="1"/>\n'
|
||||||
|
PNG = b"\x89PNG\r\n\x1a\n" + b"the rest of a picture"
|
||||||
|
WRITTEN_PAGE = b'<svg data-kokkoja="1"><g data-layer="a"><g data-stroke="s"/></g></svg>\n'
|
||||||
|
|
||||||
|
|
||||||
|
class LocalProgramTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.directory.cleanup)
|
||||||
|
self.notebooks = pathlib.Path(self.directory.name) / "notebooks"
|
||||||
|
self.config = pathlib.Path(self.directory.name) / "config"
|
||||||
|
self.server = kokkoja.make_server(0, "ws://relay.example:8770/", self.notebooks, TOKEN,
|
||||||
|
self.config)
|
||||||
|
self.port = self.server.server_address[1]
|
||||||
|
thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
self.addCleanup(thread.join)
|
||||||
|
self.addCleanup(self.server.server_close)
|
||||||
|
self.addCleanup(self.server.shutdown)
|
||||||
|
|
||||||
|
def request(self, method, path, body=None, token=TOKEN, headers=None):
|
||||||
|
"""Returns (status, body)."""
|
||||||
|
all_headers = {} if token is None else {"Authorization": f"Bearer {token}"}
|
||||||
|
all_headers.update(headers or {})
|
||||||
|
request = urllib.request.Request(f"http://127.0.0.1:{self.port}{path}", data=body,
|
||||||
|
method=method, headers=all_headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=10) as response:
|
||||||
|
return response.status, response.read()
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
with error:
|
||||||
|
return error.code, error.read()
|
||||||
|
|
||||||
|
def json(self, method, path, **arguments):
|
||||||
|
status, body = self.request(method, path, **arguments)
|
||||||
|
return status, json.loads(body)
|
||||||
|
|
||||||
|
def test_client_and_config_need_no_token(self):
|
||||||
|
status, body = self.request("GET", "/", token=None)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertIn(b"Kokkoja", body)
|
||||||
|
self.assertEqual(self.json("GET", "/config.json", token=None),
|
||||||
|
(200, {"relay": "ws://relay.example:8770", "storage": "files", "site": ""}))
|
||||||
|
|
||||||
|
def test_site_comes_from_a_kokkoja_server_relay_or_is_given(self):
|
||||||
|
self.assertEqual(kokkoja.site_of("wss://kokkoja.example/relay", ""), "https://kokkoja.example/")
|
||||||
|
self.assertEqual(kokkoja.site_of("wss://kokkoja.example/relay/", ""), "https://kokkoja.example/")
|
||||||
|
self.assertEqual(kokkoja.site_of("ws://127.0.0.1:8770", ""), "")
|
||||||
|
self.assertEqual(kokkoja.site_of("wss://relay.example:8770", ""), "")
|
||||||
|
self.assertEqual(kokkoja.site_of("ws://127.0.0.1:8770", "http://127.0.0.1:9000"),
|
||||||
|
"http://127.0.0.1:9000/")
|
||||||
|
|
||||||
|
def test_write_list_and_read_a_page(self):
|
||||||
|
self.assertEqual(self.json("GET", "/api/notebooks")[1]["notebooks"], [])
|
||||||
|
page = "/api/notebooks/" + urllib.parse.quote("Übungen A1") + "/2026_10_02_page.svg"
|
||||||
|
self.assertEqual(self.json("PUT", page, body=PAGE), (200, {"saved": "2026_10_02_page.svg"}))
|
||||||
|
self.assertEqual(self.request("GET", page), (200, PAGE))
|
||||||
|
self.assertEqual((self.notebooks / "Übungen A1" / "2026_10_02_page.svg").read_bytes(), PAGE)
|
||||||
|
self.assertEqual(self.json("GET", "/api/notebooks")[1]["notebooks"], ["Übungen A1"])
|
||||||
|
self.assertEqual(self.json("GET", "/api/notebooks/" + urllib.parse.quote("Übungen A1")),
|
||||||
|
(200, {"pages": ["2026_10_02_page.svg"]}))
|
||||||
|
|
||||||
|
def test_writing_again_replaces_the_page_and_leaves_nothing_else(self):
|
||||||
|
page = "/api/notebooks/Andy/page.svg"
|
||||||
|
self.request("PUT", page, body=b"first")
|
||||||
|
self.request("PUT", page, body=PAGE)
|
||||||
|
self.assertEqual(self.request("GET", page), (200, PAGE))
|
||||||
|
self.assertEqual([entry.name for entry in (self.notebooks / "Andy").iterdir()], ["page.svg"])
|
||||||
|
|
||||||
|
def test_token_is_required(self):
|
||||||
|
for token in (None, "wrong", ""):
|
||||||
|
with self.subTest(token=token):
|
||||||
|
self.assertEqual(self.request("GET", "/api/notebooks", token=token)[0], 401)
|
||||||
|
self.assertEqual(
|
||||||
|
self.request("PUT", "/api/notebooks/Andy/page.svg", body=PAGE, token=token)[0], 401)
|
||||||
|
self.assertFalse(self.notebooks.exists())
|
||||||
|
|
||||||
|
def test_request_from_another_web_site_is_refused(self):
|
||||||
|
for headers in ({"Origin": "https://evil.example"}, {"Host": "evil.example:8760"}):
|
||||||
|
with self.subTest(headers=headers):
|
||||||
|
status, _ = self.request("PUT", "/api/notebooks/Andy/page.svg", body=PAGE,
|
||||||
|
headers=headers)
|
||||||
|
self.assertEqual(status, 403)
|
||||||
|
status, _ = self.request("PUT", "/api/notebooks/Andy/page.svg", body=PAGE,
|
||||||
|
headers={"Origin": f"http://localhost:{self.port}"})
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
|
||||||
|
def test_names_cannot_leave_the_notebooks_directory(self):
|
||||||
|
outside = pathlib.Path(self.directory.name) / "outside.svg"
|
||||||
|
for path in ("/api/notebooks/../outside.svg",
|
||||||
|
"/api/notebooks/%2E%2E/outside.svg",
|
||||||
|
"/api/notebooks/Andy/..%2F..%2Foutside.svg",
|
||||||
|
"/api/notebooks/Andy%2F..%2F../outside.svg",
|
||||||
|
"/api/notebooks/.hidden/page.svg",
|
||||||
|
"/api/notebooks/Andy/.hidden.svg",
|
||||||
|
"/api/notebooks/Andy/sub/page.svg"):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
status, _ = self.request("PUT", path, body=PAGE)
|
||||||
|
self.assertIn(status, (400, 404))
|
||||||
|
self.assertFalse(outside.exists())
|
||||||
|
self.assertFalse(self.notebooks.exists())
|
||||||
|
|
||||||
|
def test_only_pages_can_be_written(self):
|
||||||
|
self.assertEqual(self.request("PUT", "/api/notebooks/Andy/notes.txt", body=b"x")[0], 400)
|
||||||
|
self.assertEqual(self.request("PUT", "/api/notebooks/Andy", body=b"x")[0], 405)
|
||||||
|
|
||||||
|
def test_keeping_the_current_page(self):
|
||||||
|
current = "/api/notebooks/Andy/Current.svg"
|
||||||
|
keep = "/api/notebooks/Andy/keep"
|
||||||
|
today = time.strftime("%Y_%m_%d")
|
||||||
|
|
||||||
|
# Nothing to keep: no notebook, then a blank page.
|
||||||
|
self.assertEqual(self.json("POST", keep), (200, {"kept": None, "new": False}))
|
||||||
|
self.request("PUT", current, body=PAGE)
|
||||||
|
self.assertEqual(self.json("POST", keep), (200, {"kept": None, "new": False}))
|
||||||
|
|
||||||
|
# A page with writing is kept once, however often it is asked.
|
||||||
|
self.request("PUT", current, body=WRITTEN_PAGE)
|
||||||
|
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_01.svg", "new": True}))
|
||||||
|
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_01.svg", "new": False}))
|
||||||
|
self.assertEqual((self.notebooks / "Andy" / f"{today}_01.svg").read_bytes(), WRITTEN_PAGE)
|
||||||
|
|
||||||
|
# A changed page is kept under the next serial number.
|
||||||
|
changed = WRITTEN_PAGE + b"<!-- more -->"
|
||||||
|
self.request("PUT", current, body=changed)
|
||||||
|
self.assertEqual(self.json("POST", keep), (200, {"kept": f"{today}_02.svg", "new": True}))
|
||||||
|
self.assertEqual(self.json("GET", "/api/notebooks/Andy")[1]["pages"],
|
||||||
|
[f"{today}_01.svg", f"{today}_02.svg", "Current.svg"])
|
||||||
|
|
||||||
|
def test_a_kept_page_is_dated_when_it_was_last_changed(self):
|
||||||
|
notebook = self.notebooks / "Andy"
|
||||||
|
notebook.mkdir(parents=True)
|
||||||
|
(notebook / "Current.svg").write_bytes(WRITTEN_PAGE)
|
||||||
|
last_week = time.mktime((2026, 9, 25, 16, 30, 0, 0, 0, -1))
|
||||||
|
os.utime(notebook / "Current.svg", (last_week, last_week))
|
||||||
|
(notebook / "2026_09_25_01.svg").write_bytes(b"an earlier page of that day")
|
||||||
|
self.assertEqual(self.json("POST", "/api/notebooks/Andy/keep"),
|
||||||
|
(200, {"kept": "2026_09_25_02.svg", "new": True}))
|
||||||
|
self.assertEqual((notebook / "2026_09_25_02.svg").stat().st_mtime, last_week)
|
||||||
|
|
||||||
|
def test_identity_is_kept_once_and_privately(self):
|
||||||
|
self.assertEqual(self.request("GET", "/api/identity")[0], 404)
|
||||||
|
self.assertEqual(self.request("GET", "/api/identity", token=None)[0], 401)
|
||||||
|
identity = b'{"publicKey": "abc", "privateKey": {"kty": "EC"}}'
|
||||||
|
self.assertEqual(self.json("PUT", "/api/identity", body=identity), (200, {"kept": True}))
|
||||||
|
self.assertEqual(self.request("GET", "/api/identity"), (200, identity))
|
||||||
|
self.assertEqual(self.request("PUT", "/api/identity", body=b'{"other": 1}')[0], 409)
|
||||||
|
self.assertEqual(self.request("GET", "/api/identity"), (200, identity))
|
||||||
|
self.assertEqual(self.request("PUT", "/api/identity", body=b"not json")[0], 400)
|
||||||
|
path = self.config / "identity.json"
|
||||||
|
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||||
|
|
||||||
|
def test_missing_page(self):
|
||||||
|
self.assertEqual(self.request("GET", "/api/notebooks/Andy/none.svg")[0], 404)
|
||||||
|
self.assertEqual(self.json("GET", "/api/notebooks/Andy"), (200, {"pages": []}))
|
||||||
|
|
||||||
|
# The file chooser and exporting.
|
||||||
|
|
||||||
|
def files(self, directory=None):
|
||||||
|
query = "" if directory is None else "?directory=" + urllib.parse.quote(str(directory))
|
||||||
|
return self.json("GET", "/api/files" + query)
|
||||||
|
|
||||||
|
def test_files_lists_directories_and_image_files(self):
|
||||||
|
home = pathlib.Path(self.directory.name) / "Bilder"
|
||||||
|
(home / "Übungen").mkdir(parents=True)
|
||||||
|
(home / ".hidden").mkdir()
|
||||||
|
(home / "b.PNG").write_bytes(PNG)
|
||||||
|
(home / "a.jpg").write_bytes(b"jpeg")
|
||||||
|
(home / "notes.txt").write_bytes(b"text")
|
||||||
|
(home / ".secret.png").write_bytes(PNG)
|
||||||
|
status, listing = self.files(home)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(listing, {
|
||||||
|
"directory": str(home), "parent": self.directory.name,
|
||||||
|
"directories": [{"name": "Übungen", "path": str(home / "Übungen")}],
|
||||||
|
"files": [{"name": "a.jpg", "path": str(home / "a.jpg"), "size": 4},
|
||||||
|
{"name": "b.PNG", "path": str(home / "b.PNG"), "size": len(PNG)}]})
|
||||||
|
# A path is tidied, so that going up always leads somewhere.
|
||||||
|
self.assertEqual(self.files(home / "Übungen" / "..")[1]["directory"], str(home))
|
||||||
|
|
||||||
|
def test_files_begins_in_the_notebooks_directory(self):
|
||||||
|
# Before there are notebooks: the user's home directory.
|
||||||
|
self.assertEqual(self.files()[1]["directory"], str(pathlib.Path.home()))
|
||||||
|
self.notebooks.mkdir()
|
||||||
|
self.assertEqual(self.files()[1]["directory"], str(self.notebooks))
|
||||||
|
|
||||||
|
def test_files_refusals(self):
|
||||||
|
self.assertEqual(self.files("relative/path")[0], 400)
|
||||||
|
self.assertEqual(self.files(pathlib.Path(self.directory.name) / "missing")[0], 404)
|
||||||
|
self.assertEqual(self.request("GET", "/api/files", token=None)[0], 401)
|
||||||
|
self.assertEqual(self.request("GET", "/api/file?path=/etc/hostname", token=None)[0], 401)
|
||||||
|
|
||||||
|
def test_an_image_file_is_read_and_nothing_else(self):
|
||||||
|
directory = pathlib.Path(self.directory.name)
|
||||||
|
(directory / "scan.png").write_bytes(PNG)
|
||||||
|
(directory / "letter.txt").write_bytes(b"private")
|
||||||
|
read = lambda path: self.request("GET", "/api/file?path=" + urllib.parse.quote(str(path)))
|
||||||
|
self.assertEqual(read(directory / "scan.png"), (200, PNG))
|
||||||
|
self.assertEqual(read(directory / "letter.txt")[0], 400)
|
||||||
|
self.assertEqual(read("scan.png")[0], 400)
|
||||||
|
self.assertEqual(read(directory / "missing.png")[0], 404)
|
||||||
|
|
||||||
|
def export(self, directory, name, body=None, replace=False):
|
||||||
|
query = urllib.parse.urlencode({"directory": str(directory), "name": name,
|
||||||
|
**({"replace": "1"} if replace else {})})
|
||||||
|
return self.json("PUT", "/api/file?" + query, body=PNG if body is None else body)
|
||||||
|
|
||||||
|
def test_export_writes_a_png_where_asked(self):
|
||||||
|
directory = pathlib.Path(self.directory.name) / "Export nach Hause"
|
||||||
|
directory.mkdir()
|
||||||
|
self.assertEqual(self.export(directory, "Seite 1.png"),
|
||||||
|
(200, {"saved": str(directory / "Seite 1.png")}))
|
||||||
|
self.assertEqual((directory / "Seite 1.png").read_bytes(), PNG)
|
||||||
|
self.assertEqual([entry.name for entry in directory.iterdir()], ["Seite 1.png"])
|
||||||
|
|
||||||
|
def test_export_replaces_a_file_only_when_told_to(self):
|
||||||
|
directory = pathlib.Path(self.directory.name)
|
||||||
|
(directory / "page.png").write_bytes(b"earlier")
|
||||||
|
self.assertEqual(self.export(directory, "page.png"), (409, {"error": "exists"}))
|
||||||
|
self.assertEqual((directory / "page.png").read_bytes(), b"earlier")
|
||||||
|
self.assertEqual(self.export(directory, "page.png", replace=True)[0], 200)
|
||||||
|
self.assertEqual((directory / "page.png").read_bytes(), PNG)
|
||||||
|
|
||||||
|
def test_export_refusals(self):
|
||||||
|
directory = pathlib.Path(self.directory.name)
|
||||||
|
self.assertEqual(self.export(directory, "page.svg")[0], 400)
|
||||||
|
self.assertEqual(self.export(directory, "../page.png")[0], 400)
|
||||||
|
self.assertEqual(self.export(directory, ".hidden.png")[0], 400)
|
||||||
|
self.assertEqual(self.export(directory, "page.png", body=b"#!/bin/sh\n")[0], 400)
|
||||||
|
self.assertEqual(self.export(directory / "missing", "page.png")[0], 404)
|
||||||
|
self.assertEqual(self.export("relative", "page.png")[0], 404)
|
||||||
|
query = urllib.parse.urlencode({"directory": str(directory), "name": "page.png"})
|
||||||
|
self.assertEqual(self.request("PUT", "/api/file?" + query, body=PNG, token=None)[0], 401)
|
||||||
|
self.assertEqual(list(directory.glob("*.png")), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Tests of the relay. Run from the project directory:
|
||||||
|
|
||||||
|
.venv/bin/python -m unittest discover tests
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from websockets.asyncio.client import connect
|
||||||
|
from websockets.asyncio.server import serve
|
||||||
|
from websockets.exceptions import ConnectionClosed, InvalidStatus
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "relay"))
|
||||||
|
import kokkoja_relay as relay # noqa: E402
|
||||||
|
|
||||||
|
ROOM_A = "A" * 22
|
||||||
|
ROOM_B = "B" * 22
|
||||||
|
|
||||||
|
|
||||||
|
def to_peer(destination, body):
|
||||||
|
return destination.to_bytes(4, "big") + body
|
||||||
|
|
||||||
|
|
||||||
|
class RelayTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self):
|
||||||
|
relay.rooms.clear()
|
||||||
|
self.server = await serve(relay.handle, "127.0.0.1", 0,
|
||||||
|
process_request=relay.check_path,
|
||||||
|
max_size=relay.MAX_MESSAGE_BYTES)
|
||||||
|
port = self.server.sockets[0].getsockname()[1]
|
||||||
|
self.address = f"ws://127.0.0.1:{port}"
|
||||||
|
|
||||||
|
async def asyncTearDown(self):
|
||||||
|
self.server.close()
|
||||||
|
await self.server.wait_closed()
|
||||||
|
|
||||||
|
async def join(self, room):
|
||||||
|
connection = await connect(f"{self.address}/room/{room}")
|
||||||
|
self.addAsyncCleanup(connection.close)
|
||||||
|
welcome = json.loads(await connection.recv())
|
||||||
|
self.assertEqual(welcome["type"], "welcome")
|
||||||
|
return connection, welcome
|
||||||
|
|
||||||
|
async def test_welcome_join_leave(self):
|
||||||
|
first, welcome = await self.join(ROOM_A)
|
||||||
|
self.assertEqual(welcome, {"type": "welcome", "peer": 1, "peers": []})
|
||||||
|
second, welcome = await self.join(ROOM_A)
|
||||||
|
self.assertEqual(welcome, {"type": "welcome", "peer": 2, "peers": [1]})
|
||||||
|
self.assertEqual(json.loads(await first.recv()), {"type": "join", "peer": 2})
|
||||||
|
await second.close()
|
||||||
|
self.assertEqual(json.loads(await first.recv()), {"type": "leave", "peer": 2})
|
||||||
|
|
||||||
|
async def test_broadcast_reaches_the_others_only(self):
|
||||||
|
first, _ = await self.join(ROOM_A)
|
||||||
|
second, _ = await self.join(ROOM_A)
|
||||||
|
third, _ = await self.join(ROOM_A)
|
||||||
|
await first.recv() # join 2
|
||||||
|
await first.recv() # join 3
|
||||||
|
await second.recv() # join 3
|
||||||
|
await second.send(to_peer(0, b"hello"))
|
||||||
|
self.assertEqual(await first.recv(), to_peer(2, b"hello"))
|
||||||
|
self.assertEqual(await third.recv(), to_peer(2, b"hello"))
|
||||||
|
# The sender gets nothing back: the next thing it receives is this.
|
||||||
|
await first.send(to_peer(0, b"next"))
|
||||||
|
self.assertEqual(await second.recv(), to_peer(1, b"next"))
|
||||||
|
|
||||||
|
async def test_directed_message_reaches_one_peer(self):
|
||||||
|
first, _ = await self.join(ROOM_A)
|
||||||
|
second, _ = await self.join(ROOM_A)
|
||||||
|
third, _ = await self.join(ROOM_A)
|
||||||
|
await first.recv()
|
||||||
|
await first.recv()
|
||||||
|
await second.recv()
|
||||||
|
await first.send(to_peer(3, b"for three"))
|
||||||
|
await first.send(to_peer(0, b"for all"))
|
||||||
|
self.assertEqual(await third.recv(), to_peer(1, b"for three"))
|
||||||
|
self.assertEqual(await third.recv(), to_peer(1, b"for all"))
|
||||||
|
self.assertEqual(await second.recv(), to_peer(1, b"for all"))
|
||||||
|
|
||||||
|
async def test_rooms_are_separate(self):
|
||||||
|
in_a, _ = await self.join(ROOM_A)
|
||||||
|
in_b, welcome = await self.join(ROOM_B)
|
||||||
|
self.assertEqual(welcome["peers"], [])
|
||||||
|
other_a, _ = await self.join(ROOM_A)
|
||||||
|
await in_a.recv() # join
|
||||||
|
await in_b.send(to_peer(0, b"b only"))
|
||||||
|
await other_a.send(to_peer(0, b"a only"))
|
||||||
|
self.assertEqual(await in_a.recv(), to_peer(2, b"a only"))
|
||||||
|
|
||||||
|
async def test_room_is_forgotten_when_empty(self):
|
||||||
|
first, _ = await self.join(ROOM_A)
|
||||||
|
await first.close()
|
||||||
|
await first.wait_closed()
|
||||||
|
for _ in range(50):
|
||||||
|
if not relay.rooms:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
self.assertEqual(relay.rooms, {})
|
||||||
|
|
||||||
|
async def test_bad_path_is_refused(self):
|
||||||
|
for path in ("/", "/room/short", f"/room/{ROOM_A}/extra", "/room/" + "!" * 22):
|
||||||
|
with self.assertRaises(InvalidStatus):
|
||||||
|
await connect(self.address + path)
|
||||||
|
|
||||||
|
async def test_text_message_closes_the_connection(self):
|
||||||
|
first, _ = await self.join(ROOM_A)
|
||||||
|
await first.send("text is not allowed")
|
||||||
|
with self.assertRaises(ConnectionClosed) as caught:
|
||||||
|
await first.recv()
|
||||||
|
self.assertEqual(caught.exception.rcvd.code, relay.CLOSE_BAD_MESSAGE)
|
||||||
|
|
||||||
|
async def test_room_full(self):
|
||||||
|
relay.MAX_PEERS_PER_ROOM, saved = 2, relay.MAX_PEERS_PER_ROOM
|
||||||
|
try:
|
||||||
|
await self.join(ROOM_A)
|
||||||
|
await self.join(ROOM_A)
|
||||||
|
extra = await connect(f"{self.address}/room/{ROOM_A}")
|
||||||
|
with self.assertRaises(ConnectionClosed) as caught:
|
||||||
|
await extra.recv()
|
||||||
|
self.assertEqual(caught.exception.rcvd.code, relay.CLOSE_ROOM_FULL)
|
||||||
|
finally:
|
||||||
|
relay.MAX_PEERS_PER_ROOM = saved
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user