// @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, sheetHolding, validId, validPoints, MAX_PAGE_HIDES, } from './page_model.js'; import { RelayConnection } from './relay_connection.js'; import { trimTrailingBlankLines } from './text_layout.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 {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: 'shape', id: string, before: Stroke} * | {kind: 'hide', id: string, before: HideRect | null}} UndoEntry */ /** * @typedef {object} Participant * @property {string} id * @property {string} name * @property {number | null} peer Relay peer number; null when not connected. * @property {boolean} self */ /** * @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} onParticipantsChanged * @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus * @property {() => void} [onUndoChanged] * Whether there is something of ours to undo may have changed. */ /** * @typedef {object} SessionOptions * @property {string} relay * @property {string} roomId * @property {CryptoKey} key * @property {Identity} identity * @property {string} hostId Who started the session, the id in the * invitation: the page file records it and that layer is drawn on * top; it gives no right (everyone has the same, Andy, 2026-10-18). * @property {string} name Our display name. * @property {Page} [page] The page as last saved, when returning to * the session. */ /** Approximate size at which a layer snapshot is split into messages. */ const SNAPSHOT_CHUNK_CHARACTERS = 100000; /** How many of one's own gestures can be undone, the latest first. */ export const UNDO_LIMIT = 100; /** An image is sent in pieces of this many bytes. */ const IMAGE_CHUNK_BYTES = 96 * 1024; /** 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; 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'; /** @type {ReturnType | 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 | null} */ this.pointSendTimer = null; this.lastPointSend = 0; /** * 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: Stroke} | null} A shape of ours being moved or resized by hand. */ this.shapeEditing = null; /** @type {ReturnType | null} The moved shape is sent when this fires. */ this.shapeSendTimer = null; /** @type {{id: string, before: HideRect} | null} A Hide rectangle being moved. */ this.hideEditing = null; this.events = events; // Whoever starts a session begins on page 1. Someone joining shows // a placeholder until a page arrives from the others (or starts one // with New, if nobody is there). this.page = options.page ?? (this.identity.id === this.hostId ? new Page(undefined, undefined, randomId(), 1) : new Page()); this.ownLayer = this.page.layer(this.identity.id, this.name); this.page.recomputeStrokeBounds(); this.page.updateExtent(); /** @type {Map} */ this.participants = new Map(); this.participants.set(this.identity.id, { id: this.identity.id, name: this.name, peer: null, self: true, }); 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, }); } /** @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. Everyone may start, load and change them (Andy and his // teacher, 2026-10-18: the host's rights and the co-host went; what // an accident costs is said in the documentation, and the page left // is kept in every notebook). /** * The page's rectangle may have changed with its contents. * @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. Someone with no page yet (the * others gone) starts page 1. */ newPage() { 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. * @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 * @returns {boolean} False if the page has no room for it. */ addImage(prepared) { const page = this.page; if (page.number === 0) return false; const place = placeImage(page, prepared.naturalWidth, prepared.naturalHeight); if (!page.hasRoomForImage(prepared.data.length, place.y + place.height)) return false; /** @type {PageImage} */ 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, by: this.identity.id, }; page.images.push(image); page.imagesRevision += 1; page.baseLeft = place.sheet.left; page.baseTop = place.sheet.top; page.baseWidth = place.sheet.width; page.baseHeight = place.sheet.height; this.send(0, { type: 'page', page: page.descriptor() }); this.sendImage(0, image); this.events.onImagesChanged(); this.extentChanged(true); return true; } // 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 (page.number === 0 || page.hides.length >= MAX_PAGE_HIDES) return false; const hide = { id: randomId(), x: Math.round(rect.left), y: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height), }; page.hides.push(hide); this.sheetAround(rect); page.imagesRevision += 1; this.sendPageNow(); this.events.onImagesChanged(); this.extentChanged(true); this.pushUndo({ kind: 'hide', id: hide.id, before: null }); return true; } /** * 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 }); } } /** * 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 (!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; const hide = page.hides[index]; if (!hide) return false; page.hides.splice(index, 1); page.imagesRevision += 1; this.sendPageNow(); this.events.onImagesChanged(); this.extentChanged(true); this.pushUndo({ kind: 'hide', id: hide.id, before: { ...hide } }); 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 (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; } /** * 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. */ placeImageByHand(index, rect) { const page = this.page; if (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() { const page = this.page; page.imagesRevision += 1; 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() }); } /** * 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. */ expandPage(width, height) { const page = this.page; if (page.number === 0) return false; // Equally on both sides, and top and bottom, of the PAGE as it is, // writing included (the sheet may lie inside a page that writing has // grown; measured from the sheet, the page came out short, Andy, // 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; } page.imagesRevision += 1; this.send(0, { type: 'page', page: page.descriptor() }); 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 * page like "Expand". * @returns {boolean} Whether the sheet changed. */ shrinkPage() { const page = this.page; if (page.number === 0) return false; // The blank lines at the end of one's own text blocks go first (Andy, // 2026-10-14); another's blocks are theirs, trimmed when they finish // typing. Each trim is a gesture of its own to undo; a block being // typed in is left to the editor. for (const block of this.ownLayer.texts.values()) { if (!this.beginTextEdit(block.id, 'shrink')) continue; if (trimTrailingBlankLines(block)) this.setText(block); this.endTextEdit(block.id, 'shrink'); } 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) return false; page.images.splice(index, 1); page.imagesRevision += 1; this.imageTransfers.delete(image.hash); this.sendPageNow(); 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) { const page = new Page(kept.baseWidth, kept.baseHeight, randomId(), this.page.number + 1); page.baseLeft = kept.baseLeft; page.baseTop = kept.baseTop; page.images = kept.images.map((image) => ({ ...image })); page.hides = kept.hides.map((hide) => ({ ...hide })); 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 }); } } 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(); } /** * 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 }); } } /** @param {string[]} ids Text blocks of our own layer. */ removeTexts(ids) { const removedBlocks = ids.map((id) => this.ownLayer.texts.get(id)).filter((block) => block !== undefined); const removed = ids.filter((id) => this.ownLayer.texts.delete(id)); if (removed.length === 0) return; if (this.erasing) this.erasing.texts.push(...removedBlocks); 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. Whoever presses New numbers the page one past their own, so * the highest number is the session's current page; between two * different pages of the same number (two people pressed New at the * same moment, or apart while disconnected) the one with the greater * id is taken, the same choice on every machine, and the loser's page * is kept in its notebook like any page left (2026-10-18; the host's * won until then). If it is the page we are on, it may have gained * images. * @param {unknown} received A page descriptor. * @param {number} peer Who told us. */ considerPage(received, 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 && descriptor.id > this.page.id); 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 {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); 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.hides = descriptor.hides.map((hide) => ({ ...hide })); page.imagesRevision = descriptor.revision; page.baseLeft = descriptor.left; page.baseTop = descriptor.top; 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.undoStack = []; this.erasing = null; this.textEditing = null; this.shapeEditing = null; this.hideEditing = null; this.events.onUndoChanged?.(); 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 * @param {object} [shape] What the Shape tool adds to a stroke, each * sent only when set, so that a client from before 2026-10-14 * reads the message as before. * @param {boolean} [shape.filled] A filled polygon rather than a line (the arrow). * @param {string | null} [shape.fill] A fill colour beneath the line. * @param {import('./page_model.js').ShapeKind | null} [shape.kind] The shape, for Arrange. * @returns {Stroke} */ beginStroke(color, width, shape = {}) { if (this.currentStroke) this.endStroke(); const filled = shape.filled ?? false; const fill = shape.fill ?? null; const kind = shape.kind ?? null; /** @type {Stroke} */ const stroke = { id: randomId(), color, width, points: [], done: false, filled, fill, shape: kind }; 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, ...(filled ? { filled } : {}), ...(fill ? { fill } : {}), ...(kind ? { shape: kind } : {}), }, }); return stroke; } // A shape of ours moved or resized by hand (Arrange or a hold, Andy, // 2026-10-14): one gesture to undo, the shape sent whole while it is // dragged (at most every PAGE_SEND_INTERVAL_MS, by the stroke_restore // message, which puts a stroke in place of the one with its id) and // once at the end. /** * @param {string} id * @returns {boolean} Whether the stroke is a shape of ours. */ beginShapeEdit(id) { const stroke = this.ownLayer.strokes.get(id); // Any finished stroke of ours: a shape, or handwriting under Arrange (Andy, 2026-10-14). if (!stroke || !stroke.done || this.shapeEditing) return false; this.shapeEditing = { id, before: { ...stroke, points: stroke.points.slice() } }; return true; } /** * The shape has new points: redrawn here, sent soon. * @param {string} id * @param {number[]} points */ setShapePoints(id, points) { const stroke = this.ownLayer.strokes.get(id); if (!stroke) return; stroke.points = points; this.page.recomputeStrokeBounds(); this.events.onLayerReplaced(this.identity.id); this.extentChanged(); if (this.shapeSendTimer === null) { this.shapeSendTimer = setTimeout(() => { this.shapeSendTimer = null; this.sendShape(id); }, PAGE_SEND_INTERVAL_MS); } } /** @param {string} id */ sendShape(id) { const stroke = this.ownLayer.strokes.get(id); if (stroke) this.send(0, { type: 'stroke_restore', page: this.page.id, strokes: [stroke] }); } endShapeEdit() { const editing = this.shapeEditing; this.shapeEditing = null; if (!editing) return; if (this.shapeSendTimer !== null) { clearTimeout(this.shapeSendTimer); this.shapeSendTimer = null; } this.sendShape(editing.id); const now = this.ownLayer.strokes.get(editing.id); if (now && now.points.join(' ') !== editing.before.points.join(' ')) { this.pushUndo({ kind: 'shape', id: editing.id, before: editing.before }); } } /** @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, { filled: stroke.filled, fill: stroke.fill, kind: stroke.shape }); points = last.concat(points); } const at = pointCount(stroke); appendPoints(stroke, at, points); this.page.includeStroke(stroke, at); this.queuePoints(stroke.id, at, points); this.events.onStrokeProgress(this.identity.id, stroke); this.extentChanged(); } /** * 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 }); } /** * @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) { const stroke = this.currentStroke; if (!stroke) return; this.currentStroke = null; stroke.done = true; this.sendPendingPoints(); this.send(0, { type: 'stroke_end', id: stroke.id }); this.events.onStrokeProgress(this.identity.id, stroke); 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 }; } /** @param {string[]} ids Strokes of our own layer. */ erase(ids) { const erasedStrokes = ids.map((id) => this.ownLayer.strokes.get(id)).filter((stroke) => stroke !== undefined); const erased = ids.filter((id) => this.ownLayer.strokes.delete(id)); if (erased.length === 0) return; if (this.erasing) this.erasing.strokes.push(...erasedStrokes); this.send(0, { type: 'erase', page: this.page.id, ids: erased }); this.page.recomputeStrokeBounds(); this.events.onLayerReplaced(this.identity.id); this.extentChanged(); } /** * 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 'shape': this.restoreStrokes([entry.before]); return 'a move of a shape'; case 'hide': { const page = this.page; 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; } } // 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, })); } // 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 }); this.considerPage(message.page, peer); // This peer is on our page: it can give us the images we lack. if (message.page?.id === this.page.id) this.requestImages(peer); this.page.layer(id, name); 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') { this.considerPage(message.page, peer); return; } // 'permissions' and 'view' came from a host, until 2026-10-18; an // older client's are ignored. if (message.type === 'permissions' || message.type === 'view') return; if (message.type === 'image') { this.receivedImagePiece(message); return; } 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 '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; } 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; } } /** * 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, }); 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' }); } } /** * @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 }; }