2026-10-06 15:58:02 +02:00
|
|
|
// @ts-check
|
|
|
|
|
/**
|
|
|
|
|
* A session: the page, the participants, and the messages that keep every
|
|
|
|
|
* participant's copy of the page the same.
|
|
|
|
|
*
|
|
|
|
|
* The rule that makes this simple is that each layer has one writer. A
|
|
|
|
|
* participant is the authority on their own layer, so there are no
|
|
|
|
|
* conflicting edits to reconcile. See notes/protocol.md for the messages.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
decrypt, encrypt, fromBase64, hashOf, randomId, signClaim, toBase64, verifyClaim,
|
|
|
|
|
} from './crypto.js';
|
|
|
|
|
import {
|
|
|
|
|
MAX_STROKE_POINTS, POINT_SIZE, Page, appendPoints, cleanName, cleanPageDescriptor,
|
2026-10-10 19:01:20 +02:00
|
|
|
cleanStroke, cleanText, placeImage, pointCount, sheetHolding, validId, validPoints,
|
|
|
|
|
MAX_PAGE_HIDES,
|
2026-10-06 15:58:02 +02:00
|
|
|
} from './page_model.js';
|
|
|
|
|
import { RelayConnection } from './relay_connection.js';
|
|
|
|
|
|
|
|
|
|
/** @typedef {import('./crypto.js').Identity} Identity */
|
|
|
|
|
/** @typedef {import('./page_model.js').Layer} Layer */
|
|
|
|
|
/** @typedef {import('./page_model.js').Stroke} Stroke */
|
|
|
|
|
/** @typedef {import('./page_model.js').PageImage} PageImage */
|
|
|
|
|
/** @typedef {import('./page_model.js').TextBlock} TextBlock */
|
2026-10-11 13:17:22 +02:00
|
|
|
/** @typedef {import('./page_model.js').HideRect} HideRect */
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* One gesture of ours, with what is needed to take it back: a stroke
|
|
|
|
|
* drawn; strokes and text blocks erased by one movement of the eraser;
|
|
|
|
|
* a text block edited (made, typed into, moved or resized), with the
|
|
|
|
|
* block as it was before, or null if it was new; a Hide rectangle made,
|
|
|
|
|
* moved or removed, likewise. Images are not here: Andy, 2026-10-11,
|
|
|
|
|
* the undo set is what is cheap, and an image's data is not kept.
|
|
|
|
|
* @typedef {{kind: 'stroke', id: string}
|
|
|
|
|
* | {kind: 'erase', strokes: Stroke[], texts: TextBlock[]}
|
|
|
|
|
* | {kind: 'text', id: string, before: TextBlock | null}
|
|
|
|
|
* | {kind: 'hide', id: string, before: HideRect | null}} UndoEntry
|
|
|
|
|
*/
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @typedef {object} Participant
|
|
|
|
|
* @property {string} id
|
|
|
|
|
* @property {string} name
|
|
|
|
|
* @property {number | null} peer Relay peer number; null when not connected.
|
|
|
|
|
* @property {boolean} self
|
|
|
|
|
* @property {boolean} host
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @typedef {object} SessionEvents
|
|
|
|
|
* @property {(layerId: string) => void} onLayerReplaced
|
|
|
|
|
* A layer's contents changed as a whole and must be redrawn.
|
|
|
|
|
* @property {(layerId: string, stroke: Stroke) => void} onStrokeProgress
|
|
|
|
|
* A stroke gained points or was finished.
|
|
|
|
|
* @property {(layerId: string, block: TextBlock) => void} onTextChanged
|
|
|
|
|
* A text block was made or changed.
|
|
|
|
|
* @property {(oldPage: Page, newPage: Page) => void} onPageReplaced
|
|
|
|
|
* The session moved to another page.
|
|
|
|
|
* @property {() => void} onImagesChanged
|
|
|
|
|
* An image was added to the page, or the bytes of one have arrived.
|
|
|
|
|
* @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.
|
2026-10-10 19:01:20 +02:00
|
|
|
* @property {(command: 'fit' | 'sheet') => void} onViewCommand
|
|
|
|
|
* The host pressed "Fit" or "A4": everyone's view follows (Andy,
|
|
|
|
|
* 2026-10-08), each in their own window.
|
2026-10-06 15:58:02 +02:00
|
|
|
* @property {() => void} onParticipantsChanged
|
|
|
|
|
* @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus
|
2026-10-11 13:17:22 +02:00
|
|
|
* @property {() => void} [onUndoChanged]
|
|
|
|
|
* Whether there is something of ours to undo may have changed.
|
2026-10-06 15:58:02 +02:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @typedef {object} SessionOptions
|
|
|
|
|
* @property {string} relay
|
|
|
|
|
* @property {string} roomId
|
|
|
|
|
* @property {CryptoKey} key
|
|
|
|
|
* @property {Identity} identity
|
|
|
|
|
* @property {string} hostId
|
|
|
|
|
* @property {string} name Our display name.
|
|
|
|
|
* @property {Page} [page] The page as last saved, when returning to
|
|
|
|
|
* the session.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/** Approximate size at which a layer snapshot is split into messages. */
|
|
|
|
|
const SNAPSHOT_CHUNK_CHARACTERS = 100000;
|
|
|
|
|
|
2026-10-11 13:17:22 +02:00
|
|
|
/** How many of one's own gestures can be undone, the latest first. */
|
|
|
|
|
export const UNDO_LIMIT = 100;
|
|
|
|
|
|
2026-10-06 15:58:02 +02:00
|
|
|
/** An image is sent in pieces of this many bytes. */
|
|
|
|
|
const IMAGE_CHUNK_BYTES = 96 * 1024;
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
/** While an image is dragged by hand, its new place is sent this often. */
|
|
|
|
|
const PAGE_SEND_INTERVAL_MS = 40;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The points of one's own stroke are sent at most this often: the first
|
|
|
|
|
* at once, the rest gathered (Andy, 2026-10-10, after the load test:
|
|
|
|
|
* one message per pointer event was 60 to 120 messages a second from
|
|
|
|
|
* each writer, and the relay's limit is the message rate). The delay
|
|
|
|
|
* others see is at most this, under what Andy felt as no lag. The
|
|
|
|
|
* writer's own screen is not delayed.
|
|
|
|
|
*/
|
|
|
|
|
export const POINT_SEND_INTERVAL_MS = 25;
|
|
|
|
|
|
2026-10-06 15:58:02 +02:00
|
|
|
export class Session {
|
|
|
|
|
/**
|
|
|
|
|
* @param {SessionOptions} options
|
|
|
|
|
* @param {SessionEvents} events
|
|
|
|
|
*/
|
|
|
|
|
constructor(options, events) {
|
|
|
|
|
this.roomId = options.roomId;
|
|
|
|
|
this.key = options.key;
|
|
|
|
|
this.identity = options.identity;
|
|
|
|
|
this.hostId = options.hostId;
|
|
|
|
|
this.name = cleanName(options.name) || 'Unnamed';
|
2026-10-10 19:01:20 +02:00
|
|
|
/** @type {ReturnType<typeof setTimeout> | null} A page descriptor waiting to be sent. */
|
|
|
|
|
this.pageSendTimer = null;
|
|
|
|
|
/** @type {{id: string, at: number, points: number[]} | null} Points of one's own stroke not yet sent. */
|
|
|
|
|
this.pendingPoints = null;
|
|
|
|
|
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
|
|
|
this.pointSendTimer = null;
|
|
|
|
|
this.lastPointSend = 0;
|
2026-10-11 13:17:22 +02:00
|
|
|
/**
|
|
|
|
|
* Our gestures on this page, the latest last (Andy, 2026-10-11: the
|
|
|
|
|
* latest gesture is undone, then the one before). Cleared with the
|
|
|
|
|
* page; a reloaded tab starts with none.
|
|
|
|
|
* @type {UndoEntry[]}
|
|
|
|
|
*/
|
|
|
|
|
this.undoStack = [];
|
|
|
|
|
/** @type {{strokes: Stroke[], texts: TextBlock[]} | null} The eraser's movement in progress. */
|
|
|
|
|
this.erasing = null;
|
|
|
|
|
/** @type {{id: string, owner: string, before: TextBlock | null} | null} A text block being edited. */
|
|
|
|
|
this.textEditing = null;
|
|
|
|
|
/** @type {{id: string, before: HideRect} | null} A Hide rectangle being moved. */
|
|
|
|
|
this.hideEditing = null;
|
2026-10-10 19:01:20 +02:00
|
|
|
this.events = events;
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
this.page = options.page ??
|
|
|
|
|
(this.isHost ? new Page(undefined, undefined, randomId(), 1) : new Page());
|
|
|
|
|
this.ownLayer = this.page.layer(this.identity.id, this.name);
|
|
|
|
|
this.page.recomputeStrokeBounds();
|
|
|
|
|
this.page.updateExtent();
|
|
|
|
|
|
|
|
|
|
/** @type {Map<string, Participant>} */
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @type {Map<number, string>} Relay peer number to participant id. */
|
|
|
|
|
this.peerIds = new Map();
|
|
|
|
|
/** @type {Set<number>} Peers the relay says are present. */
|
|
|
|
|
this.presentPeers = new Set();
|
|
|
|
|
/** @type {Map<number, number>} Last sequence number seen from a peer. */
|
|
|
|
|
this.lastSeq = new Map();
|
|
|
|
|
/** @type {Map<string, {name: string, strokes: Stroke[], texts: TextBlock[]}>} Snapshots being received. */
|
|
|
|
|
this.incoming = new Map();
|
|
|
|
|
/**
|
|
|
|
|
* The bytes of the page's images received so far, by each image's hash.
|
|
|
|
|
* @type {Map<string, {buffer: Uint8Array<ArrayBuffer>, received: number}>}
|
|
|
|
|
*/
|
|
|
|
|
this.imageTransfers = new Map();
|
|
|
|
|
/** @type {Map<string, Set<number>>} Peers waiting for an image from us, by its hash. */
|
|
|
|
|
this.imageRequests = new Map();
|
|
|
|
|
/**
|
|
|
|
|
* The peer whose announcement moved us to the present page. Its
|
|
|
|
|
* snapshots of any layer of that page are accepted: when a page is
|
|
|
|
|
* loaded from a notebook, that peer sends the writing kept with it.
|
|
|
|
|
*/
|
|
|
|
|
this.pageSourcePeer = 0;
|
|
|
|
|
|
|
|
|
|
this.myPeer = 0;
|
|
|
|
|
/** The peer asked for the layers of absent participants. */
|
|
|
|
|
this.statePeer = 0;
|
|
|
|
|
this.stateComplete = true;
|
|
|
|
|
/**
|
|
|
|
|
* True while our own layer may be completed from another participant's
|
|
|
|
|
* copy: on the first connection after the browser page was loaded, when
|
|
|
|
|
* the others may hold strokes of ours that we had not yet saved.
|
|
|
|
|
*/
|
|
|
|
|
this.recoverOwnLayer = false;
|
|
|
|
|
this.firstConnection = true;
|
|
|
|
|
|
|
|
|
|
/** Counts connections, so that work queued for an old one is dropped. */
|
|
|
|
|
this.epoch = 0;
|
|
|
|
|
this.seq = 0;
|
|
|
|
|
this.sendChain = Promise.resolve();
|
|
|
|
|
this.receiveChain = Promise.resolve();
|
|
|
|
|
|
|
|
|
|
/** @type {Stroke | null} The stroke we are drawing. */
|
|
|
|
|
this.currentStroke = null;
|
|
|
|
|
|
|
|
|
|
this.connection = new RelayConnection(options.relay, options.roomId, {
|
|
|
|
|
onWelcome: (peer, peers) => this.welcomed(peer, peers),
|
|
|
|
|
onJoin: (peer) => { this.presentPeers.add(peer); },
|
|
|
|
|
onLeave: (peer) => this.peerLeft(peer),
|
|
|
|
|
onMessage: (peer, body) => this.received(peer, body),
|
|
|
|
|
onStatus: (status, detail) => {
|
|
|
|
|
if (status !== 'connected') this.disconnected();
|
|
|
|
|
this.events.onStatus(status, detail);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
start() {
|
|
|
|
|
this.connection.connect();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @returns {Participant[]} Ourselves first, then the others by name. */
|
|
|
|
|
participantList() {
|
|
|
|
|
return [...this.participants.values()].sort((a, b) =>
|
|
|
|
|
Number(b.self) - Number(a.self) || a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @returns {string[]} Layer ids from bottom to top. */
|
|
|
|
|
layerOrder() {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
/**
|
|
|
|
|
* The host's "Fit" or "A4" goes to everyone. Only the host's does.
|
|
|
|
|
* @param {'fit' | 'sheet'} command
|
|
|
|
|
*/
|
|
|
|
|
sendViewCommand(command) {
|
|
|
|
|
if (!this.isHost) return;
|
|
|
|
|
this.send(0, { type: 'view', command });
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 15:58:02 +02:00
|
|
|
/**
|
|
|
|
|
* 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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The page's rectangle may have changed with its contents.
|
|
|
|
|
* @param {boolean} [byImages] Whether its images were what changed.
|
|
|
|
|
*/
|
|
|
|
|
extentChanged(byImages = false) {
|
|
|
|
|
if (this.page.updateExtent()) this.events.onPageResized(byImages);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Start a new blank page for everyone. The host may; so may a guest the
|
|
|
|
|
* host allows.
|
|
|
|
|
*/
|
|
|
|
|
newPage() {
|
|
|
|
|
if (!this.mayMakePages()) return;
|
|
|
|
|
const page = new Page(undefined, undefined, randomId(), this.page.number + 1);
|
|
|
|
|
page.updateExtent();
|
|
|
|
|
this.replacePage(page);
|
|
|
|
|
this.send(0, { type: 'page', page: page.descriptor() });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
* @param {string} prepared.hash
|
|
|
|
|
* @param {number} prepared.naturalWidth In page units.
|
|
|
|
|
* @param {number} prepared.naturalHeight
|
|
|
|
|
* @returns {boolean} False if the page has no room for it.
|
|
|
|
|
*/
|
2026-10-10 19:01:20 +02:00
|
|
|
addImage(prepared) {
|
2026-10-06 15:58:02 +02:00
|
|
|
const page = this.page;
|
|
|
|
|
if (!this.mayMakePages() || page.number === 0) return false;
|
2026-10-10 19:01:20 +02:00
|
|
|
const place = placeImage(page, prepared.naturalWidth, prepared.naturalHeight);
|
2026-10-06 15:58:02 +02:00
|
|
|
if (!page.hasRoomForImage(prepared.data.length, place.y + place.height)) return false;
|
|
|
|
|
/** @type {PageImage} */
|
|
|
|
|
const image = {
|
|
|
|
|
type: prepared.type, size: prepared.data.length, hash: prepared.hash, data: prepared.data,
|
2026-10-10 19:01:20 +02:00
|
|
|
x: place.x, y: place.y, width: place.width, height: place.height, by: this.identity.id,
|
2026-10-06 15:58:02 +02:00
|
|
|
};
|
|
|
|
|
page.images.push(image);
|
|
|
|
|
page.imagesRevision += 1;
|
2026-10-10 19:01:20 +02:00
|
|
|
page.baseLeft = place.sheet.left;
|
|
|
|
|
page.baseTop = place.sheet.top;
|
|
|
|
|
page.baseWidth = place.sheet.width;
|
|
|
|
|
page.baseHeight = place.sheet.height;
|
2026-10-06 15:58:02 +02:00
|
|
|
this.send(0, { type: 'page', page: page.descriptor() });
|
|
|
|
|
this.sendImage(0, image);
|
|
|
|
|
this.events.onImagesChanged();
|
|
|
|
|
this.extentChanged(true);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
// Hide rectangles: page objects like images, for those who may make pages.
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Hide part of the page for everyone.
|
|
|
|
|
* @param {import('./page_model.js').Rectangle} rect
|
|
|
|
|
* @returns {boolean} Whether a Hide rectangle was made.
|
|
|
|
|
*/
|
|
|
|
|
addHide(rect) {
|
|
|
|
|
const page = this.page;
|
|
|
|
|
if (!this.mayMakePages() || page.number === 0 || page.hides.length >= MAX_PAGE_HIDES) return false;
|
2026-10-11 13:17:22 +02:00
|
|
|
const hide = {
|
2026-10-10 19:01:20 +02:00
|
|
|
id: randomId(), x: Math.round(rect.left), y: Math.round(rect.top),
|
|
|
|
|
width: Math.round(rect.width), height: Math.round(rect.height),
|
2026-10-11 13:17:22 +02:00
|
|
|
};
|
|
|
|
|
page.hides.push(hide);
|
2026-10-10 19:01:20 +02:00
|
|
|
this.sheetAround(rect);
|
|
|
|
|
page.imagesRevision += 1;
|
|
|
|
|
this.sendPageNow();
|
|
|
|
|
this.events.onImagesChanged();
|
|
|
|
|
this.extentChanged(true);
|
2026-10-11 13:17:22 +02:00
|
|
|
this.pushUndo({ kind: 'hide', id: hide.id, before: null });
|
2026-10-10 19:01:20 +02:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:17:22 +02:00
|
|
|
/**
|
|
|
|
|
* A Hide rectangle is about to be moved or resized by hand: remember
|
|
|
|
|
* it as it is, so that the whole movement can be undone as one.
|
|
|
|
|
* @param {string} id
|
|
|
|
|
*/
|
|
|
|
|
beginHideEdit(id) {
|
|
|
|
|
const hide = this.page.hides.find((one) => one.id === id);
|
|
|
|
|
if (hide) this.hideEditing = { id, before: { ...hide } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The movement ended: record it if the rectangle changed. */
|
|
|
|
|
endHideEdit() {
|
|
|
|
|
const editing = this.hideEditing;
|
|
|
|
|
this.hideEditing = null;
|
|
|
|
|
if (!editing) return;
|
|
|
|
|
const hide = this.page.hides.find((one) => one.id === editing.id);
|
|
|
|
|
if (hide && !sameRect(hide, editing.before)) {
|
|
|
|
|
this.pushUndo({ kind: 'hide', id: editing.id, before: editing.before });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
/**
|
|
|
|
|
* Give a Hide rectangle a new rectangle: moved or resized by hand.
|
|
|
|
|
* @param {number} index
|
|
|
|
|
* @param {import('./page_model.js').Rectangle} rect
|
|
|
|
|
*/
|
|
|
|
|
placeHideByHand(index, rect) {
|
|
|
|
|
const page = this.page;
|
|
|
|
|
const hide = page.hides[index];
|
|
|
|
|
if (!this.mayMakePages() || !hide) return;
|
|
|
|
|
hide.x = Math.round(rect.left);
|
|
|
|
|
hide.y = Math.round(rect.top);
|
|
|
|
|
hide.width = Math.round(rect.width);
|
|
|
|
|
hide.height = Math.round(rect.height);
|
|
|
|
|
this.sheetAround(rect);
|
|
|
|
|
this.imagesChangedByHand();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove a Hide rectangle: the eraser tapped on it.
|
|
|
|
|
* @param {number} index
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
*/
|
|
|
|
|
removeHide(index) {
|
|
|
|
|
const page = this.page;
|
2026-10-11 13:17:22 +02:00
|
|
|
const hide = page.hides[index];
|
|
|
|
|
if (!this.mayMakePages() || !hide) return false;
|
2026-10-10 19:01:20 +02:00
|
|
|
page.hides.splice(index, 1);
|
|
|
|
|
page.imagesRevision += 1;
|
|
|
|
|
this.sendPageNow();
|
|
|
|
|
this.events.onImagesChanged();
|
|
|
|
|
this.extentChanged(true);
|
2026-10-11 13:17:22 +02:00
|
|
|
this.pushUndo({ kind: 'hide', id: hide.id, before: { ...hide } });
|
2026-10-10 19:01:20 +02:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Let the sheet hold a rectangle with the image margin around it.
|
|
|
|
|
* @param {import('./page_model.js').Rectangle} rect
|
|
|
|
|
*/
|
|
|
|
|
sheetAround(rect) {
|
|
|
|
|
const page = this.page;
|
|
|
|
|
const sheet = sheetHolding(page, rect);
|
|
|
|
|
page.baseLeft = sheet.left;
|
|
|
|
|
page.baseTop = sheet.top;
|
|
|
|
|
page.baseWidth = sheet.width;
|
|
|
|
|
page.baseHeight = sheet.height;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Bring an image to the front, among the images.
|
|
|
|
|
* @param {number} index
|
|
|
|
|
* @returns {number} Its index now.
|
|
|
|
|
*/
|
|
|
|
|
raiseImage(index) {
|
|
|
|
|
const page = this.page;
|
|
|
|
|
if (!this.mayMakePages() || index < 0 || index >= page.images.length) return index;
|
|
|
|
|
if (index !== page.images.length - 1) {
|
|
|
|
|
const [image] = page.images.splice(index, 1);
|
|
|
|
|
page.images.push(image);
|
|
|
|
|
this.imagesChangedByHand();
|
|
|
|
|
}
|
|
|
|
|
return page.images.length - 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 15:58:02 +02:00
|
|
|
/**
|
2026-10-10 19:01:20 +02:00
|
|
|
* Give an image a new rectangle: moved or scaled by hand (Andy's
|
|
|
|
|
* teacher, 2026-10-08). Writing stays where it is. The image comes
|
|
|
|
|
* to the front, the sheet grows to hold it, and everyone sees it move.
|
|
|
|
|
* @param {number} index
|
|
|
|
|
* @param {import('./page_model.js').Rectangle} rect
|
|
|
|
|
* @returns {number} The image's index now.
|
2026-10-06 15:58:02 +02:00
|
|
|
*/
|
2026-10-10 19:01:20 +02:00
|
|
|
placeImageByHand(index, rect) {
|
|
|
|
|
const page = this.page;
|
|
|
|
|
if (!this.mayMakePages() || index < 0 || index >= page.images.length) return index;
|
|
|
|
|
index = this.raiseImage(index);
|
|
|
|
|
const image = page.images[index];
|
|
|
|
|
image.x = Math.round(rect.left);
|
|
|
|
|
image.y = Math.round(rect.top);
|
|
|
|
|
image.width = Math.round(rect.width);
|
|
|
|
|
image.height = Math.round(rect.height);
|
|
|
|
|
this.sheetAround({ left: image.x, top: image.y, width: image.width, height: image.height });
|
|
|
|
|
this.imagesChangedByHand();
|
|
|
|
|
return index;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** An image was raised, moved or scaled: tell everyone, soon. */
|
|
|
|
|
imagesChangedByHand() {
|
2026-10-06 15:58:02 +02:00
|
|
|
const page = this.page;
|
|
|
|
|
page.imagesRevision += 1;
|
2026-10-10 19:01:20 +02:00
|
|
|
this.sendPageSoon();
|
|
|
|
|
this.events.onImagesChanged();
|
|
|
|
|
this.extentChanged(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Send the page descriptor, at most every few tens of milliseconds
|
|
|
|
|
* while an image is being dragged; sendPageNow() at the end.
|
|
|
|
|
*/
|
|
|
|
|
sendPageSoon() {
|
|
|
|
|
if (this.pageSendTimer !== null) return;
|
|
|
|
|
this.pageSendTimer = setTimeout(() => {
|
|
|
|
|
this.pageSendTimer = null;
|
|
|
|
|
this.send(0, { type: 'page', page: this.page.descriptor() });
|
|
|
|
|
}, PAGE_SEND_INTERVAL_MS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sendPageNow() {
|
|
|
|
|
if (this.pageSendTimer !== null) {
|
|
|
|
|
clearTimeout(this.pageSendTimer);
|
|
|
|
|
this.pageSendTimer = null;
|
|
|
|
|
}
|
|
|
|
|
this.send(0, { type: 'page', page: this.page.descriptor() });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-11 19:43:17 +02:00
|
|
|
* Make the sheet fill the window, for more room to write (Andy,
|
|
|
|
|
* 2026-10-08 as "Widen", which only grew the width; both ways since
|
|
|
|
|
* 2026-10-11, since there is no reason width alone suits anyone).
|
|
|
|
|
* Everyone's page grows; it is a change to the page, so it needs the
|
|
|
|
|
* right to make pages, and it ends the chance to take back an image.
|
|
|
|
|
* @param {number} width The page's new width, if wider than now.
|
|
|
|
|
* @param {number} height The page's new height, if taller than now.
|
|
|
|
|
* @returns {boolean} Whether the page grew.
|
2026-10-10 19:01:20 +02:00
|
|
|
*/
|
2026-10-11 19:43:17 +02:00
|
|
|
expandPage(width, height) {
|
2026-10-10 19:01:20 +02:00
|
|
|
const page = this.page;
|
2026-10-11 19:43:17 +02:00
|
|
|
if (!this.mayMakePages() || 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,
|
|
|
|
|
// 2026-10-08): what is on the page stays where it is seen. The same
|
|
|
|
|
// whole number of units on each side, since the page is centred on
|
|
|
|
|
// the screen: an odd growth split 1199 and 1200 moved the centre half
|
|
|
|
|
// a unit, a pixel's fraction that showed as a one-pixel move on Olion
|
|
|
|
|
// (2026-10-09). A dimension already filling the window does not grow.
|
|
|
|
|
const addLeft = Math.floor((Math.round(width) - page.width) / 2);
|
|
|
|
|
const addTop = Math.floor((Math.round(height) - page.height) / 2);
|
|
|
|
|
if (addLeft <= 0 && addTop <= 0) return false;
|
|
|
|
|
if (addLeft > 0) {
|
|
|
|
|
page.baseLeft = page.left - addLeft;
|
|
|
|
|
page.baseWidth = page.width + 2 * addLeft;
|
|
|
|
|
}
|
|
|
|
|
if (addTop > 0) {
|
|
|
|
|
page.baseTop = page.top - addTop;
|
|
|
|
|
page.baseHeight = page.height + 2 * addTop;
|
|
|
|
|
}
|
2026-10-10 19:01:20 +02:00
|
|
|
page.imagesRevision += 1;
|
2026-10-06 15:58:02 +02:00
|
|
|
this.send(0, { type: 'page', page: page.descriptor() });
|
2026-10-10 19:01:20 +02:00
|
|
|
this.extentChanged(true);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Make the sheet the smallest that holds everything on the page with
|
|
|
|
|
* the margin around it ("Shrink", Andy, 2026-10-08). A change to the
|
2026-10-11 19:43:17 +02:00
|
|
|
* page like "Expand".
|
2026-10-10 19:01:20 +02:00
|
|
|
* @returns {boolean} Whether the sheet changed.
|
|
|
|
|
*/
|
|
|
|
|
shrinkPage() {
|
|
|
|
|
const page = this.page;
|
|
|
|
|
if (!this.mayMakePages() || page.number === 0) return false;
|
|
|
|
|
const sheet = page.contentSheet();
|
|
|
|
|
if (!sheet || (sheet.left === page.baseLeft && sheet.top === page.baseTop &&
|
|
|
|
|
sheet.width === page.baseWidth && sheet.height === page.baseHeight)) return false;
|
|
|
|
|
page.baseLeft = sheet.left;
|
|
|
|
|
page.baseTop = sheet.top;
|
|
|
|
|
page.baseWidth = sheet.width;
|
|
|
|
|
page.baseHeight = sheet.height;
|
|
|
|
|
page.imagesRevision += 1;
|
|
|
|
|
this.send(0, { type: 'page', page: page.descriptor() });
|
|
|
|
|
this.extentChanged(true);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove an image: the eraser tapped on it (Andy, 2026-10-08: "the user
|
|
|
|
|
* has decided that the image should be deleted"). Allowed to whoever
|
|
|
|
|
* may make pages, and to whoever added the image. Writing on it stays
|
|
|
|
|
* where it is; the sheet keeps its size ("Shrink" trims it).
|
|
|
|
|
* @param {number} index
|
|
|
|
|
* @returns {boolean} Whether it was removed.
|
|
|
|
|
*/
|
|
|
|
|
removeImage(index) {
|
|
|
|
|
const page = this.page;
|
|
|
|
|
const image = page.images[index];
|
|
|
|
|
if (!image || !(this.mayMakePages() || image.by === this.identity.id)) return false;
|
|
|
|
|
page.images.splice(index, 1);
|
|
|
|
|
page.imagesRevision += 1;
|
|
|
|
|
this.imageTransfers.delete(image.hash);
|
|
|
|
|
this.sendPageNow();
|
2026-10-06 15:58:02 +02:00
|
|
|
this.events.onImagesChanged();
|
|
|
|
|
this.extentChanged(true);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Start a new page for everyone from a page kept in a notebook, with
|
|
|
|
|
* everything that was on it. Its layers keep their owners: a layer of
|
|
|
|
|
* someone present is theirs to erase again.
|
|
|
|
|
* @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);
|
2026-10-10 19:01:20 +02:00
|
|
|
page.baseLeft = kept.baseLeft;
|
|
|
|
|
page.baseTop = kept.baseTop;
|
2026-10-06 15:58:02 +02:00
|
|
|
page.images = kept.images.map((image) => ({ ...image }));
|
2026-10-10 19:01:20 +02:00
|
|
|
page.hides = kept.hides.map((hide) => ({ ...hide }));
|
2026-10-06 15:58:02 +02:00
|
|
|
page.imagesRevision = kept.imagesRevision;
|
|
|
|
|
for (const [id, layer] of kept.layers) {
|
|
|
|
|
const copy = page.layer(id, layer.name);
|
|
|
|
|
for (const stroke of layer.strokes.values()) {
|
|
|
|
|
copy.strokes.set(stroke.id, { ...stroke, points: stroke.points.slice(), done: true });
|
|
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
page.recomputeStrokeBounds();
|
|
|
|
|
page.updateExtent();
|
|
|
|
|
this.replacePage(page);
|
|
|
|
|
this.send(0, { type: 'page', page: page.descriptor() });
|
|
|
|
|
const sent = new Set();
|
|
|
|
|
for (const image of page.images) {
|
|
|
|
|
if (!sent.has(image.hash)) this.sendImage(0, image);
|
|
|
|
|
sent.add(image.hash);
|
|
|
|
|
}
|
|
|
|
|
for (const layer of page.layers.values()) {
|
|
|
|
|
if (!layer.isEmpty()) this.sendLayer(0, layer);
|
|
|
|
|
}
|
|
|
|
|
this.events.onParticipantsChanged();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Text on our own layer.
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Make or change a text block of ours. The whole block is sent each
|
|
|
|
|
* time, so that it appears on the other screens as it is typed.
|
|
|
|
|
* @param {TextBlock} block
|
|
|
|
|
*/
|
|
|
|
|
setText(block) {
|
|
|
|
|
if (this.page.number === 0) return;
|
|
|
|
|
this.ownLayer.texts.set(block.id, block);
|
|
|
|
|
this.send(0, { type: 'text_set', page: this.page.id, text: block });
|
|
|
|
|
this.page.recomputeStrokeBounds();
|
|
|
|
|
this.events.onTextChanged(this.identity.id, block);
|
|
|
|
|
this.extentChanged();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:17:22 +02:00
|
|
|
/**
|
|
|
|
|
* A text block of ours is about to be edited, as a whole gesture: typed
|
|
|
|
|
* into until the editing ends, or moved by hand until the pen lifts.
|
|
|
|
|
* The block as it is now is what undo restores; null for a new block.
|
|
|
|
|
* Only one gesture at a time: a hold on the block being typed in is part
|
|
|
|
|
* of the typing (Andy, 2026-10-09: a hold on the field moves the block).
|
|
|
|
|
* @param {string} id
|
|
|
|
|
* @param {string} owner Who began it: 'editor' or 'hold'.
|
|
|
|
|
* @returns {boolean} Whether this call began the gesture.
|
|
|
|
|
*/
|
|
|
|
|
beginTextEdit(id, owner) {
|
|
|
|
|
if (this.textEditing) return false;
|
|
|
|
|
const block = this.ownLayer.texts.get(id);
|
|
|
|
|
this.textEditing = { id, owner, before: block ? copyText(block) : null };
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The gesture ended: record it if the block changed.
|
|
|
|
|
* @param {string} id
|
|
|
|
|
* @param {string} owner
|
|
|
|
|
*/
|
|
|
|
|
endTextEdit(id, owner) {
|
|
|
|
|
const editing = this.textEditing;
|
|
|
|
|
if (!editing || editing.id !== id || editing.owner !== owner) return;
|
|
|
|
|
this.textEditing = null;
|
|
|
|
|
const now = this.ownLayer.texts.get(id) ?? null;
|
|
|
|
|
if (JSON.stringify(now) !== JSON.stringify(editing.before)) {
|
|
|
|
|
this.pushUndo({ kind: 'text', id, before: editing.before });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 15:58:02 +02:00
|
|
|
/** @param {string[]} ids Text blocks of our own layer. */
|
|
|
|
|
removeTexts(ids) {
|
2026-10-11 13:17:22 +02:00
|
|
|
const removedBlocks = ids.map((id) => this.ownLayer.texts.get(id)).filter((block) => block !== undefined);
|
2026-10-06 15:58:02 +02:00
|
|
|
const removed = ids.filter((id) => this.ownLayer.texts.delete(id));
|
|
|
|
|
if (removed.length === 0) return;
|
2026-10-11 13:17:22 +02:00
|
|
|
if (this.erasing) this.erasing.texts.push(...removedBlocks);
|
2026-10-06 15:58:02 +02:00
|
|
|
this.send(0, { type: 'text_remove', page: this.page.id, ids: removed });
|
|
|
|
|
this.page.recomputeStrokeBounds();
|
|
|
|
|
this.events.onLayerReplaced(this.identity.id);
|
|
|
|
|
this.extentChanged();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {number} destination
|
|
|
|
|
* @param {PageImage} image An image of the present page whose bytes we hold.
|
|
|
|
|
*/
|
|
|
|
|
sendImage(destination, image) {
|
|
|
|
|
const data = image.data;
|
|
|
|
|
if (!data) return;
|
|
|
|
|
for (let at = 0; at < data.length; at += IMAGE_CHUNK_BYTES) {
|
|
|
|
|
this.send(destination, {
|
|
|
|
|
type: 'image', page: this.page.id, hash: image.hash, at,
|
|
|
|
|
data: toBase64(data.subarray(at, at + IMAGE_CHUNK_BYTES)),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Ask a peer that is on our page for the images whose bytes we lack.
|
|
|
|
|
* @param {number} peer
|
|
|
|
|
*/
|
|
|
|
|
requestImages(peer) {
|
|
|
|
|
const asked = new Set();
|
|
|
|
|
for (const image of this.page.images) {
|
|
|
|
|
if (image.data || asked.has(image.hash)) continue;
|
|
|
|
|
asked.add(image.hash);
|
|
|
|
|
this.send(peer, { type: 'image_request', page: this.page.id, hash: image.hash });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A piece of one of the page's images arrived. Pieces are taken in
|
|
|
|
|
* sequence; the finished image must match its hash in the page
|
|
|
|
|
* descriptor, so it does not matter which participant sent it.
|
|
|
|
|
* @param {any} message
|
|
|
|
|
*/
|
|
|
|
|
async receivedImagePiece(message) {
|
|
|
|
|
const page = this.page;
|
|
|
|
|
if (message.page !== page.id || typeof message.data !== 'string') return;
|
|
|
|
|
const image = page.images.find((one) => one.hash === message.hash && !one.data);
|
|
|
|
|
if (!image) return;
|
|
|
|
|
let transfer = this.imageTransfers.get(image.hash);
|
|
|
|
|
if (!transfer) {
|
|
|
|
|
transfer = { buffer: new Uint8Array(image.size), received: 0 };
|
|
|
|
|
this.imageTransfers.set(image.hash, transfer);
|
|
|
|
|
}
|
|
|
|
|
if (message.at !== transfer.received) return;
|
|
|
|
|
const piece = fromBase64(message.data);
|
|
|
|
|
if (transfer.received + piece.length > image.size) return;
|
|
|
|
|
transfer.buffer.set(piece, transfer.received);
|
|
|
|
|
transfer.received += piece.length;
|
|
|
|
|
if (transfer.received < image.size) return;
|
|
|
|
|
|
|
|
|
|
this.imageTransfers.delete(image.hash);
|
|
|
|
|
const data = transfer.buffer;
|
|
|
|
|
const matches = await hashOf(data) === image.hash;
|
|
|
|
|
if (page !== this.page || !matches) return; // A damaged or false image: wait for another copy.
|
|
|
|
|
let filled = false;
|
|
|
|
|
for (const one of page.images) {
|
|
|
|
|
if (one.hash !== image.hash || one.data) continue;
|
|
|
|
|
one.data = data;
|
|
|
|
|
filled = true;
|
|
|
|
|
}
|
|
|
|
|
if (!filled) return;
|
|
|
|
|
this.events.onImagesChanged();
|
|
|
|
|
for (const peer of this.imageRequests.get(image.hash) ?? []) this.sendImage(peer, image);
|
|
|
|
|
this.imageRequests.delete(image.hash);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
* images.
|
|
|
|
|
* @param {unknown} received A page descriptor.
|
|
|
|
|
* @param {boolean} fromHost
|
|
|
|
|
* @param {number} peer Who told us.
|
|
|
|
|
*/
|
|
|
|
|
considerPage(received, fromHost, peer) {
|
|
|
|
|
const descriptor = cleanPageDescriptor(received);
|
|
|
|
|
if (!descriptor) return;
|
|
|
|
|
if (descriptor.id === this.page.id) {
|
|
|
|
|
this.considerImages(descriptor);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const newer = descriptor.number > this.page.number ||
|
|
|
|
|
(descriptor.number === this.page.number && fromHost);
|
|
|
|
|
if (!newer) return;
|
|
|
|
|
this.replacePage(Page.from(descriptor));
|
|
|
|
|
this.pageSourcePeer = peer;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 15:58:02 +02:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
* lists of the same revision (two participants changed the images at the
|
|
|
|
|
* same moment) everyone takes the same one, and the change that lost
|
|
|
|
|
* must be made again.
|
|
|
|
|
* @param {import('./page_model.js').PageDescriptor} descriptor
|
|
|
|
|
*/
|
|
|
|
|
considerImages(descriptor) {
|
|
|
|
|
const page = this.page;
|
2026-10-10 19:01:20 +02:00
|
|
|
/** @param {import('./page_model.js').PageDescriptor} d */
|
|
|
|
|
const listed = (d) => d.images.map((image) =>
|
|
|
|
|
`${image.hash} ${image.x} ${image.y} ${image.width} ${image.height}`).join(',') +
|
|
|
|
|
'|' + d.hides.map((hide) => `${hide.id} ${hide.x} ${hide.y} ${hide.width} ${hide.height}`).join(',') +
|
|
|
|
|
`|${d.left} ${d.top} ${d.width} ${d.height}`;
|
|
|
|
|
const ours = listed(page.descriptor());
|
|
|
|
|
const theirs = listed(descriptor);
|
2026-10-06 15:58:02 +02:00
|
|
|
if (descriptor.revision < page.imagesRevision ||
|
|
|
|
|
(descriptor.revision === page.imagesRevision && theirs <= ours)) return;
|
|
|
|
|
const held = new Map();
|
|
|
|
|
for (const image of page.images) {
|
|
|
|
|
if (image.data) held.set(image.hash, image.data);
|
|
|
|
|
}
|
|
|
|
|
page.images = descriptor.images.map((image) => ({ ...image, data: held.get(image.hash) ?? null }));
|
2026-10-10 19:01:20 +02:00
|
|
|
page.hides = descriptor.hides.map((hide) => ({ ...hide }));
|
2026-10-06 15:58:02 +02:00
|
|
|
page.imagesRevision = descriptor.revision;
|
2026-10-10 19:01:20 +02:00
|
|
|
page.baseLeft = descriptor.left;
|
|
|
|
|
page.baseTop = descriptor.top;
|
2026-10-06 15:58:02 +02:00
|
|
|
page.baseWidth = descriptor.width;
|
|
|
|
|
page.baseHeight = descriptor.height;
|
|
|
|
|
// Bytes on their way for an image that is no longer wanted are dropped,
|
|
|
|
|
// so that the same image, added again, is received from its beginning.
|
|
|
|
|
for (const hash of this.imageTransfers.keys()) {
|
|
|
|
|
if (!page.images.some((image) => image.hash === hash && !image.data)) this.imageTransfers.delete(hash);
|
|
|
|
|
}
|
|
|
|
|
this.events.onImagesChanged();
|
|
|
|
|
this.extentChanged(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @param {Page} page */
|
|
|
|
|
replacePage(page) {
|
|
|
|
|
this.endStroke();
|
|
|
|
|
const old = this.page;
|
|
|
|
|
this.page = page;
|
2026-10-11 13:17:22 +02:00
|
|
|
this.undoStack = [];
|
|
|
|
|
this.erasing = null;
|
|
|
|
|
this.textEditing = null;
|
|
|
|
|
this.hideEditing = null;
|
|
|
|
|
this.events.onUndoChanged?.();
|
2026-10-06 15:58:02 +02:00
|
|
|
this.ownLayer = page.layer(this.identity.id, this.name);
|
|
|
|
|
for (const participant of this.participants.values()) {
|
|
|
|
|
if (participant.peer !== null) page.layer(participant.id, participant.name);
|
|
|
|
|
}
|
|
|
|
|
this.incoming.clear();
|
|
|
|
|
this.imageTransfers.clear();
|
|
|
|
|
this.imageRequests.clear();
|
|
|
|
|
this.pageSourcePeer = 0;
|
|
|
|
|
this.events.onPageReplaced(old, page);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Drawing on our own layer.
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {string} color
|
|
|
|
|
* @param {number} width
|
|
|
|
|
* @returns {Stroke}
|
|
|
|
|
*/
|
|
|
|
|
beginStroke(color, width) {
|
|
|
|
|
if (this.currentStroke) this.endStroke();
|
|
|
|
|
const stroke = { id: randomId(), color, width, points: [], done: false };
|
|
|
|
|
this.ownLayer.strokes.set(stroke.id, stroke);
|
|
|
|
|
this.currentStroke = stroke;
|
|
|
|
|
this.send(0, {
|
|
|
|
|
type: 'stroke_begin', page: this.page.id, stroke: { id: stroke.id, color, width },
|
|
|
|
|
});
|
|
|
|
|
return stroke;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @param {number[]} points x, y, pressure for each point. */
|
|
|
|
|
addPoints(points) {
|
|
|
|
|
let stroke = this.currentStroke;
|
|
|
|
|
if (!stroke || points.length === 0) return;
|
|
|
|
|
if (pointCount(stroke) + points.length / POINT_SIZE > MAX_STROKE_POINTS) {
|
|
|
|
|
const last = stroke.points.slice(-POINT_SIZE);
|
|
|
|
|
this.endStroke();
|
|
|
|
|
stroke = this.beginStroke(stroke.color, stroke.width);
|
|
|
|
|
points = last.concat(points);
|
|
|
|
|
}
|
|
|
|
|
const at = pointCount(stroke);
|
|
|
|
|
appendPoints(stroke, at, points);
|
|
|
|
|
this.page.includeStroke(stroke, at);
|
2026-10-10 19:01:20 +02:00
|
|
|
this.queuePoints(stroke.id, at, points);
|
2026-10-06 15:58:02 +02:00
|
|
|
this.events.onStrokeProgress(this.identity.id, stroke);
|
|
|
|
|
this.extentChanged();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 19:01:20 +02:00
|
|
|
/**
|
|
|
|
|
* Points to send: at once if none went in the last interval, else with
|
|
|
|
|
* the next sending, which the timer makes when the interval is up.
|
|
|
|
|
* @param {string} id
|
|
|
|
|
* @param {number} at
|
|
|
|
|
* @param {number[]} points
|
|
|
|
|
*/
|
|
|
|
|
queuePoints(id, at, points) {
|
|
|
|
|
if (this.pendingPoints && this.pendingPoints.id === id) {
|
|
|
|
|
this.pendingPoints.points.push(...points);
|
|
|
|
|
} else {
|
|
|
|
|
this.sendPendingPoints();
|
|
|
|
|
this.pendingPoints = { id, at, points: points.slice() };
|
|
|
|
|
}
|
|
|
|
|
const since = performance.now() - this.lastPointSend;
|
|
|
|
|
if (since >= POINT_SEND_INTERVAL_MS) {
|
|
|
|
|
this.sendPendingPoints();
|
|
|
|
|
} else if (this.pointSendTimer === null) {
|
|
|
|
|
this.pointSendTimer = setTimeout(() => {
|
|
|
|
|
this.pointSendTimer = null;
|
|
|
|
|
this.sendPendingPoints();
|
|
|
|
|
}, POINT_SEND_INTERVAL_MS - since);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sendPendingPoints() {
|
|
|
|
|
if (this.pointSendTimer !== null) {
|
|
|
|
|
clearTimeout(this.pointSendTimer);
|
|
|
|
|
this.pointSendTimer = null;
|
|
|
|
|
}
|
|
|
|
|
const pending = this.pendingPoints;
|
|
|
|
|
if (!pending) return;
|
|
|
|
|
this.pendingPoints = null;
|
|
|
|
|
this.lastPointSend = performance.now();
|
|
|
|
|
this.send(0, { type: 'stroke_points', id: pending.id, at: pending.at, points: pending.points });
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:17:22 +02:00
|
|
|
/**
|
|
|
|
|
* @param {boolean} [record] Whether the stroke is a gesture to undo;
|
|
|
|
|
* not when it is about to be dropped, as a hold's beginning is.
|
|
|
|
|
*/
|
|
|
|
|
endStroke(record = true) {
|
2026-10-06 15:58:02 +02:00
|
|
|
const stroke = this.currentStroke;
|
|
|
|
|
if (!stroke) return;
|
|
|
|
|
this.currentStroke = null;
|
|
|
|
|
stroke.done = true;
|
2026-10-10 19:01:20 +02:00
|
|
|
this.sendPendingPoints();
|
2026-10-06 15:58:02 +02:00
|
|
|
this.send(0, { type: 'stroke_end', id: stroke.id });
|
|
|
|
|
this.events.onStrokeProgress(this.identity.id, stroke);
|
2026-10-11 13:17:22 +02:00
|
|
|
if (record && pointCount(stroke) > 0) this.pushUndo({ kind: 'stroke', id: stroke.id });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The eraser went down: what it removes until it lifts is one gesture.
|
|
|
|
|
* Harmless when already begun.
|
|
|
|
|
*/
|
|
|
|
|
beginErasing() {
|
|
|
|
|
if (!this.erasing) this.erasing = { strokes: [], texts: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The eraser lifted: the gesture is recorded if it removed anything.
|
|
|
|
|
* @returns {{strokes: number, texts: number}} What it removed.
|
|
|
|
|
*/
|
|
|
|
|
endErasing() {
|
|
|
|
|
const erasing = this.erasing;
|
|
|
|
|
this.erasing = null;
|
|
|
|
|
if (!erasing) return { strokes: 0, texts: 0 };
|
|
|
|
|
if (erasing.strokes.length || erasing.texts.length) {
|
|
|
|
|
this.pushUndo({ kind: 'erase', strokes: erasing.strokes, texts: erasing.texts });
|
|
|
|
|
}
|
|
|
|
|
return { strokes: erasing.strokes.length, texts: erasing.texts.length };
|
2026-10-06 15:58:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @param {string[]} ids Strokes of our own layer. */
|
|
|
|
|
erase(ids) {
|
2026-10-11 13:17:22 +02:00
|
|
|
const erasedStrokes = ids.map((id) => this.ownLayer.strokes.get(id)).filter((stroke) => stroke !== undefined);
|
2026-10-06 15:58:02 +02:00
|
|
|
const erased = ids.filter((id) => this.ownLayer.strokes.delete(id));
|
|
|
|
|
if (erased.length === 0) return;
|
2026-10-11 13:17:22 +02:00
|
|
|
if (this.erasing) this.erasing.strokes.push(...erasedStrokes);
|
2026-10-06 15:58:02 +02:00
|
|
|
this.send(0, { type: 'erase', page: this.page.id, ids: erased });
|
|
|
|
|
this.page.recomputeStrokeBounds();
|
|
|
|
|
this.events.onLayerReplaced(this.identity.id);
|
|
|
|
|
this.extentChanged();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:17:22 +02:00
|
|
|
/**
|
|
|
|
|
* Put erased strokes of ours back, whole, for everyone.
|
|
|
|
|
* @param {Stroke[]} strokes
|
|
|
|
|
*/
|
|
|
|
|
restoreStrokes(strokes) {
|
|
|
|
|
for (const stroke of strokes) this.ownLayer.strokes.set(stroke.id, stroke);
|
|
|
|
|
/** @type {Stroke[]} */
|
|
|
|
|
let chunk = [];
|
|
|
|
|
let size = 0;
|
|
|
|
|
for (const stroke of strokes) {
|
|
|
|
|
chunk.push(stroke);
|
|
|
|
|
size += 100 + stroke.points.length * 8;
|
|
|
|
|
if (size >= SNAPSHOT_CHUNK_CHARACTERS) {
|
|
|
|
|
this.send(0, { type: 'stroke_restore', page: this.page.id, strokes: chunk });
|
|
|
|
|
chunk = [];
|
|
|
|
|
size = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (chunk.length) this.send(0, { type: 'stroke_restore', page: this.page.id, strokes: chunk });
|
|
|
|
|
this.page.recomputeStrokeBounds();
|
|
|
|
|
this.events.onLayerReplaced(this.identity.id);
|
|
|
|
|
this.extentChanged();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Undo: one's own gestures, the latest first (Andy, 2026-10-11).
|
|
|
|
|
|
|
|
|
|
/** @param {UndoEntry} entry */
|
|
|
|
|
pushUndo(entry) {
|
|
|
|
|
this.undoStack.push(entry);
|
|
|
|
|
if (this.undoStack.length > UNDO_LIMIT) this.undoStack.shift();
|
|
|
|
|
this.events.onUndoChanged?.();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
canUndo() {
|
|
|
|
|
return this.undoStack.length > 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Take back our latest gesture.
|
|
|
|
|
* @returns {string | null} What was undone, for the log; null if nothing.
|
|
|
|
|
*/
|
|
|
|
|
undo() {
|
|
|
|
|
const entry = this.undoStack.pop();
|
|
|
|
|
if (!entry) return null;
|
|
|
|
|
this.events.onUndoChanged?.();
|
|
|
|
|
switch (entry.kind) {
|
|
|
|
|
case 'stroke':
|
|
|
|
|
this.erase([entry.id]);
|
|
|
|
|
return 'a stroke';
|
|
|
|
|
case 'erase':
|
|
|
|
|
this.restoreStrokes(entry.strokes);
|
|
|
|
|
for (const block of entry.texts) this.setText(block);
|
|
|
|
|
return `an erasure of ${entry.strokes.length} strokes and ${entry.texts.length} texts`;
|
|
|
|
|
case 'text':
|
|
|
|
|
if (entry.before) this.setText(entry.before);
|
|
|
|
|
else this.removeTexts([entry.id]);
|
|
|
|
|
return entry.before ? 'a change of text' : 'a text block';
|
|
|
|
|
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;
|
|
|
|
|
page.hides.splice(index, 1);
|
|
|
|
|
} else if (index < 0) {
|
|
|
|
|
page.hides.push({ ...entry.before });
|
|
|
|
|
this.sheetAround(rectOf(entry.before));
|
|
|
|
|
} else {
|
|
|
|
|
Object.assign(page.hides[index], entry.before);
|
|
|
|
|
this.sheetAround(rectOf(entry.before));
|
|
|
|
|
}
|
|
|
|
|
page.imagesRevision += 1;
|
|
|
|
|
this.sendPageNow();
|
|
|
|
|
this.events.onImagesChanged();
|
|
|
|
|
this.extentChanged(true);
|
|
|
|
|
return entry.before === null ? 'a Hide rectangle' : index < 0 ? 'the removal of a Hide rectangle' : 'a move of a Hide rectangle';
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 15:58:02 +02:00
|
|
|
// Sending.
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Queue a message. Messages are encrypted and sent in the sequence in
|
|
|
|
|
* which they were queued.
|
|
|
|
|
* @param {number} destination A peer number, or 0 for everyone else.
|
|
|
|
|
* @param {object | (() => Promise<object>)} message
|
|
|
|
|
*/
|
|
|
|
|
send(destination, message) {
|
|
|
|
|
if (!this.connection.connected) return;
|
|
|
|
|
const epoch = this.epoch;
|
|
|
|
|
this.sendChain = this.sendChain.then(async () => {
|
|
|
|
|
if (epoch !== this.epoch) return;
|
|
|
|
|
const body = typeof message === 'function' ? await message() : message;
|
|
|
|
|
const bytes = await encrypt(this.key, { ...body, seq: this.seq++ });
|
|
|
|
|
if (epoch === this.epoch) this.connection.send(destination, bytes);
|
|
|
|
|
}).catch((error) => console.error('Kokkoja: send failed', error));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Send a whole layer. The receiver replaces its copy of the layer.
|
|
|
|
|
* @param {number} destination
|
|
|
|
|
* @param {Layer} layer
|
|
|
|
|
*/
|
|
|
|
|
sendLayer(destination, layer) {
|
|
|
|
|
this.send(destination, {
|
|
|
|
|
type: 'layer_begin', page: this.page.id, layer: layer.id, name: layer.name,
|
|
|
|
|
});
|
|
|
|
|
/** @type {Stroke[]} */
|
|
|
|
|
let chunk = [];
|
|
|
|
|
let size = 0;
|
|
|
|
|
for (const stroke of layer.strokes.values()) {
|
|
|
|
|
chunk.push(stroke);
|
|
|
|
|
size += 100 + stroke.points.length * 8;
|
|
|
|
|
if (size >= SNAPSHOT_CHUNK_CHARACTERS) {
|
|
|
|
|
this.send(destination, { type: 'layer_strokes', layer: layer.id, strokes: chunk });
|
|
|
|
|
chunk = [];
|
|
|
|
|
size = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (chunk.length) this.send(destination, { type: 'layer_strokes', layer: layer.id, strokes: chunk });
|
|
|
|
|
if (layer.texts.size) {
|
|
|
|
|
this.send(destination, { type: 'layer_texts', layer: layer.id, texts: [...layer.texts.values()] });
|
|
|
|
|
}
|
|
|
|
|
this.send(destination, { type: 'layer_end', layer: layer.id });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Announce ourselves, with proof that this connection is ours.
|
|
|
|
|
* @param {number} destination
|
|
|
|
|
* @param {boolean} reply True when answering another participant's hello.
|
|
|
|
|
*/
|
|
|
|
|
sendHello(destination, reply) {
|
|
|
|
|
const peer = this.myPeer;
|
|
|
|
|
this.send(destination, async () => ({
|
|
|
|
|
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,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Connection events.
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {number} peer
|
|
|
|
|
* @param {number[]} peers
|
|
|
|
|
*/
|
|
|
|
|
welcomed(peer, peers) {
|
|
|
|
|
this.epoch += 1;
|
|
|
|
|
this.myPeer = peer;
|
|
|
|
|
this.presentPeers = new Set(peers);
|
|
|
|
|
const self = this.participants.get(this.identity.id);
|
|
|
|
|
if (self) self.peer = peer;
|
|
|
|
|
this.recoverOwnLayer = this.firstConnection && peers.length > 0;
|
|
|
|
|
this.firstConnection = false;
|
|
|
|
|
|
|
|
|
|
this.sendHello(0, false);
|
|
|
|
|
// While recovering, our layer is sent when the recovery is complete.
|
|
|
|
|
if (!this.recoverOwnLayer && !this.ownLayer.isEmpty()) this.sendLayer(0, this.ownLayer);
|
|
|
|
|
this.requestState();
|
|
|
|
|
this.events.onParticipantsChanged();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Ask the longest-connected peer for the layers of absent participants. */
|
|
|
|
|
requestState() {
|
|
|
|
|
this.stateComplete = this.presentPeers.size === 0;
|
|
|
|
|
if (this.stateComplete) this.recoverOwnLayer = false;
|
|
|
|
|
this.statePeer = this.stateComplete ? 0 : Math.min(...this.presentPeers);
|
|
|
|
|
if (this.statePeer) this.send(this.statePeer, { type: 'state_request' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
disconnected() {
|
|
|
|
|
this.epoch += 1;
|
|
|
|
|
this.peerIds.clear();
|
|
|
|
|
this.presentPeers.clear();
|
|
|
|
|
this.lastSeq.clear();
|
|
|
|
|
this.incoming.clear();
|
|
|
|
|
for (const participant of this.participants.values()) participant.peer = null;
|
|
|
|
|
this.finishStrokesOfAbsent();
|
|
|
|
|
this.events.onParticipantsChanged();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @param {number} peer */
|
|
|
|
|
peerLeft(peer) {
|
|
|
|
|
this.presentPeers.delete(peer);
|
|
|
|
|
this.lastSeq.delete(peer);
|
|
|
|
|
const id = this.peerIds.get(peer);
|
|
|
|
|
this.peerIds.delete(peer);
|
|
|
|
|
const participant = id ? this.participants.get(id) : undefined;
|
|
|
|
|
if (participant && participant.peer === peer) participant.peer = null;
|
|
|
|
|
this.finishStrokesOfAbsent();
|
|
|
|
|
if (peer === this.statePeer && !this.stateComplete) this.requestState();
|
|
|
|
|
this.events.onParticipantsChanged();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** A stroke whose writer has gone will get no more points. */
|
|
|
|
|
finishStrokesOfAbsent() {
|
|
|
|
|
for (const layer of this.page.layers.values()) {
|
|
|
|
|
const owner = this.participants.get(layer.id);
|
|
|
|
|
if (owner && (owner.self || owner.peer !== null)) continue;
|
|
|
|
|
for (const stroke of layer.strokes.values()) {
|
|
|
|
|
if (stroke.done) continue;
|
|
|
|
|
stroke.done = true;
|
|
|
|
|
this.events.onStrokeProgress(layer.id, stroke);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Receiving.
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {number} peer
|
|
|
|
|
* @param {Uint8Array<ArrayBuffer>} body
|
|
|
|
|
*/
|
|
|
|
|
received(peer, body) {
|
|
|
|
|
const epoch = this.epoch;
|
|
|
|
|
this.receiveChain = this.receiveChain.then(async () => {
|
|
|
|
|
if (epoch !== this.epoch) return;
|
|
|
|
|
let message;
|
|
|
|
|
try {
|
|
|
|
|
message = await decrypt(this.key, body);
|
|
|
|
|
} catch {
|
|
|
|
|
return; // Not made with the session key.
|
|
|
|
|
}
|
|
|
|
|
if (epoch !== this.epoch || !message || typeof message.seq !== 'number') return;
|
|
|
|
|
if (message.seq <= (this.lastSeq.get(peer) ?? -1)) return;
|
|
|
|
|
this.lastSeq.set(peer, message.seq);
|
|
|
|
|
if (message.type === 'hello') await this.receivedHello(peer, message, epoch);
|
|
|
|
|
else this.receivedFromParticipant(peer, message);
|
|
|
|
|
}).catch((error) => console.error('Kokkoja: receive failed', error));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {number} peer
|
|
|
|
|
* @param {any} message
|
|
|
|
|
* @param {number} epoch
|
|
|
|
|
*/
|
|
|
|
|
async receivedHello(peer, message, epoch) {
|
|
|
|
|
const id = await verifyClaim(message.publicKey, message.signature, this.roomId, peer);
|
|
|
|
|
if (epoch !== this.epoch || !id || id === this.identity.id) return;
|
|
|
|
|
|
|
|
|
|
const name = cleanName(message.name) || 'Unnamed';
|
|
|
|
|
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 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);
|
|
|
|
|
this.events.onParticipantsChanged();
|
|
|
|
|
|
|
|
|
|
if (!message.reply) {
|
|
|
|
|
this.sendHello(peer, true);
|
|
|
|
|
if (!this.ownLayer.isEmpty()) this.sendLayer(peer, this.ownLayer);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A message other than hello. It is accepted only from a peer that has
|
|
|
|
|
* proved who it is, and changes only that participant's layer.
|
|
|
|
|
* @param {number} peer
|
|
|
|
|
* @param {any} message
|
|
|
|
|
*/
|
|
|
|
|
receivedFromParticipant(peer, message) {
|
|
|
|
|
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);
|
2026-10-10 19:01:20 +02:00
|
|
|
else if (this.removesOnlyOwnImages(message.page, id)) this.considerPage(message.page, false, peer);
|
2026-10-06 15:58:02 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (message.type === 'permissions') {
|
|
|
|
|
if (id === this.hostId) this.receivedPermissions(message.ids);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-10 19:01:20 +02:00
|
|
|
if (message.type === 'view') {
|
|
|
|
|
if (id === this.hostId && (message.command === 'fit' || message.command === 'sheet')) {
|
|
|
|
|
this.events.onViewCommand(message.command);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-06 15:58:02 +02:00
|
|
|
if (message.type === 'image') {
|
|
|
|
|
this.receivedImagePiece(message);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (message.type === 'image_request') {
|
|
|
|
|
if (message.page !== this.page.id) return;
|
|
|
|
|
const image = this.page.images.find((one) => one.hash === message.hash);
|
|
|
|
|
if (!image) return;
|
|
|
|
|
if (image.data) {
|
|
|
|
|
this.sendImage(peer, image);
|
|
|
|
|
} else {
|
|
|
|
|
const waiting = this.imageRequests.get(image.hash) ?? new Set();
|
|
|
|
|
waiting.add(peer);
|
|
|
|
|
this.imageRequests.set(image.hash, waiting);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const layer = this.page.layer(id);
|
|
|
|
|
|
|
|
|
|
switch (message.type) {
|
|
|
|
|
case 'stroke_begin': {
|
|
|
|
|
if (message.page !== this.page.id) break;
|
|
|
|
|
const stroke = cleanStroke({ ...message.stroke, points: [], done: false });
|
|
|
|
|
if (stroke) layer.strokes.set(stroke.id, stroke);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'stroke_points': {
|
|
|
|
|
const stroke = layer.strokes.get(message.id);
|
|
|
|
|
if (!stroke || stroke.done || !validPoints(message.points) ||
|
|
|
|
|
!Number.isInteger(message.at) || message.at < 0) break;
|
|
|
|
|
const before = pointCount(stroke);
|
|
|
|
|
appendPoints(stroke, message.at, message.points);
|
|
|
|
|
if (pointCount(stroke) > MAX_STROKE_POINTS) stroke.points.length = MAX_STROKE_POINTS * POINT_SIZE;
|
|
|
|
|
this.page.includeStroke(stroke, before);
|
|
|
|
|
this.events.onStrokeProgress(id, stroke);
|
|
|
|
|
this.extentChanged();
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'stroke_end': {
|
|
|
|
|
const stroke = layer.strokes.get(message.id);
|
|
|
|
|
if (!stroke || stroke.done) break;
|
|
|
|
|
stroke.done = true;
|
|
|
|
|
this.events.onStrokeProgress(id, stroke);
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-10-11 13:17:22 +02:00
|
|
|
case 'stroke_restore': {
|
|
|
|
|
if (message.page !== this.page.id || !Array.isArray(message.strokes)) break;
|
|
|
|
|
let restored = false;
|
|
|
|
|
for (const received of message.strokes) {
|
|
|
|
|
const stroke = cleanStroke({ ...received, done: true });
|
|
|
|
|
if (stroke) {
|
|
|
|
|
layer.strokes.set(stroke.id, stroke);
|
|
|
|
|
restored = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!restored) break;
|
|
|
|
|
this.page.recomputeStrokeBounds();
|
|
|
|
|
this.events.onLayerReplaced(id);
|
|
|
|
|
this.extentChanged();
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-10-06 15:58:02 +02:00
|
|
|
case 'erase': {
|
|
|
|
|
if (message.page !== this.page.id || !Array.isArray(message.ids)) break;
|
|
|
|
|
let erased = false;
|
|
|
|
|
for (const strokeId of message.ids) erased = layer.strokes.delete(strokeId) || erased;
|
|
|
|
|
if (!erased) break;
|
|
|
|
|
this.page.recomputeStrokeBounds();
|
|
|
|
|
this.events.onLayerReplaced(id);
|
|
|
|
|
this.extentChanged();
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'text_set': {
|
|
|
|
|
if (message.page !== this.page.id) break;
|
|
|
|
|
const block = cleanText(message.text);
|
|
|
|
|
if (!block) break;
|
|
|
|
|
layer.texts.set(block.id, block);
|
|
|
|
|
this.page.recomputeStrokeBounds();
|
|
|
|
|
this.events.onTextChanged(id, block);
|
|
|
|
|
this.extentChanged();
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'text_remove': {
|
|
|
|
|
if (message.page !== this.page.id || !Array.isArray(message.ids)) break;
|
|
|
|
|
let removed = false;
|
|
|
|
|
for (const textId of message.ids) removed = layer.texts.delete(textId) || removed;
|
|
|
|
|
if (!removed) break;
|
|
|
|
|
this.page.recomputeStrokeBounds();
|
|
|
|
|
this.events.onLayerReplaced(id);
|
|
|
|
|
this.extentChanged();
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'layer_begin':
|
|
|
|
|
if (message.page === this.page.id && validId(message.layer) &&
|
|
|
|
|
this.acceptsLayerFrom(peer, id, message.layer)) {
|
|
|
|
|
this.incoming.set(`${peer} ${message.layer}`,
|
|
|
|
|
{ name: cleanName(message.name), strokes: [], texts: [] });
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case 'layer_texts': {
|
|
|
|
|
const snapshot = this.incoming.get(`${peer} ${message.layer}`);
|
|
|
|
|
if (!snapshot || !Array.isArray(message.texts)) break;
|
|
|
|
|
for (const received of message.texts) {
|
|
|
|
|
const block = cleanText(received);
|
|
|
|
|
if (block) snapshot.texts.push(block);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'layer_strokes': {
|
|
|
|
|
const snapshot = this.incoming.get(`${peer} ${message.layer}`);
|
|
|
|
|
if (!snapshot || !Array.isArray(message.strokes)) break;
|
|
|
|
|
for (const received of message.strokes) {
|
|
|
|
|
const stroke = cleanStroke(received);
|
|
|
|
|
if (stroke) snapshot.strokes.push(stroke);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'layer_end': {
|
|
|
|
|
const key = `${peer} ${message.layer}`;
|
|
|
|
|
const snapshot = this.incoming.get(key);
|
|
|
|
|
this.incoming.delete(key);
|
|
|
|
|
if (snapshot && this.acceptsLayerFrom(peer, id, message.layer)) {
|
|
|
|
|
this.applySnapshot(message.layer, snapshot, message.layer === id);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'state_request':
|
|
|
|
|
this.sendState(peer, id);
|
|
|
|
|
break;
|
|
|
|
|
case 'state_end':
|
|
|
|
|
if (peer !== this.statePeer) break;
|
|
|
|
|
this.stateComplete = true;
|
|
|
|
|
if (this.recoverOwnLayer) {
|
|
|
|
|
this.recoverOwnLayer = false;
|
|
|
|
|
if (!this.ownLayer.isEmpty()) this.sendLayer(0, this.ownLayer);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
* send any of its layers, our own included while we have nothing on it
|
|
|
|
|
* (a page loaded from a notebook comes with its writing). The peer we
|
|
|
|
|
* asked for the session state may send the layers of participants who
|
|
|
|
|
* are not connected, and our own layer while we are recovering it (the
|
|
|
|
|
* browser page was reloaded).
|
|
|
|
|
* @param {number} peer
|
|
|
|
|
* @param {string} senderId
|
|
|
|
|
* @param {string} layerId
|
|
|
|
|
*/
|
|
|
|
|
acceptsLayerFrom(peer, senderId, layerId) {
|
|
|
|
|
if (layerId === senderId) return true;
|
|
|
|
|
if (peer === this.pageSourcePeer) {
|
|
|
|
|
return layerId !== this.identity.id || this.ownLayer.isEmpty();
|
|
|
|
|
}
|
|
|
|
|
if (peer !== this.statePeer) return false;
|
|
|
|
|
if (layerId === this.identity.id) return this.recoverOwnLayer;
|
|
|
|
|
const owner = this.participants.get(layerId);
|
|
|
|
|
return !owner || owner.peer === null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {string} layerId
|
|
|
|
|
* @param {{name: string, strokes: Stroke[], texts: TextBlock[]}} snapshot
|
|
|
|
|
* @param {boolean} fromOwner
|
|
|
|
|
*/
|
|
|
|
|
applySnapshot(layerId, snapshot, fromOwner) {
|
|
|
|
|
const layer = this.page.layer(layerId, snapshot.name);
|
|
|
|
|
if (layerId === this.identity.id) {
|
|
|
|
|
// Recovering our own layer: keep anything drawn since we joined.
|
|
|
|
|
for (const stroke of snapshot.strokes) {
|
|
|
|
|
if (!layer.strokes.has(stroke.id)) layer.strokes.set(stroke.id, { ...stroke, done: true });
|
|
|
|
|
}
|
|
|
|
|
for (const block of snapshot.texts) {
|
|
|
|
|
if (!layer.texts.has(block.id)) layer.texts.set(block.id, block);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
layer.strokes.clear();
|
|
|
|
|
for (const stroke of snapshot.strokes) {
|
|
|
|
|
layer.strokes.set(stroke.id, fromOwner ? stroke : { ...stroke, done: true });
|
|
|
|
|
}
|
|
|
|
|
layer.texts.clear();
|
|
|
|
|
for (const block of snapshot.texts) layer.texts.set(block.id, block);
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
this.page.recomputeStrokeBounds();
|
|
|
|
|
this.events.onLayerReplaced(layerId);
|
|
|
|
|
this.extentChanged();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Answer a state request: send the layers that their owners cannot send
|
|
|
|
|
* themselves.
|
|
|
|
|
* @param {number} peer
|
|
|
|
|
* @param {string} requesterId
|
|
|
|
|
*/
|
|
|
|
|
sendState(peer, requesterId) {
|
|
|
|
|
for (const layer of this.page.layers.values()) {
|
|
|
|
|
if (layer.id === this.identity.id || layer.isEmpty()) continue;
|
|
|
|
|
const owner = this.participants.get(layer.id);
|
|
|
|
|
const ownerConnected = owner !== undefined && owner.peer !== null;
|
|
|
|
|
if (layer.id === requesterId || !ownerConnected) this.sendLayer(peer, layer);
|
|
|
|
|
}
|
|
|
|
|
this.send(peer, { type: 'state_end' });
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-11 13:17:22 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {TextBlock} block
|
|
|
|
|
* @returns {TextBlock}
|
|
|
|
|
*/
|
|
|
|
|
function copyText(block) {
|
|
|
|
|
return { ...block, lines: block.lines.slice() };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {HideRect} a
|
|
|
|
|
* @param {HideRect} b
|
|
|
|
|
*/
|
|
|
|
|
function sameRect(a, b) {
|
|
|
|
|
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {HideRect} hide
|
|
|
|
|
* @returns {import('./page_model.js').Rectangle}
|
|
|
|
|
*/
|
|
|
|
|
function rectOf(hide) {
|
|
|
|
|
return { left: hide.x, top: hide.y, width: hide.width, height: hide.height };
|
|
|
|
|
}
|