// @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, cleanStroke, cleanText, placeImage, pointCount, validId, validPoints, } 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 */ /** * @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. * @property {() => void} onParticipantsChanged * @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus */ /** * @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; /** An image is sent in pieces of this many bytes. */ const IMAGE_CHUNK_BYTES = 96 * 1024; 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'; /** * The image we have just added, which we may take back until anything * else changes on the page. * @type {PageImage | null} */ this.removableImage = null; // Every event that reports a change to the page ends that. /** @type {SessionEvents} */ this.events = { ...events, onLayerReplaced: (layerId) => { this.removableImage = null; events.onLayerReplaced(layerId); }, onStrokeProgress: (layerId, stroke) => { this.removableImage = null; events.onStrokeProgress(layerId, stroke); }, onTextChanged: (layerId, block) => { this.removableImage = null; events.onTextChanged(layerId, block); }, onPageReplaced: (oldPage, newPage) => { this.removableImage = null; events.onPageReplaced(oldPage, newPage); }, onImagesChanged: () => { this.removableImage = null; events.onImagesChanged(); }, }; this.isHost = this.identity.id === this.hostId; /** @type {Set} 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} */ 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} Relay peer number to participant id. */ this.peerIds = new Map(); /** @type {Set} Peers the relay says are present. */ this.presentPeers = new Set(); /** @type {Map} Last sequence number seen from a peer. */ this.lastSeq = new Map(); /** @type {Map} Snapshots being received. */ this.incoming = new Map(); /** * The bytes of the page's images received so far, by each image's hash. * @type {Map, received: number}>} */ this.imageTransfers = new Map(); /** @type {Map>} 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); } /** * 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} prepared.data * @param {string} prepared.hash * @param {number} prepared.naturalWidth In page units. * @param {number} prepared.naturalHeight * @param {boolean} [shrink] Whether an image wider than the sheet is * scaled down to it; otherwise it keeps its size and widens the sheet. * @returns {boolean} False if the page has no room for it. */ addImage(prepared, shrink = true) { const page = this.page; if (!this.mayMakePages() || page.number === 0) return false; const place = placeImage(prepared.naturalWidth, prepared.naturalHeight, page.imagesBottom(), shrink); 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, x: place.x, y: place.y, width: place.width, height: place.height, }; page.images.push(image); page.imagesRevision += 1; page.baseWidth = Math.max(page.baseWidth, place.pageWidth); page.baseHeight = Math.max(page.baseHeight, place.pageHeight); this.send(0, { type: 'page', page: page.descriptor() }); this.sendImage(0, image); this.events.onImagesChanged(); this.removableImage = image; this.extentChanged(true); return true; } /** * Take back the image we have just added: the remedy for having chosen * the wrong file. It works only until anything else changes on the * page (Andy, 2026-10-05), so nothing can have been written on the image, * and since it is the lowest image, no other image is affected. * @returns {boolean} Whether an image was taken back. */ removeAddedImage() { const page = this.page; const image = this.removableImage; if (!image || page.images.at(-1) !== image) return false; page.images.pop(); page.imagesRevision += 1; page.baseWidth = page.sheetWidth(); page.baseHeight = page.sheetHeight(); this.send(0, { type: 'page', page: page.descriptor() }); 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); page.images = kept.images.map((image) => ({ ...image })); 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(); } /** @param {string[]} ids Text blocks of our own layer. */ removeTexts(ids) { const removed = ids.filter((id) => this.ownLayer.texts.delete(id)); if (removed.length === 0) return; 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; } /** * 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; /** @param {Omit[]} images */ const listed = (images) => images.map((image) => `${image.hash} ${image.x} ${image.y} ${image.width} ${image.height}`).join(','); const ours = listed(page.images); const theirs = listed(descriptor.images); 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 })); page.imagesRevision = descriptor.revision; 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; 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); this.send(0, { type: 'stroke_points', id: stroke.id, at, points }); this.events.onStrokeProgress(this.identity.id, stroke); this.extentChanged(); } endStroke() { const stroke = this.currentStroke; if (!stroke) return; this.currentStroke = null; stroke.done = true; this.send(0, { type: 'stroke_end', id: stroke.id }); this.events.onStrokeProgress(this.identity.id, stroke); } /** @param {string[]} ids Strokes of our own layer. */ erase(ids) { const erased = ids.filter((id) => this.ownLayer.strokes.delete(id)); if (erased.length === 0) return; this.send(0, { type: 'erase', page: this.page.id, ids: erased }); this.page.recomputeStrokeBounds(); this.events.onLayerReplaced(this.identity.id); this.extentChanged(); } // 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)} 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} 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); return; } if (message.type === 'permissions') { if (id === this.hostId) this.receivedPermissions(message.ids); return; } 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; } 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' }); } }