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:
1 parent
5d63a0f758
commit
60ad747e32
10 files changed
+215
-354
No files matched your search
+38
-129
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user