2026-10-06 15:58:02 +02:00
|
|
|
|
// @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 {
|
2026-10-11 16:59:03 +02:00
|
|
|
|
NotebookWriter, discardNotebook, discardPage, keepCurrentPage, keepIdentity, listKeptPages, listNotebooks, notebooksInBrowser,
|
2026-10-06 15:58:02 +02:00
|
|
|
|
readCurrentPage, readIdentity, readPage, rememberToken, useBrowserStorage,
|
|
|
|
|
|
} from './notebook.js';
|
|
|
|
|
|
import { download, pickImageFile } from './browser_files.js';
|
2026-10-10 19:01:20 +02:00
|
|
|
|
import { DEFAULT_NOTEBOOK, NotebookPicker } from './notebook_picker.js';
|
2026-10-06 15:58:02 +02:00
|
|
|
|
import { pageFromSvg, pageToSvg } from './page_file.js';
|
|
|
|
|
|
import { hashOf } from './crypto.js';
|
|
|
|
|
|
import { imageResolution } from './image_info.js';
|
|
|
|
|
|
import {
|
2026-10-14 14:00:47 +02:00
|
|
|
|
LEAST_TEXT_POINTS, MAX_IMAGE_BYTES, MOST_TEXT_POINTS, POINT_SIZE, TEXT_FONTS, TEXT_SIZE,
|
|
|
|
|
|
UNITS_PER_POINT, rounded, strokeBox, strokesTouched, textAt, textsTouched,
|
2026-10-06 15:58:02 +02:00
|
|
|
|
} from './page_model.js';
|
2026-10-14 14:00:47 +02:00
|
|
|
|
import { HOLD_MS, NOMINAL_PRESSURE, attachPenInput } from './pen_input.js';
|
2026-10-10 19:01:20 +02:00
|
|
|
|
import { Renderer } from './renderer.js';
|
|
|
|
|
|
import { LEAST_IMAGE_SIDE, textBounds, rectAt } from './page_model.js';
|
2026-10-14 20:38:29 +02:00
|
|
|
|
import { inkExtent } from './stroke_geometry.js';
|
2026-10-14 14:00:47 +02:00
|
|
|
|
import {
|
2026-10-14 20:38:29 +02:00
|
|
|
|
SHAPE_KEY, SHAPE_NAMES, draggedPoints, isFilled, isTapped, lineInProgressPoints, movedPoints,
|
|
|
|
|
|
shapeNamed, straightPoints, vertexPoints,
|
2026-10-14 14:00:47 +02:00
|
|
|
|
} from './shapes.js';
|
2026-10-10 19:01:20 +02:00
|
|
|
|
import { LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js';
|
|
|
|
|
|
import { log, logText, trace, watchLog } from './log.js';
|
2026-10-06 15:58:02 +02:00
|
|
|
|
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';
|
2026-10-11 13:17:22 +02:00
|
|
|
|
import { guardSelection, inField } from './selection_guard.js';
|
2026-10-10 19:01:20 +02:00
|
|
|
|
import { QUIET_ZONE, qrModules, qrPath } from './qr_code.js';
|
2026-10-15 17:17:45 +02:00
|
|
|
|
import { manualList, manualText } from './manual.js';
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* The colours in the toolbar: black, red, green, blue, white (Andy's
|
|
|
|
|
|
* teacher, 2026-10-08), with the palette's own values so that the palette
|
|
|
|
|
|
* can leave them out.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const COLORS = ['#000000', '#e53935', '#43a047', '#1e88e5', '#ffffff'];
|
2026-10-06 15:58:02 +02:00
|
|
|
|
/** Everyone begins in black (Andy, 2026-10-03; the host began in red before). */
|
|
|
|
|
|
const DEFAULT_COLOR = COLORS[0];
|
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
|
/** Nominal line widths in page units (0.1 mm); the last is twice the one before (2026-10-08). */
|
|
|
|
|
|
const WIDTHS = [4, 7, 12, 24];
|
|
|
|
|
|
|
|
|
|
|
|
/** Whether the page grows when one's writing reaches an edge: 'no' if not. */
|
|
|
|
|
|
const EXTEND_KEY = 'kokkoja_extend';
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
|
|
/** 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;
|
2026-10-11 13:17:22 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Two taps of the eraser within this, both finding nothing to erase, undo
|
|
|
|
|
|
* the latest gesture (Andy, 2026-10-11). A tap that erases something is
|
|
|
|
|
|
* never half of a double tap, so the eraser's own taps are unaffected.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const DOUBLE_TAP_MS = 400;
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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';
|
2026-10-14 14:00:47 +02:00
|
|
|
|
/** The Shape tool's fill colour, remembered in this browser; 'none' for none. */
|
|
|
|
|
|
const FILL_KEY = 'kokkoja_fill';
|
|
|
|
|
|
|
|
|
|
|
|
/** @typedef {import('./pen_input.js').Tool} Tool */
|
|
|
|
|
|
/** @typedef {import('./shapes.js').Shape} Shape */
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* @param {string} id
|
|
|
|
|
|
* @returns {HTMLSelectElement}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function select(id) {
|
|
|
|
|
|
return /** @type {HTMLSelectElement} */ (document.getElementById(id));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** The start screen's notebook picker. */
|
|
|
|
|
|
const startPicker = new NotebookPicker(
|
|
|
|
|
|
select('notebook'), input('notebook_name'),
|
|
|
|
|
|
(message) => { element('start_message').textContent = message; },
|
|
|
|
|
|
() => {});
|
|
|
|
|
|
|
2026-10-06 15:58:02 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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) ?? '';
|
2026-10-10 19:01:20 +02:00
|
|
|
|
const remembered = localStorage.getItem(NOTEBOOK_KEY) ?? DEFAULT_NOTEBOOK;
|
|
|
|
|
|
startPicker.fill([], remembered);
|
|
|
|
|
|
startPicker.current = remembered;
|
2026-10-06 15:58:02 +02:00
|
|
|
|
const pending = invitationInAddress();
|
|
|
|
|
|
if (pending) input('invitation_entry').value = pending;
|
2026-10-10 19:01:20 +02:00
|
|
|
|
// An invitation is a request to join one session: arriving with one,
|
|
|
|
|
|
// the screen offers joining alone (Andy, 2026-10-06, after starting a
|
|
|
|
|
|
// session of his own by mistake on a guest's machine).
|
|
|
|
|
|
element('start_choice').hidden = Boolean(pending);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
// 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)}`;
|
2026-10-10 19:01:20 +02:00
|
|
|
|
showNotebooks(startPicker).catch((error) => {
|
2026-10-06 15:58:02 +02:00
|
|
|
|
element('notebook_note').textContent = String(error.message ?? error);
|
|
|
|
|
|
element('notebook_note').classList.add('problem');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Offer the existing notebooks in a picker and say where notebooks are kept.
|
|
|
|
|
|
* The picker's present choice stays chosen: the listing arrives later
|
|
|
|
|
|
* than the screen, and a notebook made meanwhile must not be lost.
|
|
|
|
|
|
* @param {NotebookPicker} picker
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function showNotebooks(picker) {
|
2026-10-06 15:58:02 +02:00
|
|
|
|
const { directory, notebooks } = await listNotebooks();
|
2026-10-10 19:01:20 +02:00
|
|
|
|
const current = picker.value;
|
|
|
|
|
|
picker.fill(notebooks, current);
|
|
|
|
|
|
picker.current = current;
|
2026-10-06 15:58:02 +02:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
|
/** How long a confirmation stays on the notice line, and a passing problem. */
|
|
|
|
|
|
const NOTICE_MS = 6000;
|
|
|
|
|
|
const PROBLEM_NOTICE_MS = 12000;
|
|
|
|
|
|
/** A reconnection shorter than this is not announced. */
|
|
|
|
|
|
const CONNECTION_GRACE_MS = 1500;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The notice line between the toolbar and the page. It says nothing
|
|
|
|
|
|
* while all is well. A STANDING notice, kept by its source until that
|
|
|
|
|
|
* source clears it, is a state: not connected, the page not being
|
2026-10-18 20:22:13 +02:00
|
|
|
|
* saved, no page from the others yet. A PASSING notice is a report of
|
2026-10-10 19:01:20 +02:00
|
|
|
|
* one event, kept for a few seconds: a page kept or exported, a file
|
|
|
|
|
|
* that was not an image. Standing notices come before a passing one.
|
|
|
|
|
|
*/
|
|
|
|
|
|
/** @type {Map<string, {text: string, problem: boolean}>} */
|
|
|
|
|
|
const standing = new Map();
|
|
|
|
|
|
let passing = { text: '', problem: false };
|
|
|
|
|
|
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
|
|
|
|
let passingTimer;
|
|
|
|
|
|
|
|
|
|
|
|
function showNotice() {
|
|
|
|
|
|
const line = element('notice');
|
|
|
|
|
|
const states = [...standing.values()];
|
|
|
|
|
|
const shown = states.length ? states : [passing];
|
|
|
|
|
|
line.textContent = shown.map((notice) => notice.text).join(' ');
|
|
|
|
|
|
line.hidden = line.textContent === '';
|
|
|
|
|
|
line.classList.toggle('problem', shown.some((notice) => notice.problem));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param {string} source
|
|
|
|
|
|
* @param {string} text '' clears the source's notice.
|
|
|
|
|
|
* @param {boolean} [problem]
|
|
|
|
|
|
*/
|
|
|
|
|
|
function standingNotice(source, text, problem = true) {
|
|
|
|
|
|
if (text) standing.set(source, { text, problem });
|
|
|
|
|
|
else standing.delete(source);
|
|
|
|
|
|
showNotice();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param {string} text
|
|
|
|
|
|
* @param {boolean} [problem]
|
|
|
|
|
|
*/
|
|
|
|
|
|
function passingNotice(text, problem = false) {
|
|
|
|
|
|
passing = { text, problem };
|
|
|
|
|
|
clearTimeout(passingTimer);
|
|
|
|
|
|
passingTimer = setTimeout(() => {
|
|
|
|
|
|
passing = { text: '', problem: false };
|
|
|
|
|
|
showNotice();
|
|
|
|
|
|
}, problem ? PROBLEM_NOTICE_MS : NOTICE_MS);
|
|
|
|
|
|
showNotice();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The last word on saving, in the Session window.
|
|
|
|
|
|
* @param {string} text
|
|
|
|
|
|
* @param {boolean} problem
|
|
|
|
|
|
*/
|
|
|
|
|
|
function showSaveState(text, problem) {
|
|
|
|
|
|
element('save_status').textContent = text;
|
|
|
|
|
|
element('save_status').classList.toggle('problem', problem);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The invitation link as a QR code in the Session window, for the camera
|
|
|
|
|
|
* of a phone or a tablet (Andy, 2026-10-10: there is no good way to send
|
|
|
|
|
|
* a link from a Linux computer to an iPad). None for a bare invitation,
|
|
|
|
|
|
* which a camera could only show as text.
|
|
|
|
|
|
* @param {string} link
|
|
|
|
|
|
*/
|
|
|
|
|
|
function showInvitationCode(link) {
|
|
|
|
|
|
const holder = element('invitation_code');
|
|
|
|
|
|
holder.replaceChildren();
|
|
|
|
|
|
holder.hidden = link === '';
|
|
|
|
|
|
if (link === '') return;
|
|
|
|
|
|
const modules = qrModules(link);
|
|
|
|
|
|
const span = modules.length + 2 * QUIET_ZONE;
|
|
|
|
|
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
|
|
|
|
svg.setAttribute('viewBox', `${-QUIET_ZONE} ${-QUIET_ZONE} ${span} ${span}`);
|
|
|
|
|
|
svg.setAttribute('shape-rendering', 'crispEdges');
|
|
|
|
|
|
svg.setAttribute('role', 'img');
|
|
|
|
|
|
svg.setAttribute('aria-label', 'The invitation as a QR code');
|
|
|
|
|
|
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
|
|
|
|
path.setAttribute('d', qrPath(modules));
|
|
|
|
|
|
path.setAttribute('fill', '#000');
|
|
|
|
|
|
svg.append(path);
|
|
|
|
|
|
holder.append(svg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 15:58:02 +02:00
|
|
|
|
/** @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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
|
/** @returns {string} The notebook chosen. */
|
2026-10-06 15:58:02 +02:00
|
|
|
|
function enteredNotebook() {
|
2026-10-10 19:01:20 +02:00
|
|
|
|
const notebook = startPicker.value || DEFAULT_NOTEBOOK;
|
2026-10-06 15:58:02 +02:00
|
|
|
|
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;
|
2026-10-10 19:01:20 +02:00
|
|
|
|
showInvitationCode(config.site ? input('invitation').value : '');
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
2026-10-14 14:00:47 +02:00
|
|
|
|
let tool = /** @type {Tool} */ ('pen');
|
|
|
|
|
|
/** The Shape tool's current shape, remembered in this browser (Andy, 2026-10-14). */
|
|
|
|
|
|
let shape = shapeNamed(localStorage.getItem(SHAPE_KEY));
|
|
|
|
|
|
/** The colour a shape is filled with, or null (Andy, 2026-10-14); separate from the line's colour. */
|
|
|
|
|
|
let fill = /^#[0-9a-f]{6}$/.exec(localStorage.getItem(FILL_KEY) ?? '')?.[0] ?? null;
|
2026-10-06 15:58:02 +02:00
|
|
|
|
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() {
|
2026-10-10 19:01:20 +02:00
|
|
|
|
const waiting = session.page.number === 0;
|
|
|
|
|
|
element('page_number').textContent = waiting ? 'No page yet' : `Page ${session.page.number}`;
|
2026-10-18 20:22:13 +02:00
|
|
|
|
standingNotice('page', waiting ? 'Waiting for the others’ page; New starts one' : '', false);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
}
|
2026-10-10 19:01:20 +02:00
|
|
|
|
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
|
|
|
|
let connectionNoticeTimer;
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
|
|
const session = new Session(
|
|
|
|
|
|
{
|
|
|
|
|
|
relay: invitation.relay, roomId, key, identity, hostId: invitation.hostId, name,
|
|
|
|
|
|
page: savedPage,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
onLayerReplaced: (layerId) => {
|
|
|
|
|
|
syncLayers();
|
|
|
|
|
|
renderer.redrawLayer(layerId);
|
2026-10-14 14:00:47 +02:00
|
|
|
|
if (layerId === session.identity.id) refreshArrangeCues();
|
2026-10-06 15:58:02 +02:00
|
|
|
|
writer.changed();
|
|
|
|
|
|
},
|
|
|
|
|
|
onStrokeProgress: (layerId, stroke) => {
|
|
|
|
|
|
syncLayers();
|
|
|
|
|
|
renderer.strokeProgress(layerId, stroke);
|
2026-10-14 14:00:47 +02:00
|
|
|
|
if (stroke.done) {
|
|
|
|
|
|
if (stroke.shape && layerId === session.identity.id) refreshArrangeCues();
|
|
|
|
|
|
writer.changed();
|
|
|
|
|
|
}
|
2026-10-06 15:58:02 +02:00
|
|
|
|
},
|
|
|
|
|
|
onTextChanged: (layerId) => {
|
|
|
|
|
|
syncLayers();
|
|
|
|
|
|
renderer.redrawLayer(layerId);
|
2026-10-14 00:01:45 +02:00
|
|
|
|
if (layerId === session.identity.id) refreshArrangeCues();
|
2026-10-06 15:58:02 +02:00
|
|
|
|
writer.changed();
|
|
|
|
|
|
},
|
|
|
|
|
|
onPageReplaced: (oldPage, newPage) => {
|
2026-10-10 19:01:20 +02:00
|
|
|
|
log(`Page ${newPage.number} (${newPage.images.length} images) replaces page ${oldPage.number}`);
|
|
|
|
|
|
// A block being typed stays on the page being left, where every
|
|
|
|
|
|
// keystroke already put it; finishing the edit now would move it
|
|
|
|
|
|
// to the new page (seen by Andy, 2026-10-06). An empty one is
|
|
|
|
|
|
// dropped from the old page before that page is kept.
|
|
|
|
|
|
const abandoned = textEditor?.discard() ?? null;
|
|
|
|
|
|
if (abandoned && abandoned.text.trim() === '') {
|
|
|
|
|
|
oldPage.layers.get(session.identity.id)?.texts.delete(abandoned.id);
|
|
|
|
|
|
}
|
2026-10-14 14:00:47 +02:00
|
|
|
|
// A line being made likewise stays on the page being left (the
|
|
|
|
|
|
// session has ended its stroke).
|
|
|
|
|
|
lineInProgress = null;
|
|
|
|
|
|
renderer.hideShapePreview();
|
2026-10-06 15:58:02 +02:00
|
|
|
|
// 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();
|
2026-10-14 00:01:45 +02:00
|
|
|
|
refreshArrangeCues();
|
2026-10-06 15:58:02 +02:00
|
|
|
|
},
|
|
|
|
|
|
onImagesChanged: () => {
|
2026-10-10 19:01:20 +02:00
|
|
|
|
log(`Page ${session.page.number}: ${session.page.images.length} images, ${session.page.hides.length} hides`);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
renderer.showImages();
|
2026-10-10 19:01:20 +02:00
|
|
|
|
renderer.drawHides();
|
2026-10-14 00:01:45 +02:00
|
|
|
|
refreshArrangeCues();
|
2026-10-06 15:58:02 +02:00
|
|
|
|
writer.writeNow();
|
|
|
|
|
|
},
|
2026-10-10 19:01:20 +02:00
|
|
|
|
onPageResized: () => {
|
|
|
|
|
|
// The view keeps its scale; the page extends beyond the window.
|
2026-10-06 15:58:02 +02:00
|
|
|
|
renderer.resize();
|
|
|
|
|
|
textEditor?.layout();
|
|
|
|
|
|
writer.changed();
|
|
|
|
|
|
},
|
|
|
|
|
|
onParticipantsChanged: () => {
|
|
|
|
|
|
syncLayers();
|
|
|
|
|
|
showParticipants(session, renderer);
|
|
|
|
|
|
},
|
2026-10-11 13:17:22 +02:00
|
|
|
|
onUndoChanged: () => {
|
|
|
|
|
|
/** @type {HTMLButtonElement} */ (element('undo')).disabled = !session.canUndo();
|
|
|
|
|
|
},
|
2026-10-06 15:58:02 +02:00
|
|
|
|
onStatus: (status, detail) => {
|
|
|
|
|
|
const text = { connecting: 'Connecting…', connected: 'Connected', failed: detail ?? 'Disconnected' };
|
2026-10-10 19:01:20 +02:00
|
|
|
|
log(`Relay: ${text[status]}`);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
element('status').textContent = text[status];
|
|
|
|
|
|
element('status').classList.toggle('problem', status !== 'connected');
|
2026-10-10 19:01:20 +02:00
|
|
|
|
// The notice line says nothing while connected, nothing during a
|
|
|
|
|
|
// short reconnection either, and stays up while a longer one lasts.
|
|
|
|
|
|
// Every attempt reports 'connecting' again, so the grace runs from
|
|
|
|
|
|
// the first of them.
|
|
|
|
|
|
if (status === 'connecting') {
|
|
|
|
|
|
if (connectionNoticeTimer === undefined && !standing.has('connection')) {
|
|
|
|
|
|
connectionNoticeTimer = setTimeout(() => {
|
|
|
|
|
|
connectionNoticeTimer = undefined;
|
|
|
|
|
|
standingNotice('connection', 'Not connected. Reconnecting…');
|
|
|
|
|
|
}, CONNECTION_GRACE_MS);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
clearTimeout(connectionNoticeTimer);
|
|
|
|
|
|
connectionNoticeTimer = undefined;
|
|
|
|
|
|
standingNotice('connection', status === 'connected' ? '' : text.failed);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const writer = new NotebookWriter(
|
|
|
|
|
|
notebook,
|
|
|
|
|
|
() => (session.page.number > 0 ? fileOf(session.page) : null),
|
2026-10-10 19:01:20 +02:00
|
|
|
|
{
|
|
|
|
|
|
onSaved: (time) => {
|
|
|
|
|
|
showSaveState(`Saved at ${time}`, false);
|
|
|
|
|
|
standingNotice('saving', '');
|
|
|
|
|
|
},
|
|
|
|
|
|
onProblem: (message) => {
|
|
|
|
|
|
showSaveState(message, true);
|
|
|
|
|
|
standingNotice('saving', message);
|
|
|
|
|
|
},
|
|
|
|
|
|
onKept: (message) => passingNotice(message),
|
2026-10-06 15:58:02 +02:00
|
|
|
|
});
|
2026-10-10 19:01:20 +02:00
|
|
|
|
showSaveState(savedPage ? 'Continued from the notebook' : '', false);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
|
// The toolbar, when the window is too narrow for one row, centres its
|
|
|
|
|
|
// rows rather than holding the two ends (Andy, 2026-10-09). Wrapping
|
|
|
|
|
|
// is told from the toolbar's height against one row's.
|
|
|
|
|
|
const toolbar = /** @type {HTMLElement} */ (document.querySelector('.toolbar'));
|
|
|
|
|
|
const watchToolbar = () => {
|
|
|
|
|
|
// One row is a control's height plus the toolbar's padding; two rows
|
|
|
|
|
|
// are nearly twice that.
|
|
|
|
|
|
const row = /** @type {HTMLElement} */ (toolbar.firstElementChild).offsetHeight;
|
|
|
|
|
|
toolbar.classList.toggle('wrapped', toolbar.clientHeight > row * 1.9);
|
|
|
|
|
|
};
|
|
|
|
|
|
new ResizeObserver(watchToolbar).observe(toolbar);
|
|
|
|
|
|
watchToolbar();
|
2026-10-14 01:07:53 +02:00
|
|
|
|
// The drawing group and the settings (colours, widths, font and size)
|
|
|
|
|
|
// keep the width of their widest state, so that Eraser, Hide and Undo,
|
|
|
|
|
|
// which stand after the settings since 2026-10-11, do not jump when a
|
|
|
|
|
|
// tool shows fewer settings (the drawing group's floor also kept the
|
|
|
|
|
|
// middle group still while there was one, until 2026-10-14). The
|
|
|
|
|
|
// floor rises if a group ever grows, as when a colour is added.
|
2026-10-11 13:17:22 +02:00
|
|
|
|
for (const part of [element('drawing'), element('settings')]) {
|
|
|
|
|
|
let widest = 0;
|
|
|
|
|
|
new ResizeObserver(() => {
|
|
|
|
|
|
const width = part.getBoundingClientRect().width;
|
|
|
|
|
|
if (width > widest) {
|
|
|
|
|
|
widest = width;
|
|
|
|
|
|
part.style.minWidth = `${width}px`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}).observe(part);
|
|
|
|
|
|
}
|
2026-10-10 19:01:20 +02:00
|
|
|
|
|
2026-10-06 15:58:02 +02:00
|
|
|
|
textEditor = new TextEditor(pageElement, renderer, {
|
|
|
|
|
|
setText: (block) => session.setText(block),
|
|
|
|
|
|
removeText: (id) => session.removeTexts([id]),
|
2026-10-11 13:17:22 +02:00
|
|
|
|
beginEdit: (id) => session.beginTextEdit(id, 'editor'),
|
|
|
|
|
|
endEdit: (id) => session.endTextEdit(id, 'editor'),
|
2026-10-10 19:01:20 +02:00
|
|
|
|
// A hold on the field being typed in moves its block, as on the page.
|
|
|
|
|
|
beginHold: (clientX, clientY) => penTarget.beginHold?.(clientX, clientY) ?? false,
|
|
|
|
|
|
holdMove: (clientX, clientY) => penTarget.holdMove?.(clientX, clientY),
|
|
|
|
|
|
holdEnd: () => penTarget.holdEnd?.(),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-11 13:17:22 +02:00
|
|
|
|
// Undo: one's own latest gesture, then the one before (Andy,
|
|
|
|
|
|
// 2026-10-11): the button, Ctrl+Z (Command+Z on a Mac) outside a field,
|
|
|
|
|
|
// and two quick taps of the eraser on empty paper (eraseEnded below).
|
2026-10-14 14:00:47 +02:00
|
|
|
|
// A block being typed, or a line being made, is finished first, so that
|
|
|
|
|
|
// the undo is of it.
|
2026-10-11 13:17:22 +02:00
|
|
|
|
const undo = () => {
|
|
|
|
|
|
textEditor?.stop();
|
2026-10-14 14:00:47 +02:00
|
|
|
|
finishLine();
|
2026-10-11 13:17:22 +02:00
|
|
|
|
const what = session.undo();
|
|
|
|
|
|
if (what) log(`Undid ${what}`);
|
|
|
|
|
|
};
|
|
|
|
|
|
element('undo').addEventListener('click', undo);
|
|
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
|
|
|
|
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey &&
|
|
|
|
|
|
event.key.toLowerCase() === 'z' && !inField()) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
undo();
|
|
|
|
|
|
}
|
2026-10-14 14:00:47 +02:00
|
|
|
|
// A line being made: Escape finishes it, Backspace takes back its
|
|
|
|
|
|
// last point.
|
|
|
|
|
|
if (lineInProgress && !inField() && (event.key === 'Escape' || event.key === 'Backspace')) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
if (event.key === 'Escape') finishLine();
|
|
|
|
|
|
else removeLastVertex();
|
|
|
|
|
|
}
|
2026-10-11 13:17:22 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
|
// Whether one's own writing may grow the page (the "Extend" box).
|
|
|
|
|
|
let extend = localStorage.getItem(EXTEND_KEY) !== 'no';
|
|
|
|
|
|
const extendBox = input('extend');
|
|
|
|
|
|
extendBox.checked = extend;
|
|
|
|
|
|
extendBox.addEventListener('change', () => {
|
|
|
|
|
|
extend = extendBox.checked;
|
|
|
|
|
|
localStorage.setItem(EXTEND_KEY, extend ? 'yes' : 'no');
|
|
|
|
|
|
});
|
|
|
|
|
|
/**
|
|
|
|
|
|
* With "Extend" off, points that would make the page grow are dropped:
|
|
|
|
|
|
* the stroke stops short of the edge and resumes when the pen is back
|
|
|
|
|
|
* inside. Nothing is stored beyond the page.
|
|
|
|
|
|
* @param {number[]} points
|
|
|
|
|
|
* @returns {number[]}
|
|
|
|
|
|
*/
|
|
|
|
|
|
const withinPage = (points) => {
|
|
|
|
|
|
if (extend) return points;
|
|
|
|
|
|
const page = session.page;
|
|
|
|
|
|
const kept = [];
|
|
|
|
|
|
for (let i = 0; i < points.length; i += 3) {
|
|
|
|
|
|
if (page.holdsWriting(points[i], points[i + 1], width)) kept.push(points[i], points[i + 1], points[i + 2]);
|
|
|
|
|
|
}
|
|
|
|
|
|
return kept;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let reportedDevice = '';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-14 14:00:47 +02:00
|
|
|
|
* What a hold of the pen is handling: a Hide rectangle, a text block or
|
|
|
|
|
|
* a shape of ours, or an image; which; whether a corner is being
|
|
|
|
|
|
* dragged (and then which, with the opposite one fixed); and how the
|
|
|
|
|
|
* pen's position maps to the rectangle. Images keep their shape when
|
|
|
|
|
|
* scaled; the others resize freely (Andy, 2026-10-08).
|
2026-10-10 19:01:20 +02:00
|
|
|
|
* @typedef {object} Held
|
2026-10-14 14:00:47 +02:00
|
|
|
|
* @property {'hide' | 'text' | 'shape' | 'image'} kind
|
2026-10-10 19:01:20 +02:00
|
|
|
|
* @property {number} index Of the Hide rectangle or image.
|
2026-10-14 14:00:47 +02:00
|
|
|
|
* @property {string} id Of the text block or the shape's stroke.
|
2026-10-10 19:01:20 +02:00
|
|
|
|
* @property {[number, number] | null} corner
|
|
|
|
|
|
* @property {[number, number]} fixed
|
|
|
|
|
|
* @property {number} aspect
|
|
|
|
|
|
* @property {number} offsetX
|
|
|
|
|
|
* @property {number} offsetY
|
|
|
|
|
|
*/
|
|
|
|
|
|
/** @type {Held | null} */
|
|
|
|
|
|
let held = null;
|
2026-10-11 13:17:22 +02:00
|
|
|
|
/** When the eraser last tapped empty paper, by performance.now(); 0 if not lately. */
|
|
|
|
|
|
let lastEmptyTap = 0;
|
2026-10-10 19:01:20 +02:00
|
|
|
|
/** @type {import('./page_model.js').Stroke | null} The stroke a hold began as. */
|
|
|
|
|
|
let strokeInProgress = null;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The rectangle a hold has moved or resized to, from the pen's position.
|
|
|
|
|
|
* @param {Held} h
|
|
|
|
|
|
* @param {import('./page_model.js').Rectangle} now The rectangle as it is.
|
|
|
|
|
|
* @param {number} x
|
|
|
|
|
|
* @param {number} y
|
|
|
|
|
|
* @param {boolean} keepShape
|
|
|
|
|
|
* @returns {import('./page_model.js').Rectangle}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function heldRect(h, now, x, y, keepShape) {
|
|
|
|
|
|
if (!h.corner) return { left: x - h.offsetX, top: y - h.offsetY, width: now.width, height: now.height };
|
|
|
|
|
|
const [fx, fy] = h.fixed;
|
|
|
|
|
|
const dirX = h.corner[0] ? 1 : -1;
|
|
|
|
|
|
const dirY = h.corner[1] ? 1 : -1;
|
|
|
|
|
|
let w = Math.max((x - fx) * dirX, LEAST_IMAGE_SIDE);
|
|
|
|
|
|
let hgt = Math.max((y - fy) * dirY, LEAST_IMAGE_SIDE);
|
|
|
|
|
|
if (keepShape) {
|
|
|
|
|
|
// Along the diagonal from the fixed corner, so the aspect ratio holds.
|
|
|
|
|
|
w = Math.max((x - fx) * dirX, (y - fy) * dirY * h.aspect, LEAST_IMAGE_SIDE, LEAST_IMAGE_SIDE * h.aspect);
|
|
|
|
|
|
hgt = w / h.aspect;
|
|
|
|
|
|
}
|
|
|
|
|
|
return { left: dirX > 0 ? fx : fx - w, top: dirY > 0 ? fy : fy - hgt, width: w, height: hgt };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param {Held['kind']} kind
|
|
|
|
|
|
* @param {number} index
|
|
|
|
|
|
* @param {string} id
|
|
|
|
|
|
* @param {import('./page_model.js').Rectangle} r
|
|
|
|
|
|
* @param {[number, number] | null} corner
|
|
|
|
|
|
* @param {number} x
|
|
|
|
|
|
* @param {number} y
|
|
|
|
|
|
* @returns {Held}
|
|
|
|
|
|
*/
|
|
|
|
|
|
const hold = (kind, index, id, r, corner, x, y) => ({
|
|
|
|
|
|
kind, index, id, corner,
|
|
|
|
|
|
fixed: corner ? [r.left + (1 - corner[0]) * r.width, r.top + (1 - corner[1]) * r.height] : [0, 0],
|
|
|
|
|
|
aspect: r.width / r.height, offsetX: x - r.left, offsetY: y - r.top,
|
2026-10-06 15:58:02 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* @param {Held} h
|
|
|
|
|
|
* @returns {import('./page_model.js').Rectangle | null}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function currentHeldRect(h) {
|
|
|
|
|
|
const page = session.page;
|
|
|
|
|
|
if (h.kind === 'hide') {
|
|
|
|
|
|
const hide = page.hides[h.index];
|
|
|
|
|
|
return hide ? { left: hide.x, top: hide.y, width: hide.width, height: hide.height } : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (h.kind === 'image') {
|
|
|
|
|
|
const image = page.images[h.index];
|
|
|
|
|
|
return image ? { left: image.x, top: image.y, width: image.width, height: image.height } : null;
|
|
|
|
|
|
}
|
2026-10-14 14:00:47 +02:00
|
|
|
|
if (h.kind === 'shape') {
|
|
|
|
|
|
const stroke = session.ownLayer.strokes.get(h.id);
|
|
|
|
|
|
return stroke ? strokeBox(stroke) : null;
|
|
|
|
|
|
}
|
2026-10-10 19:01:20 +02:00
|
|
|
|
const block = session.ownLayer.texts.get(h.id);
|
|
|
|
|
|
return block ? textBounds(block) : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 20:38:29 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* The grip around the held thing: for a stroke, around its ink rather
|
|
|
|
|
|
* than its points (Andy, 2026-10-14).
|
|
|
|
|
|
* @param {Held} h
|
|
|
|
|
|
*/
|
|
|
|
|
|
function showHeldGrip(h) {
|
|
|
|
|
|
if (h.kind === 'shape') {
|
|
|
|
|
|
const stroke = session.ownLayer.strokes.get(h.id);
|
|
|
|
|
|
renderer.showGrip(stroke ? inkExtent(stroke) : null, h.corner, true);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
renderer.showGrip(currentHeldRect(h), h.corner, heldHasCorners(h));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param {Held} h
|
|
|
|
|
|
* @returns {boolean} Whether the held thing has corners to resize by:
|
|
|
|
|
|
* everything but free text.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const heldHasCorners = (h) => h.kind !== 'text' || session.ownLayer.texts.get(h.id)?.height !== null;
|
|
|
|
|
|
|
2026-10-14 14:00:47 +02:00
|
|
|
|
/** @returns {import('./page_model.js').Stroke[]} Our shapes, in drawing order. */
|
|
|
|
|
|
const ownShapes = () => [...session.ownLayer.strokes.values()].filter((stroke) => stroke.shape !== null && stroke.done);
|
|
|
|
|
|
|
2026-10-14 00:01:45 +02:00
|
|
|
|
/**
|
2026-10-14 20:38:29 +02:00
|
|
|
|
* With the Arrange tool chosen, mark everything a press can move or
|
|
|
|
|
|
* resize with a blue box and corners (Andy, 2026-10-14): the images
|
2026-10-18 20:22:13 +02:00
|
|
|
|
* and Hides, one's own text blocks and shapes; free text, which has
|
|
|
|
|
|
* no width to resize, gets the box without the corners. The one being
|
|
|
|
|
|
* held is left out, the green grip standing in its place until the
|
|
|
|
|
|
* pointer lifts. Called whenever the images, the Hides, one's own
|
|
|
|
|
|
* text or shapes, or the hold may have changed.
|
2026-10-14 00:01:45 +02:00
|
|
|
|
*/
|
|
|
|
|
|
function refreshArrangeCues() {
|
|
|
|
|
|
if (tool !== 'arrange') {
|
|
|
|
|
|
renderer.setArrangeCues([]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-14 20:38:29 +02:00
|
|
|
|
/** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]} */
|
2026-10-14 00:01:45 +02:00
|
|
|
|
const cues = [];
|
2026-10-14 20:38:29 +02:00
|
|
|
|
const page = session.page;
|
2026-10-18 20:22:13 +02:00
|
|
|
|
page.images.forEach((im, index) => {
|
|
|
|
|
|
if (held?.kind === 'image' && held.index === index) return;
|
|
|
|
|
|
cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height }, corners: true });
|
|
|
|
|
|
});
|
|
|
|
|
|
page.hides.forEach((h, index) => {
|
|
|
|
|
|
if (held?.kind === 'hide' && held.index === index) return;
|
|
|
|
|
|
cues.push({ kind: 'hide', rect: { left: h.x, top: h.y, width: h.width, height: h.height }, corners: true });
|
|
|
|
|
|
});
|
2026-10-14 20:38:29 +02:00
|
|
|
|
for (const block of session.ownLayer.texts.values()) {
|
|
|
|
|
|
if (held?.kind === 'text' && held.id === block.id) continue;
|
|
|
|
|
|
cues.push({ kind: 'text', rect: textBounds(block), corners: block.height !== null });
|
2026-10-14 00:01:45 +02:00
|
|
|
|
}
|
2026-10-14 14:00:47 +02:00
|
|
|
|
for (const stroke of ownShapes()) {
|
2026-10-14 20:38:29 +02:00
|
|
|
|
if (held?.kind === 'shape' && held.id === stroke.id) continue;
|
|
|
|
|
|
const rect = inkExtent(stroke);
|
|
|
|
|
|
if (rect) cues.push({ kind: 'shape', rect, corners: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
// The handwritten stroke under the hovering pointer, if any.
|
|
|
|
|
|
const hovered = hoveredStroke && session.ownLayer.strokes.get(hoveredStroke);
|
|
|
|
|
|
if (hovered && !(held?.kind === 'shape' && held.id === hovered.id)) {
|
|
|
|
|
|
const rect = inkExtent(hovered);
|
|
|
|
|
|
if (rect) cues.push({ kind: 'stroke', rect, corners: true });
|
2026-10-14 00:01:45 +02:00
|
|
|
|
}
|
|
|
|
|
|
renderer.setArrangeCues(cues);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 20:38:29 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Handwriting under Arrange (Andy, 2026-10-14): a page may hold hundreds
|
|
|
|
|
|
* of strokes, so they are not all boxed at once; the one whose ink the
|
|
|
|
|
|
* pointer hovers over gets the box, kept while the pointer stays within
|
|
|
|
|
|
* it (so that its corners can be reached), and a press takes hold of a
|
|
|
|
|
|
* stroke by its ink, or by the shown box's corner, under Arrange only:
|
|
|
|
|
|
* the hold with another tool leaves handwriting alone, since resting
|
|
|
|
|
|
* the pen on one's own writing is part of writing.
|
|
|
|
|
|
* @type {string | null} The id of the stroke whose box is shown.
|
|
|
|
|
|
*/
|
|
|
|
|
|
let hoveredStroke = null;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The frontmost handwritten stroke of ours (not a shape, which has its
|
|
|
|
|
|
* cue always) within the eraser's reach of the point, by its ink.
|
|
|
|
|
|
* @param {number} x
|
|
|
|
|
|
* @param {number} y
|
|
|
|
|
|
* @returns {import('./page_model.js').Stroke | null}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function handwritingAt(x, y) {
|
|
|
|
|
|
const layer = session.ownLayer;
|
|
|
|
|
|
const ids = strokesTouched(layer, x, y, x, y, renderer.pageLength(ERASER_RADIUS));
|
|
|
|
|
|
for (let i = ids.length - 1; i >= 0; i--) {
|
|
|
|
|
|
const stroke = layer.strokes.get(ids[i]);
|
|
|
|
|
|
if (stroke && stroke.done && stroke.shape === null) return stroke;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Under Arrange, the pointer hovering at this point: the stroke under
|
|
|
|
|
|
* it gets the box, or the shown box stays while the pointer is within
|
|
|
|
|
|
* it or near its corners.
|
|
|
|
|
|
* @param {number} x
|
|
|
|
|
|
* @param {number} y
|
|
|
|
|
|
*/
|
|
|
|
|
|
function hoverHandwriting(x, y) {
|
|
|
|
|
|
const shown = hoveredStroke && session.ownLayer.strokes.get(hoveredStroke);
|
|
|
|
|
|
const box = shown ? inkExtent(shown) : null;
|
|
|
|
|
|
const reach = renderer.cornerReach();
|
|
|
|
|
|
const within = box && x >= box.left - reach && x <= box.left + box.width + reach && y >= box.top - reach && y <= box.top + box.height + reach;
|
|
|
|
|
|
const under = handwritingAt(x, y);
|
|
|
|
|
|
const next = under ? under.id : within ? hoveredStroke : null;
|
|
|
|
|
|
if (next === hoveredStroke) return;
|
|
|
|
|
|
hoveredStroke = next;
|
|
|
|
|
|
refreshArrangeCues();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 14:00:47 +02:00
|
|
|
|
// The Shape tool (Andy, 2026-10-14; shapes.js): a rectangle, an ellipse
|
|
|
|
|
|
// or an arrow is dragged open and becomes one stroke when the pointer
|
|
|
|
|
|
// lifts, a preview showing meanwhile; a line is made of taps, one
|
|
|
|
|
|
// vertex each, its stroke growing for everyone with each tap, a rubber
|
|
|
|
|
|
// band from the last vertex following a hovering pointer, until a tap
|
|
|
|
|
|
// on the last vertex (finished open) or on the first (closed), Escape,
|
|
|
|
|
|
// or a change of tool or shape.
|
|
|
|
|
|
|
|
|
|
|
|
/** @type {{stroke: import('./page_model.js').Stroke, vertices: [number, number][]} | null} */
|
|
|
|
|
|
let lineInProgress = null;
|
|
|
|
|
|
|
|
|
|
|
|
/** The line being made is complete. */
|
|
|
|
|
|
function finishLine() {
|
|
|
|
|
|
const line = lineInProgress;
|
|
|
|
|
|
if (!line) return;
|
|
|
|
|
|
lineInProgress = null;
|
|
|
|
|
|
renderer.hideShapePreview();
|
|
|
|
|
|
if (session.currentStroke === line.stroke) session.endStroke();
|
|
|
|
|
|
log(`Line of ${line.vertices.length} points`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Backspace while a line is made: its last vertex goes. A stroke in
|
|
|
|
|
|
* progress cannot lose points by any message, so the stroke is erased
|
|
|
|
|
|
* and begun again with the vertices left.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function removeLastVertex() {
|
|
|
|
|
|
const line = lineInProgress;
|
|
|
|
|
|
if (!line) return;
|
|
|
|
|
|
line.vertices.pop();
|
|
|
|
|
|
if (session.currentStroke === line.stroke) session.endStroke(false);
|
|
|
|
|
|
session.erase([line.stroke.id]);
|
|
|
|
|
|
if (line.vertices.length === 0) {
|
|
|
|
|
|
lineInProgress = null;
|
|
|
|
|
|
renderer.hideShapePreview();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
line.stroke = session.beginStroke(color, width, { fill, kind: 'line' });
|
|
|
|
|
|
session.addPoints(lineInProgressPoints(line.vertices));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Whether a shape's points all lie where the page need not grow, or
|
|
|
|
|
|
* Extend is on. With Extend off a shape is drawn whole or not at all,
|
|
|
|
|
|
* since dropping its points beyond the edge, as a stroke's are, would
|
|
|
|
|
|
* deform it.
|
|
|
|
|
|
* @param {number[]} points
|
|
|
|
|
|
*/
|
|
|
|
|
|
function pageHolds(points) {
|
|
|
|
|
|
if (extend) return true;
|
|
|
|
|
|
for (let i = 0; i < points.length; i += POINT_SIZE) {
|
|
|
|
|
|
if (!session.page.holdsWriting(points[i], points[i + 1], width)) return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The pointer went down at (x0, y0) and up at (x1, y1) with the Shape
|
|
|
|
|
|
* tool.
|
|
|
|
|
|
* @param {number} x0
|
|
|
|
|
|
* @param {number} y0
|
|
|
|
|
|
* @param {number} x1
|
|
|
|
|
|
* @param {number} y1
|
|
|
|
|
|
*/
|
|
|
|
|
|
function placeShape(x0, y0, x1, y1) {
|
|
|
|
|
|
renderer.hideShapePreview();
|
|
|
|
|
|
const dragged = Math.hypot(x1 - x0, y1 - y0) >= TAP_DISTANCE;
|
|
|
|
|
|
/** @param {number} v */
|
|
|
|
|
|
const r = (v) => rounded(v, 10);
|
|
|
|
|
|
if (!isTapped(shape)) {
|
|
|
|
|
|
if (!dragged) return;
|
|
|
|
|
|
const points = draggedPoints(shape, r(x0), r(y0), r(x1), r(y1), width);
|
|
|
|
|
|
if (!pageHolds(points)) {
|
|
|
|
|
|
trace('shape refused: it would reach past the page with Extend off');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
textEditor?.stop();
|
|
|
|
|
|
session.beginStroke(color, width, { filled: isFilled(shape), fill: isFilled(shape) ? null : fill, kind: shape });
|
|
|
|
|
|
session.addPoints(points);
|
|
|
|
|
|
session.endStroke();
|
|
|
|
|
|
log(`${SHAPE_NAMES[shape]} drawn${fill && !isFilled(shape) ? `, filled ${fill}` : ''}`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// A line: each press and release gives a vertex, the release point.
|
|
|
|
|
|
const vx = r(x1);
|
|
|
|
|
|
const vy = r(y1);
|
|
|
|
|
|
if (lineInProgress && session.currentStroke !== lineInProgress.stroke) lineInProgress = null;
|
|
|
|
|
|
const line = lineInProgress;
|
|
|
|
|
|
if (line) {
|
|
|
|
|
|
const reach = renderer.cornerReach();
|
|
|
|
|
|
const [fx, fy] = line.vertices[0];
|
|
|
|
|
|
const [lx, ly] = line.vertices[line.vertices.length - 1];
|
|
|
|
|
|
if (line.vertices.length >= 2 && Math.hypot(vx - fx, vy - fy) <= reach) {
|
|
|
|
|
|
session.addPoints([fx, fy, NOMINAL_PRESSURE]); // Closed: back to the first point.
|
|
|
|
|
|
finishLine();
|
|
|
|
|
|
} else if (Math.hypot(vx - lx, vy - ly) <= reach) {
|
|
|
|
|
|
finishLine();
|
|
|
|
|
|
} else if (pageHolds([vx, vy, NOMINAL_PRESSURE])) {
|
|
|
|
|
|
line.vertices.push([vx, vy]);
|
|
|
|
|
|
session.addPoints(vertexPoints(vx, vy));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
trace('line point refused: beyond the page with Extend off');
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// The first vertex; a drag gives the first two.
|
|
|
|
|
|
/** @type {[number, number][]} */
|
|
|
|
|
|
const vertices = dragged ? [[r(x0), r(y0)], [vx, vy]] : [[vx, vy]];
|
|
|
|
|
|
const points = lineInProgressPoints(vertices);
|
|
|
|
|
|
if (!pageHolds(points)) {
|
|
|
|
|
|
trace('line refused: beyond the page with Extend off');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
textEditor?.stop();
|
|
|
|
|
|
const stroke = session.beginStroke(color, width, { fill, kind: 'line' });
|
|
|
|
|
|
lineInProgress = { stroke, vertices };
|
|
|
|
|
|
session.addPoints(points);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The implied rectangle under a point, for the hover cue (Andy,
|
|
|
|
|
|
* 2026-10-14: text rectangles and Hides show their corners only then,
|
|
|
|
|
|
* like free text): the frontmost Hide, else the frontmost text block
|
2026-10-14 20:38:29 +02:00
|
|
|
|
* among the layers shown, with its kind, which decides the cue's form.
|
2026-10-14 14:00:47 +02:00
|
|
|
|
* @param {number} x
|
|
|
|
|
|
* @param {number} y
|
2026-10-14 20:38:29 +02:00
|
|
|
|
* @returns {{rect: import('./page_model.js').Rectangle, kind: 'hide' | 'rect' | 'free'} | null}
|
2026-10-14 14:00:47 +02:00
|
|
|
|
*/
|
|
|
|
|
|
function impliedRectAt(x, y) {
|
|
|
|
|
|
/** @param {import('./page_model.js').Rectangle} b */
|
|
|
|
|
|
const holds = (b) => x >= b.left && x <= b.left + b.width && y >= b.top && y <= b.top + b.height;
|
|
|
|
|
|
for (const h of [...session.page.hides].reverse()) {
|
|
|
|
|
|
const b = { left: h.x, top: h.y, width: h.width, height: h.height };
|
2026-10-14 20:38:29 +02:00
|
|
|
|
if (holds(b)) return { rect: b, kind: 'hide' };
|
2026-10-14 14:00:47 +02:00
|
|
|
|
}
|
|
|
|
|
|
for (const id of session.layerOrder().reverse()) {
|
|
|
|
|
|
const layer = session.page.layers.get(id);
|
|
|
|
|
|
if (!layer || renderer.hidden.has(id)) continue;
|
|
|
|
|
|
for (const block of [...layer.texts.values()].reverse()) {
|
|
|
|
|
|
const b = textBounds(block);
|
2026-10-14 20:38:29 +02:00
|
|
|
|
if (holds(b)) return { rect: b, kind: block.height === null ? 'free' : 'rect' };
|
2026-10-14 14:00:47 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 20:38:29 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* What a press of the eraser at this point would remove, for the cue
|
|
|
|
|
|
* shown while the pointer hovers (Andy, 2026-10-14): the rectangles
|
|
|
|
|
|
* around the strokes and text blocks of ours within the eraser's reach,
|
|
|
|
|
|
* whole, as the eraser takes them; or, where there is none, the Hide or
|
|
|
|
|
|
* image a tap would remove, by the same rules as eraseEnded.
|
|
|
|
|
|
* @param {number} x
|
|
|
|
|
|
* @param {number} y
|
|
|
|
|
|
* @returns {import('./page_model.js').Rectangle[]}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function eraseTargetsAt(x, y) {
|
|
|
|
|
|
const layer = session.ownLayer;
|
|
|
|
|
|
const radius = renderer.pageLength(ERASER_RADIUS);
|
|
|
|
|
|
/** @type {import('./page_model.js').Rectangle[]} */
|
|
|
|
|
|
const rects = [];
|
|
|
|
|
|
for (const id of strokesTouched(layer, x, y, x, y, radius)) {
|
|
|
|
|
|
const stroke = layer.strokes.get(id);
|
|
|
|
|
|
const rect = stroke && inkExtent(stroke);
|
|
|
|
|
|
if (rect) rects.push(rect);
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const id of textsTouched(layer, x, y, x, y, radius)) {
|
|
|
|
|
|
const block = layer.texts.get(id);
|
|
|
|
|
|
if (block) rects.push(textBounds(block));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (rects.length) return rects;
|
|
|
|
|
|
const page = session.page;
|
|
|
|
|
|
const hide = page.hideAt(x, y, 0);
|
2026-10-18 20:22:13 +02:00
|
|
|
|
if (hide) {
|
2026-10-14 20:38:29 +02:00
|
|
|
|
const h = page.hides[hide.index];
|
|
|
|
|
|
return [{ left: h.x, top: h.y, width: h.width, height: h.height }];
|
|
|
|
|
|
}
|
|
|
|
|
|
const found = page.imageAt(x, y, 0);
|
|
|
|
|
|
const image = found && page.images[found.index];
|
2026-10-18 20:22:13 +02:00
|
|
|
|
if (image) {
|
2026-10-14 20:38:29 +02:00
|
|
|
|
return [{ left: image.x, top: image.y, width: image.width, height: image.height }];
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
|
/** @type {import('./pen_input.js').PenTarget} */
|
|
|
|
|
|
const penTarget = {
|
2026-10-06 15:58:02 +02:00
|
|
|
|
toPage: (clientX, clientY) => renderer.pageFromClient(clientX, clientY),
|
|
|
|
|
|
tool: () => tool,
|
|
|
|
|
|
canWrite: () => session.page.number > 0,
|
|
|
|
|
|
beginStroke: () => {
|
|
|
|
|
|
textEditor?.stop();
|
2026-10-10 19:01:20 +02:00
|
|
|
|
strokeInProgress = session.beginStroke(color, width);
|
|
|
|
|
|
},
|
|
|
|
|
|
addPoints: (points) => {
|
|
|
|
|
|
const inside = withinPage(points);
|
|
|
|
|
|
if (inside.length) session.addPoints(inside);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
},
|
|
|
|
|
|
endStroke: () => {
|
2026-10-10 19:01:20 +02:00
|
|
|
|
strokeInProgress = null;
|
2026-10-06 15:58:02 +02:00
|
|
|
|
session.endStroke();
|
2026-10-10 19:01:20 +02:00
|
|
|
|
},
|
|
|
|
|
|
beginZoom: (clientX, clientY) => renderer.beginZoomDrag(clientX, clientY),
|
|
|
|
|
|
zoomTo: (pixelsUp) => renderer.zoomDragTo(pixelsUp),
|
|
|
|
|
|
endZoom: () => renderer.endZoomDrag(),
|
|
|
|
|
|
// A hold moves what is under the pen, or resizes it from a corner: a
|
2026-10-18 20:22:13 +02:00
|
|
|
|
// Hide rectangle, an image, or a text block of our own. The dot
|
|
|
|
|
|
// drawn meanwhile goes.
|
2026-10-10 19:01:20 +02:00
|
|
|
|
beginHold: (clientX, clientY) => {
|
|
|
|
|
|
const [x, y] = renderer.pageFromClient(clientX, clientY);
|
|
|
|
|
|
const reach = renderer.cornerReach();
|
|
|
|
|
|
const page = session.page;
|
|
|
|
|
|
/** @type {Held | null} */
|
|
|
|
|
|
let found = null;
|
2026-10-18 20:22:13 +02:00
|
|
|
|
const hide = page.hideAt(x, y, reach);
|
2026-10-10 19:01:20 +02:00
|
|
|
|
if (hide) {
|
|
|
|
|
|
const h = page.hides[hide.index];
|
|
|
|
|
|
found = hold('hide', hide.index, h.id, { left: h.x, top: h.y, width: h.width, height: h.height }, hide.corner, x, y);
|
2026-10-11 13:17:22 +02:00
|
|
|
|
session.beginHideEdit(h.id);
|
2026-10-10 19:01:20 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
const blocks = [...session.ownLayer.texts.values()];
|
|
|
|
|
|
const at = rectAt(blocks.map(textBounds), x, y, reach);
|
2026-10-14 20:38:29 +02:00
|
|
|
|
// Handwriting, under Arrange only: the shown box's corner, or a
|
|
|
|
|
|
// stroke by its ink.
|
|
|
|
|
|
/** @type {{stroke: import('./page_model.js').Stroke, corner: [number, number] | null} | null} */
|
|
|
|
|
|
let writing = null;
|
|
|
|
|
|
if (!at && tool === 'arrange') {
|
|
|
|
|
|
const shown = hoveredStroke && session.ownLayer.strokes.get(hoveredStroke);
|
|
|
|
|
|
const shownBox = shown ? inkExtent(shown) : null;
|
|
|
|
|
|
const cornerAt = shownBox ? rectAt([shownBox], x, y, reach) : null;
|
|
|
|
|
|
if (shown && cornerAt?.corner) writing = { stroke: shown, corner: cornerAt.corner };
|
|
|
|
|
|
else {
|
|
|
|
|
|
const under = handwritingAt(x, y);
|
|
|
|
|
|
if (under) writing = { stroke: under, corner: null };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const shapes = at || writing ? [] : ownShapes();
|
|
|
|
|
|
const shapeAt = at || writing ? null : rectAt(shapes.map((s) => strokeBox(s) ?? { left: 0, top: 0, width: 0, height: 0 }), x, y, reach);
|
2026-10-10 19:01:20 +02:00
|
|
|
|
if (at) {
|
|
|
|
|
|
const block = blocks[at.index];
|
|
|
|
|
|
// Free text has no corners to resize by.
|
|
|
|
|
|
const corner = block.height === null ? null : at.corner;
|
|
|
|
|
|
found = hold('text', at.index, block.id, textBounds(block), corner, x, y);
|
2026-10-11 13:17:22 +02:00
|
|
|
|
session.beginTextEdit(block.id, 'hold');
|
2026-10-14 20:38:29 +02:00
|
|
|
|
} else if (writing) {
|
|
|
|
|
|
const box = /** @type {import('./page_model.js').Rectangle} */ (strokeBox(writing.stroke));
|
|
|
|
|
|
if (session.beginShapeEdit(writing.stroke.id)) found = hold('shape', 0, writing.stroke.id, box, writing.corner, x, y);
|
2026-10-14 14:00:47 +02:00
|
|
|
|
} else if (shapeAt) {
|
|
|
|
|
|
// A shape of ours (Andy, 2026-10-14): moved, or resized from a
|
|
|
|
|
|
// corner, by the rectangle around its points.
|
|
|
|
|
|
const stroke = shapes[shapeAt.index];
|
|
|
|
|
|
const box = /** @type {import('./page_model.js').Rectangle} */ (strokeBox(stroke));
|
|
|
|
|
|
if (session.beginShapeEdit(stroke.id)) found = hold('shape', shapeAt.index, stroke.id, box, shapeAt.corner, x, y);
|
2026-10-18 20:22:13 +02:00
|
|
|
|
} else {
|
2026-10-10 19:01:20 +02:00
|
|
|
|
const image = page.imageAt(x, y, reach);
|
|
|
|
|
|
if (image) {
|
|
|
|
|
|
const index = session.raiseImage(image.index);
|
|
|
|
|
|
const im = page.images[index];
|
|
|
|
|
|
found = hold('image', index, '', { left: im.x, top: im.y, width: im.width, height: im.height }, image.corner, x, y);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!found) return false;
|
|
|
|
|
|
if (strokeInProgress) {
|
|
|
|
|
|
const begun = strokeInProgress;
|
|
|
|
|
|
strokeInProgress = null;
|
2026-10-11 13:17:22 +02:00
|
|
|
|
session.endStroke(false);
|
2026-10-10 19:01:20 +02:00
|
|
|
|
session.erase([begun.id]);
|
|
|
|
|
|
}
|
|
|
|
|
|
textEditor?.hidePreview();
|
|
|
|
|
|
held = found;
|
2026-10-14 20:38:29 +02:00
|
|
|
|
showHeldGrip(found);
|
|
|
|
|
|
refreshArrangeCues(); // The held thing's blue cue gives way to the grip.
|
2026-10-10 19:01:20 +02:00
|
|
|
|
log(`${found.kind} held${found.corner ? ' at a corner' : ''}`);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
},
|
|
|
|
|
|
holdMove: (clientX, clientY) => {
|
|
|
|
|
|
if (!held) return;
|
|
|
|
|
|
const [x, y] = renderer.pageFromClient(clientX, clientY);
|
|
|
|
|
|
const now = currentHeldRect(held);
|
|
|
|
|
|
if (!now) return;
|
2026-10-14 20:38:29 +02:00
|
|
|
|
// An image keeps its shape when resized, and so does a circle.
|
|
|
|
|
|
const keepShape = held.kind === 'image' || (held.kind === 'shape' && session.ownLayer.strokes.get(held.id)?.shape === 'circle');
|
|
|
|
|
|
const rect = heldRect(held, now, x, y, keepShape);
|
2026-10-10 19:01:20 +02:00
|
|
|
|
if (held.kind === 'image') {
|
|
|
|
|
|
held.index = session.placeImageByHand(held.index, rect);
|
|
|
|
|
|
} else if (held.kind === 'hide') {
|
|
|
|
|
|
session.placeHideByHand(held.index, rect);
|
2026-10-14 14:00:47 +02:00
|
|
|
|
} else if (held.kind === 'shape') {
|
|
|
|
|
|
const stroke = session.ownLayer.strokes.get(held.id);
|
|
|
|
|
|
if (!stroke) return;
|
|
|
|
|
|
session.setShapePoints(held.id, movedPoints(stroke, now, {
|
|
|
|
|
|
left: Math.round(rect.left), top: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height),
|
|
|
|
|
|
}));
|
2026-10-10 19:01:20 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
const block = session.ownLayer.texts.get(held.id);
|
|
|
|
|
|
if (!block) return;
|
|
|
|
|
|
block.x = Math.round(rect.left);
|
|
|
|
|
|
block.y = Math.round(rect.top);
|
|
|
|
|
|
if (block.height !== null) {
|
|
|
|
|
|
block.width = Math.round(rect.width);
|
|
|
|
|
|
Object.assign(block, layoutText(block.text, block.width, block.size, block.font));
|
|
|
|
|
|
block.height = Math.max(Math.round(rect.height), Math.ceil(block.boxHeight));
|
|
|
|
|
|
}
|
|
|
|
|
|
session.setText(block);
|
|
|
|
|
|
// The field being typed in follows its block.
|
|
|
|
|
|
if (textEditor?.block?.id === block.id) textEditor.layout();
|
|
|
|
|
|
}
|
2026-10-14 20:38:29 +02:00
|
|
|
|
showHeldGrip(held);
|
2026-10-14 00:01:45 +02:00
|
|
|
|
refreshArrangeCues();
|
2026-10-10 19:01:20 +02:00
|
|
|
|
},
|
|
|
|
|
|
holdEnd: () => {
|
|
|
|
|
|
if (!held) return;
|
|
|
|
|
|
const r = currentHeldRect(held);
|
|
|
|
|
|
if (r) log(`${held.kind} placed at ${r.left / 100}, ${r.top / 100} cm, ${r.width / 100} by ${r.height / 100} cm`);
|
2026-10-14 14:00:47 +02:00
|
|
|
|
if (held.kind === 'image' || held.kind === 'hide') session.sendPageNow();
|
2026-10-11 13:17:22 +02:00
|
|
|
|
if (held.kind === 'text') session.endTextEdit(held.id, 'hold');
|
2026-10-14 14:00:47 +02:00
|
|
|
|
if (held.kind === 'shape') session.endShapeEdit();
|
2026-10-11 13:17:22 +02:00
|
|
|
|
if (held.kind === 'hide') session.endHideEdit();
|
2026-10-10 19:01:20 +02:00
|
|
|
|
held = null;
|
|
|
|
|
|
renderer.showGrip(null);
|
2026-10-14 00:01:45 +02:00
|
|
|
|
refreshArrangeCues();
|
2026-10-06 15:58:02 +02:00
|
|
|
|
},
|
|
|
|
|
|
eraseAlong: (x0, y0, x1, y1) => {
|
|
|
|
|
|
textEditor?.stop();
|
2026-10-11 13:17:22 +02:00
|
|
|
|
session.beginErasing();
|
2026-10-06 15:58:02 +02:00
|
|
|
|
const radius = renderer.pageLength(ERASER_RADIUS);
|
2026-10-10 19:01:20 +02:00
|
|
|
|
const strokes = strokesTouched(session.ownLayer, x0, y0, x1, y1, radius);
|
|
|
|
|
|
const texts = textsTouched(session.ownLayer, x0, y0, x1, y1, radius);
|
|
|
|
|
|
session.erase(strokes);
|
|
|
|
|
|
session.removeTexts(texts);
|
|
|
|
|
|
return { strokes: strokes.length, texts: texts.length };
|
2026-10-06 15:58:02 +02:00
|
|
|
|
},
|
|
|
|
|
|
eraseEnded: (x, y, reach) => {
|
2026-10-11 13:17:22 +02:00
|
|
|
|
// What the movement erased is one gesture to undo.
|
|
|
|
|
|
const erased = session.endErasing();
|
|
|
|
|
|
if (reach >= TAP_DISTANCE) {
|
|
|
|
|
|
lastEmptyTap = 0;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-18 20:22:13 +02:00
|
|
|
|
// A tap of the eraser on a Hide rectangle or an image removes it,
|
|
|
|
|
|
// for everyone (by anyone since 2026-10-18).
|
2026-10-11 13:17:22 +02:00
|
|
|
|
let took = erased.strokes + erased.texts > 0;
|
|
|
|
|
|
if (!took) {
|
|
|
|
|
|
const hide = session.page.hideAt(x, y, 0);
|
|
|
|
|
|
if (hide && session.removeHide(hide.index)) {
|
|
|
|
|
|
log(`Hide ${hide.index + 1} removed`);
|
|
|
|
|
|
took = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const found = session.page.imageAt(x, y, 0);
|
|
|
|
|
|
if (found && session.removeImage(found.index)) {
|
|
|
|
|
|
log(`Image ${found.index + 1} removed`);
|
|
|
|
|
|
took = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (took) {
|
|
|
|
|
|
lastEmptyTap = 0;
|
2026-10-10 19:01:20 +02:00
|
|
|
|
return;
|
2026-10-06 15:58:02 +02:00
|
|
|
|
}
|
2026-10-11 13:17:22 +02:00
|
|
|
|
// A tap on empty paper: the second within DOUBLE_TAP_MS undoes.
|
|
|
|
|
|
const now = performance.now();
|
|
|
|
|
|
if (now - lastEmptyTap <= DOUBLE_TAP_MS) {
|
|
|
|
|
|
lastEmptyTap = 0;
|
|
|
|
|
|
undo();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
lastEmptyTap = now;
|
|
|
|
|
|
}
|
2026-10-06 15:58:02 +02:00
|
|
|
|
},
|
2026-10-10 19:01:20 +02:00
|
|
|
|
previewRect: (x0, y0, x1, y1) => {
|
2026-10-14 14:00:47 +02:00
|
|
|
|
const dragged = Math.hypot(x1 - x0, y1 - y0) >= TAP_DISTANCE;
|
|
|
|
|
|
if (tool === 'shape') {
|
|
|
|
|
|
// The shape as it will be, in the pen's colour and width; for a
|
|
|
|
|
|
// line, the side the press is adding.
|
|
|
|
|
|
if (isTapped(shape)) {
|
|
|
|
|
|
/** @type {[number, number]} */
|
|
|
|
|
|
const from = lineInProgress ? lineInProgress.vertices[lineInProgress.vertices.length - 1] : [x0, y0];
|
|
|
|
|
|
if (lineInProgress || dragged) renderer.showShapePreview(straightPoints([from, [x1, y1]]), color, width, false);
|
|
|
|
|
|
} else if (dragged) {
|
|
|
|
|
|
renderer.showShapePreview(draggedPoints(shape, x0, y0, x1, y1, width), color, width, isFilled(shape));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
renderer.hideShapePreview();
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (dragged) {
|
2026-10-10 19:01:20 +02:00
|
|
|
|
textEditor?.showPreview(x0, y0, x1, y1);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
textEditor?.hidePreview();
|
|
|
|
|
|
}
|
2026-10-06 15:58:02 +02:00
|
|
|
|
},
|
2026-10-14 14:00:47 +02:00
|
|
|
|
// A hovering pointer: the rubber band of a line being made, and the
|
|
|
|
|
|
// corners of free text under it (Andy, 2026-10-14: free text is used
|
|
|
|
|
|
// constantly, so its extent shows only then).
|
|
|
|
|
|
hover: (clientX, clientY) => {
|
|
|
|
|
|
const [x, y] = renderer.pageFromClient(clientX, clientY);
|
|
|
|
|
|
if (lineInProgress && tool === 'shape') {
|
|
|
|
|
|
const last = lineInProgress.vertices[lineInProgress.vertices.length - 1];
|
|
|
|
|
|
renderer.showShapePreview(straightPoints([last, [x, y]]), color, width, false);
|
|
|
|
|
|
}
|
2026-10-14 20:38:29 +02:00
|
|
|
|
// With the Eraser, the red box says what matters, so the blue cue
|
|
|
|
|
|
// of an implied rectangle stays away (the two had shown together).
|
|
|
|
|
|
const implied = tool === 'eraser' ? null : impliedRectAt(x, y);
|
|
|
|
|
|
renderer.showHoverCue(implied?.rect ?? null, implied?.kind);
|
|
|
|
|
|
renderer.setEraseCues(tool === 'eraser' ? eraseTargetsAt(x, y) : []);
|
|
|
|
|
|
if (tool === 'arrange') hoverHandwriting(x, y);
|
2026-10-14 14:00:47 +02:00
|
|
|
|
},
|
2026-10-14 20:38:29 +02:00
|
|
|
|
hoverEnded: (left = false) => {
|
2026-10-14 14:00:47 +02:00
|
|
|
|
renderer.showHoverCue(null);
|
2026-10-14 20:38:29 +02:00
|
|
|
|
renderer.setEraseCues([]);
|
2026-10-14 14:00:47 +02:00
|
|
|
|
if (lineInProgress) renderer.hideShapePreview();
|
2026-10-14 20:38:29 +02:00
|
|
|
|
// The handwriting's box stays through a press (the pointer is still
|
|
|
|
|
|
// there) and goes when the pointer leaves the page.
|
|
|
|
|
|
if (left && hoveredStroke) {
|
|
|
|
|
|
hoveredStroke = null;
|
|
|
|
|
|
refreshArrangeCues();
|
|
|
|
|
|
}
|
2026-10-14 14:00:47 +02:00
|
|
|
|
},
|
2026-10-10 19:01:20 +02:00
|
|
|
|
placeRect: (x0, y0, x1, y1) => {
|
2026-10-14 14:00:47 +02:00
|
|
|
|
if (tool === 'shape') {
|
|
|
|
|
|
placeShape(x0, y0, x1, y1);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-06 15:58:02 +02:00
|
|
|
|
if (!textEditor) return;
|
|
|
|
|
|
textEditor.hidePreview();
|
2026-10-10 19:01:20 +02:00
|
|
|
|
const dragged = Math.hypot(x1 - x0, y1 - y0) >= TAP_DISTANCE;
|
|
|
|
|
|
const rect = {
|
|
|
|
|
|
left: Math.min(x0, x1), top: Math.min(y0, y1),
|
|
|
|
|
|
width: Math.max(100, Math.round(Math.abs(x1 - x0))), height: Math.max(100, Math.round(Math.abs(y1 - y0))),
|
|
|
|
|
|
};
|
|
|
|
|
|
if (tool === 'hide') {
|
|
|
|
|
|
if (dragged && session.addHide(rect)) log('Hide made');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-06 15:58:02 +02:00
|
|
|
|
const editing = textEditor.block;
|
|
|
|
|
|
textEditor.stop();
|
|
|
|
|
|
const own = textAt(session.ownLayer, x0, y0);
|
2026-10-10 19:01:20 +02:00
|
|
|
|
if (!dragged) {
|
|
|
|
|
|
// A tap: into our own text to change it, or free text here. A
|
|
|
|
|
|
// tap elsewhere while typing only finishes the block (2026-10-09).
|
2026-10-06 15:58:02 +02:00
|
|
|
|
if (own && own !== editing) textEditor.edit(own);
|
2026-10-10 19:01:20 +02:00
|
|
|
|
else if (!own && !editing) textEditor.begin(x0, y0, null, null, { color, size: textSize, font: textFont });
|
2026-10-06 15:58:02 +02:00
|
|
|
|
} else {
|
2026-10-10 19:01:20 +02:00
|
|
|
|
// A rectangle dragged open: at least one line tall.
|
|
|
|
|
|
const line = Math.ceil(textSize * (TOP_GAP + LINE_HEIGHT));
|
|
|
|
|
|
textEditor.begin(rect.left, rect.top, rect.width, Math.max(rect.height, line),
|
2026-10-06 15:58:02 +02:00
|
|
|
|
{ color, size: textSize, font: textFont });
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
onReport: (report) => {
|
2026-10-10 19:01:20 +02:00
|
|
|
|
// To the console once, and again when the kind of device changes.
|
|
|
|
|
|
if (report.pointerType === reportedDevice || report.pointsPerSecond === 0) return;
|
|
|
|
|
|
reportedDevice = report.pointerType;
|
|
|
|
|
|
log(`Input: ${report.pointerType}, pressure ${report.pressure.toFixed(2)}, ` +
|
|
|
|
|
|
`${report.pointsPerSecond} points per second`);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
},
|
2026-10-10 19:01:20 +02:00
|
|
|
|
};
|
|
|
|
|
|
attachPenInput(pageElement, penTarget);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
|
|
buildToolbar({
|
2026-10-14 14:00:47 +02:00
|
|
|
|
color, width, textSize, textFont, shape, fill,
|
2026-10-06 15:58:02 +02:00
|
|
|
|
setTool: (value) => {
|
|
|
|
|
|
if (value !== 'text') textEditor?.stop();
|
2026-10-14 14:00:47 +02:00
|
|
|
|
if (value !== 'shape') finishLine();
|
2026-10-10 19:01:20 +02:00
|
|
|
|
if (value !== tool) trace(`Tool: ${value}`);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
tool = value;
|
2026-10-14 14:00:47 +02:00
|
|
|
|
// Only the chosen tool's settings show: the pen's and the shapes'
|
|
|
|
|
|
// colours and line widths, the text's colours, size and font,
|
|
|
|
|
|
// nothing for the eraser, Hide or Arrange.
|
2026-10-14 00:01:45 +02:00
|
|
|
|
element('colors').hidden = value === 'eraser' || value === 'hide' || value === 'arrange';
|
2026-10-14 14:00:47 +02:00
|
|
|
|
element('widths').hidden = value !== 'pen' && value !== 'shape';
|
2026-10-06 15:58:02 +02:00
|
|
|
|
element('text_style').hidden = value !== 'text';
|
2026-10-14 20:38:29 +02:00
|
|
|
|
if (value !== 'arrange') hoveredStroke = null;
|
2026-10-14 00:01:45 +02:00
|
|
|
|
refreshArrangeCues();
|
2026-10-14 20:38:29 +02:00
|
|
|
|
if (value !== 'eraser') renderer.setEraseCues([]);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
},
|
2026-10-14 14:00:47 +02:00
|
|
|
|
setShape: (value) => {
|
|
|
|
|
|
finishLine();
|
|
|
|
|
|
if (value !== shape) trace(`Shape: ${value}`);
|
|
|
|
|
|
shape = value;
|
|
|
|
|
|
localStorage.setItem(SHAPE_KEY, value);
|
|
|
|
|
|
},
|
|
|
|
|
|
setFill: (value) => {
|
|
|
|
|
|
finishLine();
|
|
|
|
|
|
fill = value;
|
|
|
|
|
|
localStorage.setItem(FILL_KEY, value ?? 'none');
|
|
|
|
|
|
trace(`Fill: ${value ?? 'none'}`);
|
|
|
|
|
|
},
|
2026-10-06 15:58:02 +02:00
|
|
|
|
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.
|
|
|
|
|
|
// 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 = () => {
|
|
|
|
|
|
textEditor?.layout();
|
|
|
|
|
|
viewChanged();
|
|
|
|
|
|
};
|
2026-10-18 20:22:13 +02:00
|
|
|
|
// Fit and Normal scale ("A4" until 2026-10-14) are each person's own
|
|
|
|
|
|
// (2026-10-18; the host's reached everyone from 2026-10-08 until then).
|
2026-10-14 01:07:53 +02:00
|
|
|
|
// The page and view commands live in the Page menu (Andy, 2026-10-14:
|
2026-10-15 17:17:45 +02:00
|
|
|
|
// seldom used, and the one row is precious).
|
2026-10-14 01:07:53 +02:00
|
|
|
|
attachPopup(element('page_menu_button'), element('page_menu'));
|
2026-10-15 17:17:45 +02:00
|
|
|
|
// Help (Andy, 2026-10-15): one click shows the manual, the tooltips of
|
|
|
|
|
|
// every command gathered into one window (Ctrl-click until then); two
|
|
|
|
|
|
// clicks within DOUBLE_TAP_MS show the note about the documentation
|
|
|
|
|
|
// site. Since one click opens a modal window, it must wait the
|
|
|
|
|
|
// interval out to be told from the first of two; the shape button's
|
|
|
|
|
|
// double click needs no such wait, its single click having chosen the
|
|
|
|
|
|
// tool already. Two clicks are counted here rather than by the
|
|
|
|
|
|
// browser's dblclick, which a pen or a touch screen does not always
|
|
|
|
|
|
// send.
|
2026-10-14 01:07:53 +02:00
|
|
|
|
const manualDialog = /** @type {HTMLDialogElement} */ (element('manual_dialog'));
|
2026-10-15 17:17:45 +02:00
|
|
|
|
const helpNote = attachPopup(element('help'), element('help_popup'), undefined, false);
|
|
|
|
|
|
const showManual = () => {
|
|
|
|
|
|
helpNote.close();
|
|
|
|
|
|
const body = element('manual_text');
|
|
|
|
|
|
body.replaceChildren(manualList(document));
|
2026-10-14 01:07:53 +02:00
|
|
|
|
manualDialog.showModal();
|
2026-10-15 17:17:45 +02:00
|
|
|
|
body.focus();
|
|
|
|
|
|
};
|
|
|
|
|
|
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
|
|
|
|
let helpClick = null;
|
|
|
|
|
|
element('help').addEventListener('click', () => {
|
|
|
|
|
|
if (helpClick !== null) {
|
|
|
|
|
|
clearTimeout(helpClick);
|
|
|
|
|
|
helpClick = null;
|
|
|
|
|
|
helpNote.open();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
helpClick = setTimeout(() => {
|
|
|
|
|
|
helpClick = null;
|
|
|
|
|
|
showManual();
|
|
|
|
|
|
}, DOUBLE_TAP_MS);
|
|
|
|
|
|
}
|
2026-10-14 01:07:53 +02:00
|
|
|
|
});
|
|
|
|
|
|
element('manual_close').addEventListener('click', () => manualDialog.close());
|
|
|
|
|
|
element('copy_manual').addEventListener('click', async () => {
|
|
|
|
|
|
try {
|
2026-10-15 17:17:45 +02:00
|
|
|
|
await navigator.clipboard.writeText(manualText(document));
|
2026-10-14 01:07:53 +02:00
|
|
|
|
element('copy_manual').textContent = 'Copied';
|
|
|
|
|
|
setTimeout(() => { element('copy_manual').textContent = 'Copy'; }, 1500);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log(`The manual could not be copied: ${error}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-10-18 20:22:13 +02:00
|
|
|
|
element('zoom_fit').addEventListener('click', () => renderer.fit());
|
|
|
|
|
|
element('zoom_sheet').addEventListener('click', () => renderer.sheetScale());
|
2026-10-11 19:43:17 +02:00
|
|
|
|
// "Expand" makes the sheet itself fill the window at the present zoom,
|
|
|
|
|
|
// in both directions; nothing on it moves (Andy, 2026-10-08 for the
|
|
|
|
|
|
// width, both ways 2026-10-11).
|
|
|
|
|
|
element('zoom_expand').addEventListener('click', () => {
|
|
|
|
|
|
const room = renderer.available();
|
|
|
|
|
|
const expanded = session.expandPage(room.width / renderer.cssPixelsPerUnit,
|
|
|
|
|
|
room.height / renderer.cssPixelsPerUnit);
|
|
|
|
|
|
if (expanded) {
|
|
|
|
|
|
log(`Page expanded to ${session.page.baseWidth / 100} by ${session.page.baseHeight / 100} cm`);
|
|
|
|
|
|
}
|
2026-10-10 19:01:20 +02:00
|
|
|
|
});
|
|
|
|
|
|
element('zoom_shrink').addEventListener('click', () => {
|
|
|
|
|
|
if (session.shrinkPage()) {
|
|
|
|
|
|
log(`Page shrunk to ${session.page.baseWidth / 100} by ${session.page.baseHeight / 100} cm`);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-10-06 15:58:02 +02:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-10-18 20:22:13 +02:00
|
|
|
|
// Everyone has every button (Andy and his teacher, 2026-10-18; until
|
|
|
|
|
|
// then New, Image, Hide, Expand and Shrink were the host's and the
|
|
|
|
|
|
// co-hosts').
|
2026-10-10 19:01:20 +02:00
|
|
|
|
element('new_page').addEventListener('click', () => {
|
|
|
|
|
|
log('New page asked for');
|
|
|
|
|
|
session.newPage();
|
|
|
|
|
|
});
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-10 19:01:20 +02:00
|
|
|
|
* Add an image file to the page, below the images already on it, at
|
|
|
|
|
|
* its natural size; moving and scaling it is done by hand afterwards.
|
2026-10-06 15:58:02 +02:00
|
|
|
|
* @param {Blob} file
|
|
|
|
|
|
*/
|
2026-10-10 19:01:20 +02:00
|
|
|
|
async function loadImage(file) {
|
2026-10-18 20:22:13 +02:00
|
|
|
|
if (session.page.number === 0) return;
|
2026-10-06 15:58:02 +02:00
|
|
|
|
try {
|
2026-10-10 19:01:20 +02:00
|
|
|
|
if (!session.addImage(await prepareImage(file))) {
|
2026-10-06 15:58:02 +02:00
|
|
|
|
throw new Error('The page has no room for another image.');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2026-10-10 19:01:20 +02:00
|
|
|
|
passingNotice(error instanceof Error && error.message ? error.message : 'That file is not an image.', true);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const chooser = new NotebookChooser((chosenNotebook, pageName, text) => {
|
|
|
|
|
|
const kept = pageFromSvg(text);
|
|
|
|
|
|
if (!kept) {
|
2026-10-10 19:01:20 +02:00
|
|
|
|
passingNotice(`${pageName} is not a Kokkoja page.`, true);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-10 19:01:20 +02:00
|
|
|
|
log(`Notebook page loaded: ${chosenNotebook} / ${pageName}`);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
session.loadPage(kept.page);
|
2026-10-11 16:59:03 +02:00
|
|
|
|
}, () => {
|
|
|
|
|
|
// A deleted notebook leaves the toolbar's list.
|
|
|
|
|
|
showNotebooks(sessionPicker).catch(() => {});
|
2026-10-06 15:58:02 +02:00
|
|
|
|
});
|
2026-10-18 20:22:13 +02:00
|
|
|
|
element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook); });
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
|
|
// 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();
|
2026-10-10 19:01:20 +02:00
|
|
|
|
if (file) loadImage(file);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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 }));
|
2026-10-10 19:01:20 +02:00
|
|
|
|
log(`Exported page ${page.number} to the downloads`);
|
|
|
|
|
|
passingNotice('Exported to your downloads');
|
2026-10-06 15:58:02 +02:00
|
|
|
|
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;
|
2026-10-10 19:01:20 +02:00
|
|
|
|
log(`Exported page ${page.number} to ${saved}`);
|
|
|
|
|
|
passingNotice(`Exported to ${saved}`);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
element('export_page').addEventListener('click', () => { exportPage(); });
|
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
|
// The notebook: choosing one, making one, and keeping the page.
|
|
|
|
|
|
const sessionPicker = new NotebookPicker(
|
|
|
|
|
|
select('session_notebook'), input('session_notebook_name'),
|
|
|
|
|
|
(message) => { if (message) passingNotice(message, true); },
|
|
|
|
|
|
(chosen) => {
|
|
|
|
|
|
if (chosen === 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(sessionPicker)).catch(() => {});
|
|
|
|
|
|
});
|
|
|
|
|
|
sessionPicker.fill([], notebook);
|
|
|
|
|
|
sessionPicker.current = notebook;
|
|
|
|
|
|
showNotebooks(sessionPicker).catch(() => {});
|
2026-10-06 15:58:02 +02:00
|
|
|
|
element('keep_page').addEventListener('click', async () => {
|
2026-10-10 19:01:20 +02:00
|
|
|
|
const { kept, new: made } = await writer.keepPage();
|
|
|
|
|
|
log(kept ? `${made ? 'Kept' : 'Already kept'} as ${kept}` : 'Nothing to keep');
|
2026-10-06 15:58:02 +02:00
|
|
|
|
// In the browser the notebook is out of reach of the user's own
|
2026-10-10 19:01:20 +02:00
|
|
|
|
// programs: a page kept just now is also downloaded, which is how a
|
|
|
|
|
|
// page gets out. One already kept was downloaded then, and is not
|
|
|
|
|
|
// again (Andy, 2026-10-06: a second press should do nothing new).
|
|
|
|
|
|
if (kept && made && notebooksInBrowser()) {
|
2026-10-06 15:58:02 +02:00
|
|
|
|
const text = await readPage(writer.notebook, kept);
|
|
|
|
|
|
if (text !== null) download(kept, new Blob([text], { type: 'image/svg+xml' }));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
|
const sessionDialog = /** @type {HTMLDialogElement} */ (element('session_dialog'));
|
|
|
|
|
|
element('session_button').addEventListener('click', () => {
|
|
|
|
|
|
showLog();
|
|
|
|
|
|
sessionDialog.showModal();
|
|
|
|
|
|
});
|
|
|
|
|
|
element('session_close').addEventListener('click', () => sessionDialog.close());
|
|
|
|
|
|
|
|
|
|
|
|
// The log in the Session window, kept current while the window is open.
|
|
|
|
|
|
const logView = element('event_log');
|
|
|
|
|
|
function showLog() {
|
|
|
|
|
|
logView.textContent = logText();
|
|
|
|
|
|
logView.scrollTop = logView.scrollHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
watchLog(() => { if (sessionDialog.open) showLog(); });
|
|
|
|
|
|
element('copy_log').addEventListener('click', async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await navigator.clipboard.writeText(logText());
|
|
|
|
|
|
element('copy_log').textContent = 'Copied';
|
|
|
|
|
|
setTimeout(() => { element('copy_log').textContent = 'Copy'; }, 1500);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// The text is selectable; the user can copy it by hand.
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-06 15:58:02 +02:00
|
|
|
|
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: {
|
2026-10-11 19:32:13 +02:00
|
|
|
|
session, renderer, writer, loadImage, textEditor, logText,
|
2026-10-15 17:17:45 +02:00
|
|
|
|
manualText: () => manualText(document),
|
2026-10-11 16:59:03 +02:00
|
|
|
|
notebook: { listNotebooks, listKeptPages, readPage, readCurrentPage, notebooksInBrowser, discardNotebook, discardPage },
|
2026-10-06 15:58:02 +02:00
|
|
|
|
} });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 01:07:53 +02:00
|
|
|
|
/** The closers of every popup, so that opening one closes the others. @type {Set<() => void>} */
|
|
|
|
|
|
const popupClosers = new Set();
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A popup of Kokkoja's own below a toolbar button (the colour palette,
|
2026-10-14 14:00:47 +02:00
|
|
|
|
* the Page menu, the Help note, the shapes; no system menu or dialog,
|
|
|
|
|
|
* 2026-10-14). The button's press opens it below the button, left edges
|
|
|
|
|
|
* aligned, or right edges when it would run past the window; a press
|
|
|
|
|
|
* anywhere else, Escape, or a press on an item marked `.menu_item`
|
|
|
|
|
|
* closes it.
|
2026-10-14 01:07:53 +02:00
|
|
|
|
* @param {HTMLElement} button
|
|
|
|
|
|
* @param {HTMLElement} popup
|
|
|
|
|
|
* @param {() => void} [onOpen] Fill the popup before it shows.
|
2026-10-14 14:00:47 +02:00
|
|
|
|
* @param {boolean} [byClick] Whether the button's click opens and closes
|
|
|
|
|
|
* it; the Shape button opens its menu by a hold instead.
|
|
|
|
|
|
* @returns {{open: () => void, close: () => void}}
|
2026-10-14 01:07:53 +02:00
|
|
|
|
*/
|
2026-10-14 14:00:47 +02:00
|
|
|
|
export function attachPopup(button, popup, onOpen, byClick = true) {
|
2026-10-14 01:07:53 +02:00
|
|
|
|
const close = () => {
|
|
|
|
|
|
popup.hidden = true;
|
|
|
|
|
|
button.setAttribute('aria-expanded', 'false');
|
|
|
|
|
|
};
|
|
|
|
|
|
popupClosers.add(close);
|
2026-10-14 14:00:47 +02:00
|
|
|
|
const open = () => {
|
2026-10-14 01:07:53 +02:00
|
|
|
|
// One popup at a time (a keyboard's Enter opens without the press
|
|
|
|
|
|
// that would close the other).
|
|
|
|
|
|
for (const other of popupClosers) other();
|
|
|
|
|
|
onOpen?.();
|
|
|
|
|
|
const box = button.getBoundingClientRect();
|
|
|
|
|
|
// Measured at the window's left edge: placed near the right edge, an
|
|
|
|
|
|
// absolute box shrinks to the room left and its text wraps.
|
|
|
|
|
|
popup.style.left = '0px';
|
|
|
|
|
|
popup.style.top = `${box.bottom + 6}px`;
|
|
|
|
|
|
popup.hidden = false;
|
|
|
|
|
|
button.setAttribute('aria-expanded', 'true');
|
|
|
|
|
|
const width = popup.getBoundingClientRect().width;
|
|
|
|
|
|
const fits = box.left + width <= window.innerWidth - 8;
|
|
|
|
|
|
popup.style.left = `${fits ? box.left : Math.max(8, box.right - width)}px`;
|
2026-10-14 14:00:47 +02:00
|
|
|
|
};
|
|
|
|
|
|
if (byClick) {
|
|
|
|
|
|
button.addEventListener('click', () => {
|
|
|
|
|
|
if (popup.hidden) open();
|
|
|
|
|
|
else close();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-10-14 01:07:53 +02:00
|
|
|
|
popup.addEventListener('click', (event) => {
|
|
|
|
|
|
const item = /** @type {Element} */ (event.target).closest('.menu_item');
|
|
|
|
|
|
// A label's click reaches here twice, once as the input's; close on the second.
|
|
|
|
|
|
if (item && !(event.target instanceof HTMLLabelElement)) close();
|
|
|
|
|
|
});
|
|
|
|
|
|
document.addEventListener('pointerdown', (event) => {
|
|
|
|
|
|
const target = /** @type {Node} */ (event.target);
|
|
|
|
|
|
if (!popup.hidden && !popup.contains(target) && !button.contains(target)) close();
|
|
|
|
|
|
});
|
|
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
|
|
|
|
if (event.key === 'Escape' && !popup.hidden) close();
|
|
|
|
|
|
});
|
2026-10-14 14:00:47 +02:00
|
|
|
|
return { open, close };
|
2026-10-14 01:07:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 15:58:02 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* @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
|
2026-10-14 14:00:47 +02:00
|
|
|
|
* @param {Shape} options.shape The Shape tool's shape to begin with.
|
|
|
|
|
|
* @param {string | null} options.fill Its fill colour, or none.
|
|
|
|
|
|
* @param {(tool: Tool) => void} options.setTool
|
|
|
|
|
|
* @param {(shape: Shape) => void} options.setShape
|
|
|
|
|
|
* @param {(fill: string | null) => void} options.setFill
|
2026-10-06 15:58:02 +02:00
|
|
|
|
* @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) {
|
2026-10-14 14:00:47 +02:00
|
|
|
|
const shapeButton = element('shape_tool');
|
|
|
|
|
|
/** The Shape button's hold has just opened the menu: the click that follows chooses nothing. */
|
|
|
|
|
|
let shapeMenuOpened = false;
|
|
|
|
|
|
/** @param {Element} button */
|
|
|
|
|
|
const chooseTool = (button) => {
|
|
|
|
|
|
// The tool buttons stand in two groups since 2026-10-11 (Pen, Text
|
|
|
|
|
|
// and the shapes before the colours they use, Hide, Arrange and
|
|
|
|
|
|
// Eraser after), so the pressed one is marked among all of them, not
|
|
|
|
|
|
// among siblings.
|
|
|
|
|
|
for (const other of document.querySelectorAll('[data-tool]')) {
|
|
|
|
|
|
other.setAttribute('aria-pressed', String(other === button));
|
|
|
|
|
|
}
|
|
|
|
|
|
options.setTool(/** @type {Tool} */ (button.getAttribute('data-tool')));
|
|
|
|
|
|
};
|
2026-10-06 15:58:02 +02:00
|
|
|
|
for (const button of document.querySelectorAll('[data-tool]')) {
|
|
|
|
|
|
button.addEventListener('click', () => {
|
2026-10-14 14:00:47 +02:00
|
|
|
|
if (button === shapeButton && shapeMenuOpened) {
|
|
|
|
|
|
shapeMenuOpened = false;
|
|
|
|
|
|
return;
|
2026-10-11 13:17:22 +02:00
|
|
|
|
}
|
2026-10-14 14:00:47 +02:00
|
|
|
|
chooseTool(button);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The Shape button names the current shape, Rectangle to begin with,
|
|
|
|
|
|
// and a quick press chooses the tool; a press held for HOLD_MS, a
|
|
|
|
|
|
// press with the right button (a pen's barrel button) or a double
|
|
|
|
|
|
// click (Andy, 2026-10-14) opens the menu of shapes, and the shape
|
|
|
|
|
|
// chosen there is the button's from then on and the tool is chosen
|
|
|
|
|
|
// (Andy, 2026-10-14: one button for any number of shapes). The menu
|
|
|
|
|
|
// marks the current shape, and ends with the fill colours. The button
|
|
|
|
|
|
// keeps the width of its widest name, so that nothing in the toolbar
|
|
|
|
|
|
// moves when the shape changes.
|
|
|
|
|
|
const shapeMenu = element('shape_menu');
|
|
|
|
|
|
const shapePopup = attachPopup(shapeButton, shapeMenu, undefined, false);
|
|
|
|
|
|
let widest = 0;
|
|
|
|
|
|
for (const name of Object.values(SHAPE_NAMES)) {
|
|
|
|
|
|
shapeButton.textContent = name;
|
|
|
|
|
|
widest = Math.max(widest, shapeButton.getBoundingClientRect().width);
|
|
|
|
|
|
}
|
|
|
|
|
|
shapeButton.style.minWidth = `${Math.ceil(widest)}px`;
|
|
|
|
|
|
/** @param {Shape} shape */
|
|
|
|
|
|
const showShape = (shape) => {
|
|
|
|
|
|
shapeButton.textContent = SHAPE_NAMES[shape];
|
|
|
|
|
|
for (const item of shapeMenu.querySelectorAll('[data-shape]')) {
|
|
|
|
|
|
item.setAttribute('aria-checked', String(item.getAttribute('data-shape') === shape));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
showShape(options.shape);
|
|
|
|
|
|
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
|
|
|
|
let shapeHold = null;
|
|
|
|
|
|
const cancelShapeHold = () => {
|
|
|
|
|
|
if (shapeHold !== null) clearTimeout(shapeHold);
|
|
|
|
|
|
shapeHold = null;
|
|
|
|
|
|
};
|
|
|
|
|
|
const openShapeMenu = () => {
|
|
|
|
|
|
shapeMenuOpened = true;
|
|
|
|
|
|
shapePopup.open();
|
|
|
|
|
|
};
|
|
|
|
|
|
shapeButton.addEventListener('pointerdown', (event) => {
|
|
|
|
|
|
if (event.button !== 0) return;
|
|
|
|
|
|
shapeMenuOpened = false;
|
|
|
|
|
|
cancelShapeHold();
|
|
|
|
|
|
shapeHold = setTimeout(() => {
|
|
|
|
|
|
shapeHold = null;
|
|
|
|
|
|
openShapeMenu();
|
|
|
|
|
|
}, HOLD_MS);
|
|
|
|
|
|
});
|
|
|
|
|
|
for (const type of ['pointerup', 'pointercancel', 'pointerleave']) shapeButton.addEventListener(type, cancelShapeHold);
|
|
|
|
|
|
shapeButton.addEventListener('contextmenu', (event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
cancelShapeHold();
|
|
|
|
|
|
openShapeMenu();
|
|
|
|
|
|
shapeMenuOpened = false; // No click follows a secondary press.
|
|
|
|
|
|
});
|
|
|
|
|
|
// A double click, told from two clicks within DOUBLE_TAP_MS rather than
|
|
|
|
|
|
// from the browser's dblclick event, which a pen or a touch screen does
|
|
|
|
|
|
// not always send; both clicks have chosen the tool already.
|
|
|
|
|
|
let lastShapeClick = -Infinity;
|
|
|
|
|
|
shapeButton.addEventListener('click', () => {
|
|
|
|
|
|
const now = performance.now();
|
|
|
|
|
|
if (now - lastShapeClick <= DOUBLE_TAP_MS) {
|
|
|
|
|
|
lastShapeClick = -Infinity;
|
|
|
|
|
|
openShapeMenu();
|
|
|
|
|
|
shapeMenuOpened = false;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
lastShapeClick = now;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
// The fill colours: the toolbar's colours, those added to it, and
|
|
|
|
|
|
// "none", a state of their own (Andy, 2026-10-14). A press keeps the
|
|
|
|
|
|
// menu open.
|
|
|
|
|
|
const fills = element('shape_fill');
|
|
|
|
|
|
/** @param {string | null} value */
|
|
|
|
|
|
const addFill = (value) => {
|
|
|
|
|
|
const button = document.createElement('button');
|
|
|
|
|
|
button.type = 'button';
|
|
|
|
|
|
button.className = `swatch${value === null ? ' none' : ''}`;
|
|
|
|
|
|
if (value !== null) button.style.setProperty('--swatch', value);
|
|
|
|
|
|
button.setAttribute('aria-label', value === null ? 'No fill' : `Fill ${value}`);
|
|
|
|
|
|
button.setAttribute('data-help', value === null ? 'Draw the shapes unfilled.' : 'Fill the shapes with this color.');
|
|
|
|
|
|
button.setAttribute('aria-pressed', String(value === options.fill));
|
|
|
|
|
|
button.addEventListener('click', () => {
|
|
|
|
|
|
press(fills, button);
|
|
|
|
|
|
options.setFill(value);
|
|
|
|
|
|
});
|
|
|
|
|
|
fills.append(button);
|
|
|
|
|
|
};
|
|
|
|
|
|
addFill(null);
|
|
|
|
|
|
for (const value of COLORS) addFill(value);
|
|
|
|
|
|
for (const item of shapeMenu.querySelectorAll('[data-shape]')) {
|
|
|
|
|
|
item.addEventListener('click', () => {
|
|
|
|
|
|
const chosen = shapeNamed(item.getAttribute('data-shape'));
|
|
|
|
|
|
options.setShape(chosen);
|
|
|
|
|
|
showShape(chosen);
|
|
|
|
|
|
chooseTool(shapeButton);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-10-14 14:00:47 +02:00
|
|
|
|
if (/^#[0-9a-f]{6}$/.test(color)) {
|
|
|
|
|
|
addSwatch(color);
|
|
|
|
|
|
addFill(color);
|
|
|
|
|
|
}
|
2026-10-06 15:58:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// "+" 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));
|
2026-10-14 14:00:47 +02:00
|
|
|
|
addFill(color); // The shapes' fills follow the toolbar's colours.
|
2026-10-06 15:58:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
press(colors, button);
|
|
|
|
|
|
options.setColor(color);
|
|
|
|
|
|
};
|
2026-10-10 19:01:20 +02:00
|
|
|
|
// A colour that is already in the row is missing from the palette, its
|
|
|
|
|
|
// place in the grid left empty (Andy's teacher, 2026-10-08).
|
|
|
|
|
|
const inRow = () => new Set([...colors.querySelectorAll('.swatch:not(.more)')]
|
|
|
|
|
|
.map((swatch) => swatch.getAttribute('aria-label')?.replace('Color ', '')));
|
|
|
|
|
|
const showPalette = () => {
|
|
|
|
|
|
palette.replaceChildren();
|
|
|
|
|
|
const taken = inRow();
|
|
|
|
|
|
for (const color of PALETTE) {
|
|
|
|
|
|
if (taken.has(color)) {
|
|
|
|
|
|
const gap = document.createElement('span');
|
|
|
|
|
|
gap.className = 'swatch missing';
|
|
|
|
|
|
palette.append(gap);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-10-14 01:07:53 +02:00
|
|
|
|
attachPopup(more, palette, showPalette);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
|
|
const label = document.createElement('label');
|
|
|
|
|
|
const visible = document.createElement('input');
|
|
|
|
|
|
visible.type = 'checkbox';
|
|
|
|
|
|
visible.checked = !renderer.hidden.has(participant.id);
|
2026-10-14 01:07:53 +02:00
|
|
|
|
visible.setAttribute('aria-label', 'Shown');
|
2026-10-06 15:58:02 +02:00
|
|
|
|
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 (!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();
|
2026-10-10 19:01:20 +02:00
|
|
|
|
guardSelection();
|
|
|
|
|
|
log(`Browser: ${navigator.userAgent}; window ${innerWidth}×${innerHeight}, pixel ratio ${devicePixelRatio}`);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|