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

+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();
}