Kokkoja as of 2026-10-10: the teacher's requests, the Session window, notices, the QR code, the license
This commit is contained in:
1 parent
95c80d04c9
commit
1d087af633
32 files changed
+4917
-890
No files matched your search
+585
-120
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user