Everyone has the same buttons; the view is personal

The host's rights and the co-host are gone: anyone in a session may
start a new page, put an image on the page, load a kept page, hide,
expand and shrink, and move or remove images and Hides; the page left
is kept in every notebook.  Fit and Normal scale act on your own screen
only.  A session goes on without whoever started it, and a newcomer to
an empty room starts page 1 with New.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-18 20:22:13 +02:00
1 parent 5d63a0f758
commit 60ad747e32
10 files changed
+215 -354

No files matched your search

+4 -12
View File
@@ -15,10 +15,9 @@
* a Trash the entries are moved there, and the button says so;
* in the browser they are gone for good, and the button says that. The
* notebook being written in cannot be marked: its mark explains.
* Everyone has the chooser (Andy, 2026-10-11), so that a guest can see
* and clear what their notebook holds, in a browser above all; loading
* a page into the session is for the host and co-hosts, and a card says
* so to anyone else.
* Everyone has the chooser (Andy, 2026-10-11), so that anyone can see
* and clear what their notebook holds, in a browser above all, and
* since 2026-10-18 anyone may load a page into the session from it.
*/
import {
@@ -163,7 +162,6 @@ export class NotebookChooser {
/** The notebook being written in, which cannot be deleted while it is. */
this.writing = '';
/** Whether this participant may load a page into the session. */
this.mayLoad = true;
/** Whether deletions go to a Trash (the local program's) or are for good. */
this.trash = false;
/** Counts openings, so that pictures fetched for an earlier one are dropped. */
@@ -188,12 +186,10 @@ export class NotebookChooser {
/**
* Show the notebooks, or the pages of the only notebook.
* @param {string} [notebook] Open on this notebook: the one being written in.
* @param {boolean} [mayLoad] Whether a page may be loaded into the session.
*/
async open(notebook, mayLoad = true) {
async open(notebook) {
this.opening += 1;
this.writing = notebook ?? '';
this.mayLoad = mayLoad;
this.clearMarks();
this.dialog.showModal();
element('chooser_message').textContent = '';
@@ -289,10 +285,6 @@ export class NotebookChooser {
title.textContent = pageTitle(pageName);
card.append(picture, title);
card.addEventListener('click', async () => {
if (!this.mayLoad) {
this.say('Only the host and co-hosts can start a page for everyone from a kept page.');
return;
}
const text = await readPage(notebook, pageName);
if (text === null) return;
this.dialog.close();
+4 -1
View File
@@ -83,7 +83,10 @@ export function randomId(byteCount = 9) {
* @typedef {object} Invitation
* @property {string} relay Address of the relay, ws:// or wss://.
* @property {string} secret The session secret.
* @property {string} hostId Participant id of the host.
* @property {string} hostId Participant id of whoever started the
* session (named the host until 2026-10-18; the field keeps its name
* so that invitations and page files are unchanged). It gives no
* right.
*/
/**
+32 -82
View File
@@ -230,7 +230,7 @@ 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
* saved, no page from the others 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.
*/
@@ -470,7 +470,6 @@ async function enterSession(invitationText, name, notebook) {
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 {Tool} */ ('pen');
/** The Shape tool's current shape, remembered in this browser (Andy, 2026-10-14). */
let shape = shapeNamed(localStorage.getItem(SHAPE_KEY));
@@ -501,7 +500,7 @@ async function enterSession(invitationText, name, notebook) {
function showPageNumber() {
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);
standingNotice('page', waiting ? 'Waiting for the others’ page; New starts one' : '', false);
}
/** @type {ReturnType<typeof setTimeout> | undefined} */
let connectionNoticeTimer;
@@ -568,18 +567,6 @@ async function enterSession(invitationText, name, notebook) {
textEditor?.layout();
writer.changed();
},
onViewCommand: (command) => {
log(`The host's view: ${command === 'fit' ? 'Fit' : 'Normal scale'}`);
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);
refreshArrangeCues();
},
onParticipantsChanged: () => {
syncLayers();
showParticipants(session, renderer);
@@ -846,12 +833,11 @@ async function enterSession(invitationText, name, notebook) {
/**
* With the Arrange tool chosen, mark everything a press can move or
* resize with a blue box and corners (Andy, 2026-10-14): the images
* and Hides for the host and co-hosts, one's own text blocks and
* shapes; free text, which has no width to resize, gets the box without
* the corners. The one being held is left out, the green grip standing
* in its place until the pointer lifts. Called whenever the images,
* the Hides, one's own text or shapes, the right, or the hold may have
* changed.
* and Hides, one's own text blocks and shapes; free text, which has
* no width to resize, gets the box without the corners. The one being
* held is left out, the green grip standing in its place until the
* pointer lifts. Called whenever the images, the Hides, one's own
* text or shapes, or the hold may have changed.
*/
function refreshArrangeCues() {
if (tool !== 'arrange') {
@@ -861,16 +847,14 @@ async function enterSession(invitationText, name, notebook) {
/** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]} */
const cues = [];
const page = session.page;
if (session.mayMakePages()) {
page.images.forEach((im, index) => {
if (held?.kind === 'image' && held.index === index) return;
cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height }, corners: true });
});
page.hides.forEach((h, index) => {
if (held?.kind === 'hide' && held.index === index) return;
cues.push({ kind: 'hide', rect: { left: h.x, top: h.y, width: h.width, height: h.height }, corners: true });
});
}
page.images.forEach((im, index) => {
if (held?.kind === 'image' && held.index === index) return;
cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height }, corners: true });
});
page.hides.forEach((h, index) => {
if (held?.kind === 'hide' && held.index === index) return;
cues.push({ kind: 'hide', rect: { left: h.x, top: h.y, width: h.width, height: h.height }, corners: true });
});
for (const block of session.ownLayer.texts.values()) {
if (held?.kind === 'text' && held.id === block.id) continue;
cues.push({ kind: 'text', rect: textBounds(block), corners: block.height !== null });
@@ -1110,13 +1094,13 @@ async function enterSession(invitationText, name, notebook) {
if (rects.length) return rects;
const page = session.page;
const hide = page.hideAt(x, y, 0);
if (hide && session.mayMakePages()) {
if (hide) {
const h = page.hides[hide.index];
return [{ left: h.x, top: h.y, width: h.width, height: h.height }];
}
const found = page.imageAt(x, y, 0);
const image = found && page.images[found.index];
if (image && (session.mayMakePages() || image.by === session.identity.id)) {
if (image) {
return [{ left: image.x, top: image.y, width: image.width, height: image.height }];
}
return [];
@@ -1143,15 +1127,15 @@ async function enterSession(invitationText, name, notebook) {
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.
// Hide rectangle, an image, 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;
const hide = page.hideAt(x, y, reach);
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);
@@ -1190,7 +1174,7 @@ async function enterSession(invitationText, name, notebook) {
const stroke = shapes[shapeAt.index];
const box = /** @type {import('./page_model.js').Rectangle} */ (strokeBox(stroke));
if (session.beginShapeEdit(stroke.id)) found = hold('shape', shapeAt.index, stroke.id, box, shapeAt.corner, x, y);
} else if (session.mayMakePages()) {
} else {
const image = page.imageAt(x, y, reach);
if (image) {
const index = session.raiseImage(image.index);
@@ -1277,8 +1261,8 @@ async function enterSession(invitationText, name, notebook) {
lastEmptyTap = 0;
return;
}
// A tap of the eraser on a Hide rectangle or an image removes it
// (the right to make pages, or having added the image).
// A tap of the eraser on a Hide rectangle or an image removes it,
// for everyone (by anyone since 2026-10-18).
let took = erased.strokes + erased.texts > 0;
if (!took) {
const hide = session.page.hideAt(x, y, 0);
@@ -1458,8 +1442,8 @@ async function enterSession(invitationText, name, notebook) {
textEditor?.layout();
viewChanged();
};
// The host's Fit and Normal scale ("A4" until 2026-10-14) reach everyone (Andy, 2026-10-08); a guest's
// are the guest's own.
// Fit and Normal scale ("A4" until 2026-10-14) are each person's own
// (2026-10-18; the host's reached everyone from 2026-10-08 until then).
// The page and view commands live in the Page menu (Andy, 2026-10-14:
// seldom used, and the one row is precious).
attachPopup(element('page_menu_button'), element('page_menu'));
@@ -1505,14 +1489,8 @@ async function enterSession(invitationText, name, notebook) {
log(`The manual could not be copied: ${error}`);
}
});
element('zoom_fit').addEventListener('click', () => {
renderer.fit();
session.sendViewCommand('fit');
});
element('zoom_sheet').addEventListener('click', () => {
renderer.sheetScale();
session.sendViewCommand('sheet');
});
element('zoom_fit').addEventListener('click', () => renderer.fit());
element('zoom_sheet').addEventListener('click', () => renderer.sheetScale());
// "Expand" makes the sheet itself fill the window at the present zoom,
// in both directions; nothing on it moves (Andy, 2026-10-08 for the
// width, both ways 2026-10-11).
@@ -1541,21 +1519,9 @@ async function enterSession(invitationText, name, notebook) {
element('desk').addEventListener('scroll', viewChanged, { passive: true });
window.addEventListener('pagehide', saveView);
// The host drives: the host starts new pages, and may let a guest do so.
function showHostTools() {
// New and Image are the host's; Notebooks, in the same group so that
// nothing moves for the host, is everyone's (the chooser reserves
// loading).
for (const id of ['new_page', 'load_image']) element(id).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_expand').hidden = !session.mayMakePages();
element('zoom_shrink').hidden = !session.mayMakePages();
}
showHostTools();
// Everyone has every button (Andy and his teacher, 2026-10-18; until
// then New, Image, Hide, Expand and Shrink were the host's and the
// co-hosts').
element('new_page').addEventListener('click', () => {
log('New page asked for');
session.newPage();
@@ -1567,7 +1533,7 @@ async function enterSession(invitationText, name, notebook) {
* @param {Blob} file
*/
async function loadImage(file) {
if (!session.mayMakePages() || session.page.number === 0) return;
if (session.page.number === 0) return;
try {
if (!session.addImage(await prepareImage(file))) {
throw new Error('The page has no room for another image.');
@@ -1588,7 +1554,7 @@ async function enterSession(invitationText, name, notebook) {
// A deleted notebook leaves the toolbar's list.
showNotebooks(sessionPicker).catch(() => {});
});
element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook, session.mayMakePages()); });
element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook); });
// Image files are chosen, and pictures of the page exported, through a
// chooser of our own, which begins where the notebooks are.
@@ -2056,20 +2022,6 @@ function showParticipants(session, renderer) {
const present = participant.self || participant.peer !== null;
item.classList.toggle('absent', !present);
if (session.isHost && !participant.self) {
// 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, expand, shrink and hide, as you can.');
const allow = document.createElement('input');
allow.type = 'checkbox';
allow.className = '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');
const visible = document.createElement('input');
visible.type = 'checkbox';
@@ -2082,8 +2034,6 @@ 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');
+1 -1
View File
@@ -165,7 +165,7 @@ function strokeShapes(stroke) {
* @param {object} options
* @param {string} options.session The session the page belongs to (its
* room id), so that returning to the session continues the page.
* @param {string} options.hostId
* @param {string} options.hostId Who started the session (`data-host`).
* @param {Set<string>} [options.hidden] Layers to leave out: for a picture
* of the page as someone shows it, never for the page file itself.
* @returns {string} The page file.
+8 -7
View File
@@ -338,8 +338,8 @@ function withoutData(image) {
}
/**
* What identifies a page and gives its size. The host sends it to the
* others when the page changes.
* What identifies a page and gives its size. Whoever starts or changes
* a page sends it to the others.
* @typedef {object} PageDescriptor
* @property {string} id
* @property {number} number Counts the pages of a session from 1.
@@ -504,8 +504,8 @@ export class Page {
/**
* @param {number} [width]
* @param {number} [height]
* @param {string} [id] Empty for the placeholder a guest shows
* before the host's page arrives.
* @param {string} [id] Empty for the placeholder someone joining
* shows before a page arrives.
* @param {number} [number] 0 for the placeholder.
*/
constructor(width = BLANK_PAGE_WIDTH, height = BLANK_PAGE_HEIGHT, id = '', number = 0) {
@@ -781,9 +781,10 @@ export class Page {
}
/**
* Layer ids from bottom to top. The host's layer is on top so that
* corrections lie over the writing they correct.
* @param {string} hostId
* Layer ids from bottom to top. The layer of whoever started the
* session is on top, so that a teacher's corrections lie over the
* writing they correct (a drawing order, not a right).
* @param {string} hostId Who started the session.
* @returns {string[]}
*/
layerOrder(hostId) {
+38 -129
View File
@@ -46,7 +46,6 @@ import { trimTrailingBlankLines } from './text_layout.js';
* @property {string} name
* @property {number | null} peer Relay peer number; null when not connected.
* @property {boolean} self
* @property {boolean} host
*/
/**
@@ -64,11 +63,6 @@ import { trimTrailingBlankLines } from './text_layout.js';
* @property {(byImages: boolean) => void} onPageResized
* The page grew (or shrank) around its contents; `byImages` if an
* image was added or taken back, not if writing reached an edge.
* @property {() => void} onPermissionsChanged
* Who may start pages changed.
* @property {(command: 'fit' | 'sheet') => void} onViewCommand
* The host pressed "Fit" or "Normal scale": 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
* @property {() => void} [onUndoChanged]
@@ -81,7 +75,9 @@ import { trimTrailingBlankLines } from './text_layout.js';
* @property {string} roomId
* @property {CryptoKey} key
* @property {Identity} identity
* @property {string} hostId
* @property {string} hostId Who started the session, the id in the
* invitation: the page file records it and that layer is drawn on
* top; it gives no right (everyone has the same, Andy, 2026-10-18).
* @property {string} name Our display name.
* @property {Page} [page] The page as last saved, when returning to
* the session.
@@ -146,13 +142,11 @@ export class Session {
this.hideEditing = null;
this.events = events;
this.isHost = this.identity.id === this.hostId;
/** @type {Set<string>} Guests the host allows to start pages. */
this.permitted = new Set();
// The host begins a session with page 1. A guest shows a placeholder
// until the host's page arrives.
// Whoever starts a session begins on page 1. Someone joining shows
// a placeholder until a page arrives from the others (or starts one
// with New, if nobody is there).
this.page = options.page ??
(this.isHost ? new Page(undefined, undefined, randomId(), 1) : new Page());
(this.identity.id === this.hostId ? new Page(undefined, undefined, randomId(), 1) : new Page());
this.ownLayer = this.page.layer(this.identity.id, this.name);
this.page.recomputeStrokeBounds();
this.page.updateExtent();
@@ -161,13 +155,11 @@ export class Session {
this.participants = new Map();
this.participants.set(this.identity.id, {
id: this.identity.id, name: this.name, peer: null, self: true,
host: this.identity.id === this.hostId,
});
for (const layer of this.page.layers.values()) {
if (layer.id === this.identity.id) continue;
this.participants.set(layer.id, {
id: layer.id, name: layer.name || 'Unnamed', peer: null, self: false,
host: layer.id === this.hostId,
});
}
@@ -241,34 +233,10 @@ export class Session {
return this.page.layerOrder(this.hostId);
}
// Pages.
/** @returns {boolean} Whether we may start pages: the host, or a guest the host allows. */
mayMakePages() {
return this.isHost || this.permitted.has(this.identity.id);
}
/**
* The host's "Fit" or "Normal scale" 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
* @param {boolean} allowed
*/
setPermitted(id, allowed) {
if (!this.isHost || id === this.identity.id) return;
if (allowed) this.permitted.add(id);
else this.permitted.delete(id);
this.send(0, { type: 'permissions', ids: [...this.permitted] });
this.events.onPermissionsChanged();
}
// Pages. Everyone may start, load and change them (Andy and his
// teacher, 2026-10-18: the host's rights and the co-host went; what
// an accident costs is said in the documentation, and the page left
// is kept in every notebook).
/**
* The page's rectangle may have changed with its contents.
@@ -279,11 +247,10 @@ export class Session {
}
/**
* Start a new blank page for everyone. The host may; so may a guest the
* host allows.
* Start a new blank page for everyone. Someone with no page yet (the
* others gone) starts page 1.
*/
newPage() {
if (!this.mayMakePages()) return;
const page = new Page(undefined, undefined, randomId(), this.page.number + 1);
page.updateExtent();
this.replacePage(page);
@@ -292,7 +259,6 @@ export class Session {
/**
* Add an image to the page for everyone, below the images already on it.
* The host may; so may a guest the host allows.
* @param {object} prepared
* @param {PageImage['type']} prepared.type
* @param {Uint8Array<ArrayBuffer>} prepared.data
@@ -303,7 +269,7 @@ export class Session {
*/
addImage(prepared) {
const page = this.page;
if (!this.mayMakePages() || page.number === 0) return false;
if (page.number === 0) return false;
const place = placeImage(page, prepared.naturalWidth, prepared.naturalHeight);
if (!page.hasRoomForImage(prepared.data.length, place.y + place.height)) return false;
/** @type {PageImage} */
@@ -333,7 +299,7 @@ export class Session {
*/
addHide(rect) {
const page = this.page;
if (!this.mayMakePages() || page.number === 0 || page.hides.length >= MAX_PAGE_HIDES) return false;
if (page.number === 0 || page.hides.length >= MAX_PAGE_HIDES) return false;
const hide = {
id: randomId(), x: Math.round(rect.left), y: Math.round(rect.top),
width: Math.round(rect.width), height: Math.round(rect.height),
@@ -377,7 +343,7 @@ export class Session {
placeHideByHand(index, rect) {
const page = this.page;
const hide = page.hides[index];
if (!this.mayMakePages() || !hide) return;
if (!hide) return;
hide.x = Math.round(rect.left);
hide.y = Math.round(rect.top);
hide.width = Math.round(rect.width);
@@ -394,7 +360,7 @@ export class Session {
removeHide(index) {
const page = this.page;
const hide = page.hides[index];
if (!this.mayMakePages() || !hide) return false;
if (!hide) return false;
page.hides.splice(index, 1);
page.imagesRevision += 1;
this.sendPageNow();
@@ -424,7 +390,7 @@ export class Session {
*/
raiseImage(index) {
const page = this.page;
if (!this.mayMakePages() || index < 0 || index >= page.images.length) return index;
if (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);
@@ -443,7 +409,7 @@ export class Session {
*/
placeImageByHand(index, rect) {
const page = this.page;
if (!this.mayMakePages() || index < 0 || index >= page.images.length) return index;
if (index < 0 || index >= page.images.length) return index;
index = this.raiseImage(index);
const image = page.images[index];
image.x = Math.round(rect.left);
@@ -496,7 +462,7 @@ export class Session {
*/
expandPage(width, height) {
const page = this.page;
if (!this.mayMakePages() || page.number === 0) return false;
if (page.number === 0) return false;
// Equally on both sides, and top and bottom, 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,
@@ -530,7 +496,7 @@ export class Session {
*/
shrinkPage() {
const page = this.page;
if (!this.mayMakePages() || page.number === 0) return false;
if (page.number === 0) return false;
// The blank lines at the end of one's own text blocks go first (Andy,
// 2026-10-14); another's blocks are theirs, trimmed when they finish
// typing. Each trim is a gesture of its own to undo; a block being
@@ -564,7 +530,7 @@ export class Session {
removeImage(index) {
const page = this.page;
const image = page.images[index];
if (!image || !(this.mayMakePages() || image.by === this.identity.id)) return false;
if (!image) return false;
page.images.splice(index, 1);
page.imagesRevision += 1;
this.imageTransfers.delete(image.hash);
@@ -581,7 +547,6 @@ export class Session {
* @param {Page} kept As read from the page file.
*/
loadPage(kept) {
if (!this.mayMakePages()) return;
const page = new Page(kept.baseWidth, kept.baseHeight, randomId(), this.page.number + 1);
page.baseLeft = kept.baseLeft;
page.baseTop = kept.baseTop;
@@ -595,9 +560,7 @@ export class Session {
}
for (const block of layer.texts.values()) copy.texts.set(block.id, { ...block, lines: block.lines.slice() });
if (!this.participants.has(id)) {
this.participants.set(id, {
id, name: layer.name || 'Unnamed', peer: null, self: false, host: id === this.hostId,
});
this.participants.set(id, { id, name: layer.name || 'Unnamed', peer: null, self: false });
}
}
page.recomputeStrokeBounds();
@@ -744,15 +707,18 @@ export class Session {
/**
* Move to a page that another participant is on, if it is newer than
* ours. Pages are numbered by the host, so the highest number is the
* session's current page; between two pages of the same number, the
* host's is taken. If it is the page we are on, it may have gained
* ours. Whoever presses New numbers the page one past their own, so
* the highest number is the session's current page; between two
* different pages of the same number (two people pressed New at the
* same moment, or apart while disconnected) the one with the greater
* id is taken, the same choice on every machine, and the loser's page
* is kept in its notebook like any page left (2026-10-18; the host's
* won until then). If it is the page we are on, it may have gained
* images.
* @param {unknown} received A page descriptor.
* @param {boolean} fromHost
* @param {number} peer Who told us.
*/
considerPage(received, fromHost, peer) {
considerPage(received, peer) {
const descriptor = cleanPageDescriptor(received);
if (!descriptor) return;
if (descriptor.id === this.page.id) {
@@ -760,44 +726,12 @@ export class Session {
return;
}
const newer = descriptor.number > this.page.number ||
(descriptor.number === this.page.number && fromHost);
(descriptor.number === this.page.number && descriptor.id > this.page.id);
if (!newer) return;
this.replacePage(Page.from(descriptor));
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
@@ -1116,7 +1050,6 @@ export class Session {
return 'a move of a shape';
case 'hide': {
const page = this.page;
if (!this.mayMakePages()) return null;
const index = page.hides.findIndex((one) => one.id === entry.id);
if (entry.before === null) {
if (index < 0) return null;
@@ -1197,7 +1130,6 @@ export class Session {
type: 'hello', reply, name: this.name, publicKey: this.identity.publicKey,
signature: await signClaim(this.identity, this.roomId, peer),
page: this.page.number > 0 ? this.page.descriptor() : null,
permitted: this.isHost ? [...this.permitted] : undefined,
}));
}
@@ -1305,9 +1237,8 @@ export class Session {
const previous = this.participants.get(id);
if (previous && previous.peer !== null) this.peerIds.delete(previous.peer);
this.peerIds.set(peer, id);
this.participants.set(id, { id, name, peer, self: false, host: id === this.hostId });
if (id === this.hostId) this.receivedPermissions(message.permitted);
this.considerPage(message.page, id === this.hostId, peer);
this.participants.set(id, { id, name, peer, self: false });
this.considerPage(message.page, peer);
// This peer is on our page: it can give us the images we lack.
if (message.page?.id === this.page.id) this.requestImages(peer);
this.page.layer(id, name);
@@ -1329,20 +1260,12 @@ export class Session {
const id = this.peerIds.get(peer);
if (!id) return;
if (message.type === 'page') {
if (id === this.hostId || this.permitted.has(id)) this.considerPage(message.page, true, peer);
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);
}
this.considerPage(message.page, peer);
return;
}
// 'permissions' and 'view' came from a host, until 2026-10-18; an
// older client's are ignored.
if (message.type === 'permissions' || message.type === 'view') return;
if (message.type === 'image') {
this.receivedImagePiece(message);
return;
@@ -1482,19 +1405,6 @@ export class Session {
}
}
/**
* The host said who may start pages.
* @param {unknown} ids
*/
receivedPermissions(ids) {
if (!Array.isArray(ids)) return;
const permitted = new Set(ids.filter(validId));
const same = permitted.size === this.permitted.size && [...permitted].every((id) => this.permitted.has(id));
if (same) return;
this.permitted = permitted;
this.events.onPermissionsChanged();
}
/**
* Whether to accept a snapshot of a layer from a peer. A participant may
* always send their own layer. The peer that moved us to this page may
@@ -1543,7 +1453,6 @@ export class Session {
if (!this.participants.has(layerId)) {
this.participants.set(layerId, {
id: layerId, name: snapshot.name || 'Unnamed', peer: null, self: false,
host: layerId === this.hostId,
});
this.events.onParticipantsChanged();
}