Files
kokkoja-rel/client/js/main.js
T
ackandClaude Fable 5.1 95c80d04c9 Kokkoja: first public assembly
Assembled from the development repository by tools/make_release.sh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 15:58:02 +02:00

959 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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);
}