Kokkoja as of 2026-10-10: the teacher's requests, the Session window, notices, the QR code, the license
This commit is contained in:
1 parent
95c80d04c9
commit
1d087af633
32 files changed
+4917
-890
No files matched your search
+330
-57
@@ -13,7 +13,8 @@ import {
|
||||
} from './crypto.js';
|
||||
import {
|
||||
MAX_STROKE_POINTS, POINT_SIZE, Page, appendPoints, cleanName, cleanPageDescriptor,
|
||||
cleanStroke, cleanText, placeImage, pointCount, validId, validPoints,
|
||||
cleanStroke, cleanText, placeImage, pointCount, sheetHolding, validId, validPoints,
|
||||
MAX_PAGE_HIDES,
|
||||
} from './page_model.js';
|
||||
import { RelayConnection } from './relay_connection.js';
|
||||
|
||||
@@ -49,6 +50,9 @@ import { RelayConnection } from './relay_connection.js';
|
||||
* image was added or taken back, not if writing reached an edge.
|
||||
* @property {() => void} onPermissionsChanged
|
||||
* Who may start pages changed.
|
||||
* @property {(command: 'fit' | 'sheet') => void} onViewCommand
|
||||
* The host pressed "Fit" or "A4": everyone's view follows (Andy,
|
||||
* 2026-10-08), each in their own window.
|
||||
* @property {() => void} onParticipantsChanged
|
||||
* @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus
|
||||
*/
|
||||
@@ -71,6 +75,19 @@ const SNAPSHOT_CHUNK_CHARACTERS = 100000;
|
||||
/** 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
|
||||
@@ -82,37 +99,14 @@ export class Session {
|
||||
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();
|
||||
},
|
||||
};
|
||||
/** @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;
|
||||
this.events = events;
|
||||
|
||||
this.isHost = this.identity.id === this.hostId;
|
||||
/** @type {Set<string>} Guests the host allows to start pages. */
|
||||
@@ -216,6 +210,15 @@ export class Session {
|
||||
return this.isHost || this.permitted.has(this.identity.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow a guest to start pages, or stop allowing it. Only the host may.
|
||||
* @param {string} id
|
||||
@@ -258,48 +261,233 @@ export class Session {
|
||||
* @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) {
|
||||
addImage(prepared) {
|
||||
const page = this.page;
|
||||
if (!this.mayMakePages() || page.number === 0) return false;
|
||||
const place = placeImage(prepared.naturalWidth, prepared.naturalHeight, page.imagesBottom(), shrink);
|
||||
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,
|
||||
x: place.x, y: place.y, width: place.width, height: place.height, by: this.identity.id,
|
||||
};
|
||||
page.images.push(image);
|
||||
page.imagesRevision += 1;
|
||||
page.baseWidth = Math.max(page.baseWidth, place.pageWidth);
|
||||
page.baseHeight = Math.max(page.baseHeight, place.pageHeight);
|
||||
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.removableImage = image;
|
||||
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 (!this.mayMakePages() || page.number === 0 || page.hides.length >= MAX_PAGE_HIDES) return false;
|
||||
page.hides.push({
|
||||
id: randomId(), x: Math.round(rect.left), y: Math.round(rect.top),
|
||||
width: Math.round(rect.width), height: Math.round(rect.height),
|
||||
});
|
||||
this.sheetAround(rect);
|
||||
page.imagesRevision += 1;
|
||||
this.sendPageNow();
|
||||
this.events.onImagesChanged();
|
||||
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.
|
||||
* Give a Hide rectangle a new rectangle: moved or resized by hand.
|
||||
* @param {number} index
|
||||
* @param {import('./page_model.js').Rectangle} rect
|
||||
*/
|
||||
removeAddedImage() {
|
||||
placeHideByHand(index, rect) {
|
||||
const page = this.page;
|
||||
const image = this.removableImage;
|
||||
if (!image || page.images.at(-1) !== image) return false;
|
||||
page.images.pop();
|
||||
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;
|
||||
if (!this.mayMakePages() || !page.hides[index]) return false;
|
||||
page.hides.splice(index, 1);
|
||||
page.imagesRevision += 1;
|
||||
this.sendPageNow();
|
||||
this.events.onImagesChanged();
|
||||
this.extentChanged(true);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (!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() {
|
||||
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 wider, for more room to write (Andy, 2026-10-08:
|
||||
* "Widen" enlarges the page rather than zooming). Everyone's page
|
||||
* widens; 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.
|
||||
* @returns {boolean} Whether the page widened.
|
||||
*/
|
||||
widenPage(width) {
|
||||
const page = this.page;
|
||||
const added = Math.round(width) - page.width;
|
||||
if (!this.mayMakePages() || page.number === 0 || added <= 0) return false;
|
||||
// Equally on both sides 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 page's centre half a unit, a fraction
|
||||
// of a pixel that showed as a one-pixel move on Olion (2026-10-09).
|
||||
const half = Math.floor(added / 2);
|
||||
if (half === 0) return false;
|
||||
page.baseLeft = page.left - half;
|
||||
page.baseWidth = page.width + 2 * half;
|
||||
page.imagesRevision += 1;
|
||||
page.baseWidth = page.sheetWidth();
|
||||
page.baseHeight = page.sheetHeight();
|
||||
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 "Widen".
|
||||
* @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();
|
||||
this.events.onImagesChanged();
|
||||
this.extentChanged(true);
|
||||
return true;
|
||||
@@ -314,7 +502,10 @@ export class Session {
|
||||
loadPage(kept) {
|
||||
if (!this.mayMakePages()) return;
|
||||
const page = new Page(kept.baseWidth, kept.baseHeight, randomId(), this.page.number + 1);
|
||||
page.baseLeft = kept.baseLeft;
|
||||
page.baseTop = kept.baseTop;
|
||||
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);
|
||||
@@ -460,6 +651,38 @@ export class Session {
|
||||
this.pageSourcePeer = peer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a page descriptor from a participant without the right to
|
||||
* make pages is one they may still send: our page, the sheet as it is,
|
||||
* and the images ours with only images they added left out (Andy,
|
||||
* 2026-10-08: whoever added an image may remove it, right or no right).
|
||||
* @param {any} received
|
||||
* @param {string} id The sender.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
removesOnlyOwnImages(received, id) {
|
||||
const descriptor = cleanPageDescriptor(received);
|
||||
const page = this.page;
|
||||
if (!descriptor || descriptor.id !== page.id || descriptor.left !== page.baseLeft ||
|
||||
descriptor.top !== page.baseTop || descriptor.width !== page.baseWidth ||
|
||||
descriptor.height !== page.baseHeight ||
|
||||
JSON.stringify(descriptor.hides) !== JSON.stringify(page.hides)) return false;
|
||||
/** @param {Omit<PageImage, 'data'>} image */
|
||||
const key = (image) => `${image.hash} ${image.x} ${image.y} ${image.width} ${image.height} ${image.by}`;
|
||||
let at = 0;
|
||||
let removed = 0;
|
||||
for (const ours of page.images) {
|
||||
if (at < descriptor.images.length && key(descriptor.images[at]) === key(ours)) {
|
||||
at += 1;
|
||||
} else if (ours.by === id) {
|
||||
removed += 1;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return at === descriptor.images.length && removed > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Another participant's list of the images on the page we are on. The
|
||||
* list with the higher revision is the later one. Of two different
|
||||
@@ -470,11 +693,13 @@ export class Session {
|
||||
*/
|
||||
considerImages(descriptor) {
|
||||
const page = this.page;
|
||||
/** @param {Omit<PageImage, 'data'>[]} 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);
|
||||
/** @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();
|
||||
@@ -482,7 +707,10 @@ export class Session {
|
||||
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,
|
||||
@@ -541,16 +769,54 @@ export class Session {
|
||||
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.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 });
|
||||
}
|
||||
|
||||
endStroke() {
|
||||
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);
|
||||
}
|
||||
@@ -756,12 +1022,19 @@ export class Session {
|
||||
if (!id) return;
|
||||
if (message.type === 'page') {
|
||||
if (id === this.hostId || this.permitted.has(id)) this.considerPage(message.page, true, peer);
|
||||
else if (this.removesOnlyOwnImages(message.page, id)) this.considerPage(message.page, false, peer);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'permissions') {
|
||||
if (id === this.hostId) this.receivedPermissions(message.ids);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'view') {
|
||||
if (id === this.hostId && (message.command === 'fit' || message.command === 'sheet')) {
|
||||
this.events.onViewCommand(message.command);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === 'image') {
|
||||
this.receivedImagePiece(message);
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user