Kokkoja as of 2026-10-10: the teacher's requests, the Session window, notices, the QR code, the license

This commit is contained in:
ack committed 2026-10-10 19:01:20 +02:00
1 parent 95c80d04c9
commit 1d087af633
32 files changed
+4917 -890

No files matched your search

+65 -37
View File
@@ -15,17 +15,19 @@
<input id="name" type="text" maxlength="40" autocomplete="off">
<label for="notebook">Notebook for the pages of this session</label>
<input id="notebook" type="text" maxlength="100" autocomplete="off" list="notebook_choices">
<datalist id="notebook_choices"></datalist>
<select id="notebook" class="notebook_pick"
data-help="The notebook this session’s pages are saved in. The last entry makes a new notebook."></select>
<input id="notebook_name" type="text" maxlength="100" autocomplete="off" spellcheck="false"
placeholder="Name of the new notebook" aria-label="Name of the new notebook" hidden>
<p id="notebook_note" class="note notebook_directory"></p>
<section>
<section id="start_choice">
<h2>Start a session</h2>
<p>You will be given an invitation to send to the others.</p>
<button id="start_session" type="button">Start a session</button>
</section>
<section>
<section id="join_choice">
<h2>Join a session</h2>
<label for="invitation_entry">Invitation</label>
<input id="invitation_entry" type="text" autocomplete="off" spellcheck="false">
@@ -42,9 +44,11 @@
<button type="button" data-tool="pen" aria-pressed="true"
data-help="Write on the page. Only your own writing can be erased.">Pen</button>
<button type="button" data-tool="text" aria-pressed="false"
data-help="Type on the page. Tap where the text should begin, or drag along its top edge to set the width at which it wraps. Tap your own text to change or move it.">Text</button>
data-help="Type on the page. Tap where the text should begin, or drag open a rectangle: a red box, white inside, in which the text wraps and which grows downward as you type. Tap your own text to change it; rest the pen on it to move it, or near a corner of a box to resize it.">Text</button>
<button type="button" data-tool="eraser" aria-pressed="false"
data-help="Erase your own strokes and text by moving over them.">Eraser</button>
data-help="Erase your own strokes and text by moving over them. A tap on an image, or on a Hide rectangle, removes it, if you are the host or a co-host (or added the image yourself).">Eraser</button>
<button type="button" data-tool="hide" aria-pressed="false" hidden
data-help="Hide part of the page for everyone: drag open a rectangle, and what lies under it is covered in white, marked by blue corners. Rest the pen on it to move it, or near a corner to resize it; a tap of the eraser removes it.">Hide</button>
</div>
<div class="group" id="colors" role="radiogroup" aria-label="Color">
<button id="more_colors" type="button" class="swatch more"
@@ -61,54 +65,81 @@
<input id="text_size" type="number" min="6" max="100" step="1" value="14"
aria-label="Text size in points" data-help="Text size in points.">
</div>
<div class="toolbar_right">
<div class="group" role="radiogroup" aria-label="Zoom">
<button id="zoom_fit" type="button" aria-pressed="true"
data-help="Show the whole page in the window. Ctrl and the scroll wheel zoom in and out.">Fit</button>
<button id="zoom_in" type="button" data-help="Zoom in by a step. Ctrl and the scroll wheel do the same about the pointer.">+</button>
<button id="zoom_out" type="button" data-help="Zoom out by a step.">−</button>
<div class="group" aria-label="View">
<button id="zoom_fit" type="button"
data-help="Show the whole page in the window. Pressed by the host, it does so on every screen. Ctrl (Command on a Mac) with the scroll wheel, or with a pen stroke up or down, zooms about the pointer.">Fit</button>
<button id="zoom_sheet" type="button"
data-help="Show the page at the scale a new page has: one A4 sheet filling the window. A shrunk page is then seen at its size on the sheet. Pressed by the host, it does so on every screen.">A4</button>
<button id="zoom_widen" type="button" hidden
data-help="Make the page wider, for everyone, until it fills the window at this zoom. Nothing on it moves.">Widen</button>
<button id="zoom_shrink" type="button" hidden
data-help="Make the page, for everyone, just large enough for what is on it, with the margin around. Nothing on it moves.">Shrink</button>
<label class="toggle" data-help="Let the page grow when your writing reaches an edge. Off, your writing stops at the edge. Images always get the room they need.">
Extend <input id="extend" type="checkbox" checked>
</label>
</div>
<div class="toolbar_right">
<div class="group" id="host_tools" hidden>
<button id="new_page" type="button"
data-help="Start a new blank page for everyone. The current page is kept in each notebook.">New</button>
<button id="load_image" type="button"
data-help="Put an image file, such as a scanned exercise, on the page for everyone. It goes below the images already there. The view keeps its scale; scroll down to a new image. For a page of its own, press New first. An image you have just added can be taken back with a tap of the eraser, until anything else is done on the page.">Image</button>
data-help="Put an image file, such as a scanned exercise, on the page for everyone: below the images already there, centred, at its own size. Rest the pen on an image to move it, or near a corner to scale it; it comes to the front. A tap of the eraser on an image removes it; writing on it stays.">Image</button>
<button id="load_notebook_page" type="button"
data-help="Start a new page for everyone from a page kept in a notebook, with everything on it. The current page is kept in each notebook.">Notebook</button>
</div>
<span id="page_number" class="page_number"></span>
<span id="status" class="status"></span>
<div class="group" aria-label="Notebook">
<select id="session_notebook" class="notebook_pick" aria-label="Current notebook"
data-help="The notebook the page is saved in. Choosing another continues the page there; the last entry makes a new notebook."></select>
<input id="session_notebook_name" type="text" maxlength="100" autocomplete="off" spellcheck="false"
placeholder="Name of the new notebook" aria-label="Name of the new notebook" hidden>
<button id="keep_page" type="button"
data-help="Keep a dated copy of the page as it is now. The page is also kept by itself whenever it is about to be replaced.">Keep</button>
<button id="export_page" type="button"
data-help="Save a picture of the page, as it is shown here, in a PNG file at a place you choose.">Export</button>
</div>
<button id="session_button" type="button"
data-help="Who is here, and the invitation to send to others.">Session</button>
</div>
</header>
<div id="notice" class="notice" hidden></div>
<div class="workspace">
<div id="desk" class="desk">
<div id="page" class="page"></div>
</div>
</div>
</div>
<aside class="panel">
<h2>Participants</h2>
<ul id="participants"></ul>
<h2>Invitation</h2>
<dialog id="session_dialog" class="chooser session">
<header>
<h2>Session</h2>
<button id="session_close" type="button" aria-label="Close">×</button>
</header>
<div class="session_body">
<h3>Participants</h3>
<ul id="participants"></ul>
<h3>Invitation</h3>
<div class="invitation_line">
<input id="invitation" type="text" readonly spellcheck="false">
<button id="copy_invitation" type="button"
data-help="Copy the invitation, to send to the others by any means.">Copy</button>
<h2><label for="session_notebook">Current notebook</label></h2>
<input id="session_notebook" type="text" maxlength="100" autocomplete="off"
list="notebook_choices">
<button id="keep_page" type="button"
data-help="Keep a dated copy of the page as it is now. The page is also kept by itself whenever it is about to be replaced.">Keep page</button>
<button id="export_page" type="button"
data-help="Save a picture of the page, as it is shown here, in a PNG file at a place you choose.">Export</button>
<p id="save_status" class="note"></p>
<p class="note notebook_directory"></p>
<p id="pen_report" class="report"></p>
</aside>
</div>
<div id="invitation_code" class="invitation_code" hidden
data-help="The invitation as a QR code. Point the camera of a phone or a tablet at it, and the link opens there."></div>
<p class="note session_state">
<span id="page_number" class="page_number"></span>
<span id="status" class="status"></span>
<span id="save_status" class="save_status"></span>
</p>
<p class="note notebook_directory"></p>
<h3>Log</h3>
<div class="log_line">
<pre id="event_log" class="event_log"></pre>
<button id="copy_log" type="button"
data-help="Copy the log: what happened in this session and every press and release of the pen, for finding a fault.">Copy</button>
</div>
</div>
</div>
</dialog>
<dialog id="notebook_chooser" class="chooser">
<header>
@@ -136,9 +167,6 @@
<nav id="files_directories" class="files_directories"></nav>
<div id="files_entries" class="files_entries"></div>
</div>
<label id="files_shrink_line" class="files_shrink">
<input id="files_shrink" type="checkbox" checked> Shrink an image wider than the page to fit it
</label>
<div id="files_save" class="files_save" hidden>
<input id="files_name" type="text" spellcheck="false" autocomplete="off"
aria-label="File name">
+2 -3
View File
@@ -137,9 +137,8 @@ export const backend = {
async keepPage(notebook) {
return transaction('readwrite', async (pages) => {
const current = /** @type {StoredPage | undefined} */ (await result(pages.get([notebook, CURRENT_PAGE])));
if (!current || !(current.text.includes('data-stroke=') || current.text.includes('<image'))) {
return { kept: null, new: false };
}
const blank = !['data-stroke=', 'data-text=', '<image'].some((mark) => current?.text.includes(mark));
if (!current || blank) return { kept: null, new: false };
const kept = (await pagesOf(pages, notebook)).filter((page) => KEPT_PAGE.test(page.name));
const same = kept.find((page) => page.text === current.text);
if (same) return { kept: same.name, new: false };
+2 -1
View File
@@ -53,6 +53,8 @@ export class NotebookChooser {
try {
const { notebooks } = await listNotebooks();
this.showNotebooks(notebooks);
// The notebook being written in is chosen already, its pages shown;
// the list is always there to see (Andy, 2026-10-06).
const chosen = notebook && notebooks.includes(notebook) ? notebook
: notebooks.length === 1 ? notebooks[0] : '';
await this.showPages(chosen);
@@ -65,7 +67,6 @@ export class NotebookChooser {
showNotebooks(notebooks) {
const list = element('chooser_notebooks');
list.replaceChildren();
list.hidden = notebooks.length <= 1;
for (const notebook of notebooks) {
const button = document.createElement('button');
button.type = 'button';
-17
View File
@@ -13,9 +13,6 @@ import { listFiles, readFile } from './files.js';
/** Where the chooser was last used, for each of its two purposes. */
const DIRECTORY_KEYS = { image: 'kokkoja_image_directory', export: 'kokkoja_export_directory' };
/** Whether an image wider than the page is shrunk to fit it: 'no' if not. */
const SHRINK_KEY = 'kokkoja_image_shrink';
/** The longer side of the small pictures of image files, in pixels. */
const SMALL_PICTURE_SIDE = 240;
/** No small picture is made of a file larger than this. */
@@ -34,11 +31,6 @@ export class FileChooser {
this.dialog = /** @type {HTMLDialogElement} */ (element('file_chooser'));
this.directoryField = /** @type {HTMLInputElement} */ (element('files_directory'));
this.nameField = /** @type {HTMLInputElement} */ (element('files_name'));
this.shrinkBox = /** @type {HTMLInputElement} */ (element('files_shrink'));
this.shrinkBox.checked = localStorage.getItem(SHRINK_KEY) !== 'no';
this.shrinkBox.addEventListener('change', () => {
localStorage.setItem(SHRINK_KEY, this.shrinkBox.checked ? 'yes' : 'no');
});
/** @type {'image' | 'export'} */
this.purpose = 'image';
/** @type {import('./files.js').DirectoryListing | null} */
@@ -84,14 +76,6 @@ export class FileChooser {
return this.open('image', 'Image');
}
/**
* @returns {boolean} Whether the user wants an image wider than the page
* shrunk to fit it (the other choice keeps its size and widens the page).
*/
shrinksWideImages() {
return this.shrinkBox.checked;
}
/**
* Let the user choose a directory and a name, and save there.
* @param {string} title
@@ -119,7 +103,6 @@ export class FileChooser {
this.purpose = purpose;
element('files_title').textContent = title;
element('files_save').hidden = purpose !== 'export';
element('files_shrink_line').hidden = purpose !== 'image';
element('files_directories').replaceChildren();
element('files_entries').replaceChildren();
this.dialog.showModal();
+9 -1
View File
@@ -11,13 +11,21 @@
/** Resolutions outside this range are mistakes, not information. */
const LEAST_DPI = 30;
const MOST_DPI = 2400;
/**
* The nominal screen resolutions that cameras, phones and screenshots
* write without meaning them; an image so marked arrives at the default
* size instead (Andy, 2026-10-08, with every image now placed at its
* natural size: at 72 dpi a phone's photo would be over a metre wide).
*/
const NOMINAL_DPI = [72, 96];
/**
* @param {number | null} dpi
* @returns {number | null}
*/
function plausible(dpi) {
return dpi !== null && dpi >= LEAST_DPI && dpi <= MOST_DPI ? dpi : null;
if (dpi === null || dpi < LEAST_DPI || dpi > MOST_DPI) return null;
return NOMINAL_DPI.some((nominal) => Math.abs(dpi - nominal) < 0.5) ? null : dpi;
}
/**
+58
View File
@@ -0,0 +1,58 @@
// @ts-check
/**
* A record of the singular events of a session in the browser's console
* (F12), for finding faults: pages, images, notebook pages, exports, the
* co-host right, the connection, and what the input device delivers.
* Nothing continuous goes here (Andy, 2026-10-08).
*
* Since 2026-10-10 the same record, plus a trace of the input events
* (every press, release and cancellation, not the movement between), is
* kept in memory and shown in the Session window with a Copy button, so
* that a guest on a tablet, with no console to open, can send it when
* the pen misbehaves (Andy's iPad, where the Pencil stopped for a while
* now and then).
*/
/** How many entries are kept; the oldest go first. */
const KEPT = 400;
/** @type {string[]} */
const entries = [];
/** @type {(() => void) | null} Told of every new entry. */
let listener = null;
/** @returns {string} The time to the millisecond, HH:MM:SS.mmm. */
function now() {
const date = new Date();
const two = (/** @type {number} */ n) => String(n).padStart(2, '0');
return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}.` +
String(date.getMilliseconds()).padStart(3, '0');
}
/** @param {string} what */
function keep(what) {
entries.push(`${now()} ${what}`);
if (entries.length > KEPT) entries.shift();
listener?.();
}
/** A singular event: the console and the record. @param {string} what */
export function log(what) {
console.log(`Kokkoja ${new Date().toLocaleTimeString()}: ${what}`);
keep(what);
}
/** An input event: the record only. @param {string} what */
export function trace(what) {
keep(what);
}
/** @returns {string} The record, one entry a line. */
export function logText() {
return entries.join('\n');
}
/** @param {(() => void) | null} onEntry Called at every new entry. */
export function watchLog(onEntry) {
listener = onEntry;
}
+585 -120
View File
@@ -13,6 +13,7 @@ import {
readCurrentPage, readIdentity, readPage, rememberToken, useBrowserStorage,
} from './notebook.js';
import { download, pickImageFile } from './browser_files.js';
import { DEFAULT_NOTEBOOK, NotebookPicker } from './notebook_picker.js';
import { pageFromSvg, pageToSvg } from './page_file.js';
import { hashOf } from './crypto.js';
import { imageResolution } from './image_info.js';
@@ -21,7 +22,10 @@ import {
UNITS_PER_POINT, strokesTouched, textAt, textsTouched,
} from './page_model.js';
import { attachPenInput } from './pen_input.js';
import { Renderer, ZOOM_STEP } from './renderer.js';
import { Renderer } from './renderer.js';
import { LEAST_IMAGE_SIDE, textBounds, rectAt } from './page_model.js';
import { LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js';
import { log, logText, trace, watchLog } from './log.js';
import { Session } from './session.js';
import { NotebookChooser } from './chooser.js';
import { FileChooser } from './file_chooser.js';
@@ -29,13 +33,23 @@ import { writePicture } from './files.js';
import { pageToPng } from './page_picture.js';
import { TextEditor } from './text_editor.js';
import { enableTooltips } from './tooltip.js';
import { guardSelection } from './selection_guard.js';
import { QUIET_ZONE, qrModules, qrPath } from './qr_code.js';
const COLORS = ['#1c1c1e', '#1f4fd8', '#d42a2a', '#1c8a3c'];
/**
* 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'];
/** Everyone begins in black (Andy, 2026-10-03; the host began in red before). */
const DEFAULT_COLOR = COLORS[0];
/** Nominal line widths in page units (0.1 mm). */
const WIDTHS = [4, 7, 12];
/** 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';
/** Radius of the eraser on the screen, in CSS pixels. */
const ERASER_RADIUS = 10;
@@ -66,7 +80,6 @@ const PALETTE = [
'#ffffff', '#bdbdbd', '#757575', '#424242', '#8d6e63', '#5d4037', '#3e2723', '#000000',
];
const NOTEBOOK_KEY = 'kokkoja_notebook';
const DEFAULT_NOTEBOOK = 'Notebook';
/**
* Where the installed Kokkoja program serves the client, for a guest who
@@ -104,6 +117,20 @@ function input(id) {
return /** @type {HTMLInputElement} */ (document.getElementById(id));
}
/**
* @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; },
() => {});
/**
* Our identity: the signing keys that make us the same participant in
* every session. The local program keeps them, so that they survive the
@@ -129,30 +156,37 @@ function showStart(message = '') {
element('start').hidden = false;
element('start_message').textContent = message;
input('name').value = localStorage.getItem(NAME_KEY) ?? '';
input('notebook').value = localStorage.getItem(NOTEBOOK_KEY) ?? DEFAULT_NOTEBOOK;
const remembered = localStorage.getItem(NOTEBOOK_KEY) ?? DEFAULT_NOTEBOOK;
startPicker.fill([], remembered);
startPicker.current = remembered;
const pending = invitationInAddress();
if (pending) input('invitation_entry').value = pending;
// 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);
// From a web site, a guest who has the Kokkoja program installed may
// prefer it, for a notebook of real files.
const installed = /** @type {HTMLAnchorElement} */ (element('open_installed'));
installed.hidden = !(pending && notebooksInBrowser());
installed.href = `${INSTALLED_ADDRESS}#join=${encodeURIComponent(pending)}`;
showNotebooks().catch((error) => {
showNotebooks(startPicker).catch((error) => {
element('notebook_note').textContent = String(error.message ?? error);
element('notebook_note').classList.add('problem');
});
}
/** Offer the existing notebooks and say where notebooks are kept. */
async function showNotebooks() {
/**
* 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) {
const { directory, notebooks } = await listNotebooks();
const choices = element('notebook_choices');
choices.replaceChildren();
for (const notebook of notebooks) {
const option = document.createElement('option');
option.value = notebook;
choices.append(option);
}
const current = picker.value;
picker.fill(notebooks, current);
picker.current = current;
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.';
@@ -169,6 +203,96 @@ function addressValues() {
return new URLSearchParams(location.hash.slice(1));
}
/** 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
* saved, no page from the host yet. A PASSING notice is a report of
* 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);
}
/** @returns {string} The invitation in the page address, or ''. */
function invitationInAddress() {
return addressValues().get('join') ?? '';
@@ -195,9 +319,9 @@ function enteredName() {
return name;
}
/** @returns {string} The notebook entered. */
/** @returns {string} The notebook chosen. */
function enteredNotebook() {
const notebook = input('notebook').value.trim() || DEFAULT_NOTEBOOK;
const notebook = startPicker.value || DEFAULT_NOTEBOOK;
localStorage.setItem(NOTEBOOK_KEY, notebook);
return notebook;
}
@@ -327,9 +451,10 @@ async function enterSession(invitationText, name, notebook) {
// installed. The link carries the invitation itself.
const plainInvitation = formatInvitation(invitation);
input('invitation').value = config.site ? formatInvitationLink(config.site, plainInvitation) : plainInvitation;
showInvitationCode(config.site ? input('invitation').value : '');
const isHost = identity.id === invitation.hostId;
let tool = /** @type {'pen' | 'eraser' | 'text'} */ ('pen');
let tool = /** @type {'pen' | 'eraser' | 'text' | 'hide'} */ ('pen');
let color = DEFAULT_COLOR;
let width = WIDTHS[1];
let textSize = TEXT_SIZE;
@@ -353,9 +478,12 @@ async function enterSession(invitationText, name, notebook) {
const fileOf = (page) => pageToSvg(page, { session: roomId, hostId: invitation.hostId });
function showPageNumber() {
element('page_number').textContent = session.page.number > 0
? `Page ${session.page.number}` : 'Waiting for the host’s page';
const waiting = session.page.number === 0;
element('page_number').textContent = waiting ? 'No page yet' : `Page ${session.page.number}`;
standingNotice('page', waiting ? 'Waiting for the host’s page' : '', false);
}
/** @type {ReturnType<typeof setTimeout> | undefined} */
let connectionNoticeTimer;
const session = new Session(
{
@@ -379,7 +507,15 @@ async function enterSession(invitationText, name, notebook) {
writer.changed();
},
onPageReplaced: (oldPage, newPage) => {
textEditor?.stop();
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);
}
// Nothing is lost: the page being left is kept in the notebook.
writer.pageReplaced(oldPage.number > 0 ? fileOf(oldPage) : null);
renderer.setPage(newPage);
@@ -389,17 +525,25 @@ async function enterSession(invitationText, name, notebook) {
showPageNumber();
},
onImagesChanged: () => {
log(`Page ${session.page.number}: ${session.page.images.length} images, ${session.page.hides.length} hides`);
renderer.showImages();
renderer.drawHides();
writer.writeNow();
},
onPageResized: (byImages) => {
// An image added or taken back does not rescale what is in view.
if (byImages) renderer.holdScale();
onPageResized: () => {
// The view keeps its scale; the page extends beyond the window.
renderer.resize();
textEditor?.layout();
writer.changed();
},
onViewCommand: (command) => {
log(`The host's view: ${command === 'fit' ? 'Fit' : 'A4'}`);
if (command === 'fit') renderer.fit();
else renderer.sheetScale();
},
onPermissionsChanged: () => {
log(`May start pages: ${session.mayMakePages() ? 'yes' : 'no'}` +
(session.isHost ? `; guests allowed: ${session.permitted.size}` : ''));
showHostTools();
showParticipants(session, renderer);
},
@@ -409,19 +553,43 @@ async function enterSession(invitationText, name, notebook) {
},
onStatus: (status, detail) => {
const text = { connecting: 'Connecting…', connected: 'Connected', failed: detail ?? 'Disconnected' };
log(`Relay: ${text[status]}`);
element('status').textContent = text[status];
element('status').classList.toggle('problem', status !== 'connected');
// 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);
},
});
const writer = new NotebookWriter(
notebook,
() => (session.page.number > 0 ? fileOf(session.page) : null),
(message, problem) => {
element('save_status').textContent = message;
element('save_status').classList.toggle('problem', problem);
{
onSaved: (time) => {
showSaveState(`Saved at ${time}`, false);
standingNotice('saving', '');
},
onProblem: (message) => {
showSaveState(message, true);
standingNotice('saving', message);
},
onKept: (message) => passingNotice(message),
});
element('save_status').textContent = savedPage ? 'Continued from the notebook' : '';
showSaveState(savedPage ? 'Continued from the notebook' : '', false);
const pageElement = element('page');
renderer = new Renderer(pageElement, session.page);
@@ -436,75 +604,305 @@ async function enterSession(invitationText, name, notebook) {
textEditor?.layout();
}).observe(/** @type {Element} */ (pageElement.parentElement));
// 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();
textEditor = new TextEditor(pageElement, renderer, {
setText: (block) => session.setText(block),
removeText: (id) => session.removeTexts([id]),
// 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?.(),
});
attachPenInput(pageElement, {
// 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 = '';
/**
* What a hold of the pen is handling: a Hide rectangle, a text block 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).
* @typedef {object} Held
* @property {'hide' | 'text' | 'image'} kind
* @property {number} index Of the Hide rectangle or image.
* @property {string} id Of the text block.
* @property {[number, number] | null} corner
* @property {[number, number]} fixed
* @property {number} aspect
* @property {number} offsetX
* @property {number} offsetY
*/
/** @type {Held | null} */
let held = null;
/** @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,
});
/**
* @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;
}
const block = session.ownLayer.texts.get(h.id);
return block ? textBounds(block) : null;
}
/** @type {import('./pen_input.js').PenTarget} */
const penTarget = {
toPage: (clientX, clientY) => renderer.pageFromClient(clientX, clientY),
tool: () => tool,
canWrite: () => session.page.number > 0,
beginStroke: () => {
textEditor?.stop();
renderer.holdFit = true;
session.beginStroke(color, width);
strokeInProgress = session.beginStroke(color, width);
},
addPoints: (points) => {
const inside = withinPage(points);
if (inside.length) session.addPoints(inside);
},
addPoints: (points) => session.addPoints(points),
endStroke: () => {
strokeInProgress = null;
session.endStroke();
renderer.holdFit = false;
if (renderer.zoom === 'fit') renderer.resize();
},
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
// Hide rectangle or an image (for the host and co-hosts), or a
// text block of our own. The dot drawn meanwhile goes.
beginHold: (clientX, clientY) => {
const [x, y] = renderer.pageFromClient(clientX, clientY);
const reach = renderer.cornerReach();
const page = session.page;
/** @type {Held | null} */
let found = null;
const hide = session.mayMakePages() ? page.hideAt(x, y, reach) : null;
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);
} else {
const blocks = [...session.ownLayer.texts.values()];
const at = rectAt(blocks.map(textBounds), x, y, reach);
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);
} else if (session.mayMakePages()) {
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;
session.endStroke();
session.erase([begun.id]);
}
textEditor?.hidePreview();
held = found;
renderer.showGrip(currentHeldRect(found), found.corner);
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;
const rect = heldRect(held, now, x, y, held.kind === 'image');
if (held.kind === 'image') {
held.index = session.placeImageByHand(held.index, rect);
} else if (held.kind === 'hide') {
session.placeHideByHand(held.index, rect);
} 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();
}
renderer.showGrip(currentHeldRect(held), held.corner);
},
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`);
if (held.kind !== 'text') session.sendPageNow();
held = null;
renderer.showGrip(null);
},
eraseAlong: (x0, y0, x1, y1) => {
textEditor?.stop();
const radius = renderer.pageLength(ERASER_RADIUS);
session.erase(strokesTouched(session.ownLayer, x0, y0, x1, y1, radius));
session.removeTexts(textsTouched(session.ownLayer, x0, y0, x1, y1, radius));
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 };
},
eraseEnded: (x, y, reach) => {
// A tap of the eraser on the image we have just added takes it back.
const image = session.removableImage;
if (image && reach < TAP_DISTANCE && x >= image.x && x <= image.x + image.width &&
y >= image.y && y <= image.y + image.height) {
session.removeAddedImage();
// A tap of the eraser on a Hide rectangle or an image removes it
// (the right to make pages, or having added the image).
if (reach >= TAP_DISTANCE) return;
const hide = session.page.hideAt(x, y, 0);
if (hide && session.removeHide(hide.index)) {
log(`Hide ${hide.index + 1} removed`);
return;
}
const found = session.page.imageAt(x, y, 0);
if (found && session.removeImage(found.index)) log(`Image ${found.index + 1} removed`);
},
previewRect: (x0, y0, x1, y1) => {
if (Math.abs(x1 - x0) >= TAP_DISTANCE || Math.abs(y1 - y0) >= TAP_DISTANCE) {
textEditor?.showPreview(x0, y0, x1, y1);
} else {
textEditor?.hidePreview();
}
},
previewText: (x0, y0, x1) => {
if (Math.abs(x1 - x0) >= TAP_DISTANCE) textEditor?.showPreview(x0, y0, x1);
else textEditor?.hidePreview();
},
placeText: (x0, y0, x1, y1) => {
placeRect: (x0, y0, x1, y1) => {
if (!textEditor) return;
textEditor.hidePreview();
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;
}
const editing = textEditor.block;
textEditor.stop();
const own = textAt(session.ownLayer, x0, y0);
if (Math.hypot(x1 - x0, y1 - y0) < TAP_DISTANCE) {
// A tap: into our own text to change it, or a new block here.
if (!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).
if (own && own !== editing) textEditor.edit(own);
else if (!own) textEditor.begin(x0, y0, null, { color, size: textSize, font: textFont });
else if (!own && !editing) textEditor.begin(x0, y0, null, null, { color, size: textSize, font: textFont });
} else {
// A drag along the top edge: a new block that wraps at its width.
textEditor.begin(Math.min(x0, x1), y0, Math.max(100, Math.round(Math.abs(x1 - x0))),
// 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),
{ color, size: textSize, font: textFont });
}
},
onReport: (report) => {
element('pen_report').textContent =
`Input: ${report.pointerType}, pressure ${report.pressure.toFixed(2)}, ` +
`${report.pointsPerSecond} points per second`;
// 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`);
},
});
};
attachPenInput(pageElement, penTarget);
buildToolbar({
color, width, textSize, textFont,
setTool: (value) => {
if (value !== 'text') textEditor?.stop();
if (value !== tool) trace(`Tool: ${value}`);
tool = value;
// Only the chosen tool's settings show: the pen's colours and line
// widths, the text's colours, size and font, nothing for the eraser.
element('colors').hidden = value === 'eraser';
element('colors').hidden = value === 'eraser' || value === 'hide';
element('widths').hidden = value !== 'pen';
element('text_style').hidden = value !== 'text';
},
@@ -524,9 +922,6 @@ async function enterSession(invitationText, name, notebook) {
});
// Zoom is each person's own; it changes nothing on the page.
const showZoom = () => {
element('zoom_fit').setAttribute('aria-pressed', String(renderer.zoom === 'fit'));
};
// A reloaded tab shows the page as it was last seen in it (Andy,
// 2026-10-05: the least surprise): the zoom and the place scrolled to
// are remembered in the tab, for the page they belong to.
@@ -541,14 +936,31 @@ async function enterSession(invitationText, name, notebook) {
viewTimer = setTimeout(saveView, 300);
};
renderer.onZoomChanged = () => {
showZoom();
textEditor?.layout();
viewChanged();
};
element('zoom_fit').addEventListener('click', () => renderer.setZoom('fit'));
element('zoom_in').addEventListener('click', () => renderer.zoomBy(ZOOM_STEP));
element('zoom_out').addEventListener('click', () => renderer.zoomBy(1 / ZOOM_STEP));
showZoom();
// The host's Fit and A4 reach everyone (Andy, 2026-10-08); a guest's
// are the guest's own.
element('zoom_fit').addEventListener('click', () => {
renderer.fit();
session.sendViewCommand('fit');
});
element('zoom_sheet').addEventListener('click', () => {
renderer.sheetScale();
session.sendViewCommand('sheet');
});
// "Widen" makes the sheet itself wider, until it fills the window at
// the present zoom; nothing on it moves (Andy, 2026-10-08).
element('zoom_widen').addEventListener('click', () => {
const room = renderer.available().width / renderer.cssPixelsPerUnit;
const widened = session.widenPage(room);
if (widened) log(`Page widened to ${session.page.baseWidth / 100} cm`);
});
element('zoom_shrink').addEventListener('click', () => {
if (session.shrinkPage()) {
log(`Page shrunk to ${session.page.baseWidth / 100} by ${session.page.baseHeight / 100} cm`);
}
});
try {
const seen = JSON.parse(sessionStorage.getItem(viewKey) ?? 'null');
if (seen && seen.page === session.page.id && (seen.zoom === 'fit' || seen.zoom > 0) &&
@@ -564,35 +976,42 @@ async function enterSession(invitationText, name, notebook) {
// The host drives: the host starts new pages, and may let a guest do so.
function showHostTools() {
element('host_tools').hidden = !session.mayMakePages();
// The Hide tool is for the host and co-hosts; losing the right
// while it is chosen puts the pen back in hand.
const hideTool = /** @type {HTMLElement} */ (document.querySelector('[data-tool=hide]'));
hideTool.hidden = !session.mayMakePages();
if (hideTool.hidden && tool === 'hide') /** @type {HTMLElement} */ (document.querySelector('[data-tool=pen]')).click();
element('zoom_widen').hidden = !session.mayMakePages();
element('zoom_shrink').hidden = !session.mayMakePages();
}
showHostTools();
element('new_page').addEventListener('click', () => session.newPage());
element('new_page').addEventListener('click', () => {
log('New page asked for');
session.newPage();
});
/**
* Add an image file to the page, below the images already on it.
* 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.
* @param {Blob} file
* @param {boolean} [shrink] Whether an image wider than the sheet is
* scaled down to it.
*/
async function loadImage(file, shrink = true) {
async function loadImage(file) {
if (!session.mayMakePages() || session.page.number === 0) return;
try {
if (!session.addImage(await prepareImage(file), shrink)) {
if (!session.addImage(await prepareImage(file))) {
throw new Error('The page has no room for another image.');
}
} catch (error) {
element('save_status').textContent =
error instanceof Error && error.message ? error.message : 'That file is not an image.';
element('save_status').classList.add('problem');
passingNotice(error instanceof Error && error.message ? error.message : 'That file is not an image.', true);
}
}
const chooser = new NotebookChooser((chosenNotebook, pageName, text) => {
const kept = pageFromSvg(text);
if (!kept) {
element('save_status').textContent = `${pageName} is not a Kokkoja page.`;
element('save_status').classList.add('problem');
passingNotice(`${pageName} is not a Kokkoja page.`, true);
return;
}
log(`Notebook page loaded: ${chosenNotebook} / ${pageName}`);
session.loadPage(kept.page);
});
element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook); });
@@ -608,7 +1027,7 @@ async function enterSession(invitationText, name, notebook) {
return;
}
const file = await fileChooser.chooseImage();
if (file) loadImage(file, fileChooser.shrinksWideImages());
if (file) loadImage(file);
});
/**
@@ -628,8 +1047,8 @@ async function enterSession(invitationText, name, notebook) {
if (notebooksInBrowser()) {
download(`${day}_page_${page.number}.png`,
await pageToPng(page, { session: roomId, hostId: session.hostId, hidden: renderer.hidden }));
element('save_status').textContent = 'Exported to your downloads';
element('save_status').classList.remove('problem');
log(`Exported page ${page.number} to the downloads`);
passingNotice('Exported to your downloads');
return;
}
const saved = await fileChooser.chooseWhereToSave(
@@ -639,38 +1058,64 @@ async function enterSession(invitationText, name, notebook) {
await pageToPng(page, { session: roomId, hostId: session.hostId, hidden: renderer.hidden }),
replace));
if (saved === null) return;
element('save_status').textContent = `Exported to ${saved}`;
element('save_status').classList.remove('problem');
log(`Exported page ${page.number} to ${saved}`);
passingNotice(`Exported to ${saved}`);
}
element('export_page').addEventListener('click', () => { exportPage(); });
// The notebook: its name, and keeping the page.
const notebookField = input('session_notebook');
notebookField.value = notebook;
showNotebooks().catch(() => {});
notebookField.addEventListener('change', () => {
const chosen = notebookField.value.trim();
if (!chosen || chosen === writer.notebook) {
notebookField.value = writer.notebook;
return;
}
sessionStorage.setItem(notebookKey, chosen);
localStorage.setItem(NOTEBOOK_KEY, chosen);
writer.changeNotebook(chosen, (text) => {
const saved = pageFromSvg(text);
return saved !== null && saved.session === roomId && saved.page.id === session.page.id;
}).then(() => showNotebooks()).catch(() => {});
});
// 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(() => {});
element('keep_page').addEventListener('click', async () => {
const kept = await writer.keepPage();
const { kept, new: made } = await writer.keepPage();
log(kept ? `${made ? 'Kept' : 'Already kept'} as ${kept}` : 'Nothing to keep');
// In the browser the notebook is out of reach of the user's own
// programs: a kept page is also downloaded, which is how a page gets out.
if (kept && notebooksInBrowser()) {
// 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()) {
const text = await readPage(writer.notebook, kept);
if (text !== null) download(kept, new Blob([text], { type: 'image/svg+xml' }));
}
});
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.
}
});
element('copy_invitation').addEventListener('click', async () => {
const field = input('invitation');
field.select();
@@ -716,7 +1161,7 @@ function press(group, chosen) {
* @param {number} options.width Initial width.
* @param {number} options.textSize
* @param {import('./page_model.js').TextFont} options.textFont
* @param {(tool: 'pen' | 'eraser' | 'text') => void} options.setTool
* @param {(tool: 'pen' | 'eraser' | 'text' | 'hide') => void} options.setTool
* @param {(color: string) => void} options.setColor
* @param {(width: number) => void} options.setWidth
* @param {(size: number) => void} options.setTextSize
@@ -727,7 +1172,7 @@ function buildToolbar(options) {
button.addEventListener('click', () => {
press(/** @type {Element} */ (button.parentElement), button);
const chosen = button.getAttribute('data-tool');
options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : 'pen');
options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : chosen === 'hide' ? 'hide' : 'pen');
});
}
@@ -778,20 +1223,35 @@ function buildToolbar(options) {
press(colors, button);
options.setColor(color);
};
for (const color of PALETTE) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'swatch';
button.style.setProperty('--swatch', color);
button.setAttribute('aria-label', `Color ${color}`);
button.addEventListener('click', () => choose(color));
palette.append(button);
}
// 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);
}
};
more.addEventListener('click', () => {
if (!palette.hidden) {
palette.hidden = true;
return;
}
showPalette();
const box = more.getBoundingClientRect();
palette.style.left = `${box.left}px`;
palette.style.top = `${box.bottom + 6}px`;
@@ -861,15 +1321,17 @@ function showParticipants(session, renderer) {
item.classList.toggle('absent', !present);
if (session.isHost && !participant.self) {
const allow = document.createElement('button');
allow.type = 'button';
// A checkbox with its label to the left, like Extend (Andy, 2026-10-09).
const allowLine = document.createElement('label');
allowLine.className = 'allow_line';
allowLine.setAttribute('data-help', 'Make this participant a co-host: they may then start pages, put images on the page and arrange them, load notebook pages, widen, shrink and hide, as you can.');
const allow = document.createElement('input');
allow.type = 'checkbox';
allow.className = 'allow';
allow.textContent = 'pages';
allow.setAttribute('data-help', 'Let this participant start new pages and load images.');
const allowed = session.permitted.has(participant.id);
allow.setAttribute('aria-pressed', String(allowed));
allow.addEventListener('click', () => session.setPermitted(participant.id, !allowed));
item.append(allow);
allow.checked = session.permitted.has(participant.id);
allow.addEventListener('change', () => session.setPermitted(participant.id, allow.checked));
allowLine.append('Co-host ', allow);
item.append(allowLine);
}
const label = document.createElement('label');
@@ -884,6 +1346,7 @@ function showParticipants(session, renderer) {
const roles = [];
if (participant.self) roles.push('you');
if (participant.host) roles.push('host');
else if (!session.isHost && session.permitted.has(participant.id)) roles.push('co-host');
if (!present) roles.push('away');
if (roles.length) {
const role = document.createElement('span');
@@ -932,6 +1395,8 @@ async function resume() {
}
enableTooltips();
guardSelection();
log(`Browser: ${navigator.userAgent}; window ${innerWidth}×${innerHeight}, pixel ratio ${devicePixelRatio}`);
const token = addressValues().get('token');
if (token) {
+16 -15
View File
@@ -205,12 +205,15 @@ export class NotebookWriter {
* @param {string} notebook
* @param {() => string | null} content Produces the file of the present
* page, or null if there is no page to save yet.
* @param {(message: string, problem: boolean) => void} onStatus
* @param {object} events
* @param {(time: string) => void} events.onSaved The page was written, at that time of day.
* @param {(message: string) => void} events.onKept What keeping the page did.
* @param {(message: string) => void} events.onProblem A write or keep failed.
*/
constructor(notebook, content, onStatus) {
constructor(notebook, content, events) {
this.notebook = notebook;
this.content = content;
this.onStatus = onStatus;
this.events = events;
/** @type {number | null} */
this.timer = null;
/** Time of the oldest change not yet written, or 0. */
@@ -232,7 +235,7 @@ export class NotebookWriter {
*/
enqueue(work) {
this.queue = this.queue.then(work).catch((error) => {
this.onStatus(error instanceof Error ? error.message : String(error), true);
this.events.onProblem(error instanceof Error ? error.message : String(error));
// The page is still in memory: try to write it again shortly.
if (this.timer === null) this.timer = setTimeout(() => this.writeNow(), RETRY_MS);
});
@@ -272,27 +275,25 @@ export class NotebookWriter {
const text = this.content();
if (text === null) return;
await this.put(text);
this.onStatus(`Saved at ${new Date().toLocaleTimeString()}`, false);
this.events.onSaved(new Date().toLocaleTimeString());
});
}
/**
* The user asked to keep the page as it is now.
* @returns {Promise<string | null>} The name of the kept page, or null
* if there was nothing to keep.
* @returns {Promise<{kept: string | null, new: boolean}>} The name of
* the kept page, or null if there was nothing to keep, and whether
* the copy was made just now.
*/
keepPage() {
this.writeNow();
/** @type {string | null} */
let kept = null;
let result = { kept: /** @type {string | null} */ (null), new: false };
return this.enqueue(async () => {
const result = await keepCurrentPage(this.notebook);
kept = result.kept;
this.onStatus(
result = await keepCurrentPage(this.notebook);
this.events.onKept(
result.kept === null ? 'The page is blank: nothing to keep'
: result.new ? `Kept as ${result.kept}` : `Already kept as ${result.kept}`,
false);
}).then(() => kept);
: result.new ? `Kept as ${result.kept}` : `Already kept as ${result.kept}`);
}).then(() => result);
}
/**
+151
View File
@@ -0,0 +1,151 @@
// @ts-check
/**
* A notebook picker: a list of the notebooks with "New notebook…" as its
* last entry, which asks for the name in a field shown in the list's
* place (not in a browser dialog). Used on the start screen and in the
* session's panel. A notebook exists once a page is written into it,
* so making one is naming it.
*/
import { listNotebooks } from './notebook.js';
/** The notebook used when none is chosen: the one for whatever has no notebook of its own. */
export const DEFAULT_NOTEBOOK = 'General';
/** The value of the "New notebook…" entry; no notebook can be named so. */
const NEW = '/new';
/** What the local program accepts as a notebook name (NAME in kokkoja.py). */
const NAME_ALLOWED = /^[^\x00-\x1f/\\:*?"<>|.][^\x00-\x1f/\\:*?"<>|]{0,119}$/;
const NAME_REFUSED = 'A notebook name cannot contain / \\ : * ? " < > | and cannot begin with a dot.';
export class NotebookPicker {
/**
* @param {HTMLSelectElement} select
* @param {HTMLInputElement} entry The field for a new name.
* @param {(message: string) => void} say Explains a refused name; '' clears.
* @param {(notebook: string) => void} onChosen The choice changed.
*/
constructor(select, entry, say, onChosen) {
this.select = select;
this.entry = entry;
this.say = say;
this.onChosen = onChosen;
/** The choice before "New notebook…" was picked, to return to. */
this.previous = '';
select.addEventListener('change', () => {
if (select.value === NEW) this.beginNew();
else onChosen(select.value);
});
entry.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
this.confirm();
} else if (event.key === 'Escape') {
event.preventDefault();
this.cancel();
}
});
// Leaving the field with a usable name confirms it; with none, gives up.
entry.addEventListener('blur', () => {
if (entry.hidden) return;
if (NAME_ALLOWED.test(entry.value.trim())) this.confirm();
else this.cancel();
});
}
/** @returns {string} The chosen notebook. */
get value() {
return this.select.value === NEW ? this.previous : this.select.value;
}
/**
* List the notebooks, with the given one chosen (added to the list if
* it is not there yet, since a notebook exists once a page is written).
* @param {string} current
*/
async show(current) {
let names = /** @type {string[]} */ ([]);
try {
names = (await listNotebooks()).notebooks;
} catch {
// Without the list, the current notebook alone is offered.
}
this.fill(names, current);
}
/**
* @param {string[]} names
* @param {string} current
*/
fill(names, current) {
const all = [...new Set([...names, current])].filter(Boolean).sort((a, b) => a.localeCompare(b));
this.select.replaceChildren();
for (const name of all) {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
this.select.append(option);
}
const make = document.createElement('option');
make.value = NEW;
make.textContent = 'New notebook…';
this.select.append(make);
this.select.value = current;
}
beginNew() {
this.previous = this.previous || this.select.options[0]?.value || '';
this.say('');
this.select.hidden = true;
this.entry.value = '';
this.entry.hidden = false;
this.entry.focus();
}
confirm() {
const name = this.entry.value.trim();
if (!NAME_ALLOWED.test(name)) {
this.say(name ? NAME_REFUSED : '');
if (!name) this.cancel();
return;
}
this.say('');
if (![...this.select.options].some((option) => option.value === name)) {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
this.select.insertBefore(option, this.select.options[this.select.options.length - 1]);
}
this.select.value = name;
this.previous = name;
this.endEntry();
this.onChosen(name);
}
cancel() {
this.select.value = this.previous;
this.endEntry();
}
endEntry() {
this.entry.hidden = true;
this.select.hidden = false;
}
/**
* The choice, made from outside; it is also the one to return to if
* "New notebook…" is given up.
* @param {string} name
*/
set current(name) {
this.previous = name;
this.select.value = name;
}
/** @returns {string} */
get current() {
return this.value;
}
}
+69 -10
View File
@@ -28,15 +28,22 @@
import { fromBase64, toBase64 } from './crypto.js';
import {
Page, POINT_SIZE, cleanImage, cleanName, cleanPageDescriptor, cleanStroke, cleanText,
pointCount, validId,
POINT_SIZE, Page, cleanHide, cleanImage, cleanName, cleanPageDescriptor, cleanStroke, cleanText, pointCount, textBounds, validId,
} from './page_model.js';
import { partAt } from './stroke_geometry.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP } from './text_layout.js';
import { LEFT_GAP, baselineOf } from './text_layout.js';
/** @typedef {import('./page_model.js').Stroke} Stroke */
const FORMAT_VERSION = '1';
/** A text rectangle's frame: the palette's red, 0.2 mm (Andy's teacher, 2026-10-08). */
export const TEXT_FRAME_COLOR = '#e53935';
export const TEXT_FRAME_WIDTH = 2;
/**
* Free text is marked by an L at its top-left corner, two segments this
* long (5 mm; 3 mm first), in the frame's red (Andy, 2026-10-09).
*/
export const TEXT_MARK = 50;
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
/**
@@ -58,7 +65,7 @@ function imageElement(image) {
text = toBase64(image.data);
imageText.set(image.data, text);
}
return `<image data-hash="${image.hash}" x="${image.x}" y="${image.y}"` +
return `<image data-hash="${image.hash}"${image.by ? ` data-by="${image.by}"` : ''} x="${image.x}" y="${image.y}"` +
` width="${image.width}" height="${image.height}"` +
` preserveAspectRatio="none" href="data:${image.type};base64,${text}"/>`;
}
@@ -86,15 +93,26 @@ function escapedContent(text) {
* @returns {string}
*/
function textElements(block) {
// Each line at its baseline, placed as on the screen.
const lines = block.lines.map((line, i) =>
`<tspan x="${number(block.x + block.size * LEFT_GAP)}"` +
` y="${number(block.y + block.size * (TOP_GAP + i * LINE_HEIGHT))}">${escapedContent(line)}</tspan>`);
` y="${number(block.y + baselineOf(block.size, block.font, i))}">${escapedContent(line)}</tspan>`);
const bounds = textBounds(block);
// A text rectangle's red frame, or free text's corner mark, is part of
// the page (a rectangle's white ground is written beneath all layers:
// see pageToSvg).
const frame = block.height === null
? `<path d="M${block.x} ${block.y + TEXT_MARK}V${block.y}H${block.x + TEXT_MARK}"` +
` fill="none" stroke="${TEXT_FRAME_COLOR}" stroke-width="${TEXT_FRAME_WIDTH}"/>`
: `<rect x="${bounds.left}" y="${bounds.top}" width="${bounds.width}" height="${bounds.height}"` +
` fill="none" stroke="${TEXT_FRAME_COLOR}" stroke-width="${TEXT_FRAME_WIDTH}"/>`;
return `<g data-text="${escaped(block.id)}" data-x="${block.x}" data-y="${block.y}"` +
` data-width="${block.width ?? ''}" data-size="${block.size}" data-font="${block.font}"` +
` data-width="${block.width ?? ''}" data-height="${block.height ?? ''}"` +
` data-size="${block.size}" data-font="${block.font}"` +
` data-box="${block.boxWidth} ${block.boxHeight}">` +
`<desc>${escapedContent(block.text)}</desc>` +
`<desc>${escapedContent(block.text)}</desc>${frame}` +
`<text font-family="${block.font}" font-size="${block.size}" fill="${block.color}"` +
` dominant-baseline="text-before-edge" xml:space="preserve">${lines.join('')}</text></g>`;
` xml:space="preserve">${lines.join('')}</text></g>`;
}
/**
@@ -153,7 +171,9 @@ export function pageToSvg(page, { session, hostId, hidden }) {
`<svg xmlns="${SVG_NAMESPACE}" data-kokkoja="${FORMAT_VERSION}"` +
` data-session="${escaped(session)}" data-host="${escaped(hostId)}"` +
` data-page="${escaped(page.id)}" data-number="${page.number}"` +
` data-base="${page.baseWidth} ${page.baseHeight}" data-revision="${page.imagesRevision}"` +
` data-base="${page.baseWidth} ${page.baseHeight}` +
`${page.baseLeft || page.baseTop ? ` ${page.baseLeft} ${page.baseTop}` : ''}"` +
` data-revision="${page.imagesRevision}"` +
` viewBox="${page.left} ${page.top} ${page.width} ${page.height}"` +
` width="${page.width / 100}cm" height="${page.height / 100}cm">`,
`<title>Kokkoja page ${page.number}</title>`,
@@ -164,6 +184,20 @@ export function pageToSvg(page, { session, hostId, hidden }) {
const shown = imageElement(image);
if (shown) lines.push(shown);
}
// The white grounds of the text rectangles: above the images, beneath
// every layer, so that they hide the image and never anyone's writing.
const grounds = [];
for (const id of page.layerOrder(hostId)) {
const layer = page.layers.get(id);
if (!layer || hidden?.has(id)) continue;
for (const block of layer.texts.values()) {
if (block.height === null) continue;
const b = textBounds(block);
grounds.push(`<rect data-ground="${escaped(block.id)}" x="${b.left}" y="${b.top}"` +
` width="${b.width}" height="${b.height}" fill="#ffffff"/>`);
}
}
if (grounds.length) lines.push('<g data-grounds="">', ...grounds, '</g>');
for (const id of page.layerOrder(hostId)) {
const layer = page.layers.get(id);
if (!layer || layer.isEmpty() || hidden?.has(id)) continue;
@@ -178,6 +212,15 @@ export function pageToSvg(page, { session, hostId, hidden }) {
for (const block of layer.texts.values()) lines.push(textElements(block));
lines.push('</g>');
}
// Hide rectangles: white, above all writing.
if (page.hides.length) {
lines.push('<g data-hides="">');
for (const hide of page.hides) {
lines.push(`<rect data-hide="${escaped(hide.id)}" x="${hide.x}" y="${hide.y}"` +
` width="${hide.width}" height="${hide.height}" fill="#ffffff"/>`);
}
lines.push('</g>');
}
lines.push('</svg>', '');
return lines.join('\n');
}
@@ -195,14 +238,19 @@ export function pageFromSvg(text) {
if (box.length !== 4 || !(box[2] > 0) || !(box[3] > 0)) return null;
const base = (root.getAttribute('data-base') ?? '').split(/\s+/).map(Number);
// data-base: the sheet's width and height, then its left and top edges
// when they are not 0 (a sheet changed by "Widen" or "Shrink").
const descriptor = cleanPageDescriptor({
id: root.getAttribute('data-page'), number: Number(root.getAttribute('data-number')),
width: base.length === 2 ? base[0] : box[2], height: base.length === 2 ? base[1] : box[3],
width: base.length >= 2 ? base[0] : box[2], height: base.length >= 2 ? base[1] : box[3],
left: base.length >= 3 ? base[2] : 0, top: base.length >= 4 ? base[3] : 0,
revision: Number(root.getAttribute('data-revision') ?? 0),
});
if (!descriptor) return null;
const page = new Page(descriptor.width, descriptor.height, descriptor.id, descriptor.number);
page.baseLeft = descriptor.left;
page.baseTop = descriptor.top;
page.imagesRevision = descriptor.revision;
/** @type {Map<string, Uint8Array<ArrayBuffer>>} An image shown twice is held once. */
const held = new Map();
@@ -215,11 +263,20 @@ export function pageFromSvg(text) {
type: address[1], size: data.length, hash,
x: Number(shown.getAttribute('x')), y: Number(shown.getAttribute('y')),
width: Number(shown.getAttribute('width')), height: Number(shown.getAttribute('height')),
by: shown.getAttribute('data-by') ?? '',
});
if (!image) continue;
held.set(hash, data);
page.images.push({ ...image, data });
}
for (const shown of root.querySelectorAll('g[data-hides] rect[data-hide]')) {
const hide = cleanHide({
id: shown.getAttribute('data-hide'),
x: Number(shown.getAttribute('x')), y: Number(shown.getAttribute('y')),
width: Number(shown.getAttribute('width')), height: Number(shown.getAttribute('height')),
});
if (hide) page.hides.push(hide);
}
for (const group of root.querySelectorAll('g[data-layer]')) {
const layerId = group.getAttribute('data-layer');
if (!validId(layerId)) continue;
@@ -237,11 +294,13 @@ export function pageFromSvg(text) {
}
for (const element of group.querySelectorAll('g[data-text]')) {
const widthText = element.getAttribute('data-width') ?? '';
const heightText = element.getAttribute('data-height') ?? '';
const box = (element.getAttribute('data-box') ?? '').split(' ').map(Number);
const block = cleanText({
id: element.getAttribute('data-text'),
x: Number(element.getAttribute('data-x')), y: Number(element.getAttribute('data-y')),
width: widthText === '' ? null : Number(widthText),
height: heightText === '' ? null : Number(heightText),
size: Number(element.getAttribute('data-size')),
font: element.getAttribute('data-font') ?? 'sans-serif',
color: element.querySelector('text')?.getAttribute('fill'),
+252 -62
View File
@@ -8,21 +8,24 @@
*/
/** A blank page is A4. */
export const BLANK_PAGE_WIDTH = 2100;
export const BLANK_PAGE_HEIGHT = 2970;
// An A4 sheet in landscape (Andy, 2026-10-08; portrait until then).
export const BLANK_PAGE_WIDTH = 2970;
export const BLANK_PAGE_HEIGHT = 2100;
/**
* The page grows to keep this much room around everything written on it,
* in steps of the same amount so that it does not shift with every pen
* point. 8 mm, Andy's choice (2026-10-03; 15 mm before).
* point; "Shrink" leaves the same room. 5 mm, Andy's choice (2026-10-08;
* 8 mm from 2026-10-03, 15 mm before that).
*/
export const WRITING_MARGIN = 80;
export const WRITING_MARGIN = 50;
/**
* An image is placed this far inside the sheet's edge. 8 mm, Andy's
* choice; it is deliberately a separate setting from WRITING_MARGIN.
* An image is placed this far inside the sheet's edge, and below the
* image above. 5 mm, Andy's choice (2026-10-08; 8 mm before); it is
* deliberately a separate setting from WRITING_MARGIN.
*/
export const IMAGE_MARGIN = 80;
export const IMAGE_MARGIN = 50;
/**
* @typedef {object} Stroke
@@ -45,6 +48,11 @@ export const POINT_SIZE = 3;
* @property {number} x
* @property {number} y
* @property {number | null} width Page units, or null for no wrapping.
* @property {number | null} height With a width: the block is a *text
* rectangle* (Andy's teacher, 2026-10-08), drawn with a red frame on
* a white ground that hides the image beneath, wrapping inside and
* growing downward by lines when the text needs more. Null for free
* text, which is only as large as its lines.
* @property {string} text As typed, with the writer's line breaks.
* @property {string[]} lines As shown, laid out by the writer's client.
* @property {number} size Font size in page units.
@@ -163,17 +171,62 @@ export function cleanText(received) {
!received.lines.every((/** @type {unknown} */ line) => typeof line === 'string') ||
![received.x, received.y].every((v) => Number.isFinite(v) && Math.abs(v) <= 10 * MAX_PAGE_SIZE) ||
(received.width !== null && !(received.width >= 10 && received.width <= MAX_PAGE_SIZE)) ||
(received.height != null && !(received.height >= 10 && received.height <= 10 * MAX_PAGE_SIZE)) ||
!(received.size >= 10 && received.size <= 400) ||
!TEXT_FONTS.includes(received.font) ||
!(received.boxWidth >= 0 && received.boxWidth <= MAX_PAGE_SIZE) ||
!(received.boxHeight >= 0 && received.boxHeight <= 10 * MAX_PAGE_SIZE)) return null;
return {
id: received.id, x: received.x, y: received.y, width: received.width,
height: received.height == null || received.width === null ? null : received.height,
text: received.text, lines: received.lines.slice(), size: received.size, font: received.font,
color: received.color, boxWidth: received.boxWidth, boxHeight: received.boxHeight,
};
}
/**
* The rectangle a text block takes up: its own, grown to its lines, for a
* text rectangle; the lines' box for free text.
* @param {TextBlock} block
* @returns {Rectangle}
*/
export function textBounds(block) {
if (block.width !== null && block.height !== null) {
return { left: block.x, top: block.y, width: block.width, height: Math.max(block.height, block.boxHeight) };
}
return { left: block.x, top: block.y, width: block.boxWidth, height: block.boxHeight };
}
/**
* A *Hide* rectangle: white, drawn above all writing, hiding what lies
* beneath (Andy's teacher, 2026-10-08). A page object like an image,
* made, moved and removed by those who may make pages, unaffected by
* anyone hiding a layer on their own screen.
* @typedef {object} HideRect
* @property {string} id
* @property {number} x
* @property {number} y
* @property {number} width
* @property {number} height
*/
/**
* @param {any} received
* @returns {HideRect | null}
*/
export function cleanHide(received) {
if (!received || !validId(received.id) ||
![received.x, received.y].every((v) => Number.isFinite(v) && Math.abs(v) <= MAX_PAGE_SIZE) ||
![received.width, received.height].every((v) => v >= 1 && v <= MAX_PAGE_SIZE)) return null;
return {
id: received.id, x: Math.round(received.x), y: Math.round(received.y),
width: Math.round(received.width), height: Math.round(received.height),
};
}
/** A page holds at most this many Hide rectangles. */
export const MAX_PAGE_HIDES = 50;
export class Layer {
/**
* @param {string} id The participant's id.
@@ -205,6 +258,9 @@ export class Layer {
* @property {number} y
* @property {number} width
* @property {number} height
* @property {string} by The participant who added it ('' if unknown):
* they may remove it even without the right to make pages (Andy,
* 2026-10-08).
* @property {Uint8Array<ArrayBuffer> | null} data Null until the bytes
* have arrived from another participant.
*/
@@ -225,11 +281,17 @@ function withoutData(image) {
* @typedef {object} PageDescriptor
* @property {string} id
* @property {number} number Counts the pages of a session from 1.
* @property {number} left The sheet's left edge: 0 to begin with; less
* once "Widen" has grown the sheet, which grows it equally on both
* sides so that nothing on it moves; anything once "Shrink" has made
* the sheet the contents' bounding box (Andy, 2026-10-08).
* @property {number} top The sheet's top edge: 0 until "Shrink".
* @property {number} width
* @property {number} height
* @property {Omit<PageImage, 'data'>[]} images From the top of the page down.
* @property {number} revision Counts the changes made to `images`, so
* that of two lists the later one can be told.
* @property {Omit<PageImage, 'data'>[]} images In stacking order, the last in front.
* @property {HideRect[]} hides The Hide rectangles, in stacking order.
* @property {number} revision Counts the changes made to `images`, `hides`
* and the sheet, so that of two lists the later one can be told.
*/
/**
@@ -262,46 +324,83 @@ export function cleanImage(received) {
![received.width, received.height].every((v) => v >= 1 && v <= MAX_PAGE_SIZE)) return null;
return {
type: received.type, size: received.size, hash: received.hash,
x: received.x, y: received.y, width: received.width, height: received.height, data: null,
x: received.x, y: received.y, width: received.width, height: received.height,
by: validId(received.by) ? received.by : '', data: null,
};
}
/**
* Where an image of a given natural size goes on a page: centred across
* the A4 sheet, inside the margin (Andy, 2026-10-03: his teacher's scans
* are often partial and wider than they are tall). The first image lies
* at the top of the sheet; each later one lies below the lowest image
* already there, the margin apart, so that a page can be made of a series
* of images (Andy, 2026-10-05).
* Where an image goes on the page, and the sheet that then holds it.
*
* An image wider than the sheet's width inside the margin is scaled down
* to it, or, if `shrink` is false, keeps its natural size: it then begins
* at the left margin and the sheet is widened to hold it (Andy,
* 2026-10-05, as a choice to try beside the scaling).
* Below the lowest image on the page, or at the page's top if there is
* none, the margin apart, centred across the page's width, at its
* natural size: one way for every image (Andy, 2026-10-08, dropping the
* shrink-to-fit choice and composition mode, since an image can be moved
* and scaled by hand). An image wider than the page widens the sheet
* equally on both sides; one wider than a page can be is scaled to that.
* @param {Page} page
* @param {number} naturalWidth In page units.
* @param {number} naturalHeight
* @param {number} [below] The bottom edge of the lowest image already on
* the page; 0 if there is none.
* @param {boolean} [shrink]
* @returns {{x: number, y: number, width: number, height: number,
* pageWidth: number, pageHeight: number}}
* The image's rectangle, and how large the sheet must be to hold it.
* @returns {{x: number, y: number, width: number, height: number, sheet: Rectangle}}
* The image's rectangle, and the sheet that holds it and everything
* the sheet held before.
*/
export function placeImage(naturalWidth, naturalHeight, below = 0, shrink = true) {
const room = BLANK_PAGE_WIDTH - 2 * IMAGE_MARGIN;
// At its natural size an image may still be no wider than a page can be.
const scale = Math.min(1, (shrink ? room : MAX_PAGE_SIZE - 2 * IMAGE_MARGIN) / naturalWidth);
export function placeImage(page, naturalWidth, naturalHeight) {
const scale = Math.min(1, (MAX_PAGE_SIZE - 2 * IMAGE_MARGIN) / naturalWidth);
const width = Math.round(naturalWidth * scale);
const height = Math.round(naturalHeight * scale);
const x = width <= room ? Math.round((BLANK_PAGE_WIDTH - width) / 2) : IMAGE_MARGIN;
const x = page.left + Math.round((page.width - width) / 2);
const below = page.images.length ? page.imagesBottom() : page.top;
const y = Math.round(below) + IMAGE_MARGIN;
return {
x, y, width, height,
pageWidth: Math.max(BLANK_PAGE_WIDTH, x + width + IMAGE_MARGIN),
pageHeight: Math.max(BLANK_PAGE_HEIGHT, y + height + IMAGE_MARGIN),
};
return { x, y, width, height, sheet: sheetHolding(page, { left: x, top: y, width, height }) };
}
/**
* Which of some rectangles lies under a point, the last (frontmost) first,
* and whether the point is within `reach` of one of its corners, which
* counts before lying inside.
* @param {Rectangle[]} rects
* @param {number} x
* @param {number} y
* @param {number} reach
* @returns {{index: number, corner: [number, number] | null} | null}
* The corner as (0 or 1, 0 or 1): left or right, top or bottom.
*/
export function rectAt(rects, x, y, reach) {
for (let index = rects.length - 1; index >= 0; index -= 1) {
const r = rects[index];
for (const cx of [0, 1]) {
for (const cy of [0, 1]) {
if (Math.hypot(x - (r.left + cx * r.width), y - (r.top + cy * r.height)) <= reach) {
return { index, corner: [cx, cy] };
}
}
}
if (x >= r.left && x <= r.left + r.width && y >= r.top && y <= r.top + r.height) {
return { index, corner: null };
}
}
return null;
}
/**
* The sheet that holds what it holds now and the given rectangle with
* the image margin around it.
* @param {Page} page
* @param {Rectangle} rect
* @returns {Rectangle}
*/
export function sheetHolding(page, rect) {
const left = Math.min(page.baseLeft, rect.left - IMAGE_MARGIN);
const top = Math.min(page.baseTop, rect.top - IMAGE_MARGIN);
const right = Math.max(page.baseLeft + page.baseWidth, rect.left + rect.width + IMAGE_MARGIN);
const bottom = Math.max(page.baseTop + page.baseHeight, rect.top + rect.height + IMAGE_MARGIN);
return { left, top, width: right - left, height: bottom - top };
}
/** An image is never scaled below this, in page units (1 cm). */
export const LEAST_IMAGE_SIDE = 100;
/**
* Check a page descriptor that came from outside.
* @param {any} received
@@ -320,9 +419,20 @@ export function cleanPageDescriptor(received) {
if (!image) return null;
images.push(withoutData(image));
}
const listedHides = received.hides ?? [];
if (!Array.isArray(listedHides) || listedHides.length > MAX_PAGE_HIDES) return null;
const hides = [];
for (const one of listedHides) {
const hide = cleanHide(one);
if (!hide) return null;
hides.push(hide);
}
/** @param {any} value */
const edge = (value) => (Number.isFinite(value) && Math.abs(value) <= MAX_PAGE_SIZE ? Math.round(value) : 0);
return {
id: received.id, number: received.number, width: received.width, height: received.height,
images,
id: received.id, number: received.number, left: edge(received.left), top: edge(received.top),
width: received.width, height: received.height,
images, hides,
revision: Number.isInteger(received.revision) && received.revision >= 0 ? received.revision : 0,
};
}
@@ -340,12 +450,16 @@ export class Page {
* The size of the sheet before anything is written on it: A4, or taller
* if its images need the room.
*/
this.baseLeft = 0;
this.baseTop = 0;
this.baseWidth = width;
this.baseHeight = height;
this.id = id;
this.number = number;
/** @type {PageImage[]} From the top of the page down. */
this.images = [];
/** @type {HideRect[]} In stacking order. */
this.hides = [];
/** Counts the changes made to `images`. */
this.imagesRevision = 0;
/** The page's present rectangle. See updateExtent(). */
@@ -362,28 +476,47 @@ export class Page {
/** @returns {PageDescriptor} */
descriptor() {
return {
id: this.id, number: this.number, width: this.baseWidth, height: this.baseHeight,
id: this.id, number: this.number, left: this.baseLeft, top: this.baseTop,
width: this.baseWidth, height: this.baseHeight,
images: this.images.map(withoutData),
hides: this.hides.map((hide) => ({ ...hide })),
revision: this.imagesRevision,
};
}
/**
* The Hide rectangle under a point, and whether the point is near one of
* its corners, the frontmost first; as imageAt.
* @param {number} x
* @param {number} y
* @param {number} reach
* @returns {{index: number, corner: [number, number] | null} | null}
*/
hideAt(x, y, reach) {
return rectAt(this.hides.map((hide) => ({ left: hide.x, top: hide.y, width: hide.width, height: hide.height })),
x, y, reach);
}
/**
* The image under a point, and whether the point is near one of its
* corners: the frontmost (last) image whose rectangle holds the point
* or has a corner within `reach` of it.
* @param {number} x
* @param {number} y
* @param {number} reach Page units.
* @returns {{index: number, corner: [number, number] | null} | null}
* The corner as (0 or 1, 0 or 1): left or right, top or bottom.
*/
imageAt(x, y, reach) {
return rectAt(this.images.map((image) => ({ left: image.x, top: image.y, width: image.width, height: image.height })),
x, y, reach);
}
/** @returns {number} The bottom edge of the lowest image; 0 if there is none. */
imagesBottom() {
return Math.max(0, ...this.images.map((image) => image.y + image.height));
}
/** @returns {number} How tall the sheet must be to hold its images: A4 at least. */
sheetHeight() {
return Math.max(BLANK_PAGE_HEIGHT, this.imagesBottom() + IMAGE_MARGIN);
}
/** @returns {number} How wide the sheet must be to hold its images: A4 at least. */
sheetWidth() {
return Math.max(BLANK_PAGE_WIDTH,
...this.images.map((image) => image.x + image.width + IMAGE_MARGIN));
}
/** @returns {boolean} Whether its images have made the sheet larger than A4. */
isLargerThanSheet() {
return this.baseWidth > BLANK_PAGE_WIDTH || this.baseHeight > BLANK_PAGE_HEIGHT;
@@ -407,7 +540,38 @@ export class Page {
* @returns {Rectangle}
*/
baseExtent() {
return { left: 0, top: 0, width: this.baseWidth, height: this.baseHeight };
return { left: this.baseLeft, top: this.baseTop, width: this.baseWidth, height: this.baseHeight };
}
/**
* The smallest sheet that holds everything on the page with the margin
* around it: what "Shrink" makes the sheet (Andy, 2026-10-08).
* @returns {Rectangle | null} Null if the page is blank.
*/
contentSheet() {
/** @type {Rectangle | null} */
let box = this.strokeBounds ? { ...this.strokeBounds } : null;
for (const item of [...this.images, ...this.hides]) {
if (!box) {
box = { left: item.x, top: item.y, width: item.width, height: item.height };
continue;
}
const right = Math.max(box.left + box.width, item.x + item.width);
const bottom = Math.max(box.top + box.height, item.y + item.height);
box.left = Math.min(box.left, item.x);
box.top = Math.min(box.top, item.y);
box.width = right - box.left;
box.height = bottom - box.top;
}
if (!box) return null;
// The greater of the two margins (Andy, 2026-10-08), exactly, the same
// on all four sides (a grid made them uneven, Andy, 2026-10-08).
const margin = Math.max(WRITING_MARGIN, IMAGE_MARGIN);
return {
left: Math.floor(box.left - margin), top: Math.floor(box.top - margin),
width: Math.ceil(box.left + box.width + margin) - Math.floor(box.left - margin),
height: Math.ceil(box.top + box.height + margin) - Math.floor(box.top - margin),
};
}
/**
@@ -440,7 +604,8 @@ export class Page {
* @param {TextBlock} block
*/
includeText(block) {
const corners = [block.x, block.y, 0, block.x + block.boxWidth, block.y + block.boxHeight, 0];
const bounds = textBounds(block);
const corners = [bounds.left, bounds.top, 0, bounds.left + bounds.width, bounds.top + bounds.height, 0];
this.includeStroke({ id: '', color: '', width: 0, points: corners, done: true });
}
@@ -453,6 +618,22 @@ export class Page {
}
}
/**
* Whether a point of a stroke of the given nominal width lies where the
* page would not grow to hold it: inside the edges by the margin and
* the stroke's reach. With "Extend" off, writing stops there (Andy,
* 2026-10-08), so that the page keeps its size on everyone's screen.
* @param {number} x
* @param {number} y
* @param {number} width
* @returns {boolean}
*/
holdsWriting(x, y, width) {
const inset = WRITING_MARGIN + width * 2; // The reach of includeStroke.
return x >= this.left + inset && x <= this.left + this.width - inset &&
y >= this.top + inset && y <= this.top + this.height - inset;
}
/**
* Set the page's rectangle from what is on it: the base rectangle,
* widened to hold every stroke with the margin around it, in steps of
@@ -467,11 +648,17 @@ export class Page {
let bottom = base.top + base.height;
const b = this.strokeBounds;
if (b) {
// Writing that comes within the margin of a sheet's edge moves that
// edge out in steps of the margin, counted from the sheet's edge
// (not from a fixed grid, since 2026-10-08: a sheet that "Shrink"
// made exact stays exact until writing reaches its margin).
const step = WRITING_MARGIN;
left = Math.min(left, Math.floor((b.left - WRITING_MARGIN) / step) * step);
top = Math.min(top, Math.floor((b.top - WRITING_MARGIN) / step) * step);
right = Math.max(right, Math.ceil((b.left + b.width + WRITING_MARGIN) / step) * step);
bottom = Math.max(bottom, Math.ceil((b.top + b.height + WRITING_MARGIN) / step) * step);
/** @param {number} short How far the writing's margin reaches past an edge. */
const steps = (short) => (short > 0 ? Math.ceil(short / step) * step : 0);
left -= steps(left - (b.left - WRITING_MARGIN));
top -= steps(top - (b.top - WRITING_MARGIN));
right += steps(b.left + b.width + WRITING_MARGIN - right);
bottom += steps(b.top + b.height + WRITING_MARGIN - bottom);
}
const changed = left !== this.left || top !== this.top ||
right - left !== this.width || bottom - top !== this.height;
@@ -489,7 +676,10 @@ export class Page {
*/
static from(descriptor) {
const page = new Page(descriptor.width, descriptor.height, descriptor.id, descriptor.number);
page.baseLeft = descriptor.left;
page.baseTop = descriptor.top;
page.images = descriptor.images.map((image) => ({ ...image, data: null }));
page.hides = descriptor.hides.map((hide) => ({ ...hide }));
page.imagesRevision = descriptor.revision;
page.updateExtent();
return page;
@@ -567,11 +757,12 @@ export function textsTouched(layer, x0, y0, x1, y1, radius) {
const touched = [];
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / radius));
for (const block of layer.texts.values()) {
const b = textBounds(block);
for (let s = 0; s <= steps; s++) {
const x = x0 + (x1 - x0) * s / steps;
const y = y0 + (y1 - y0) * s / steps;
if (x >= block.x - radius && x <= block.x + block.boxWidth + radius &&
y >= block.y - radius && y <= block.y + block.boxHeight + radius) {
if (x >= b.left - radius && x <= b.left + b.width + radius &&
y >= b.top - radius && y <= b.top + b.height + radius) {
touched.push(block.id);
break;
}
@@ -588,9 +779,8 @@ export function textsTouched(layer, x0, y0, x1, y1, radius) {
*/
export function textAt(layer, x, y) {
for (const block of [...layer.texts.values()].reverse()) {
if (x >= block.x && x <= block.x + block.boxWidth && y >= block.y && y <= block.y + block.boxHeight) {
return block;
}
const b = textBounds(block); // A text rectangle counts whole, empty part included.
if (x >= b.left && x <= b.left + b.width && y >= b.top && y <= b.top + b.height) return block;
}
return null;
}
+174 -21
View File
@@ -5,6 +5,7 @@
*/
import { rounded } from './page_model.js';
import { trace } from './log.js';
/** Bit in PointerEvent.buttons for the eraser end of a pen. */
const ERASER_BUTTON = 32;
@@ -19,23 +20,53 @@ const NOMINAL_PRESSURE = 0.4;
* @typedef {object} PenTarget
* @property {(clientX: number, clientY: number) => [number, number]} toPage
* Convert a window position to page units.
* @property {() => 'pen' | 'eraser' | 'text'} tool The tool selected in the toolbar.
* @property {() => 'pen' | 'eraser' | 'text' | 'hide'} tool The tool selected in the toolbar.
* @property {() => boolean} canWrite False while there is no page to write on.
* @property {() => void} beginStroke
* @property {(points: number[]) => void} addPoints
* @property {() => void} endStroke
* @property {(x0: number, y0: number, x1: number, y1: number) => void} eraseAlong
* @property {(x0: number, y0: number, x1: number, y1: number) => {strokes: number, texts: number}} eraseAlong
* Erases one's own strokes and text blocks along the movement and says how many.
* @property {(x: number, y: number, reach: number) => void} [eraseEnded]
* The eraser went down at (x, y) and has come up again, having moved
* no farther than `reach` from there.
* @property {(x0: number, y0: number, x1: number, y1: number) => void} placeText
* With the text tool, the pointer went down at (x0, y0) and up at (x1, y1).
* @property {(x0: number, y0: number, x1: number) => void} [previewText]
* With the text tool, the pointer is down at (x0, y0) and now at x1.
* @property {(x0: number, y0: number, x1: number, y1: number) => void} placeRect
* With the text or Hide tool, the pointer went down at (x0, y0) and up
* at (x1, y1): a tap, or a rectangle dragged open.
* @property {(x0: number, y0: number, x1: number, y1: number) => void} [previewRect]
* With the text or Hide tool, the pointer is down at (x0, y0) and now at (x1, y1).
* @property {(clientX: number, clientY: number) => boolean} [beginHold]
* The pointer has rested on the page for HOLD_MS, with any tool: a
* hold. True if something under it took the hold (an image to move
* or scale); the stroke or text placement begun is then abandoned
* here, and the pointer's movement goes to holdMove. With the
* eraser, erasing waits until the pointer moves or is lifted, so that
* a hold erases nothing (Andy, 2026-10-08: every tool can move).
* @property {(clientX: number, clientY: number) => void} [holdMove]
* @property {() => void} [holdEnd]
* @property {(clientX: number, clientY: number) => void} [beginZoom]
* The pointer went down with Ctrl (or Command) held: a zoom drag.
* @property {(pixelsUp: number) => void} [zoomTo]
* The zoom drag has moved this far up the screen (negative: down).
* @property {() => void} [endZoom]
* @property {(report: PenReport) => void} [onReport]
*/
/**
* A zoom drag shorter than this, in screen pixels, zooms nothing, so that
* a tap with Ctrl held is not a zoom.
*/
const ZOOM_DEAD_ZONE = 4;
/**
* A press that rests this long becomes a hold, which moves or scales the
* image under it (Andy's teacher, 2026-10-08; 600 ms so that a careful
* start of a stroke is not one). Moving farther than HOLD_SLACK_PX
* before then makes it a stroke after all.
*/
export const HOLD_MS = 600;
export const HOLD_SLACK_PX = 6;
/**
* What the input device is delivering, for judging pen support.
* @typedef {object} PenReport
@@ -44,6 +75,13 @@ const NOMINAL_PRESSURE = 0.4;
* @property {number} pointsPerSecond
*/
/** @param {EventTarget | null} target @returns {string} The element, for the trace. */
function describeTarget(target) {
if (!(target instanceof Element)) return 'nothing';
const name = target.tagName.toLowerCase();
return target.id ? `${name}#${target.id}` : (target.className ? `${name}.${target.className}` : name);
}
/**
* @param {HTMLElement} surface The element that receives the pointer.
* @param {PenTarget} target
@@ -53,6 +91,28 @@ export function attachPenInput(surface, target) {
let activePointer = null;
let erasing = false;
let placingText = false;
let zooming = false;
let zoomStartY = 0;
/** @type {ReturnType<typeof setTimeout> | null} */
let holdTimer = null;
let holding = false;
let pressX = 0;
let pressY = 0;
/** @type {PointerEvent | null} The eraser's press, not yet erased with. */
let eraseWaiting = null;
/** The eraser's press erases now, if it was waiting. */
function eraseFromPress() {
if (!eraseWaiting) return;
const press = eraseWaiting;
eraseWaiting = null;
erase(press);
}
function cancelHoldTimer() {
if (holdTimer !== null) clearTimeout(holdTimer);
holdTimer = null;
}
let textStartX = 0;
let textStartY = 0;
let eraseStartX = 0;
@@ -63,6 +123,9 @@ export function attachPenInput(surface, target) {
let lastY = NaN;
let strokeStart = 0;
let strokePoints = 0;
/** What the eraser removed since its press, for the trace. */
let erasedStrokes = 0;
let erasedTexts = 0;
/**
* @param {PointerEvent} event
@@ -103,35 +166,77 @@ export function attachPenInput(surface, target) {
function erase(event) {
for (const e of allEvents(event)) {
const [x, y] = target.toPage(e.clientX, e.clientY);
target.eraseAlong(Number.isNaN(lastX) ? x : lastX, Number.isNaN(lastY) ? y : lastY, x, y);
const removed = target.eraseAlong(Number.isNaN(lastX) ? x : lastX, Number.isNaN(lastY) ? y : lastY, x, y);
erasedStrokes += removed.strokes;
erasedTexts += removed.texts;
eraseReach = Math.max(eraseReach, Math.hypot(x - eraseStartX, y - eraseStartY));
lastX = x;
lastY = y;
}
}
/** @param {PointerEvent} event @returns {string} The press, for the trace. */
function describe(event) {
const where = event.target === surface ? '' : ` on ${describeTarget(event.target)}`;
return `${event.pointerType} ${event.pointerId} buttons ${event.buttons} pressure ${event.pressure.toFixed(2)}${where}`;
}
surface.addEventListener('pointerdown', (event) => {
if (activePointer !== null || !target.canWrite() || event.target !== surface) return;
/** @param {string} why */
const refuse = (why) => trace(`press ${describe(event)} refused: ${why}`);
if (activePointer !== null) return refuse(`pointer ${activePointer} still active`);
if (event.target !== surface) return refuse('not on the page');
if (event.pointerType === 'pen') penSeen = true;
// Once a pen has been used, a touch is taken to be the resting hand.
if (event.pointerType === 'touch' && penSeen) return;
if (event.pointerType === 'mouse' && event.button !== 0) return;
if (event.pointerType === 'touch' && penSeen) return refuse('a touch after a pen: the resting hand');
if (event.pointerType === 'mouse' && event.button !== 0) return refuse('not the main button');
// With Ctrl (Command on a Mac) held, a drag up or down zooms and
// draws nothing (Andy, 2026-10-08).
zooming = (event.ctrlKey || event.metaKey) && target.beginZoom !== undefined;
if (!zooming && !target.canWrite()) return refuse('cannot write now');
event.preventDefault();
surface.setPointerCapture(event.pointerId);
activePointer = event.pointerId;
trace(`press ${describe(event)}, ${target.tool()}${zooming ? ', zooming' : ''}`);
if (zooming) {
zoomStartY = event.clientY;
target.beginZoom?.(event.clientX, event.clientY);
return;
}
const tool = target.tool();
erasing = tool === 'eraser' || (event.buttons & ERASER_BUTTON) !== 0;
placingText = tool === 'text' && !erasing;
placingText = (tool === 'text' || tool === 'hide') && !erasing;
lastX = NaN;
lastY = NaN;
strokeStart = performance.now();
strokePoints = 0;
erasedStrokes = 0;
erasedTexts = 0;
holding = false;
eraseWaiting = null;
// Resting the pointer, with any tool, may become a hold on an image.
if (target.beginHold) {
pressX = event.clientX;
pressY = event.clientY;
holdTimer = setTimeout(() => {
holdTimer = null;
if (activePointer === null) return;
if (target.beginHold?.(pressX, pressY)) {
holding = true;
placingText = false;
eraseWaiting = null;
} else {
eraseFromPress();
}
}, HOLD_MS);
}
if (placingText) {
[textStartX, textStartY] = target.toPage(event.clientX, event.clientY);
} else if (erasing) {
[eraseStartX, eraseStartY] = target.toPage(event.clientX, event.clientY);
eraseReach = 0;
erase(event);
if (target.beginHold) eraseWaiting = event;
else erase(event);
} else {
target.beginStroke();
draw(event);
@@ -140,23 +245,53 @@ export function attachPenInput(surface, target) {
surface.addEventListener('pointermove', (event) => {
if (event.pointerId !== activePointer) return;
if (placingText) {
const [x] = target.toPage(event.clientX, event.clientY);
target.previewText?.(textStartX, textStartY, x);
if (holding) {
target.holdMove?.(event.clientX, event.clientY);
return;
}
if (erasing) erase(event);
else draw(event);
if (holdTimer !== null && Math.hypot(event.clientX - pressX, event.clientY - pressY) > HOLD_SLACK_PX) {
cancelHoldTimer();
eraseFromPress();
}
if (zooming) {
const up = zoomStartY - event.clientY;
const beyond = Math.max(0, Math.abs(up) - ZOOM_DEAD_ZONE);
target.zoomTo?.(Math.sign(up) * beyond);
return;
}
if (placingText) {
const [x, y] = target.toPage(event.clientX, event.clientY);
target.previewRect?.(textStartX, textStartY, x, y);
return;
}
if (erasing) {
if (!eraseWaiting) erase(event);
} else {
draw(event);
}
});
/** @param {PointerEvent} event */
function finish(event) {
if (event.pointerId !== activePointer) return;
if (event.pointerId !== activePointer) {
trace(`${event.type} ${event.pointerType} ${event.pointerId}, not the active pointer`);
return;
}
trace(`${event.type} ${event.pointerType} ${event.pointerId}, ` +
(erasing ? `erased ${erasedStrokes} strokes and ${erasedTexts} texts` : `${strokePoints} points`));
activePointer = null;
if (placingText) {
cancelHoldTimer();
if (holding) {
holding = false;
target.holdEnd?.();
} else if (zooming) {
zooming = false;
target.endZoom?.();
} else if (placingText) {
const [x, y] = target.toPage(event.clientX, event.clientY);
if (event.type === 'pointerup') target.placeText(textStartX, textStartY, x, y);
if (event.type === 'pointerup') target.placeRect(textStartX, textStartY, x, y);
} else if (erasing) {
eraseFromPress();
if (event.type === 'pointerup') target.eraseEnded?.(eraseStartX, eraseStartY, eraseReach);
} else {
target.endStroke();
@@ -164,7 +299,25 @@ export function attachPenInput(surface, target) {
}
surface.addEventListener('pointerup', finish);
surface.addEventListener('pointercancel', finish);
surface.addEventListener('lostpointercapture', (event) => {
if (event.pointerId === activePointer) trace(`capture of pointer ${event.pointerId} lost while active`);
});
for (const type of ['touchstart', 'touchend', 'touchcancel']) {
surface.addEventListener(type, (event) => {
const touches = [...(/** @type {TouchEvent} */ (event).changedTouches)]
.map((touch) => /** @type {{touchType?: string}} */ (touch).touchType ?? 'touch');
trace(`${type} ${touches.join(' ')}${event.target === surface ? '' : ` on ${describeTarget(event.target)}`}`);
});
}
// The side button of a pen asks for a context menu.
surface.addEventListener('contextmenu', (event) => event.preventDefault());
// Safari on iOS: cancelling pointerdown leaves the touch's own
// behaviour (selection, the magnifier, the callout) to WebKit; only a
// cancelled touchstart stops it. The text field is a child of the
// surface and keeps its touches, or it could not be tapped into.
surface.addEventListener('touchstart', (event) => {
if (event.target === surface) event.preventDefault();
}, { passive: false });
}
+388
View File
@@ -0,0 +1,388 @@
// @ts-check
/**
* A QR code of a text, so that the invitation can be read by 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). Written for Kokkoja since
* the client uses no packages: byte mode, any version from 1 to 40,
* error correction level M, the mask chosen by the standard's penalty
* rules (ISO/IEC 18004). `tests/test_qr_code.py` compares every version
* and level with Nayuki's reference encoder, mask choice included.
*/
/** Error correction levels, as the format bits name them. */
const LEVELS = { L: 1, M: 0, Q: 3, H: 2 };
/** @typedef {keyof typeof LEVELS} Level */
/** Error correction codewords per block, by level then version (index 0 unused). */
const ECC_PER_BLOCK = {
L: [0, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
M: [0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
Q: [0, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
H: [0, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
};
/** Number of blocks, by level then version. */
const BLOCKS = {
L: [0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25],
M: [0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49],
Q: [0, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],
H: [0, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81],
};
/** @param {number} version @returns {number} Modules not taken by function patterns. */
function rawDataModules(version) {
let result = (16 * version + 128) * version + 64;
if (version >= 2) {
const aligns = Math.floor(version / 7) + 2;
result -= (25 * aligns - 10) * aligns - 55;
if (version >= 7) result -= 36;
}
return result;
}
/** @param {number} version @param {Level} level @returns {number} Data codewords. */
function dataCodewords(version, level) {
return Math.floor(rawDataModules(version) / 8) - ECC_PER_BLOCK[level][version] * BLOCKS[level][version];
}
/** @param {number} version @returns {number} Bits of the byte count in byte mode. */
function countBits(version) {
return version < 10 ? 8 : 16;
}
/**
* @param {number} byteCount
* @param {Level} level
* @returns {number} The smallest version holding that many bytes.
*/
function versionFor(byteCount, level) {
for (let version = 1; version <= 40; version++) {
if (4 + countBits(version) + byteCount * 8 <= dataCodewords(version, level) * 8) return version;
}
throw new Error('Too long for a QR code');
}
/** @param {number} version @returns {number[]} Centres of the alignment patterns, one axis. */
function alignmentPositions(version) {
if (version === 1) return [];
const count = Math.floor(version / 7) + 2;
const size = version * 4 + 17;
const step = Math.floor((version * 8 + count * 3 + 5) / (count * 4 - 4)) * 2;
const result = [6];
for (let position = size - 7; result.length < count; position -= step) result.splice(1, 0, position);
return result;
}
/* Reed-Solomon over GF(2^8) with the standard's polynomial. */
/** @param {number} x @param {number} y @returns {number} */
function multiply(x, y) {
let z = 0;
for (let i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11d);
z ^= ((y >>> i) & 1) * x;
}
return z;
}
/** @param {number} degree @returns {number[]} The generator polynomial's coefficients, highest first after the leading 1. */
function generator(degree) {
let result = Array(degree - 1).fill(0).concat([1]);
let root = 1;
for (let i = 0; i < degree; i++) {
result = result.map((coefficient, j) => multiply(coefficient, root) ^ (j + 1 < result.length ? result[j + 1] : 0));
root = multiply(root, 2);
}
return result;
}
/** @param {number[]} data @param {number[]} divisor @returns {number[]} The remainder. */
function remainder(data, divisor) {
const result = Array(divisor.length).fill(0);
for (const byte of data) {
const factor = byte ^ /** @type {number} */ (result.shift());
result.push(0);
divisor.forEach((coefficient, i) => { result[i] ^= multiply(coefficient, factor); });
}
return result;
}
/**
* @param {Uint8Array} bytes
* @param {number} version
* @param {Level} level
* @returns {number[]} All codewords, data and error correction interleaved.
*/
function codewords(bytes, version, level) {
/** @type {number[]} */
const bits = [];
/** @param {number} value @param {number} count */
const append = (value, count) => { for (let i = count - 1; i >= 0; i--) bits.push((value >>> i) & 1); };
append(4, 4);
append(bytes.length, countBits(version));
for (const byte of bytes) append(byte, 8);
const capacity = dataCodewords(version, level) * 8;
append(0, Math.min(4, capacity - bits.length));
append(0, (8 - bits.length % 8) % 8);
for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) append(pad, 8);
/** @type {number[]} */
const data = [];
for (let i = 0; i < bits.length; i += 8) data.push(parseInt(bits.slice(i, i + 8).join(''), 2));
const blockCount = BLOCKS[level][version];
const eccCount = ECC_PER_BLOCK[level][version];
const total = Math.floor(rawDataModules(version) / 8);
const shortBlocks = blockCount - total % blockCount;
const shortLength = Math.floor(total / blockCount);
const divisor = generator(eccCount);
/** @type {number[][]} */
const blocks = [];
for (let i = 0, start = 0; i < blockCount; i++) {
const length = shortLength - eccCount + (i < shortBlocks ? 0 : 1);
const block = data.slice(start, start + length);
start += length;
const ecc = remainder(block, divisor);
if (i < shortBlocks) block.push(0); // A placeholder, skipped below.
blocks.push(block.concat(ecc));
}
/** @type {number[]} */
const result = [];
for (let i = 0; i < blocks[0].length; i++) {
blocks.forEach((block, j) => {
if (i !== shortLength - eccCount || j >= shortBlocks) result.push(block[i]);
});
}
return result;
}
/**
* The modules of a QR code: `modules[y][x]` is true where dark.
* @typedef {boolean[][]} Modules
*/
/**
* @param {Uint8Array} bytes
* @param {Level} level
* @returns {Modules}
*/
function encodeBytes(bytes, level) {
const version = versionFor(bytes.length, level);
const size = version * 4 + 17;
/** @type {Modules} */
const modules = Array.from({ length: size }, () => Array(size).fill(false));
/** @type {boolean[][]} Where function patterns are, not to be masked. */
const reserved = Array.from({ length: size }, () => Array(size).fill(false));
/** @param {number} x @param {number} y @param {boolean} dark */
const setFunction = (x, y, dark) => { modules[y][x] = dark; reserved[y][x] = true; };
// Timing patterns, finders with their separators, alignment patterns.
for (let i = 0; i < size; i++) {
setFunction(6, i, i % 2 === 0);
setFunction(i, 6, i % 2 === 0);
}
/** @param {number} cx @param {number} cy */
const finder = (cx, cy) => {
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= size || y >= size) continue;
const distance = Math.max(Math.abs(dx), Math.abs(dy));
setFunction(x, y, distance !== 2 && distance !== 4);
}
}
};
finder(3, 3);
finder(size - 4, 3);
finder(3, size - 4);
const aligns = alignmentPositions(version);
aligns.forEach((cx, i) => {
aligns.forEach((cy, j) => {
const onFinder = (i === 0 && j === 0) || (i === 0 && j === aligns.length - 1) ||
(i === aligns.length - 1 && j === 0);
if (onFinder) return;
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
setFunction(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
}
}
});
});
/** @param {number} mask */
const drawFormat = (mask) => {
const data = (LEVELS[level] << 3) | mask;
let rem = data;
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
const bits = ((data << 10) | rem) ^ 0x5412;
/** @param {number} i @returns {boolean} */
const bit = (i) => ((bits >>> i) & 1) !== 0;
for (let i = 0; i <= 5; i++) setFunction(8, i, bit(i));
setFunction(8, 7, bit(6));
setFunction(8, 8, bit(7));
setFunction(7, 8, bit(8));
for (let i = 9; i < 15; i++) setFunction(14 - i, 8, bit(i));
for (let i = 0; i < 8; i++) setFunction(size - 1 - i, 8, bit(i));
for (let i = 8; i < 15; i++) setFunction(8, size - 15 + i, bit(i));
setFunction(8, size - 8, true);
};
drawFormat(0); // Reserves the places; redrawn with the chosen mask.
if (version >= 7) {
let rem = version;
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
const bits = (version << 12) | rem;
for (let i = 0; i < 18; i++) {
const dark = ((bits >>> i) & 1) !== 0;
const a = size - 11 + (i % 3);
const b = Math.floor(i / 3);
setFunction(a, b, dark);
setFunction(b, a, dark);
}
}
// The codewords, in the zigzag the standard prescribes.
const words = codewords(bytes, version, level);
let i = 0;
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5;
for (let vertical = 0; vertical < size; vertical++) {
for (let j = 0; j < 2; j++) {
const x = right - j;
const upward = ((right + 1) & 2) === 0;
const y = upward ? size - 1 - vertical : vertical;
if (reserved[y][x] || i >= words.length * 8) continue;
modules[y][x] = ((words[i >>> 3] >>> (7 - (i & 7))) & 1) !== 0;
i++;
}
}
}
// The mask with the least penalty.
/** @type {((x: number, y: number) => boolean)[]} */
const masks = [
(x, y) => (x + y) % 2 === 0,
(x, y) => y % 2 === 0,
(x, y) => x % 3 === 0,
(x, y) => (x + y) % 3 === 0,
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
(x, y) => (x * y) % 2 + (x * y) % 3 === 0,
(x, y) => ((x * y) % 2 + (x * y) % 3) % 2 === 0,
(x, y) => ((x + y) % 2 + (x * y) % 3) % 2 === 0,
];
/** @param {number} mask */
const applyMask = (mask) => {
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (!reserved[y][x] && masks[mask](x, y)) modules[y][x] = !modules[y][x];
}
}
};
let best = 0;
let least = Infinity;
for (let mask = 0; mask < 8; mask++) {
applyMask(mask);
drawFormat(mask);
const score = penalty(modules);
if (score < least) {
least = score;
best = mask;
}
applyMask(mask);
}
applyMask(best);
drawFormat(best);
return modules;
}
/**
* The standard's four penalty rules, as Nayuki's reference encoder reads
* them: a finder-like pattern is a run of dark, light, dark, light, dark
* in the ratio 1:1:3:1:1 at any scale, with light four times its unit
* or more on a side (the edge counting as light), scored once a side.
* The tests compare the mask chosen with that encoder's.
* @param {Modules} modules
* @returns {number}
*/
function penalty(modules) {
const size = modules.length;
let score = 0;
/** @param {boolean[]} line */
const scoreLine = (line) => {
/** The lengths of the latest runs, the latest first. */
const runs = [0, 0, 0, 0, 0, 0, 0];
/** @param {number} length */
const addRun = (length) => {
if (runs[0] === 0) length += size; // The edge is light without end.
runs.pop();
runs.unshift(length);
};
/** Finder-like patterns ending with the light run just added. */
const patterns = () => {
const n = runs[1];
const core = n > 0 && runs[2] === n && runs[3] === n * 3 && runs[4] === n && runs[5] === n;
return (core && runs[0] >= n * 4 && runs[6] >= n ? 1 : 0) +
(core && runs[6] >= n * 4 && runs[0] >= n ? 1 : 0);
};
let color = false;
let run = 0;
for (const dark of line) {
if (dark === color) {
run++;
if (run === 5) score += 3;
else if (run > 5) score += 1;
continue;
}
addRun(run);
if (!color) score += patterns() * 40;
color = dark;
run = 1;
}
if (color) {
addRun(run);
run = 0;
}
addRun(run + size);
score += patterns() * 40;
};
for (let y = 0; y < size; y++) scoreLine(modules[y]);
for (let x = 0; x < size; x++) scoreLine(modules.map((row) => row[x]));
let dark = 0;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (modules[y][x]) dark++;
if (x + 1 < size && y + 1 < size) {
const a = modules[y][x];
if (modules[y][x + 1] === a && modules[y + 1][x] === a && modules[y + 1][x + 1] === a) score += 3;
}
}
}
const total = size * size;
const steps = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
return score + steps * 10;
}
/**
* @param {string} text
* @param {Level} [level]
* @returns {Modules}
*/
export function qrModules(text, level = 'M') {
return encodeBytes(new TextEncoder().encode(text), level);
}
/** The light border the standard requires, in modules. */
export const QUIET_ZONE = 4;
/**
* @param {Modules} modules
* @returns {string} An SVG path of the dark modules, one unit a module,
* for a viewBox of `-QUIET_ZONE -QUIET_ZONE size+2*QUIET_ZONE
* size+2*QUIET_ZONE`.
*/
export function qrPath(modules) {
/** @type {string[]} */
const parts = [];
modules.forEach((row, y) => {
row.forEach((dark, x) => { if (dark) parts.push(`M${x} ${y}h1v1h-1z`); });
});
return parts.join('');
}
+305 -64
View File
@@ -10,9 +10,10 @@
* stroke grows.
*/
import { BLANK_PAGE_HEIGHT, BLANK_PAGE_WIDTH, POINT_SIZE, pointCount } from './page_model.js';
import { BLANK_PAGE_HEIGHT, BLANK_PAGE_WIDTH, POINT_SIZE, pointCount, textBounds } from './page_model.js';
import { TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js';
import { partAt, widthAt } from './stroke_geometry.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, fontOf } from './text_layout.js';
import { LEFT_GAP, LINE_HEIGHT, baselineOf, fontOf } from './text_layout.js';
/** @typedef {import('./page_model.js').Page} Page */
/** @typedef {import('./page_model.js').Stroke} Stroke */
@@ -24,31 +25,140 @@ import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, fontOf } from './text_layout.js';
*/
/**
* One step of zooming, by button or scroll wheel, is this factor (10%,
* Andy's first choice to try, 2026-10-03).
* One click of the scroll wheel zooms by this factor (10%, Andy's choice
* to try, 2026-10-03).
*/
export const ZOOM_STEP = 1.1;
/**
* A pen drag with Ctrl held zooms by this factor for every 100 screen
* pixels of movement, continuously (Andy, 2026-10-08; a constant so that
* it can be tuned).
*/
export const ZOOM_PER_100_PIXELS = 2;
const LEAST_CSS_PIXELS_PER_UNIT = 0.05;
const MOST_CSS_PIXELS_PER_UNIT = 8;
/**
* How the page is scaled on screen: fitted to the window, or a fixed
* number of CSS pixels per page unit.
* How near a corner of an image a press must be, in screen pixels, to
* scale the image rather than move it (Andy, 2026-10-08; a constant to
* tune). In screen pixels so that the handle is the same size under
* the pen at any zoom.
*/
export const CORNER_REACH_PX = 24;
/**
* How a text rectangle's frame is shown on screen: the whole rectangle,
* as Andy's teacher is used to, or only its corners, which is quieter
* (Andy, 2026-10-08: a constant, to try both). The page file and the
* export always hold the whole rectangle.
* @type {'rectangle' | 'corners'}
*/
export const TEXT_FRAME_STYLE = 'rectangle';
/** The length of a corner mark, in page units, when only corners are shown. */
const CORNER_MARK = 60;
/** Hide rectangles are marked by blue corners on screen, for everyone. */
const HIDE_CORNER_COLOR = '#1e88e5';
const HIDE_CORNER_WIDTH = 2;
/**
* How the page is scaled on screen: a number of CSS pixels per page unit.
* 'fit' asks for the scale at which the whole page fits the window, and
* is replaced by that number as soon as the page is shown: "Fit" is a
* command, not a mode (Andy, 2026-10-08), so a page that grows afterwards
* extends beyond the window instead of being refitted.
* @typedef {'fit' | number} Zoom
*/
/**
* Draw a text block.
* Stroke lines on the device pixel grid, so that they are as crisp as a
* CSS border and the same width: a line of `width` page units becomes a
* whole number of pixels, at least one, and its edges land on pixels.
* The lines are given in page units and drawn through the context's
* transform, which maps page units to device pixels.
* @param {CanvasRenderingContext2D} context
* @param {[number, number][][]} polylines
* @param {string} color
* @param {number} width
*/
function strokeCrisp(context, polylines, color, width) {
const t = context.getTransform();
const pixels = Math.max(1, Math.round(width * t.a));
/** @param {number} v */
const snap = (v) => Math.round(v);
context.save();
context.setTransform(1, 0, 0, 1, 0, 0);
context.strokeStyle = color;
context.lineWidth = pixels;
context.lineCap = 'butt';
context.lineJoin = 'miter';
for (const line of polylines) {
context.beginPath();
line.forEach(([x, y], i) => {
// A line's centre at half a pixel in: its edges on the grid.
const dx = snap(t.a * x + t.e);
const dy = snap(t.d * y + t.f);
const px = dx + (pixels % 2 ? 0.5 : 0);
const py = dy + (pixels % 2 ? 0.5 : 0);
if (i === 0) context.moveTo(px, py);
else context.lineTo(px, py);
});
context.stroke();
}
context.restore();
}
/**
* Draw the corners of a rectangle as short L-shaped marks.
* @param {CanvasRenderingContext2D} context
* @param {import('./page_model.js').Rectangle} r
* @param {string} color
* @param {number} width
*/
function drawCornerMarks(context, r, color, width) {
const mark = Math.min(CORNER_MARK, r.width / 2, r.height / 2);
/** @type {[number, number][][]} */
const lines = [];
for (const [cx, cy] of [[0, 0], [1, 0], [0, 1], [1, 1]]) {
const x = r.left + cx * r.width;
const y = r.top + cy * r.height;
lines.push([[x, y + (cy ? -mark : mark)], [x, y], [x + (cx ? -mark : mark), y]]);
}
strokeCrisp(context, lines, color, width);
}
/**
* Draw a text block: a text rectangle's red frame, then its lines.
* @param {CanvasRenderingContext2D} context
* @param {TextBlock} block
*/
function drawText(context, block) {
if (block.height === null) {
// Free text: an L at its top-left corner.
strokeCrisp(context, [[[block.x, block.y + TEXT_MARK], [block.x, block.y], [block.x + TEXT_MARK, block.y]]],
TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH);
} else {
const f = textBounds(block);
if (TEXT_FRAME_STYLE === 'rectangle') {
const r = f.left + f.width;
const b = f.top + f.height;
strokeCrisp(context, [[[f.left, f.top], [r, f.top], [r, b], [f.left, b], [f.left, f.top]]],
TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH);
} else {
drawCornerMarks(context, f, TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH);
}
}
context.font = fontOf(block.size, block.font);
context.fillStyle = block.color;
context.textBaseline = 'top';
context.textBaseline = 'alphabetic';
// On whole device pixels, the lines a whole number of pixels apart, as
// the text field lays them out (TextEditor.layout): the same pixels in
// both, whatever the zoom (Andy, 2026-10-09).
const t = context.getTransform();
const x = Math.round(t.a * (block.x + block.size * LEFT_GAP) + t.e);
const first = Math.round(t.d * (block.y + baselineOf(block.size, block.font, 0)) + t.f);
const spacing = Math.round(t.d * block.size * LINE_HEIGHT);
block.lines.forEach((line, i) => {
context.fillText(line, block.x + block.size * LEFT_GAP,
block.y + block.size * (TOP_GAP + i * LINE_HEIGHT));
context.fillText(line, (x - t.e) / t.a, (first + i * spacing - t.f) / t.d);
});
}
@@ -93,16 +203,31 @@ export class Renderer {
/** Region of the tail canvas in use, in page units, or null. */
this.tailRegion = /** @type {Region | null} */ (null);
/** The white grounds of text rectangles: above the images, beneath every layer. */
this.groundCanvas = this.makeCanvas();
this.groundCanvas.classList.add('grounds');
this.pageElement.append(this.groundCanvas);
this.tailCanvas = this.makeCanvas();
this.tailCanvas.classList.add('tails');
this.pageElement.append(this.tailCanvas);
/** The Hide rectangles, above all writing, with their blue corners. */
this.hideCanvas = this.makeCanvas();
this.hideCanvas.classList.add('hides');
this.pageElement.append(this.hideCanvas);
/**
* The elements that show the page's images beneath the layers, one for
* each image whose bytes are here, and the bytes each one shows.
* @type {{element: HTMLImageElement, data: Uint8Array | null}[]}
* each image, in the images' sequence, and the bytes each one shows.
* @type {{element: HTMLImageElement, data: Uint8Array | null, hash: string}[]}
*/
this.imageElements = [];
/** The green cue around an image being handled by hand. */
this.grip = document.createElement('div');
this.grip.className = 'image_grip';
this.grip.hidden = true;
this.pageElement.append(this.grip);
/** @type {{rect: import('./page_model.js').Rectangle, corner: [number, number] | null} | null} */
this.gripShown = null;
/** Canvas pixels per page unit. */
this.scale = 1;
@@ -113,14 +238,10 @@ export class Renderer {
this.zoom = 'fit';
/** CSS pixels per page unit as last shown. */
this.cssPixelsPerUnit = 1;
/**
* True while the user is drawing a stroke. At "Fit" the page is not
* refitted then: if it were, writing past an edge would move the edge
* away from the pen with every step of growth.
*/
this.holdFit = false;
/** @type {(() => void) | null} Told when the zoom changes. */
this.onZoomChanged = null;
/** A zoom by pen drag in progress: the scale it started from and its anchor. */
this.zoomDrag = /** @type {{zoom: number, clientX: number, clientY: number} | null} */ (null);
/**
* True until a page that its images have made larger than a sheet has
* been shown for the first time. Such a page is a series of exercises
@@ -137,9 +258,10 @@ export class Renderer {
this.editingText = null;
this.setPage(page);
// Ctrl and the scroll wheel zoom about the pointer, as elsewhere.
// Ctrl (Command on a Mac) and the scroll wheel zoom about the
// pointer, as elsewhere.
this.desk().addEventListener('wheel', (event) => {
if (!event.ctrlKey) return;
if (!(event.ctrlKey || event.metaKey)) return;
event.preventDefault();
this.zoomBy(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, event.clientX, event.clientY);
}, { passive: false });
@@ -150,6 +272,63 @@ export class Renderer {
return /** @type {HTMLElement} */ (this.pageElement.parentElement);
}
/**
* The room the page can be shown in, in CSS pixels.
* @returns {{width: number, height: number}}
*/
available() {
const desk = this.desk();
const style = getComputedStyle(desk);
// offsetWidth, not clientWidth: a fitted page needs no scrollbars, so
// the room they take while zoomed in must not count.
return {
width: desk.offsetWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
height: desk.offsetHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom),
};
}
/**
* Show the page at the scale a new page has: that at which one A4
* sheet fits the window ("A4", Andy, 2026-10-08, so that a shrunk page
* is seen at its size on the sheet rather than blown up by "Fit").
*/
sheetScale() {
const { width, height } = this.available();
if (!(width > 0) || !(height > 0)) return;
this.setZoom(1 / Math.max(BLANK_PAGE_WIDTH / width, BLANK_PAGE_HEIGHT / height));
}
/** Show the whole page in the window. */
fit() {
this.setZoom('fit');
this.desk().scrollTop = 0;
this.desk().scrollLeft = 0;
}
/**
* A zoom by pen drag begins at this point, which stays under the pen.
* @param {number} clientX
* @param {number} clientY
*/
beginZoomDrag(clientX, clientY) {
this.zoomDrag = { zoom: this.cssPixelsPerUnit, clientX, clientY };
}
/**
* The pen of a zoom drag has moved this far up from where it began
* (negative: down). ZOOM_PER_100_PIXELS per 100 pixels, continuously.
* @param {number} pixelsUp
*/
zoomDragTo(pixelsUp) {
const drag = this.zoomDrag;
if (!drag) return;
this.setZoom(drag.zoom * ZOOM_PER_100_PIXELS ** (pixelsUp / 100), drag.clientX, drag.clientY);
}
endZoomDrag() {
this.zoomDrag = null;
}
/**
* Zoom in or out by a factor, from whatever the zoom is now.
* @param {number} factor
@@ -208,6 +387,7 @@ export class Renderer {
this.canvases.clear();
this.hidden.clear();
this.active.clear();
this.gripShown = null;
this.showImages();
this.resize();
// Once more after the browser has laid the page out, in case anything
@@ -249,18 +429,6 @@ export class Renderer {
this.onZoomChanged?.();
}
/**
* Keep the present scale from now on, if the page is being fitted to the
* window. Called when an image is added or taken back: the page then
* extends beyond the window, to be scrolled to, and what is in view
* stays as it is (Andy, 2026-10-05).
*/
holdScale() {
if (this.zoom !== 'fit' || this.shownLeft === null) return;
this.zoom = this.cssPixelsPerUnit;
this.onZoomChanged?.();
}
/** Place the image elements where the images lie on the page. */
layoutImages() {
const page = this.page;
@@ -275,42 +443,82 @@ export class Renderer {
}
/**
* Show the page's images whose bytes are here. An element that already
* shows the right bytes is left alone, so that adding an image does not
* make the others flicker.
* Show the page's images whose bytes are here, in their sequence (the
* last in front). An element that already shows an image keeps it when
* the images are reordered, so that nothing flickers.
*/
showImages() {
const images = this.page.images;
for (const [i, image] of images.entries()) {
let shown = this.imageElements[i];
const spare = this.imageElements.slice();
/** @type {typeof this.imageElements} */
const shownNow = [];
for (const image of images) {
const at = spare.findIndex((one) => one.hash === image.hash);
let shown = at >= 0 ? spare.splice(at, 1)[0] : null;
if (!shown) {
const element = document.createElement('img');
element.className = 'page_image';
element.alt = '';
element.draggable = false;
element.hidden = true;
// Beneath the layers, in the sequence of the images.
const before = this.imageElements[i - 1]?.element.nextSibling ?? this.pageElement.firstChild;
this.pageElement.insertBefore(element, before);
shown = this.imageElements[i] = { element, data: null };
shown = { element, data: null, hash: image.hash };
}
if (shown.data === image.data) continue;
if (shown.element.src) URL.revokeObjectURL(shown.element.src);
shown.data = image.data;
shown.element.hidden = !image.data;
if (image.data) {
shown.element.src = URL.createObjectURL(new Blob([image.data], { type: image.type }));
} else {
shown.element.removeAttribute('src');
if (shown.data !== image.data) {
if (shown.element.src) URL.revokeObjectURL(shown.element.src);
shown.data = image.data;
shown.element.hidden = !image.data;
if (image.data) {
shown.element.src = URL.createObjectURL(new Blob([image.data], { type: image.type }));
} else {
shown.element.removeAttribute('src');
}
}
shownNow.push(shown);
}
for (const gone of this.imageElements.splice(images.length)) {
for (const gone of spare) {
if (gone.element.src) URL.revokeObjectURL(gone.element.src);
gone.element.remove();
}
// Beneath the layers, in the sequence of the images.
const firstLayer = this.pageElement.querySelector('canvas');
for (const shown of shownNow) this.pageElement.insertBefore(shown.element, firstLayer);
this.imageElements = shownNow;
this.layoutImages();
}
/**
* Show, or hide, the cue around an image being handled: its outline,
* and the corner being dragged if it is being scaled.
* @param {import('./page_model.js').Rectangle | null} rect Page units.
* @param {[number, number] | null} [corner]
*/
showGrip(rect, corner = null) {
this.gripShown = rect ? { rect, corner } : null;
this.layoutGrip();
}
layoutGrip() {
const shown = this.gripShown;
this.grip.hidden = !shown;
if (!shown) return;
const page = this.page;
const style = this.grip.style;
style.left = `${(shown.rect.left - page.left) / page.width * 100}%`;
style.top = `${(shown.rect.top - page.top) / page.height * 100}%`;
style.width = `${shown.rect.width / page.width * 100}%`;
style.height = `${shown.rect.height / page.height * 100}%`;
this.grip.classList.toggle('corner', shown.corner !== null);
if (shown.corner) {
style.setProperty('--corner-x', shown.corner[0] ? '100%' : '0%');
style.setProperty('--corner-y', shown.corner[1] ? '100%' : '0%');
}
}
/** @returns {number} CORNER_REACH_PX in page units at the present zoom. */
cornerReach() {
return CORNER_REACH_PX / this.cssPixelsPerUnit;
}
makeCanvas() {
const canvas = document.createElement('canvas');
canvas.className = 'layer';
@@ -341,18 +549,13 @@ export class Renderer {
* redraw. Called when the window changes, when the page grows and when
* the zoom changes.
*
* At "Fit", the whole page is shown within the window, and refitted
* when the page grows, except while the user is drawing (see holdFit).
* A zoom of 'fit' (a page shown for the first time, or "Fit" pressed)
* becomes the scale at which the whole page fits the window, and stays
* a number from then on: growth extends the page beyond the window.
*/
resize() {
const desk = this.desk();
const deskStyle = getComputedStyle(desk);
// offsetWidth, not clientWidth: a fitted page needs no scrollbars, so
// the room they take while zoomed in must not count.
const availableWidth = desk.offsetWidth -
parseFloat(deskStyle.paddingLeft) - parseFloat(deskStyle.paddingRight);
const availableHeight = desk.offsetHeight -
parseFloat(deskStyle.paddingTop) - parseFloat(deskStyle.paddingBottom);
const { width: availableWidth, height: availableHeight } = this.available();
if (!(availableWidth > 0) || !(availableHeight > 0)) return;
const first = this.zoom === 'fit' && this.sheetFirst;
this.sheetFirst = false;
@@ -360,14 +563,16 @@ export class Renderer {
this.zoom = Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT,
1 / Math.max(BLANK_PAGE_WIDTH / availableWidth, BLANK_PAGE_HEIGHT / availableHeight)));
}
let unitsPerCssPixel;
if (this.zoom !== 'fit') unitsPerCssPixel = 1 / this.zoom;
else if (this.holdFit && this.shownLeft !== null) unitsPerCssPixel = 1 / this.cssPixelsPerUnit;
else unitsPerCssPixel = Math.max(this.page.width / availableWidth, this.page.height / availableHeight);
const fitted = this.zoom === 'fit';
const unitsPerCssPixel = this.zoom === 'fit'
? Math.max(this.page.width / availableWidth, this.page.height / availableHeight)
: 1 / this.zoom;
this.cssPixelsPerUnit = 1 / unitsPerCssPixel;
if (fitted) this.zoom = this.cssPixelsPerUnit;
this.pageElement.style.width = `${this.page.width / unitsPerCssPixel}px`;
this.pageElement.style.height = `${this.page.height / unitsPerCssPixel}px`;
this.layoutImages();
this.layoutGrip();
// When the page grew to the left or upward, keep what is in view in view.
if (this.shownLeft !== null && this.page.left < this.shownLeft) {
desk.scrollLeft += (this.shownLeft - this.page.left) / unitsPerCssPixel;
@@ -380,10 +585,14 @@ export class Renderer {
this.scale = (window.devicePixelRatio || 1) / unitsPerCssPixel;
this.sizeCanvas(this.tailCanvas);
this.sizeCanvas(this.groundCanvas);
this.sizeCanvas(this.hideCanvas);
this.tailRegion = null;
for (const id of this.canvases.keys()) this.redrawLayer(id, true);
this.drawGrounds();
this.drawHides();
this.drawTails();
if (first) this.onZoomChanged?.();
if (first || fitted) this.onZoomChanged?.();
}
/**
@@ -412,6 +621,7 @@ export class Renderer {
else this.hidden.add(id);
const canvas = this.canvases.get(id);
if (canvas) canvas.hidden = !visible;
this.drawGrounds();
this.drawTails();
}
@@ -438,9 +648,40 @@ export class Renderer {
for (const block of layer.texts.values()) {
if (block.id !== this.editingText) drawText(context, block);
}
this.drawGrounds();
this.drawTails();
}
/**
* Draw the white grounds of every text rectangle of every layer shown
* here, beneath the layers: they hide the image, never anyone's writing.
*/
drawGrounds() {
const context = this.context(this.groundCanvas);
context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height);
context.fillStyle = '#ffffff';
for (const [id, layer] of this.page.layers) {
if (this.hidden.has(id)) continue;
for (const block of layer.texts.values()) {
if (block.height === null || block.id === this.editingText) continue;
const b = textBounds(block);
context.fillRect(b.left, b.top, b.width, b.height);
}
}
}
/** Draw the Hide rectangles, white with blue corners, above everything. */
drawHides() {
const context = this.context(this.hideCanvas);
context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height);
for (const hide of this.page.hides) {
context.fillStyle = '#ffffff';
context.fillRect(hide.x, hide.y, hide.width, hide.height);
drawCornerMarks(context, { left: hide.x, top: hide.y, width: hide.width, height: hide.height },
HIDE_CORNER_COLOR, HIDE_CORNER_WIDTH);
}
}
/**
* A stroke gained points or was finished: draw what is new.
* @param {string} layerId
+79
View File
@@ -0,0 +1,79 @@
// @ts-check
/**
* No selection of text that the stylesheet declares unselectable.
*
* Safari on iOS and iPadOS sometimes selected the save status at the
* toolbar's right end when a stroke began with an Apple Pencil on Andy's
* iPad (2026-10-10), no hand on the glass: WebKit may take a Pencil drag
* for a selection gesture, and the selection snaps to the nearest text.
* The prefixed -webkit-user-select: none made it rarer, not gone, so
* WebKit's gesture path does not fully respect the property. Two
* guards, both inert where the browser already does the right thing:
* a selection that is about to start in unselectable text is refused
* (selectstart), and one that WebKit makes there anyway is cleared the
* moment it appears (selectionchange). Each is logged, in the console
* once per place and in the record every time, as evidence of which
* path the browser took. Selections inside fields
* (the invitation, a text block being typed) are left alone: a field's
* own selection reaches document.getSelection() in some browsers as a
* range on the field's parent, which may be unselectable.
*/
import { log, trace } from './log.js';
/**
* @param {Node | null | undefined} node
* @returns {boolean} Whether the stylesheet forbids selecting it.
*/
function unselectable(node) {
const element = node instanceof Element ? node : node?.parentElement;
if (!element) return false;
const style = getComputedStyle(element);
const value = style.getPropertyValue('-webkit-user-select') ||
style.getPropertyValue('user-select');
return value === 'none';
}
/**
* @param {Node | null | undefined} node
* @returns {string} The element, for the log.
*/
function describe(node) {
const element = node instanceof Element ? node : node?.parentElement;
if (!element) return 'nothing';
const name = element.tagName.toLowerCase();
if (element.id) return `${name}#${element.id}`;
return element.className ? `${name}.${element.className}` : name;
}
/** Whether the selection belongs to a focused text field. */
function inField() {
const active = document.activeElement;
return active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement ||
(active instanceof HTMLElement && active.isContentEditable);
}
export function guardSelection() {
/** @type {Set<string>} */
const logged = new Set();
/** @param {string} what */
function once(what) {
if (logged.has(what)) return trace(what);
logged.add(what);
log(what);
}
document.addEventListener('selectstart', (event) => {
if (inField() || !(event.target instanceof Node) || !unselectable(event.target)) return;
event.preventDefault();
once(`Refused a selection starting in unselectable ${describe(event.target)}`);
});
document.addEventListener('selectionchange', () => {
const selection = document.getSelection();
if (!selection || selection.isCollapsed || inField()) return;
if (!unselectable(selection.anchorNode) && !unselectable(selection.focusNode)) return;
const where = describe(selection.anchorNode);
selection.removeAllRanges();
once(`Cleared a selection the browser made in unselectable ${where}`);
});
}
+330 -57
View File
@@ -13,7 +13,8 @@ import {
} from './crypto.js';
import {
MAX_STROKE_POINTS, POINT_SIZE, Page, appendPoints, cleanName, cleanPageDescriptor,
cleanStroke, cleanText, placeImage, pointCount, validId, validPoints,
cleanStroke, cleanText, placeImage, pointCount, sheetHolding, validId, validPoints,
MAX_PAGE_HIDES,
} from './page_model.js';
import { RelayConnection } from './relay_connection.js';
@@ -49,6 +50,9 @@ import { RelayConnection } from './relay_connection.js';
* image was added or taken back, not if writing reached an edge.
* @property {() => void} onPermissionsChanged
* Who may start pages changed.
* @property {(command: 'fit' | 'sheet') => void} onViewCommand
* The host pressed "Fit" or "A4": everyone's view follows (Andy,
* 2026-10-08), each in their own window.
* @property {() => void} onParticipantsChanged
* @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus
*/
@@ -71,6 +75,19 @@ const SNAPSHOT_CHUNK_CHARACTERS = 100000;
/** An image is sent in pieces of this many bytes. */
const IMAGE_CHUNK_BYTES = 96 * 1024;
/** While an image is dragged by hand, its new place is sent this often. */
const PAGE_SEND_INTERVAL_MS = 40;
/**
* The points of one's own stroke are sent at most this often: the first
* at once, the rest gathered (Andy, 2026-10-10, after the load test:
* one message per pointer event was 60 to 120 messages a second from
* each writer, and the relay's limit is the message rate). The delay
* others see is at most this, under what Andy felt as no lag. The
* writer's own screen is not delayed.
*/
export const POINT_SEND_INTERVAL_MS = 25;
export class Session {
/**
* @param {SessionOptions} options
@@ -82,37 +99,14 @@ export class Session {
this.identity = options.identity;
this.hostId = options.hostId;
this.name = cleanName(options.name) || 'Unnamed';
/**
* The image we have just added, which we may take back until anything
* else changes on the page.
* @type {PageImage | null}
*/
this.removableImage = null;
// Every event that reports a change to the page ends that.
/** @type {SessionEvents} */
this.events = {
...events,
onLayerReplaced: (layerId) => {
this.removableImage = null;
events.onLayerReplaced(layerId);
},
onStrokeProgress: (layerId, stroke) => {
this.removableImage = null;
events.onStrokeProgress(layerId, stroke);
},
onTextChanged: (layerId, block) => {
this.removableImage = null;
events.onTextChanged(layerId, block);
},
onPageReplaced: (oldPage, newPage) => {
this.removableImage = null;
events.onPageReplaced(oldPage, newPage);
},
onImagesChanged: () => {
this.removableImage = null;
events.onImagesChanged();
},
};
/** @type {ReturnType<typeof setTimeout> | null} A page descriptor waiting to be sent. */
this.pageSendTimer = null;
/** @type {{id: string, at: number, points: number[]} | null} Points of one's own stroke not yet sent. */
this.pendingPoints = null;
/** @type {ReturnType<typeof setTimeout> | null} */
this.pointSendTimer = null;
this.lastPointSend = 0;
this.events = events;
this.isHost = this.identity.id === this.hostId;
/** @type {Set<string>} Guests the host allows to start pages. */
@@ -216,6 +210,15 @@ export class Session {
return this.isHost || this.permitted.has(this.identity.id);
}
/**
* The host's "Fit" or "A4" goes to everyone. Only the host's does.
* @param {'fit' | 'sheet'} command
*/
sendViewCommand(command) {
if (!this.isHost) return;
this.send(0, { type: 'view', command });
}
/**
* Allow a guest to start pages, or stop allowing it. Only the host may.
* @param {string} id
@@ -258,48 +261,233 @@ export class Session {
* @param {string} prepared.hash
* @param {number} prepared.naturalWidth In page units.
* @param {number} prepared.naturalHeight
* @param {boolean} [shrink] Whether an image wider than the sheet is
* scaled down to it; otherwise it keeps its size and widens the sheet.
* @returns {boolean} False if the page has no room for it.
*/
addImage(prepared, shrink = true) {
addImage(prepared) {
const page = this.page;
if (!this.mayMakePages() || page.number === 0) return false;
const place = placeImage(prepared.naturalWidth, prepared.naturalHeight, page.imagesBottom(), shrink);
const place = placeImage(page, prepared.naturalWidth, prepared.naturalHeight);
if (!page.hasRoomForImage(prepared.data.length, place.y + place.height)) return false;
/** @type {PageImage} */
const image = {
type: prepared.type, size: prepared.data.length, hash: prepared.hash, data: prepared.data,
x: place.x, y: place.y, width: place.width, height: place.height,
x: place.x, y: place.y, width: place.width, height: place.height, by: this.identity.id,
};
page.images.push(image);
page.imagesRevision += 1;
page.baseWidth = Math.max(page.baseWidth, place.pageWidth);
page.baseHeight = Math.max(page.baseHeight, place.pageHeight);
page.baseLeft = place.sheet.left;
page.baseTop = place.sheet.top;
page.baseWidth = place.sheet.width;
page.baseHeight = place.sheet.height;
this.send(0, { type: 'page', page: page.descriptor() });
this.sendImage(0, image);
this.events.onImagesChanged();
this.removableImage = image;
this.extentChanged(true);
return true;
}
// Hide rectangles: page objects like images, for those who may make pages.
/**
* Hide part of the page for everyone.
* @param {import('./page_model.js').Rectangle} rect
* @returns {boolean} Whether a Hide rectangle was made.
*/
addHide(rect) {
const page = this.page;
if (!this.mayMakePages() || page.number === 0 || page.hides.length >= MAX_PAGE_HIDES) return false;
page.hides.push({
id: randomId(), x: Math.round(rect.left), y: Math.round(rect.top),
width: Math.round(rect.width), height: Math.round(rect.height),
});
this.sheetAround(rect);
page.imagesRevision += 1;
this.sendPageNow();
this.events.onImagesChanged();
this.extentChanged(true);
return true;
}
/**
* Take back the image we have just added: the remedy for having chosen
* the wrong file. It works only until anything else changes on the
* page (Andy, 2026-10-05), so nothing can have been written on the image,
* and since it is the lowest image, no other image is affected.
* @returns {boolean} Whether an image was taken back.
* Give a Hide rectangle a new rectangle: moved or resized by hand.
* @param {number} index
* @param {import('./page_model.js').Rectangle} rect
*/
removeAddedImage() {
placeHideByHand(index, rect) {
const page = this.page;
const image = this.removableImage;
if (!image || page.images.at(-1) !== image) return false;
page.images.pop();
const hide = page.hides[index];
if (!this.mayMakePages() || !hide) return;
hide.x = Math.round(rect.left);
hide.y = Math.round(rect.top);
hide.width = Math.round(rect.width);
hide.height = Math.round(rect.height);
this.sheetAround(rect);
this.imagesChangedByHand();
}
/**
* Remove a Hide rectangle: the eraser tapped on it.
* @param {number} index
* @returns {boolean}
*/
removeHide(index) {
const page = this.page;
if (!this.mayMakePages() || !page.hides[index]) return false;
page.hides.splice(index, 1);
page.imagesRevision += 1;
this.sendPageNow();
this.events.onImagesChanged();
this.extentChanged(true);
return true;
}
/**
* Let the sheet hold a rectangle with the image margin around it.
* @param {import('./page_model.js').Rectangle} rect
*/
sheetAround(rect) {
const page = this.page;
const sheet = sheetHolding(page, rect);
page.baseLeft = sheet.left;
page.baseTop = sheet.top;
page.baseWidth = sheet.width;
page.baseHeight = sheet.height;
}
/**
* Bring an image to the front, among the images.
* @param {number} index
* @returns {number} Its index now.
*/
raiseImage(index) {
const page = this.page;
if (!this.mayMakePages() || index < 0 || index >= page.images.length) return index;
if (index !== page.images.length - 1) {
const [image] = page.images.splice(index, 1);
page.images.push(image);
this.imagesChangedByHand();
}
return page.images.length - 1;
}
/**
* Give an image a new rectangle: moved or scaled by hand (Andy's
* teacher, 2026-10-08). Writing stays where it is. The image comes
* to the front, the sheet grows to hold it, and everyone sees it move.
* @param {number} index
* @param {import('./page_model.js').Rectangle} rect
* @returns {number} The image's index now.
*/
placeImageByHand(index, rect) {
const page = this.page;
if (!this.mayMakePages() || index < 0 || index >= page.images.length) return index;
index = this.raiseImage(index);
const image = page.images[index];
image.x = Math.round(rect.left);
image.y = Math.round(rect.top);
image.width = Math.round(rect.width);
image.height = Math.round(rect.height);
this.sheetAround({ left: image.x, top: image.y, width: image.width, height: image.height });
this.imagesChangedByHand();
return index;
}
/** An image was raised, moved or scaled: tell everyone, soon. */
imagesChangedByHand() {
const page = this.page;
page.imagesRevision += 1;
this.sendPageSoon();
this.events.onImagesChanged();
this.extentChanged(true);
}
/**
* Send the page descriptor, at most every few tens of milliseconds
* while an image is being dragged; sendPageNow() at the end.
*/
sendPageSoon() {
if (this.pageSendTimer !== null) return;
this.pageSendTimer = setTimeout(() => {
this.pageSendTimer = null;
this.send(0, { type: 'page', page: this.page.descriptor() });
}, PAGE_SEND_INTERVAL_MS);
}
sendPageNow() {
if (this.pageSendTimer !== null) {
clearTimeout(this.pageSendTimer);
this.pageSendTimer = null;
}
this.send(0, { type: 'page', page: this.page.descriptor() });
}
/**
* Make the sheet wider, for more room to write (Andy, 2026-10-08:
* "Widen" enlarges the page rather than zooming). Everyone's page
* widens; it is a change to the page, so it needs the right to make
* pages, and it ends the chance to take back an image.
* @param {number} width The page's new width, if wider than now.
* @returns {boolean} Whether the page widened.
*/
widenPage(width) {
const page = this.page;
const added = Math.round(width) - page.width;
if (!this.mayMakePages() || page.number === 0 || added <= 0) return false;
// Equally on both sides of the PAGE as it is, writing included (the
// sheet may lie inside a page that writing has grown; measured from
// the sheet, the page came out short, Andy, 2026-10-08): what is on
// the page stays where it is seen. The same whole number of units on
// each side, since the page is centred on the screen: an odd growth
// split 1199 and 1200 moved the page's centre half a unit, a fraction
// of a pixel that showed as a one-pixel move on Olion (2026-10-09).
const half = Math.floor(added / 2);
if (half === 0) return false;
page.baseLeft = page.left - half;
page.baseWidth = page.width + 2 * half;
page.imagesRevision += 1;
page.baseWidth = page.sheetWidth();
page.baseHeight = page.sheetHeight();
this.send(0, { type: 'page', page: page.descriptor() });
this.extentChanged(true);
return true;
}
/**
* Make the sheet the smallest that holds everything on the page with
* the margin around it ("Shrink", Andy, 2026-10-08). A change to the
* page like "Widen".
* @returns {boolean} Whether the sheet changed.
*/
shrinkPage() {
const page = this.page;
if (!this.mayMakePages() || page.number === 0) return false;
const sheet = page.contentSheet();
if (!sheet || (sheet.left === page.baseLeft && sheet.top === page.baseTop &&
sheet.width === page.baseWidth && sheet.height === page.baseHeight)) return false;
page.baseLeft = sheet.left;
page.baseTop = sheet.top;
page.baseWidth = sheet.width;
page.baseHeight = sheet.height;
page.imagesRevision += 1;
this.send(0, { type: 'page', page: page.descriptor() });
this.extentChanged(true);
return true;
}
/**
* Remove an image: the eraser tapped on it (Andy, 2026-10-08: "the user
* has decided that the image should be deleted"). Allowed to whoever
* may make pages, and to whoever added the image. Writing on it stays
* where it is; the sheet keeps its size ("Shrink" trims it).
* @param {number} index
* @returns {boolean} Whether it was removed.
*/
removeImage(index) {
const page = this.page;
const image = page.images[index];
if (!image || !(this.mayMakePages() || image.by === this.identity.id)) return false;
page.images.splice(index, 1);
page.imagesRevision += 1;
this.imageTransfers.delete(image.hash);
this.sendPageNow();
this.events.onImagesChanged();
this.extentChanged(true);
return true;
@@ -314,7 +502,10 @@ export class Session {
loadPage(kept) {
if (!this.mayMakePages()) return;
const page = new Page(kept.baseWidth, kept.baseHeight, randomId(), this.page.number + 1);
page.baseLeft = kept.baseLeft;
page.baseTop = kept.baseTop;
page.images = kept.images.map((image) => ({ ...image }));
page.hides = kept.hides.map((hide) => ({ ...hide }));
page.imagesRevision = kept.imagesRevision;
for (const [id, layer] of kept.layers) {
const copy = page.layer(id, layer.name);
@@ -460,6 +651,38 @@ export class Session {
this.pageSourcePeer = peer;
}
/**
* Whether a page descriptor from a participant without the right to
* make pages is one they may still send: our page, the sheet as it is,
* and the images ours with only images they added left out (Andy,
* 2026-10-08: whoever added an image may remove it, right or no right).
* @param {any} received
* @param {string} id The sender.
* @returns {boolean}
*/
removesOnlyOwnImages(received, id) {
const descriptor = cleanPageDescriptor(received);
const page = this.page;
if (!descriptor || descriptor.id !== page.id || descriptor.left !== page.baseLeft ||
descriptor.top !== page.baseTop || descriptor.width !== page.baseWidth ||
descriptor.height !== page.baseHeight ||
JSON.stringify(descriptor.hides) !== JSON.stringify(page.hides)) return false;
/** @param {Omit<PageImage, 'data'>} image */
const key = (image) => `${image.hash} ${image.x} ${image.y} ${image.width} ${image.height} ${image.by}`;
let at = 0;
let removed = 0;
for (const ours of page.images) {
if (at < descriptor.images.length && key(descriptor.images[at]) === key(ours)) {
at += 1;
} else if (ours.by === id) {
removed += 1;
} else {
return false;
}
}
return at === descriptor.images.length && removed > 0;
}
/**
* Another participant's list of the images on the page we are on. The
* list with the higher revision is the later one. Of two different
@@ -470,11 +693,13 @@ export class Session {
*/
considerImages(descriptor) {
const page = this.page;
/** @param {Omit<PageImage, 'data'>[]} images */
const listed = (images) => images.map((image) =>
`${image.hash} ${image.x} ${image.y} ${image.width} ${image.height}`).join(',');
const ours = listed(page.images);
const theirs = listed(descriptor.images);
/** @param {import('./page_model.js').PageDescriptor} d */
const listed = (d) => d.images.map((image) =>
`${image.hash} ${image.x} ${image.y} ${image.width} ${image.height}`).join(',') +
'|' + d.hides.map((hide) => `${hide.id} ${hide.x} ${hide.y} ${hide.width} ${hide.height}`).join(',') +
`|${d.left} ${d.top} ${d.width} ${d.height}`;
const ours = listed(page.descriptor());
const theirs = listed(descriptor);
if (descriptor.revision < page.imagesRevision ||
(descriptor.revision === page.imagesRevision && theirs <= ours)) return;
const held = new Map();
@@ -482,7 +707,10 @@ export class Session {
if (image.data) held.set(image.hash, image.data);
}
page.images = descriptor.images.map((image) => ({ ...image, data: held.get(image.hash) ?? null }));
page.hides = descriptor.hides.map((hide) => ({ ...hide }));
page.imagesRevision = descriptor.revision;
page.baseLeft = descriptor.left;
page.baseTop = descriptor.top;
page.baseWidth = descriptor.width;
page.baseHeight = descriptor.height;
// Bytes on their way for an image that is no longer wanted are dropped,
@@ -541,16 +769,54 @@ export class Session {
const at = pointCount(stroke);
appendPoints(stroke, at, points);
this.page.includeStroke(stroke, at);
this.send(0, { type: 'stroke_points', id: stroke.id, at, points });
this.queuePoints(stroke.id, at, points);
this.events.onStrokeProgress(this.identity.id, stroke);
this.extentChanged();
}
/**
* Points to send: at once if none went in the last interval, else with
* the next sending, which the timer makes when the interval is up.
* @param {string} id
* @param {number} at
* @param {number[]} points
*/
queuePoints(id, at, points) {
if (this.pendingPoints && this.pendingPoints.id === id) {
this.pendingPoints.points.push(...points);
} else {
this.sendPendingPoints();
this.pendingPoints = { id, at, points: points.slice() };
}
const since = performance.now() - this.lastPointSend;
if (since >= POINT_SEND_INTERVAL_MS) {
this.sendPendingPoints();
} else if (this.pointSendTimer === null) {
this.pointSendTimer = setTimeout(() => {
this.pointSendTimer = null;
this.sendPendingPoints();
}, POINT_SEND_INTERVAL_MS - since);
}
}
sendPendingPoints() {
if (this.pointSendTimer !== null) {
clearTimeout(this.pointSendTimer);
this.pointSendTimer = null;
}
const pending = this.pendingPoints;
if (!pending) return;
this.pendingPoints = null;
this.lastPointSend = performance.now();
this.send(0, { type: 'stroke_points', id: pending.id, at: pending.at, points: pending.points });
}
endStroke() {
const stroke = this.currentStroke;
if (!stroke) return;
this.currentStroke = null;
stroke.done = true;
this.sendPendingPoints();
this.send(0, { type: 'stroke_end', id: stroke.id });
this.events.onStrokeProgress(this.identity.id, stroke);
}
@@ -756,12 +1022,19 @@ export class Session {
if (!id) return;
if (message.type === 'page') {
if (id === this.hostId || this.permitted.has(id)) this.considerPage(message.page, true, peer);
else if (this.removesOnlyOwnImages(message.page, id)) this.considerPage(message.page, false, peer);
return;
}
if (message.type === 'permissions') {
if (id === this.hostId) this.receivedPermissions(message.ids);
return;
}
if (message.type === 'view') {
if (id === this.hostId && (message.command === 'fit' || message.command === 'sheet')) {
this.events.onViewCommand(message.command);
}
return;
}
if (message.type === 'image') {
this.receivedImagePiece(message);
return;
+164 -83
View File
@@ -1,15 +1,19 @@
// @ts-check
/**
* Editing a text block: a real text field laid over the page, so that
* umlauts, dead keys and selection work as they do everywhere, with
* handles for moving the block and changing its width.
* umlauts, dead keys and selection work as they do everywhere. Moving a
* block and resizing a text rectangle are done by a hold of the pen, as
* for images (main.js), not by handles here (2026-10-08).
*
* While a block is being edited the renderer leaves it out, and the field
* shows it; everyone else sees it drawn, updated at every keystroke.
*/
import { randomId } from './crypto.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js';
import { textBounds } from './page_model.js';
import { TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js';
import { HOLD_MS, HOLD_SLACK_PX } from './pen_input.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, baselineOf, fontMetrics, layoutText } from './text_layout.js';
/** @typedef {import('./page_model.js').TextBlock} TextBlock */
/** @typedef {import('./page_model.js').TextFont} TextFont */
@@ -18,6 +22,7 @@ import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js';
/** A block without a width is shown at least this wide, in page units. */
const LEAST_FIELD_WIDTH = 300;
export class TextEditor {
/**
* @param {HTMLElement} pageElement
@@ -25,6 +30,13 @@ export class TextEditor {
* @param {object} target
* @param {(block: TextBlock) => void} target.setText The block changed.
* @param {(id: string) => void} target.removeText The block was left empty.
* @param {(clientX: number, clientY: number) => boolean} [target.beginHold]
* The pen rested on the field for HOLD_MS: a hold, as on the page
* (Andy, 2026-10-09: the box being typed in can be moved without
* stopping). Then the pen's movement goes to holdMove, and holdEnd
* when it lifts.
* @param {(clientX: number, clientY: number) => void} [target.holdMove]
* @param {() => void} [target.holdEnd]
*/
constructor(pageElement, renderer, target) {
this.pageElement = pageElement;
@@ -38,6 +50,7 @@ export class TextEditor {
this.field.spellcheck = false;
this.field.hidden = true;
this.field.addEventListener('input', () => this.changed());
this.watchForHold();
this.field.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
@@ -45,95 +58,111 @@ export class TextEditor {
}
});
this.corner = this.makeHandle('text_handle corner', 'Move the text');
this.edge = this.makeHandle('text_edge', 'Move the text');
this.end = this.makeHandle('text_handle end', 'Change the width');
/** Shows the top edge while it is being dragged out. */
/** Free text's corner mark while it is being edited (the renderer leaves the block out then). */
this.mark = document.createElement('div');
this.mark.className = 'text_mark';
this.mark.hidden = true;
/** Shows a rectangle while it is being dragged open. */
this.preview = document.createElement('div');
this.preview.className = 'text_preview';
this.preview.className = 'rect_preview';
this.preview.hidden = true;
this.pageElement.append(this.field, this.edge, this.corner, this.end, this.preview);
this.pageElement.append(this.field, this.mark, this.preview);
}
/**
* Show where the top edge of a new block would run, from (x0, y0) to
* (x1, y0), while the pointer is dragging.
* A press that rests on the field becomes a hold that moves the block,
* as a hold on the page would; a press that moves at once selects text
* as in any field.
*/
watchForHold() {
const field = this.field;
/** @type {ReturnType<typeof setTimeout> | null} */
let timer = null;
let holding = false;
let pointer = -1;
let pressX = 0;
let pressY = 0;
const cancel = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
};
field.addEventListener('pointerdown', (event) => {
if (!this.target.beginHold || (event.pointerType === 'mouse' && event.button !== 0)) return;
pointer = event.pointerId;
pressX = event.clientX;
pressY = event.clientY;
cancel();
timer = setTimeout(() => {
timer = null;
if (!this.target.beginHold?.(pressX, pressY)) return;
holding = true;
field.setPointerCapture(pointer);
}, HOLD_MS);
});
field.addEventListener('pointermove', (event) => {
if (event.pointerId !== pointer) return;
if (holding) {
event.preventDefault();
this.target.holdMove?.(event.clientX, event.clientY);
} else if (timer !== null && Math.hypot(event.clientX - pressX, event.clientY - pressY) > HOLD_SLACK_PX) {
cancel();
}
});
const finish = (/** @type {PointerEvent} */ event) => {
if (event.pointerId !== pointer) return;
cancel();
pointer = -1;
if (holding) {
holding = false;
this.target.holdEnd?.();
field.focus();
}
};
field.addEventListener('pointerup', finish);
field.addEventListener('pointercancel', finish);
}
/**
* Show the rectangle being dragged open, from (x0, y0) to (x1, y1).
* @param {number} x0
* @param {number} y0
* @param {number} x1
* @param {number} y1
*/
showPreview(x0, y0, x1) {
showPreview(x0, y0, x1, y1) {
const scale = this.renderer.cssPixelsPerUnit;
const page = this.renderer.page;
this.preview.hidden = false;
this.preview.style.left = `${(Math.min(x0, x1) - page.left) * scale}px`;
this.preview.style.top = `${(y0 - page.top) * scale}px`;
this.preview.style.top = `${(Math.min(y0, y1) - page.top) * scale}px`;
this.preview.style.width = `${Math.abs(x1 - x0) * scale}px`;
this.preview.style.height = `${Math.abs(y1 - y0) * scale}px`;
}
hidePreview() {
this.preview.hidden = true;
}
/**
* @param {string} className
* @param {string} help
* @returns {HTMLElement}
*/
makeHandle(className, help) {
const handle = document.createElement('div');
handle.className = className;
handle.hidden = true;
handle.setAttribute('data-help', help);
const widening = className.includes('end');
handle.addEventListener('pointerdown', (event) => {
if (!this.block) return;
event.preventDefault();
event.stopPropagation();
handle.setPointerCapture(event.pointerId);
const [startX, startY] = this.renderer.pageFromClient(event.clientX, event.clientY);
const { x, y, width } = this.block;
const move = (/** @type {PointerEvent} */ e) => {
if (!this.block) return;
const [nowX, nowY] = this.renderer.pageFromClient(e.clientX, e.clientY);
if (widening) {
this.block.width = Math.max(100, Math.round((width ?? 0) + nowX - startX));
} else {
this.block.x = Math.round(x + nowX - startX);
this.block.y = Math.round(y + nowY - startY);
}
this.changed();
};
const finish = () => {
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', finish);
handle.removeEventListener('pointercancel', finish);
this.field.focus();
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', finish);
handle.addEventListener('pointercancel', finish);
});
return handle;
}
/** @returns {boolean} */
get editing() {
return this.block !== null;
}
/**
* Start a new block.
* Start a new block: free text, or a text rectangle with a width and a
* height.
* @param {number} x
* @param {number} y
* @param {number | null} width
* @param {number | null} height
* @param {{color: string, size: number, font: TextFont}} style
*/
begin(x, y, width, style) {
begin(x, y, width, height, style) {
this.stop();
/** @type {TextBlock} */
const block = {
id: randomId(), x: Math.round(x), y: Math.round(y), width, text: '', lines: [''],
id: randomId(), x: Math.round(x), y: Math.round(y), width,
height: width === null ? null : height, text: '', lines: [''],
size: style.size, font: style.font, color: style.color, boxWidth: 0, boxHeight: 0,
};
Object.assign(block, layoutText('', width, style.size, style.font));
@@ -163,6 +192,8 @@ export class TextEditor {
this.layout();
this.target.setText(block); // Redrawn without this block.
this.field.focus();
// The cursor at the end of the text (Andy, 2026-10-09).
this.field.setSelectionRange(block.text.length, block.text.length);
}
/** Finish editing. An empty block is removed. */
@@ -172,58 +203,108 @@ export class TextEditor {
this.block = null;
this.renderer.editingText = null;
this.field.hidden = true;
this.mark.hidden = true;
this.field.value = '';
for (const handle of [this.corner, this.edge, this.end]) handle.hidden = true;
if (block.text.trim() === '') this.target.removeText(block.id);
else this.target.setText(block);
}
/**
* The page was replaced under the editor: leave off without sending
* anything, since the block already reached the page it belonged to at
* every keystroke, and that page is being kept. Finishing normally
* would put the block on the new page.
* @returns {TextBlock | null} The block that was being edited.
*/
discard() {
const block = this.block;
if (!block) return null;
this.block = null;
this.renderer.editingText = null;
this.field.hidden = true;
this.mark.hidden = true;
this.field.value = '';
return block;
}
/** The text, position or width changed: lay it out and tell the page. */
changed() {
const block = this.block;
if (!block) return;
block.text = this.field.value;
Object.assign(block, layoutText(block.text, block.width, block.size, block.font));
// A text rectangle grows downward by lines when the text needs more.
if (block.height !== null && block.boxHeight > block.height) block.height = Math.ceil(block.boxHeight);
this.layout();
this.target.setText(block);
}
/** Place the field and the handles over the block, at the present zoom. */
/** Place the field over the block, at the present zoom. */
layout() {
const block = this.block;
if (!block) return;
const scale = this.renderer.cssPixelsPerUnit;
const page = this.renderer.page;
const left = (block.x - page.left) * scale;
const top = (block.y - page.top) * scale;
// Everything on whole device pixels, placed as the canvas places the
// drawn block (renderer.js drawText): the same pixels in both, so that
// nothing moves when editing ends (Andy, 2026-10-09).
const dpr = window.devicePixelRatio || 1;
/** @param {number} px CSS pixels. @returns {number} The nearest whole device pixel, in CSS pixels. */
const whole = (px) => Math.round(px * dpr) / dpr;
const left = whole((block.x - page.left) * scale);
const top = whole((block.y - page.top) * scale);
const isRect = block.height !== null;
const fieldWidth = (block.width ?? Math.max(LEAST_FIELD_WIDTH, block.boxWidth + block.size)) * scale;
const style = this.field.style;
this.field.classList.toggle('rect', isRect);
style.left = `${left}px`;
style.top = `${top}px`;
// A little wider than the block, so that the field never wraps a line
// earlier than the layout did.
style.width = `${fieldWidth - block.size * LEFT_GAP * scale + 2}px`;
style.paddingTop = `${block.size * TOP_GAP * scale}px`;
style.paddingLeft = `${block.size * LEFT_GAP * scale}px`;
// The text's left edge and its first baseline, where the canvas draws
// them; the lines a whole number of pixels apart.
const textLeft = whole((block.x + block.size * LEFT_GAP - page.left) * scale);
const firstBaseline = whole((block.y + baselineOf(block.size, block.font, 0) - page.top) * scale);
const lineHeight = whole(block.size * LINE_HEIGHT * scale);
const { ascent, descent } = fontMetrics(block.size, block.font);
// In a line box the baseline lies half the leading and the ascent down.
const baselineInLine = (lineHeight - (ascent + descent) * scale) / 2 + ascent * scale;
// A box's field is exactly the box, on whole pixels, and draws the
// frame as an outline inside its edge, which takes no room, at the
// width the canvas draws it.
const frame = Math.max(1, Math.round(TEXT_FRAME_WIDTH * scale * dpr)) / dpr;
style.outlineWidth = isRect ? `${frame}px` : '0';
style.outlineOffset = `${-frame}px`;
const padTop = firstBaseline - top - baselineInLine;
style.paddingTop = `${padTop}px`;
style.paddingLeft = `${textLeft - left}px`;
// Room below the last line, or the field scrolls by a few pixels to
// keep the cursor in view and the text jumps.
const slack = (LINE_HEIGHT - 1) / 2 * block.size * scale + 1;
style.paddingBottom = `${slack}px`;
style.font = `${block.size * scale}px ${block.font}`;
style.lineHeight = String(LINE_HEIGHT);
style.lineHeight = `${lineHeight}px`;
style.color = block.color;
style.whiteSpace = block.width === null ? 'pre' : 'pre-wrap';
// Exactly as tall as the lines: no spare line below the text.
style.height = `${block.lines.length * block.size * LINE_HEIGHT * scale}px`;
this.corner.hidden = false;
this.corner.style.left = `${left}px`;
this.corner.style.top = `${top}px`;
const hasWidth = block.width !== null;
this.edge.hidden = !hasWidth;
this.end.hidden = !hasWidth;
if (hasWidth) {
this.edge.style.left = `${left}px`;
this.edge.style.top = `${top}px`;
this.edge.style.width = `${fieldWidth}px`;
this.end.style.left = `${left + fieldWidth}px`;
this.end.style.top = `${top}px`;
if (isRect) {
const bounds = textBounds(block);
// The canvas strokes the far lines on the pixel at the edge itself,
// so the field reaches one pixel past it.
style.boxSizing = 'border-box';
style.width = `${whole((bounds.left + bounds.width - page.left) * scale) - left + 1 / dpr}px`;
style.height = `${Math.max(block.lines.length * lineHeight + padTop + slack,
whole((bounds.top + bounds.height - page.top) * scale) - top + 1 / dpr)}px`;
} else {
style.boxSizing = 'content-box';
// A little wider than the block, so that the field never wraps a
// line earlier than the layout did.
style.width = `${fieldWidth - (textLeft - left) + 2}px`;
style.height = `${block.lines.length * lineHeight}px`;
}
this.field.scrollTop = 0;
this.mark.hidden = isRect;
this.mark.style.left = `${left}px`;
this.mark.style.top = `${top}px`;
this.mark.style.width = `${whole(TEXT_MARK * scale)}px`;
this.mark.style.height = `${whole(TEXT_MARK * scale)}px`;
this.mark.style.borderWidth = `${Math.max(1, Math.round(TEXT_FRAME_WIDTH * scale * dpr)) / dpr}px`;
}
}
+39 -2
View File
@@ -14,9 +14,10 @@ export const LINE_HEIGHT = 1.25;
/**
* Room between a block's top edge and its first line, as a multiple of
* the font size (Andy, 2026-10-03: the text should not sit on the edge).
* the font size (Andy, 2026-10-03: the text should not sit on the edge;
* halved from 0.3 on 2026-10-09).
*/
export const TOP_GAP = 0.3;
export const TOP_GAP = 0.15;
/**
* Room between a block's left edge (its corner square) and the text, as a
@@ -51,6 +52,42 @@ export function textWidth(text, size, family) {
return measurer.measureText(text).width;
}
/**
* Where the baseline of line `i` of a block lies below the block's top, in
* page units, placed as a browser places text in a line box: the gap, then
* half the leading (the line height less the font's own ascent and
* descent), then the ascent. The text field and the canvas then agree
* to the pixel (Andy, 2026-10-09: the text moved when editing ended).
* @param {number} size
* @param {string} family
* @param {number} i
* @returns {number}
*/
export function baselineOf(size, family, i) {
const { ascent, descent } = fontMetrics(size, family);
const halfLeading = (size * LINE_HEIGHT - ascent - descent) / 2;
return size * (TOP_GAP + i * LINE_HEIGHT) + halfLeading + ascent;
}
/**
* The font's ascent and descent, in page units, as the browser lays
* lines out with them.
* @param {number} size
* @param {string} family
* @returns {{ascent: number, descent: number}}
*/
export function fontMetrics(size, family) {
if (!measurer) {
measurer = /** @type {CanvasRenderingContext2D} */ (document.createElement('canvas').getContext('2d'));
}
measurer.font = fontOf(size, family);
const metrics = measurer.measureText('Mg');
return {
ascent: metrics.fontBoundingBoxAscent ?? size * 0.8,
descent: metrics.fontBoundingBoxDescent ?? size * 0.2,
};
}
/**
* Break one paragraph into lines no wider than `width`. Breaks come at
* spaces; a word wider than the width is broken where it must be.
+10 -2
View File
@@ -9,7 +9,7 @@
* are covered too, since the listeners are on the document.
*/
const DELAY_MS = 1000;
const DELAY_MS = 2000;
const GAP_PX = 6;
/** @type {HTMLElement | null} */
@@ -66,8 +66,16 @@ export function enableTooltips() {
timer = setTimeout(() => { timer = null; show(element); }, DELAY_MS);
});
document.addEventListener('pointerleave', hide);
document.addEventListener('pointerdown', hide);
// Focus that comes from a click brings no tooltip: the user is already
// acting on the element, and a list it opens would be covered (Andy,
// 2026-10-07, the notebook list). Focus from the keyboard does.
let pressedAt = -Infinity;
document.addEventListener('pointerdown', () => {
pressedAt = performance.now();
hide();
});
document.addEventListener('focusin', (event) => {
if (performance.now() - pressedAt < 500) return;
const element = helpElement(event.target);
if (element) show(element);
});
+135 -70
View File
@@ -5,8 +5,7 @@
--panel: #f6f6f7;
--desk: #e4e4e7;
--accent: #1f4fd8;
--text_edge: #ababab; /* The top edge of a text block being edited: 67% grey. */
--text_handle: #ababab; /* Its squares: the same. */
--text_frame: #e53935; /* A text rectangle's frame: the palette's red. */
font-family: system-ui, sans-serif;
font-size: 15px;
color: var(--ink);
@@ -20,6 +19,14 @@ html, body { height: 100%; margin: 0; }
button, input { font: inherit; color: inherit; }
/* A drag across the controls selects nothing: their text is not for
copying (Andy, 2026-10-07). Fields stay selectable. Safari on iOS
and iPadOS knows only the prefixed property (MDN's compatibility
data, 2026-10-10); without it the Pencil's first movement sometimes
selected the save status on Andy's iPad. */
button, select, label, .toolbar, .panel h2, #participants, .chooser header
{ -webkit-user-select: none; user-select: none; }
button {
padding: 0.35rem 0.8rem;
border: 1px solid var(--line);
@@ -35,12 +42,14 @@ button[aria-pressed="true"] {
box-shadow: inset 0 0 0 1px var(--accent);
}
input[type="text"] {
input[type="text"], select.notebook_pick {
width: 100%;
padding: 0.4rem 0.5rem;
border: 1px solid var(--line);
border-radius: 6px;
background: white;
font: inherit;
color: inherit;
}
h2 {
@@ -80,15 +89,29 @@ h2 {
height: 100%;
}
/* One row, holding everything (Andy's teacher, 2026-10-08: the whole
width of the window belongs to the page); it wraps only when the window
is too narrow for it. */
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.25rem;
padding: 0.5rem 0.75rem;
gap: 0.5rem 0.55rem;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
/* When the window is too narrow for one row, the tools are centred
instead of standing at the two ends (Andy, 2026-10-09; main.js sets
the class by measuring). */
.toolbar.wrapped { justify-content: center; }
.toolbar.wrapped .toolbar_right { margin-left: 0; justify-content: center; }
.toolbar .notebook_pick { width: 9rem; }
.toolbar .toggle { display: flex; align-items: center; gap: 0.3rem; cursor: pointer; }
.toolbar .toggle input { margin: 0; }
.group { display: flex; align-items: center; gap: 0.35rem; }
.swatch {
@@ -100,13 +123,16 @@ h2 {
background-clip: content-box;
}
/* The same height as the buttons, so the toolbar does not change height. */
/* The same height as the buttons, so the toolbar does not change height;
narrow enough that a host's toolbar fits a 1920-pixel screen in one
row (measured 2026-10-09: about 1750 pixels with these settings). */
.toolbar button:not(.swatch), #text_size, #text_font {
box-sizing: border-box;
height: 2rem;
padding-top: 0;
padding-bottom: 0;
}
.toolbar button:not(.swatch) { padding-left: 0.6rem; padding-right: 0.6rem; }
#text_size, #text_font {
margin: 0;
@@ -137,6 +163,8 @@ h2 {
}
.palette .swatch { width: 1.5rem; height: 1.5rem; }
/* A colour already in the toolbar leaves its place in the grid empty. */
.palette .swatch.missing { visibility: hidden; }
.swatch.more {
--swatch: white;
@@ -161,24 +189,36 @@ h2 {
background: var(--ink);
}
#zoom_in, #zoom_out {
width: 2.2rem;
padding: 0.1rem 0;
font-size: 1.35rem;
line-height: 1;
}
/* What concerns the page and the view, not the tool: it never moves. */
/* The page, the notebook and the session: flush right, the page number
and what is going on at the very end (Andy, 2026-10-08). */
.toolbar_right {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.25rem;
gap: 0.5rem 0.55rem;
margin-left: auto;
min-width: 0;
}
.page_number { color: var(--muted); }
.status { min-width: 6.5rem; text-align: right; color: var(--muted); }
.status.problem { color: #b42318; }
/* The notice line between the toolbar and the page: nothing, and no
room, while all is well; a problem in red while it lasts; a
confirmation for a few seconds (Andy, 2026-10-10: the toolbar's
"Page 3 Connected Saved at …" was noise, and the room is needed for
one row). */
.notice {
padding: 0.3rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--panel);
text-align: center;
font-size: 0.9rem;
color: var(--muted);
}
.notice.problem { color: #b42318; }
/* The quiet facts in the Session window: the page, the connection, the
last save. */
.session_state span { margin-right: 1rem; white-space: nowrap; }
.session_state .problem { color: #b42318; }
.workspace {
display: flex;
@@ -190,17 +230,40 @@ h2 {
flex: 1;
min-width: 0;
overflow: auto;
padding: 1.25rem;
padding: 0.5rem;
background: var(--desk);
}
/* The cue around an image being moved or scaled by hand: green, with a
square at the corner being dragged. */
.image_grip {
position: absolute;
z-index: 3;
box-sizing: border-box;
border: 2px solid #2e7d32;
pointer-events: none;
}
.image_grip.corner::after {
content: "";
position: absolute;
left: var(--corner-x);
top: var(--corner-y);
width: 12px;
height: 12px;
transform: translate(-50%, -50%);
background: #2e7d32;
}
.page {
position: relative;
margin: 0 auto;
background: white;
box-shadow: 0 1px 4px rgb(0 0 0 / 0.25);
touch-action: none;
-webkit-user-select: none; /* Safari on iOS: see the controls rule. */
user-select: none;
-webkit-touch-callout: none; /* No callout from a long press of the Pencil. */
cursor: crosshair;
}
@@ -228,75 +291,78 @@ h2 {
outline: none;
}
.text_handle {
/* A rectangle being dragged open, for text or for Hide. */
.rect_preview {
position: absolute;
z-index: 3;
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
border: 1px solid white;
border-radius: 2px;
background: var(--text_handle);
cursor: move;
box-sizing: border-box;
border: 2px dotted #ababab;
pointer-events: none;
}
.text_handle.end { cursor: ew-resize; }
/* The field of a text rectangle shows its frame and white ground itself,
since the renderer leaves the block out while it is edited. */
.text_field.rect {
background: white;
outline-style: solid;
outline-color: var(--text_frame);
}
.text_edge {
/* Free text's corner mark while it is edited: an L at its top-left corner. */
.text_mark {
position: absolute;
z-index: 3;
height: 0;
margin-top: -1px;
border-top: 2px solid var(--text_edge);
cursor: move;
}
.text_edge::before, .text_edge::after {
content: "";
position: absolute;
top: 0;
width: 0;
height: 8px;
border-left: 2px solid var(--text_edge);
}
.text_edge::before { left: 0; }
.text_edge::after { right: 0; }
/* The top edge of a new block, while it is being dragged out. */
.text_preview {
position: absolute;
z-index: 3;
height: 0;
margin-top: -1px;
border-top: 2px solid var(--text_edge);
box-sizing: border-box;
border-style: solid none none solid;
border-color: var(--text_frame);
pointer-events: none;
}
.panel {
width: 16rem;
padding: 0 1rem 1rem;
overflow: auto;
border-left: 1px solid var(--line);
background: var(--panel);
}
/* The Session dialog: who is here, and the invitation. */
.panel ul { margin: 0; padding: 0; list-style: none; }
/* fit-content, not auto: a modal dialog has its top and bottom pinned by
the browser's own style, so an auto height filled the window up to the
maximum (seen 2026-10-10: 1100 px of dialog around 350 px of content). */
.chooser.session { width: min(32rem, 90vw); height: fit-content; max-height: 85vh; }
.session_body h3 { margin: 1rem 0 0.4rem; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
.session_body ul { margin: 0; padding: 0; list-style: none; }
.panel li {
.session_body li {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.2rem 0;
}
.panel li.absent { color: var(--muted); }
.panel li label { flex: 1; }
.panel li .role { font-size: 0.8rem; color: var(--muted); }
.panel li .allow { order: 1; padding: 0.1rem 0.4rem; font-size: 0.75rem; }
.panel button { margin-top: 0.4rem; }
.session_body li.absent { color: var(--muted); }
.session_body li label { flex: 1; }
.session_body li .role { font-size: 0.8rem; color: var(--muted); }
.session_body li .allow_line { order: 1; display: flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--muted); cursor: pointer; }
.session_body li .allow_line input { margin: 0; }
.invitation_line { display: flex; gap: 0.5rem; align-items: center; }
.invitation_line input { flex: 1; min-width: 0; }
/* The invitation as a QR code, for the camera of a phone or a tablet. */
.invitation_code { width: min(13rem, 60vw); margin: 0.75rem auto 0; }
.invitation_code svg { display: block; width: 100%; height: auto; }
/* The log, the latest entry at the bottom and in view, selectable. */
.log_line { display: flex; gap: 0.5rem; align-items: flex-start; }
.event_log {
flex: 1;
min-width: 0;
margin: 0;
max-height: 10rem;
overflow: auto;
padding: 0.3rem 0.4rem;
border: 1px solid var(--line);
border-radius: 6px;
font-size: 0.7rem;
line-height: 1.35;
color: var(--muted);
white-space: pre;
-webkit-user-select: text;
user-select: text;
}
/* The notebook chooser */
@@ -396,7 +462,6 @@ h2 {
overflow: auto;
}
.files_shrink { display: block; margin-top: 0.75rem; font-size: 0.9rem; }
.files_existing {
display: flex;