Everyone has the same buttons; the view is personal

The host's rights and the co-host are gone: anyone in a session may
start a new page, put an image on the page, load a kept page, hide,
expand and shrink, and move or remove images and Hides; the page left
is kept in every notebook.  Fit and Normal scale act on your own screen
only.  A session goes on without whoever started it, and a newcomer to
an empty room starts page 1 with New.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-18 20:22:13 +02:00
1 parent 5d63a0f758
commit 60ad747e32
10 files changed
+207 -346

No files matched your search

+7 -7
View File
@@ -13,24 +13,24 @@ may be used.
## Documentation
The user documentation (an introduction, Kokkoja in five minutes, a
guest's Kokkoja, a host's Kokkoja, and how Kokkoja works) is being moved
The user documentation (an introduction, Kokkoja in five minutes,
using Kokkoja, and how Kokkoja works) is being moved
to a new format and is not in this repository at the moment. It will
return with a later release.
## In short
A guest needs nothing but a web browser: open the invitation link, enter
a name, join. A host can start a session from the same web site,
Joining needs nothing but a web browser: open the invitation link, enter
a name, join. Anyone can start a session from the same web site,
`https://app.kokkoja.com/`, with the notebooks kept in the
browser.
browser; everyone in a session has the same buttons.
A host who wants notebooks as files needs Python 3 and this directory:
Whoever wants notebooks as files needs Python 3 and this directory:
python3 kokkoja.py --relay wss://app.kokkoja.com/relay
opens Kokkoja in the browser; *Start a session* gives an invitation to
send to the guests. Notebooks are directories in `Kokkoja` in the
send to the others. Notebooks are directories in `Kokkoja` in the
Documents directory.
## What is here
+7 -7
View File
@@ -82,12 +82,12 @@
</div>
</div>
<div class="group after_settings" role="radiogroup" aria-label="Hide, Arrange and Eraser">
<button type="button" data-tool="hide" aria-pressed="false" hidden
<button type="button" data-tool="hide" aria-pressed="false"
data-help="Hide part of the page for everyone: drag open a rectangle, and what lies under it is covered in white, marked by blue corners. Rest the pen on it to move it, or near a corner to resize it, or use Arrange; a tap of the eraser removes it.">Hide</button>
<button type="button" data-tool="arrange" aria-pressed="false"
data-help="Move and resize: the corners of every image are marked in blue, as those of Hides and text rectangles always are. Drag inside an image, a Hide or a text rectangle of yours to move it, drag a corner to resize it (an image keeps its shape). A press elsewhere does nothing. With any other tool, resting the pen on one of them for half a second does the same.">Arrange</button>
<button type="button" data-tool="eraser" aria-pressed="false"
data-help="Erase your own strokes and text by moving over them. A tap on an image, or on a Hide rectangle, removes it, if you are the host or a co-host (or added the image yourself). Two quick taps on empty paper, away from anything the eraser would remove, undo your latest mark; again, and the one before.">Eraser</button>
data-help="Erase your own strokes and text by moving over them. A tap on an image, or on a Hide rectangle, removes it for everyone. Two quick taps on empty paper, away from anything the eraser would remove, undo your latest mark; again, and the one before.">Eraser</button>
</div>
<button id="undo" type="button" disabled
data-help="Take back your latest mark: a stroke, an erasure, a text block typed or moved, a Hide rectangle. Again for the one before. Also Ctrl+Z (Command+Z on a Mac), or two quick taps of the eraser on empty paper. Only your own marks, and only on this page since you opened it.">Undo</button>
@@ -99,14 +99,14 @@
<div id="page_menu" class="popup menu" role="menu" hidden>
<div class="menu_title">Change the display of the page</div>
<button id="zoom_fit" type="button" class="menu_item" role="menuitem"
data-help="Show the whole page in the window. Pressed by the host, it does so on every screen. Ctrl (Command on a Mac) with the scroll wheel, or with a pen stroke up or down, zooms about the pointer.">Fit to the window</button>
data-help="Show the whole page in the window, on your screen only. Ctrl (Command on a Mac) with the scroll wheel, or with a pen stroke up or down, zooms about the pointer.">Fit to the window</button>
<button id="zoom_sheet" type="button" class="menu_item" role="menuitem"
data-help="Show the page at the scale a new page has, one full page filling the window. A shrunk page is then seen at its own size rather than enlarged. Pressed by the host, it does so on every screen.">Normal scale</button>
data-help="Show the page at the scale a new page has, one full page filling the window. A shrunk page is then seen at its own size rather than enlarged. On your screen only.">Normal scale</button>
<hr>
<div class="menu_title">Change the size of the page</div>
<button id="zoom_expand" type="button" class="menu_item" role="menuitem" hidden
<button id="zoom_expand" type="button" class="menu_item" role="menuitem"
data-help="Make the page fill the window, for everyone, at this zoom, in both directions. Nothing on it moves.">Expand to the window</button>
<button id="zoom_shrink" type="button" class="menu_item" role="menuitem" hidden
<button id="zoom_shrink" type="button" class="menu_item" role="menuitem"
data-help="Make the page, for everyone, just large enough for what is on it, with the margin around. Nothing on it moves.">Shrink to the contents</button>
<hr>
<label class="menu_item toggle" role="menuitemcheckbox"
@@ -119,7 +119,7 @@
<button id="load_image" type="button"
data-help="Put an image file, such as a scanned exercise, on the page for everyone: below the images already there, centred, at its own size. Rest the pen on an image to move it, or near a corner to scale it; it comes to the front. A tap of the eraser on an image removes it; writing on it stays.">Image</button>
<button id="load_notebook_page" type="button"
data-help="Your notebooks and the pages kept in them, to look at or to delete. The host and co-hosts can start a new page for everyone from a kept page, with everything on it; the current page is then kept in each notebook.">Notebooks</button>
data-help="Your notebooks and the pages kept in them, to look at or to delete. A kept page starts a new page for everyone, with everything on it; the current page is then kept in each notebook.">Notebooks</button>
</div>
<div class="group" aria-label="Notebook">
<select id="session_notebook" class="notebook_pick" aria-label="Current notebook"
+4 -12
View File
@@ -15,10 +15,9 @@
* a Trash the entries are moved there, and the button says so;
* in the browser they are gone for good, and the button says that. The
* notebook being written in cannot be marked: its mark explains.
* Everyone has the chooser (Andy, 2026-10-11), so that a guest can see
* and clear what their notebook holds, in a browser above all; loading
* a page into the session is for the host and co-hosts, and a card says
* so to anyone else.
* Everyone has the chooser (Andy, 2026-10-11), so that anyone can see
* and clear what their notebook holds, in a browser above all, and
* since 2026-10-18 anyone may load a page into the session from it.
*/
import {
@@ -163,7 +162,6 @@ export class NotebookChooser {
/** The notebook being written in, which cannot be deleted while it is. */
this.writing = '';
/** Whether this participant may load a page into the session. */
this.mayLoad = true;
/** Whether deletions go to a Trash (the local program's) or are for good. */
this.trash = false;
/** Counts openings, so that pictures fetched for an earlier one are dropped. */
@@ -188,12 +186,10 @@ export class NotebookChooser {
/**
* Show the notebooks, or the pages of the only notebook.
* @param {string} [notebook] Open on this notebook: the one being written in.
* @param {boolean} [mayLoad] Whether a page may be loaded into the session.
*/
async open(notebook, mayLoad = true) {
async open(notebook) {
this.opening += 1;
this.writing = notebook ?? '';
this.mayLoad = mayLoad;
this.clearMarks();
this.dialog.showModal();
element('chooser_message').textContent = '';
@@ -289,10 +285,6 @@ export class NotebookChooser {
title.textContent = pageTitle(pageName);
card.append(picture, title);
card.addEventListener('click', async () => {
if (!this.mayLoad) {
this.say('Only the host and co-hosts can start a page for everyone from a kept page.');
return;
}
const text = await readPage(notebook, pageName);
if (text === null) return;
this.dialog.close();
+4 -1
View File
@@ -83,7 +83,10 @@ export function randomId(byteCount = 9) {
* @typedef {object} Invitation
* @property {string} relay Address of the relay, ws:// or wss://.
* @property {string} secret The session secret.
* @property {string} hostId Participant id of the host.
* @property {string} hostId Participant id of whoever started the
* session (named the host until 2026-10-18; the field keeps its name
* so that invitations and page files are unchanged). It gives no
* right.
*/
/**
+24 -74
View File
@@ -230,7 +230,7 @@ const CONNECTION_GRACE_MS = 1500;
* The notice line between the toolbar and the page. It says nothing
* while all is well. A STANDING notice, kept by its source until that
* source clears it, is a state: not connected, the page not being
* saved, no page from the host yet. A PASSING notice is a report of
* saved, no page from the others yet. A PASSING notice is a report of
* one event, kept for a few seconds: a page kept or exported, a file
* that was not an image. Standing notices come before a passing one.
*/
@@ -470,7 +470,6 @@ async function enterSession(invitationText, name, notebook) {
input('invitation').value = config.site ? formatInvitationLink(config.site, plainInvitation) : plainInvitation;
showInvitationCode(config.site ? input('invitation').value : '');
const isHost = identity.id === invitation.hostId;
let tool = /** @type {Tool} */ ('pen');
/** The Shape tool's current shape, remembered in this browser (Andy, 2026-10-14). */
let shape = shapeNamed(localStorage.getItem(SHAPE_KEY));
@@ -501,7 +500,7 @@ async function enterSession(invitationText, name, notebook) {
function showPageNumber() {
const waiting = session.page.number === 0;
element('page_number').textContent = waiting ? 'No page yet' : `Page ${session.page.number}`;
standingNotice('page', waiting ? 'Waiting for the host’s page' : '', false);
standingNotice('page', waiting ? 'Waiting for the others’ page; New starts one' : '', false);
}
/** @type {ReturnType<typeof setTimeout> | undefined} */
let connectionNoticeTimer;
@@ -568,18 +567,6 @@ async function enterSession(invitationText, name, notebook) {
textEditor?.layout();
writer.changed();
},
onViewCommand: (command) => {
log(`The host's view: ${command === 'fit' ? 'Fit' : 'Normal scale'}`);
if (command === 'fit') renderer.fit();
else renderer.sheetScale();
},
onPermissionsChanged: () => {
log(`May start pages: ${session.mayMakePages() ? 'yes' : 'no'}` +
(session.isHost ? `; guests allowed: ${session.permitted.size}` : ''));
showHostTools();
showParticipants(session, renderer);
refreshArrangeCues();
},
onParticipantsChanged: () => {
syncLayers();
showParticipants(session, renderer);
@@ -846,12 +833,11 @@ async function enterSession(invitationText, name, notebook) {
/**
* With the Arrange tool chosen, mark everything a press can move or
* resize with a blue box and corners (Andy, 2026-10-14): the images
* and Hides for the host and co-hosts, one's own text blocks and
* shapes; free text, which has no width to resize, gets the box without
* the corners. The one being held is left out, the green grip standing
* in its place until the pointer lifts. Called whenever the images,
* the Hides, one's own text or shapes, the right, or the hold may have
* changed.
* and Hides, one's own text blocks and shapes; free text, which has
* no width to resize, gets the box without the corners. The one being
* held is left out, the green grip standing in its place until the
* pointer lifts. Called whenever the images, the Hides, one's own
* text or shapes, or the hold may have changed.
*/
function refreshArrangeCues() {
if (tool !== 'arrange') {
@@ -861,7 +847,6 @@ async function enterSession(invitationText, name, notebook) {
/** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]} */
const cues = [];
const page = session.page;
if (session.mayMakePages()) {
page.images.forEach((im, index) => {
if (held?.kind === 'image' && held.index === index) return;
cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height }, corners: true });
@@ -870,7 +855,6 @@ async function enterSession(invitationText, name, notebook) {
if (held?.kind === 'hide' && held.index === index) return;
cues.push({ kind: 'hide', rect: { left: h.x, top: h.y, width: h.width, height: h.height }, corners: true });
});
}
for (const block of session.ownLayer.texts.values()) {
if (held?.kind === 'text' && held.id === block.id) continue;
cues.push({ kind: 'text', rect: textBounds(block), corners: block.height !== null });
@@ -1110,13 +1094,13 @@ async function enterSession(invitationText, name, notebook) {
if (rects.length) return rects;
const page = session.page;
const hide = page.hideAt(x, y, 0);
if (hide && session.mayMakePages()) {
if (hide) {
const h = page.hides[hide.index];
return [{ left: h.x, top: h.y, width: h.width, height: h.height }];
}
const found = page.imageAt(x, y, 0);
const image = found && page.images[found.index];
if (image && (session.mayMakePages() || image.by === session.identity.id)) {
if (image) {
return [{ left: image.x, top: image.y, width: image.width, height: image.height }];
}
return [];
@@ -1143,15 +1127,15 @@ async function enterSession(invitationText, name, notebook) {
zoomTo: (pixelsUp) => renderer.zoomDragTo(pixelsUp),
endZoom: () => renderer.endZoomDrag(),
// A hold moves what is under the pen, or resizes it from a corner: a
// Hide rectangle or an image (for the host and co-hosts), or a
// text block of our own. The dot drawn meanwhile goes.
// Hide rectangle, an image, or a text block of our own. The dot
// drawn meanwhile goes.
beginHold: (clientX, clientY) => {
const [x, y] = renderer.pageFromClient(clientX, clientY);
const reach = renderer.cornerReach();
const page = session.page;
/** @type {Held | null} */
let found = null;
const hide = session.mayMakePages() ? page.hideAt(x, y, reach) : null;
const hide = page.hideAt(x, y, reach);
if (hide) {
const h = page.hides[hide.index];
found = hold('hide', hide.index, h.id, { left: h.x, top: h.y, width: h.width, height: h.height }, hide.corner, x, y);
@@ -1190,7 +1174,7 @@ async function enterSession(invitationText, name, notebook) {
const stroke = shapes[shapeAt.index];
const box = /** @type {import('./page_model.js').Rectangle} */ (strokeBox(stroke));
if (session.beginShapeEdit(stroke.id)) found = hold('shape', shapeAt.index, stroke.id, box, shapeAt.corner, x, y);
} else if (session.mayMakePages()) {
} else {
const image = page.imageAt(x, y, reach);
if (image) {
const index = session.raiseImage(image.index);
@@ -1277,8 +1261,8 @@ async function enterSession(invitationText, name, notebook) {
lastEmptyTap = 0;
return;
}
// A tap of the eraser on a Hide rectangle or an image removes it
// (the right to make pages, or having added the image).
// A tap of the eraser on a Hide rectangle or an image removes it,
// for everyone (by anyone since 2026-10-18).
let took = erased.strokes + erased.texts > 0;
if (!took) {
const hide = session.page.hideAt(x, y, 0);
@@ -1458,8 +1442,8 @@ async function enterSession(invitationText, name, notebook) {
textEditor?.layout();
viewChanged();
};
// The host's Fit and Normal scale ("A4" until 2026-10-14) reach everyone (Andy, 2026-10-08); a guest's
// are the guest's own.
// Fit and Normal scale ("A4" until 2026-10-14) are each person's own
// (2026-10-18; the host's reached everyone from 2026-10-08 until then).
// The page and view commands live in the Page menu (Andy, 2026-10-14:
// seldom used, and the one row is precious).
attachPopup(element('page_menu_button'), element('page_menu'));
@@ -1505,14 +1489,8 @@ async function enterSession(invitationText, name, notebook) {
log(`The manual could not be copied: ${error}`);
}
});
element('zoom_fit').addEventListener('click', () => {
renderer.fit();
session.sendViewCommand('fit');
});
element('zoom_sheet').addEventListener('click', () => {
renderer.sheetScale();
session.sendViewCommand('sheet');
});
element('zoom_fit').addEventListener('click', () => renderer.fit());
element('zoom_sheet').addEventListener('click', () => renderer.sheetScale());
// "Expand" makes the sheet itself fill the window at the present zoom,
// in both directions; nothing on it moves (Andy, 2026-10-08 for the
// width, both ways 2026-10-11).
@@ -1541,21 +1519,9 @@ async function enterSession(invitationText, name, notebook) {
element('desk').addEventListener('scroll', viewChanged, { passive: true });
window.addEventListener('pagehide', saveView);
// The host drives: the host starts new pages, and may let a guest do so.
function showHostTools() {
// New and Image are the host's; Notebooks, in the same group so that
// nothing moves for the host, is everyone's (the chooser reserves
// loading).
for (const id of ['new_page', 'load_image']) element(id).hidden = !session.mayMakePages();
// The Hide tool is for the host and co-hosts; losing the right
// while it is chosen puts the pen back in hand.
const hideTool = /** @type {HTMLElement} */ (document.querySelector('[data-tool=hide]'));
hideTool.hidden = !session.mayMakePages();
if (hideTool.hidden && tool === 'hide') /** @type {HTMLElement} */ (document.querySelector('[data-tool=pen]')).click();
element('zoom_expand').hidden = !session.mayMakePages();
element('zoom_shrink').hidden = !session.mayMakePages();
}
showHostTools();
// Everyone has every button (Andy and his teacher, 2026-10-18; until
// then New, Image, Hide, Expand and Shrink were the host's and the
// co-hosts').
element('new_page').addEventListener('click', () => {
log('New page asked for');
session.newPage();
@@ -1567,7 +1533,7 @@ async function enterSession(invitationText, name, notebook) {
* @param {Blob} file
*/
async function loadImage(file) {
if (!session.mayMakePages() || session.page.number === 0) return;
if (session.page.number === 0) return;
try {
if (!session.addImage(await prepareImage(file))) {
throw new Error('The page has no room for another image.');
@@ -1588,7 +1554,7 @@ async function enterSession(invitationText, name, notebook) {
// A deleted notebook leaves the toolbar's list.
showNotebooks(sessionPicker).catch(() => {});
});
element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook, session.mayMakePages()); });
element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook); });
// Image files are chosen, and pictures of the page exported, through a
// chooser of our own, which begins where the notebooks are.
@@ -2056,20 +2022,6 @@ function showParticipants(session, renderer) {
const present = participant.self || participant.peer !== null;
item.classList.toggle('absent', !present);
if (session.isHost && !participant.self) {
// A checkbox with its label to the left, like Extend (Andy, 2026-10-09).
const allowLine = document.createElement('label');
allowLine.className = 'allow_line';
allowLine.setAttribute('data-help', 'Make this participant a co-host: they may then start pages, put images on the page and arrange them, load notebook pages, expand, shrink and hide, as you can.');
const allow = document.createElement('input');
allow.type = 'checkbox';
allow.className = 'allow';
allow.checked = session.permitted.has(participant.id);
allow.addEventListener('change', () => session.setPermitted(participant.id, allow.checked));
allowLine.append('Co-host ', allow);
item.append(allowLine);
}
const label = document.createElement('label');
const visible = document.createElement('input');
visible.type = 'checkbox';
@@ -2082,8 +2034,6 @@ function showParticipants(session, renderer) {
const roles = [];
if (participant.self) roles.push('you');
if (participant.host) roles.push('host');
else if (!session.isHost && session.permitted.has(participant.id)) roles.push('co-host');
if (!present) roles.push('away');
if (roles.length) {
const role = document.createElement('span');
+1 -1
View File
@@ -165,7 +165,7 @@ function strokeShapes(stroke) {
* @param {object} options
* @param {string} options.session The session the page belongs to (its
* room id), so that returning to the session continues the page.
* @param {string} options.hostId
* @param {string} options.hostId Who started the session (`data-host`).
* @param {Set<string>} [options.hidden] Layers to leave out: for a picture
* of the page as someone shows it, never for the page file itself.
* @returns {string} The page file.
+8 -7
View File
@@ -338,8 +338,8 @@ function withoutData(image) {
}
/**
* What identifies a page and gives its size. The host sends it to the
* others when the page changes.
* What identifies a page and gives its size. Whoever starts or changes
* a page sends it to the others.
* @typedef {object} PageDescriptor
* @property {string} id
* @property {number} number Counts the pages of a session from 1.
@@ -504,8 +504,8 @@ export class Page {
/**
* @param {number} [width]
* @param {number} [height]
* @param {string} [id] Empty for the placeholder a guest shows
* before the host's page arrives.
* @param {string} [id] Empty for the placeholder someone joining
* shows before a page arrives.
* @param {number} [number] 0 for the placeholder.
*/
constructor(width = BLANK_PAGE_WIDTH, height = BLANK_PAGE_HEIGHT, id = '', number = 0) {
@@ -781,9 +781,10 @@ export class Page {
}
/**
* Layer ids from bottom to top. The host's layer is on top so that
* corrections lie over the writing they correct.
* @param {string} hostId
* Layer ids from bottom to top. The layer of whoever started the
* session is on top, so that a teacher's corrections lie over the
* writing they correct (a drawing order, not a right).
* @param {string} hostId Who started the session.
* @returns {string[]}
*/
layerOrder(hostId) {
+38 -129
View File
@@ -46,7 +46,6 @@ import { trimTrailingBlankLines } from './text_layout.js';
* @property {string} name
* @property {number | null} peer Relay peer number; null when not connected.
* @property {boolean} self
* @property {boolean} host
*/
/**
@@ -64,11 +63,6 @@ import { trimTrailingBlankLines } from './text_layout.js';
* @property {(byImages: boolean) => void} onPageResized
* The page grew (or shrank) around its contents; `byImages` if an
* image was added or taken back, not if writing reached an edge.
* @property {() => void} onPermissionsChanged
* Who may start pages changed.
* @property {(command: 'fit' | 'sheet') => void} onViewCommand
* The host pressed "Fit" or "Normal scale": everyone's view follows (Andy,
* 2026-10-08), each in their own window.
* @property {() => void} onParticipantsChanged
* @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus
* @property {() => void} [onUndoChanged]
@@ -81,7 +75,9 @@ import { trimTrailingBlankLines } from './text_layout.js';
* @property {string} roomId
* @property {CryptoKey} key
* @property {Identity} identity
* @property {string} hostId
* @property {string} hostId Who started the session, the id in the
* invitation: the page file records it and that layer is drawn on
* top; it gives no right (everyone has the same, Andy, 2026-10-18).
* @property {string} name Our display name.
* @property {Page} [page] The page as last saved, when returning to
* the session.
@@ -146,13 +142,11 @@ export class Session {
this.hideEditing = null;
this.events = events;
this.isHost = this.identity.id === this.hostId;
/** @type {Set<string>} Guests the host allows to start pages. */
this.permitted = new Set();
// The host begins a session with page 1. A guest shows a placeholder
// until the host's page arrives.
// Whoever starts a session begins on page 1. Someone joining shows
// a placeholder until a page arrives from the others (or starts one
// with New, if nobody is there).
this.page = options.page ??
(this.isHost ? new Page(undefined, undefined, randomId(), 1) : new Page());
(this.identity.id === this.hostId ? new Page(undefined, undefined, randomId(), 1) : new Page());
this.ownLayer = this.page.layer(this.identity.id, this.name);
this.page.recomputeStrokeBounds();
this.page.updateExtent();
@@ -161,13 +155,11 @@ export class Session {
this.participants = new Map();
this.participants.set(this.identity.id, {
id: this.identity.id, name: this.name, peer: null, self: true,
host: this.identity.id === this.hostId,
});
for (const layer of this.page.layers.values()) {
if (layer.id === this.identity.id) continue;
this.participants.set(layer.id, {
id: layer.id, name: layer.name || 'Unnamed', peer: null, self: false,
host: layer.id === this.hostId,
});
}
@@ -241,34 +233,10 @@ export class Session {
return this.page.layerOrder(this.hostId);
}
// Pages.
/** @returns {boolean} Whether we may start pages: the host, or a guest the host allows. */
mayMakePages() {
return this.isHost || this.permitted.has(this.identity.id);
}
/**
* The host's "Fit" or "Normal scale" goes to everyone. Only the host's does.
* @param {'fit' | 'sheet'} command
*/
sendViewCommand(command) {
if (!this.isHost) return;
this.send(0, { type: 'view', command });
}
/**
* Allow a guest to start pages, or stop allowing it. Only the host may.
* @param {string} id
* @param {boolean} allowed
*/
setPermitted(id, allowed) {
if (!this.isHost || id === this.identity.id) return;
if (allowed) this.permitted.add(id);
else this.permitted.delete(id);
this.send(0, { type: 'permissions', ids: [...this.permitted] });
this.events.onPermissionsChanged();
}
// Pages. Everyone may start, load and change them (Andy and his
// teacher, 2026-10-18: the host's rights and the co-host went; what
// an accident costs is said in the documentation, and the page left
// is kept in every notebook).
/**
* The page's rectangle may have changed with its contents.
@@ -279,11 +247,10 @@ export class Session {
}
/**
* Start a new blank page for everyone. The host may; so may a guest the
* host allows.
* Start a new blank page for everyone. Someone with no page yet (the
* others gone) starts page 1.
*/
newPage() {
if (!this.mayMakePages()) return;
const page = new Page(undefined, undefined, randomId(), this.page.number + 1);
page.updateExtent();
this.replacePage(page);
@@ -292,7 +259,6 @@ export class Session {
/**
* Add an image to the page for everyone, below the images already on it.
* The host may; so may a guest the host allows.
* @param {object} prepared
* @param {PageImage['type']} prepared.type
* @param {Uint8Array<ArrayBuffer>} prepared.data
@@ -303,7 +269,7 @@ export class Session {
*/
addImage(prepared) {
const page = this.page;
if (!this.mayMakePages() || page.number === 0) return false;
if (page.number === 0) return false;
const place = placeImage(page, prepared.naturalWidth, prepared.naturalHeight);
if (!page.hasRoomForImage(prepared.data.length, place.y + place.height)) return false;
/** @type {PageImage} */
@@ -333,7 +299,7 @@ export class Session {
*/
addHide(rect) {
const page = this.page;
if (!this.mayMakePages() || page.number === 0 || page.hides.length >= MAX_PAGE_HIDES) return false;
if (page.number === 0 || page.hides.length >= MAX_PAGE_HIDES) return false;
const hide = {
id: randomId(), x: Math.round(rect.left), y: Math.round(rect.top),
width: Math.round(rect.width), height: Math.round(rect.height),
@@ -377,7 +343,7 @@ export class Session {
placeHideByHand(index, rect) {
const page = this.page;
const hide = page.hides[index];
if (!this.mayMakePages() || !hide) return;
if (!hide) return;
hide.x = Math.round(rect.left);
hide.y = Math.round(rect.top);
hide.width = Math.round(rect.width);
@@ -394,7 +360,7 @@ export class Session {
removeHide(index) {
const page = this.page;
const hide = page.hides[index];
if (!this.mayMakePages() || !hide) return false;
if (!hide) return false;
page.hides.splice(index, 1);
page.imagesRevision += 1;
this.sendPageNow();
@@ -424,7 +390,7 @@ export class Session {
*/
raiseImage(index) {
const page = this.page;
if (!this.mayMakePages() || index < 0 || index >= page.images.length) return index;
if (index < 0 || index >= page.images.length) return index;
if (index !== page.images.length - 1) {
const [image] = page.images.splice(index, 1);
page.images.push(image);
@@ -443,7 +409,7 @@ export class Session {
*/
placeImageByHand(index, rect) {
const page = this.page;
if (!this.mayMakePages() || index < 0 || index >= page.images.length) return index;
if (index < 0 || index >= page.images.length) return index;
index = this.raiseImage(index);
const image = page.images[index];
image.x = Math.round(rect.left);
@@ -496,7 +462,7 @@ export class Session {
*/
expandPage(width, height) {
const page = this.page;
if (!this.mayMakePages() || page.number === 0) return false;
if (page.number === 0) return false;
// Equally on both sides, and top and bottom, of the PAGE as it is,
// writing included (the sheet may lie inside a page that writing has
// grown; measured from the sheet, the page came out short, Andy,
@@ -530,7 +496,7 @@ export class Session {
*/
shrinkPage() {
const page = this.page;
if (!this.mayMakePages() || page.number === 0) return false;
if (page.number === 0) return false;
// The blank lines at the end of one's own text blocks go first (Andy,
// 2026-10-14); another's blocks are theirs, trimmed when they finish
// typing. Each trim is a gesture of its own to undo; a block being
@@ -564,7 +530,7 @@ export class Session {
removeImage(index) {
const page = this.page;
const image = page.images[index];
if (!image || !(this.mayMakePages() || image.by === this.identity.id)) return false;
if (!image) return false;
page.images.splice(index, 1);
page.imagesRevision += 1;
this.imageTransfers.delete(image.hash);
@@ -581,7 +547,6 @@ export class Session {
* @param {Page} kept As read from the page file.
*/
loadPage(kept) {
if (!this.mayMakePages()) return;
const page = new Page(kept.baseWidth, kept.baseHeight, randomId(), this.page.number + 1);
page.baseLeft = kept.baseLeft;
page.baseTop = kept.baseTop;
@@ -595,9 +560,7 @@ export class Session {
}
for (const block of layer.texts.values()) copy.texts.set(block.id, { ...block, lines: block.lines.slice() });
if (!this.participants.has(id)) {
this.participants.set(id, {
id, name: layer.name || 'Unnamed', peer: null, self: false, host: id === this.hostId,
});
this.participants.set(id, { id, name: layer.name || 'Unnamed', peer: null, self: false });
}
}
page.recomputeStrokeBounds();
@@ -744,15 +707,18 @@ export class Session {
/**
* Move to a page that another participant is on, if it is newer than
* ours. Pages are numbered by the host, so the highest number is the
* session's current page; between two pages of the same number, the
* host's is taken. If it is the page we are on, it may have gained
* ours. Whoever presses New numbers the page one past their own, so
* the highest number is the session's current page; between two
* different pages of the same number (two people pressed New at the
* same moment, or apart while disconnected) the one with the greater
* id is taken, the same choice on every machine, and the loser's page
* is kept in its notebook like any page left (2026-10-18; the host's
* won until then). If it is the page we are on, it may have gained
* images.
* @param {unknown} received A page descriptor.
* @param {boolean} fromHost
* @param {number} peer Who told us.
*/
considerPage(received, fromHost, peer) {
considerPage(received, peer) {
const descriptor = cleanPageDescriptor(received);
if (!descriptor) return;
if (descriptor.id === this.page.id) {
@@ -760,44 +726,12 @@ export class Session {
return;
}
const newer = descriptor.number > this.page.number ||
(descriptor.number === this.page.number && fromHost);
(descriptor.number === this.page.number && descriptor.id > this.page.id);
if (!newer) return;
this.replacePage(Page.from(descriptor));
this.pageSourcePeer = peer;
}
/**
* Whether a page descriptor from a participant without the right to
* make pages is one they may still send: our page, the sheet as it is,
* and the images ours with only images they added left out (Andy,
* 2026-10-08: whoever added an image may remove it, right or no right).
* @param {any} received
* @param {string} id The sender.
* @returns {boolean}
*/
removesOnlyOwnImages(received, id) {
const descriptor = cleanPageDescriptor(received);
const page = this.page;
if (!descriptor || descriptor.id !== page.id || descriptor.left !== page.baseLeft ||
descriptor.top !== page.baseTop || descriptor.width !== page.baseWidth ||
descriptor.height !== page.baseHeight ||
JSON.stringify(descriptor.hides) !== JSON.stringify(page.hides)) return false;
/** @param {Omit<PageImage, 'data'>} image */
const key = (image) => `${image.hash} ${image.x} ${image.y} ${image.width} ${image.height} ${image.by}`;
let at = 0;
let removed = 0;
for (const ours of page.images) {
if (at < descriptor.images.length && key(descriptor.images[at]) === key(ours)) {
at += 1;
} else if (ours.by === id) {
removed += 1;
} else {
return false;
}
}
return at === descriptor.images.length && removed > 0;
}
/**
* Another participant's list of the images on the page we are on. The
* list with the higher revision is the later one. Of two different
@@ -1116,7 +1050,6 @@ export class Session {
return 'a move of a shape';
case 'hide': {
const page = this.page;
if (!this.mayMakePages()) return null;
const index = page.hides.findIndex((one) => one.id === entry.id);
if (entry.before === null) {
if (index < 0) return null;
@@ -1197,7 +1130,6 @@ export class Session {
type: 'hello', reply, name: this.name, publicKey: this.identity.publicKey,
signature: await signClaim(this.identity, this.roomId, peer),
page: this.page.number > 0 ? this.page.descriptor() : null,
permitted: this.isHost ? [...this.permitted] : undefined,
}));
}
@@ -1305,9 +1237,8 @@ export class Session {
const previous = this.participants.get(id);
if (previous && previous.peer !== null) this.peerIds.delete(previous.peer);
this.peerIds.set(peer, id);
this.participants.set(id, { id, name, peer, self: false, host: id === this.hostId });
if (id === this.hostId) this.receivedPermissions(message.permitted);
this.considerPage(message.page, id === this.hostId, peer);
this.participants.set(id, { id, name, peer, self: false });
this.considerPage(message.page, peer);
// This peer is on our page: it can give us the images we lack.
if (message.page?.id === this.page.id) this.requestImages(peer);
this.page.layer(id, name);
@@ -1329,20 +1260,12 @@ export class Session {
const id = this.peerIds.get(peer);
if (!id) return;
if (message.type === 'page') {
if (id === this.hostId || this.permitted.has(id)) this.considerPage(message.page, true, peer);
else if (this.removesOnlyOwnImages(message.page, id)) this.considerPage(message.page, false, peer);
return;
}
if (message.type === 'permissions') {
if (id === this.hostId) this.receivedPermissions(message.ids);
return;
}
if (message.type === 'view') {
if (id === this.hostId && (message.command === 'fit' || message.command === 'sheet')) {
this.events.onViewCommand(message.command);
}
this.considerPage(message.page, peer);
return;
}
// 'permissions' and 'view' came from a host, until 2026-10-18; an
// older client's are ignored.
if (message.type === 'permissions' || message.type === 'view') return;
if (message.type === 'image') {
this.receivedImagePiece(message);
return;
@@ -1482,19 +1405,6 @@ export class Session {
}
}
/**
* The host said who may start pages.
* @param {unknown} ids
*/
receivedPermissions(ids) {
if (!Array.isArray(ids)) return;
const permitted = new Set(ids.filter(validId));
const same = permitted.size === this.permitted.size && [...permitted].every((id) => this.permitted.has(id));
if (same) return;
this.permitted = permitted;
this.events.onPermissionsChanged();
}
/**
* Whether to accept a snapshot of a layer from a peer. A participant may
* always send their own layer. The peer that moved us to this page may
@@ -1543,7 +1453,6 @@ export class Session {
if (!this.participants.has(layerId)) {
this.participants.set(layerId, {
id: layerId, name: snapshot.name || 'Unnamed', peer: null, self: false,
host: layerId === this.hostId,
});
this.events.onParticipantsChanged();
}
-2
View File
@@ -529,8 +529,6 @@ h2 {
.session_body li.absent { color: var(--muted); }
.session_body li label { flex: 1; }
.session_body li .role { font-size: 0.8rem; color: var(--muted); }
.session_body li .allow_line { order: 1; display: flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--muted); cursor: pointer; }
.session_body li .allow_line input { margin: 0; }
.invitation_line { display: flex; gap: 0.5rem; align-items: center; }
.invitation_line input { flex: 1; min-width: 0; }
/* The invitation as a QR code, for the camera of a phone or a tablet. */
+114 -106
View File
@@ -136,7 +136,7 @@ PAGE_STATE = """
hash: one.hash, x: one.x, y: one.y, width: one.width, height: one.height,
ready: one.data !== null, shown: i < shown.length && !shown[i].hidden})),
participants: session.participantList().map(
(p) => ({name: p.name, host: p.host, present: p.self || p.peer !== null})),
(p) => ({name: p.name, present: p.self || p.peer !== null})),
layers,
texts,
};
@@ -265,14 +265,6 @@ PEN_WAVE = """
page.setPointerCapture = capture;
"""
# Clicks the host's "Co-host" box beside the named participant.
TOGGLE_PERMISSION = """
const find = () => [...document.querySelectorAll('#participants li')]
.find((li) => li.textContent.includes(arguments[0])).querySelector('.allow');
find().click();
return String(find().checked); // The list is rebuilt by the click.
"""
# Makes an image of the given size in pixels and puts it on the page, as
# the "Image" button does with a file. Arguments: width, height, kind
# ('png' or 'jpeg'), a resolution in dots per inch to record in the file,
@@ -799,7 +791,10 @@ class EndToEndTest(unittest.TestCase):
def test_browser_guest(self):
"""A guest with nothing installed: the host's invitation is a link to
the Kokkoja web site, and the guest's notebook is in the browser."""
the Kokkoja web site, and the guest's notebook is in the browser.
Everyone has every button (2026-10-18): the guest puts an image on
the page, goes on without the host, and a newcomer to an empty
room starts page 1."""
firefox = self.firefox
today = time.strftime("%Y_%m_%d")
@@ -959,14 +954,11 @@ class EndToEndTest(unittest.TestCase):
self.assertTrue(picture.startswith(b"\x89PNG"))
self.assertIn("downloads", firefox.run("return document.getElementById('notice').textContent;"))
# Allowed by the host, the guest puts an image on the page (the
# file would come from the browser's own chooser).
self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;"))
firefox.switch_to(host_tab)
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Guest"), "true")
firefox.switch_to(guest_tab)
self.wait_until(lambda s: not firefox.run("return document.getElementById('new_page').hidden;"),
"the guest to be allowed to start pages")
# The guest puts an image on the page (the file would come from the
# browser's own chooser); nobody's leave is needed (2026-10-18).
self.assertFalse(firefox.run("return document.getElementById('new_page').hidden;"))
self.assertFalse(firefox.run("return document.getElementById('load_image').hidden;"))
self.assertIsNone(firefox.run("return document.querySelector('#participants .allow');"), "no Co-host box")
firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, False)
guest = self.wait_until(lambda s: s["image"] and s["image"]["ready"], "the guest's image")
firefox.switch_to(host_tab)
@@ -985,9 +977,34 @@ class EndToEndTest(unittest.TestCase):
self.assertEqual(reloaded["layers"]["Guest"], guest["layers"]["Guest"])
self.assertEqual(len(reloaded["participants"]), 2)
# The host leaves the session; the tabs are closed for the next test.
# The host leaves; the guest goes on, and starts a new page without
# anyone's leave. A newcomer to the room with nobody left in it
# has no page, and New gives page 1; between two different pages
# of one number the greater id wins, on every machine alike.
firefox.switch_to(host_tab)
firefox.go("about:blank")
firefox.switch_to(guest_tab)
self.wait_until(lambda s: [p["present"] for p in s["participants"]] == [True, False], "the host to be away")
firefox.run("document.getElementById('new_page').click();")
self.wait_until(lambda s: s["page"]["number"] == 2, "the guest's new page")
firefox.in_session("DELETE", "/window")
firefox.switch_to(host_tab)
firefox.go(self.address("Late", join=urllib.parse.unquote(link.split("#join=")[1])))
self.enter_name("Latecomer", "join_session")
self.wait_until(lambda s: s["status"] == "Connected", "the latecomer to connect")
time.sleep(0.5)
self.assertEqual(self.state()["page"]["number"], 0)
self.assertIn("New starts one", firefox.run("return document.getElementById('notice').textContent;"))
firefox.run("document.getElementById('new_page').click();")
self.wait_until(lambda s: s["page"]["number"] == 1, "the latecomer's page 1")
tie = lambda id: firefox.run("""
const session = window.kokkoja.session;
session.considerPage({...session.page.descriptor(), id: arguments[0]}, 0);
return [session.page.number, session.page.id];
""", id)
own = firefox.run("return window.kokkoja.session.page.id;")
self.assertEqual(tie("-" * 22), [1, own], "a lesser id loses")
self.assertEqual(tie("z" * 22), [1, "z" * 22], "a greater id wins")
firefox.go("about:blank")
def test_tooltips(self):
@@ -1086,7 +1103,8 @@ class EndToEndTest(unittest.TestCase):
def test_hide_rectangles(self):
"""The teacher's requests of 2026-10-08: a Hide rectangle covers part
of the page in white for everyone, marked by blue corners."""
of the page in white for everyone, marked by blue corners. Everyone
has the tool and may move or remove a Hide (2026-10-18)."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
@@ -1096,7 +1114,7 @@ class EndToEndTest(unittest.TestCase):
firefox.go(self.address("Student", join=invitation))
self.enter_name("Watcher", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the watcher")
self.assertTrue(firefox.run("return document.querySelector('[data-tool=hide]').hidden;"))
self.assertFalse(firefox.run("return document.querySelector('[data-tool=hide]').hidden;"))
firefox.switch_to(host_tab)
self.assertFalse(firefox.run("return document.querySelector('[data-tool=hide]').hidden;"))
@@ -1121,8 +1139,9 @@ class EndToEndTest(unittest.TestCase):
self.assertFalse(white_at(hide[0] + hide[2] + 100, hide[1] + hide[3] + 100))
# The watcher sees it; hiding the host's layer on the watcher's
# screen does not uncover it; the watcher can neither move nor
# remove it.
# screen does not uncover it. The watcher moves it by a hold, as
# anyone may (2026-10-18), and takes the move back with Undo; the
# host sees both.
firefox.switch_to(guest_tab)
self.wait_until(lambda s: s["hides"] == made["hides"], "the Hide to reach the watcher")
firefox.run("""
@@ -1134,9 +1153,16 @@ class EndToEndTest(unittest.TestCase):
origin = self.state()["origin"]
firefox.run_until_done(PEN_HOLD, (hide[0] + 50 - origin[0]) * vscale, (hide[1] + 50 - origin[1]) * vscale,
100 * vscale, 0, 800)
firefox.run(ERASER_AT, hide[0] + 50, hide[1] + 50, 5)
time.sleep(0.5)
self.assertEqual(self.state()["hides"], made["hides"])
moved_by_watcher = [hide[0] + 100, *hide[1:]]
self.wait_until(lambda s: s["hides"] == [moved_by_watcher], "the Hide moved by the watcher")
firefox.switch_to(host_tab)
self.wait_until(lambda s: s["hides"] == [moved_by_watcher], "the watcher's move to reach the host")
firefox.switch_to(guest_tab)
firefox.run("document.getElementById('undo').click();")
self.wait_until(lambda s: s["hides"] == made["hides"], "the watcher's Undo")
firefox.switch_to(host_tab)
self.wait_until(lambda s: s["hides"] == made["hides"], "the Undo to reach the host")
firefox.switch_to(guest_tab)
# The host moves it by a hold, resizes it freely from a corner,
# and the file carries it above every layer.
@@ -1409,43 +1435,36 @@ class EndToEndTest(unittest.TestCase):
firefox.run("document.querySelector('[data-tool=pen]').click();")
scaled = erased_not
# A guest given the right adds an image, keeps the power to remove it
# after the right is withdrawn, and cannot remove the host's.
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Viewer"), "true")
# The guest adds an image and removes it with a tap of the eraser,
# nobody's leave being needed (2026-10-18).
firefox.switch_to(guest_tab)
self.wait_until(lambda s: not firefox.run("return document.getElementById('new_page').hidden;"),
"the viewer to be allowed")
self.assertFalse(firefox.run("return document.getElementById('new_page').hidden;"))
firefox.run_until_done(LOAD_IMAGE, 200, 100, "png", 0, False)
added = self.wait_until(lambda s: len(s["images"]) == 3, "the viewer's image")
firefox.switch_to(host_tab)
self.wait_until(lambda s: len(s["images"]) == 3, "the viewer's image to reach the host")
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Viewer"), "false")
firefox.switch_to(guest_tab)
self.wait_until(lambda s: firefox.run("return document.getElementById('new_page').hidden;"),
"the right to be withdrawn")
own = added["images"][2]
firefox.run(ERASER_AT, own["x"] + 100, own["y"] + 50, 5)
self.wait_until(lambda s: len(s["images"]) == 2, "the viewer's own image removed")
hosts = self.state()["images"][1]
firefox.run(ERASER_AT, hosts["x"] + 50, hosts["y"] + 50, 5)
time.sleep(0.5)
self.assertEqual(len(self.state()["images"]), 2)
firefox.switch_to(host_tab)
self.wait_until(lambda s: len(s["images"]) == 2, "the removal to reach the host")
firefox.switch_to(guest_tab)
# The viewer sees the same, and cannot move an image: a hold there
# draws a dot instead.
firefox.switch_to(guest_tab)
# The viewer sees the same, and moves the host's image by a hold,
# which draws no dot; the host sees the move.
viewer = self.wait_until(lambda s: s["images"] and rect(s, 1) == rect(scaled, 1), "the viewer's copy")
self.assertEqual(viewer["images"][1]["hash"], scaled["images"][1]["hash"])
vscale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
viewer_strokes = len(viewer["layers"].get("Viewer", []))
firefox.run_until_done(PEN_HOLD, (viewer["images"][1]["x"] + 50 - viewer["origin"][0]) * vscale,
(viewer["images"][1]["y"] + 50 - viewer["origin"][1]) * vscale, 60 * vscale, 0, 800)
time.sleep(0.5)
after = self.state()
self.assertEqual(rect(after, 1), rect(scaled, 1))
self.assertEqual(len(after["layers"].get("Viewer", [])), 1)
after = self.wait_until(lambda s: s["images"][1]["x"] == scaled["images"][1]["x"] + 60, "the image moved by the viewer")
self.assertEqual(rect(after, 1)[1:], rect(scaled, 1)[1:])
self.assertEqual(len(after["layers"].get("Viewer", [])), viewer_strokes)
firefox.switch_to(host_tab)
self.wait_until(lambda s: s["images"][1]["x"] == scaled["images"][1]["x"] + 60, "the viewer's move to reach the host")
firefox.switch_to(guest_tab)
firefox.in_session("DELETE", "/window")
firefox.switch_to(host_tab)
@@ -1501,8 +1520,9 @@ class EndToEndTest(unittest.TestCase):
image (and, until 2026-10-14, every Hide and own text rectangle,
which carry blue corners at all times since), a drag inside one
moves it and a drag on a corner resizes it, at once, no hold; a
press elsewhere does nothing. A guest without the right sees no
cues, their own text rectangle's corners being drawn anyway."""
press elsewhere does nothing. A guest sees the same cues on the
image and the Hide (2026-10-18; none before), and one on their
own text."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
@@ -1640,17 +1660,17 @@ class EndToEndTest(unittest.TestCase):
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
self.assertEqual(cues(), ["image", "hide", "text", "text"])
# The watcher, without the right, is shown no cue on the image or
# the Hide, only on their own text.
# The watcher is shown the cues on the image and the Hide too, and
# on their own text.
firefox.switch_to(guest_tab)
self.wait_until(lambda s: len(s["images"]) == 1 and len(s["hides"]) == 1, "the page at the watcher")
choose("arrange")
self.assertEqual(cues(), [])
self.assertEqual(cues(), ["image", "hide"])
choose("text")
firefox.run(TEXT_GESTURE, 0.1, 0.8, 0.3, 0.9)
firefox.type_into(".text_field", "Mine")
choose("arrange")
self.assertEqual(cues(), ["text"])
self.assertEqual(cues(), ["image", "hide", "text"])
mine = self.wait_until(lambda s: s["texts"].get("Watcher") and s["texts"]["Watcher"][0]["text"] == "Mine", "the watcher's box")
mine = mine["texts"]["Watcher"][0]
self.assert_hover_cue(mine["x"] + 20, mine["y"] + 20, [mine["x"], mine["y"], mine["width"], mine["height"]], "rect")
@@ -2224,7 +2244,7 @@ class EndToEndTest(unittest.TestCase):
self.assertNotIn("Copy", names)
self.assertEqual(len(names), len(set(names)), "no two entries of one name")
self.assertIn("\nPen\nWrite on the page. Only your own writing can be erased.\n", manual)
self.assertIn("\nFit to the window\nShow the whole page in the window.", manual)
self.assertIn("\nFit to the window\nShow the whole page in the window, on your screen only.", manual)
self.assertTrue(all(len(line) <= 80 for line in manual.split("\n")), "wrapped at 80")
self.assertTrue(any(len(line) > 60 for line in manual.split("\n")), "filled, not one word a line")
# Every tooltip is in it; those that differ only by a number (the
@@ -2460,7 +2480,6 @@ class EndToEndTest(unittest.TestCase):
"the student to receive the teacher's layer")
self.assertEqual(student["layers"]["Teacher"], teacher["layers"]["Teacher"])
teacher_first_page = {"Teacher": teacher["layers"]["Teacher"]}
self.assertEqual([p["host"] for p in student["participants"]], [False, True])
# The student writes with a pen; the teacher sees the same strokes.
for fx in (0.15, 0.4, 0.65):
@@ -2545,12 +2564,11 @@ class EndToEndTest(unittest.TestCase):
self.assertIn("Already kept", firefox.run(
"return document.getElementById('notice').textContent;"))
# Only the host can start a new page.
self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;"))
firefox.switch_to(teacher_tab)
# Everyone has New (2026-10-18; the host alone until then).
self.assertFalse(firefox.run("return document.getElementById('new_page').hidden;"))
firefox.switch_to(teacher_tab)
# The host starts a new page. Everyone moves to it, and everyone's
# The teacher starts a new page. Everyone moves to it, and everyone's
# old page is kept without anyone asking: the teacher's now, the
# student's already.
firefox.run("document.getElementById('new_page').click();")
@@ -2624,8 +2642,10 @@ class EndToEndTest(unittest.TestCase):
self.assertEqual(doubled["strokes"], before["strokes"])
firefox.run(CTRL_DRAG, 0.5, 0.3, 0.5, 0.3, 104)
self.assertAlmostEqual(zoom()["width"], before["width"], delta=2)
# The host's Fit and Normal scale reach the guest, who then fits the page to
# the guest's own window; the guest's own Fit stays the guest's.
# Fit and Normal scale are each person's own (2026-10-18; the
# host's reached everyone from 2026-10-08 until then): the
# teacher's Fit leaves the student's zoom alone, and the student's
# Normal scale the teacher's.
firefox.switch_to(student_tab)
firefox.run("""
document.getElementById('desk').dispatchEvent(new WheelEvent('wheel',
@@ -2634,12 +2654,13 @@ class EndToEndTest(unittest.TestCase):
guest_zoomed = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
firefox.switch_to(teacher_tab)
firefox.run("document.getElementById('zoom_fit').click();")
firefox.switch_to(student_tab)
self.wait_until_true(lambda: firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;"),
lambda v: v < guest_zoomed, "the guest's view to follow the host's Fit")
firefox.run("document.getElementById('zoom_sheet').click();")
firefox.switch_to(teacher_tab)
host_scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
firefox.switch_to(student_tab)
time.sleep(0.5)
self.assertEqual(firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;"), guest_zoomed)
firefox.run("document.getElementById('zoom_sheet').click();")
self.assertNotEqual(firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;"), guest_zoomed)
firefox.switch_to(teacher_tab)
time.sleep(0.5)
self.assertEqual(firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;"), host_scale)
@@ -2679,7 +2700,7 @@ class EndToEndTest(unittest.TestCase):
self.assertGreater(wide_page["height"], teacher["page"]["height"])
firefox.switch_to(student_tab)
self.wait_until(lambda s: s["page"] == wide_page, "the expanded page to reach the student")
self.assertTrue(firefox.run("return document.getElementById('zoom_expand').hidden;"))
self.assertFalse(firefox.run("return document.getElementById('zoom_expand').hidden;"))
self.assertEqual(firefox.run("return window.kokkoja.session.page.baseLeft;"),
-((wide_page["width"] - teacher["page"]["width"]) // 2))
# The page file carries the sheet's edges and reads back with them.
@@ -3007,8 +3028,8 @@ class EndToEndTest(unittest.TestCase):
self.assertEqual(alone["page"], teacher["page"])
self.assertEqual(alone["image"], teacher["image"])
self.assertEqual(alone["participants"], [
{"name": "Teacher", "host": True, "present": True},
{"name": "Student", "host": False, "present": False}])
{"name": "Teacher", "present": True},
{"name": "Student", "present": False}])
firefox.run("document.getElementById('zoom_fit').click();") # The view came back scrolled.
firefox.drag("#page", 100, 500, 300, 520)
self.current_page("Teacher notebook", {"Teacher": 1, "Student": 1}, number=3)
@@ -3082,22 +3103,14 @@ class EndToEndTest(unittest.TestCase):
return [shown.naturalWidth, shown.naturalHeight];
"""), [2400, 1600])
# The student joins the new session. The host lets the student
# start pages, the student loads an image, and the host withdraws
# the permission.
# The student joins the new session and starts a page with an
# image on it, as anyone may (2026-10-18).
invitation = firefox.run("return document.getElementById('invitation').value;")
firefox.switch_to(student_tab)
firefox.go(self.address("Student", join=invitation))
self.enter_name("Student", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 2 and s["image"] and s["image"]["ready"],
"the student to reach the large image page")
self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;"))
firefox.switch_to(teacher_tab)
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Student"), "true")
firefox.switch_to(student_tab)
self.wait_until(
lambda s: not firefox.run("return document.getElementById('new_page').hidden;"),
"the student to be allowed to start pages")
firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, True)
student = self.wait_until(lambda s: s["page"]["number"] == 3, "the student's image page")
self.assertEqual(student["page"], {"number": 3, "width": 2970, "height": 2100})
@@ -3106,14 +3119,7 @@ class EndToEndTest(unittest.TestCase):
lambda s: s["page"]["number"] == 3 and s["image"] and s["image"]["ready"],
"the student's image to reach the teacher")
self.assertEqual(teacher["image"], student["image"])
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Student"), "false")
firefox.switch_to(student_tab)
self.wait_until(
lambda s: firefox.run("return document.getElementById('new_page').hidden;"),
"the student's permission to be withdrawn")
firefox.run("window.kokkoja.session.newPage();")
time.sleep(1)
self.assertEqual(self.state()["page"]["number"], 3)
# Writing past the edge of the page makes the page grow, for
# everyone, in steps of 5 mm.
@@ -3370,19 +3376,6 @@ class EndToEndTest(unittest.TestCase):
"""), lambda names: "Other notebook" not in names, "the toolbar's list to lose the notebook")
firefox.run("document.getElementById('chooser_close').click();")
# A guest has the chooser too, to look at and clear their own
# notebook; a card tells them that loading is the host's.
firefox.switch_to(student_tab)
number = self.state()["page"]["number"]
firefox.run("document.getElementById('load_notebook_page').click();")
self.wait_until_true(lambda: firefox.run("""
return document.querySelectorAll('#chooser_pages .page_card').length;
"""), lambda n: n >= 1, "the student's chooser to list kept pages")
firefox.run("document.querySelector('#chooser_pages .page_card').click();")
self.assertIn("Only the host and co-hosts", firefox.run("return document.getElementById('chooser_message').textContent;"))
self.assertTrue(firefox.run("return document.getElementById('notebook_chooser').open;"))
self.assertEqual(self.state()["page"]["number"], number)
firefox.run("document.getElementById('chooser_close').click();")
firefox.switch_to(teacher_tab)
# A page that its images have made longer than a sheet is first
# shown at the scale at which one sheet fits the window.
@@ -3394,7 +3387,20 @@ class EndToEndTest(unittest.TestCase):
"the loaded page of three images to reach the student")
self.assertEqual(student["images"], stacked["images"])
self.assertEqual(firefox.run(VIEW), long_view)
# The student's chooser loads a kept page for everyone too
# (2026-10-18; a card refused until then): a new page, the
# teacher following.
number = student["page"]["number"]
firefox.run("document.getElementById('load_notebook_page').click();")
self.wait_until_true(lambda: firefox.run("""
return document.querySelectorAll('#chooser_pages .page_card').length;
"""), lambda n: n >= 1, "the student's chooser to list kept pages")
firefox.run("document.querySelector('#chooser_pages .page_card').click();")
self.wait_until(lambda s: s["page"]["number"] == number + 1, "the page the student loaded")
self.assertFalse(firefox.run("return document.getElementById('notebook_chooser').open;"))
firefox.switch_to(teacher_tab)
self.wait_until(lambda s: s["page"]["number"] == number + 1, "the student's loaded page to reach the teacher")
# Five colours and four widths in the toolbar. "+" among the
# colours opens a palette of our own, in which the colours already
@@ -3557,9 +3563,10 @@ class EndToEndTest(unittest.TestCase):
self.assertTrue(all(abs(a - b) <= 3 for a, b in zip(colours["image"], (200, 60, 60))), colours)
firefox.run("document.querySelector('#participants input[type=checkbox]').click();")
# A tap of the eraser on an image removes it, for whoever may make
# pages or added the image; a sweep across it does not. Writing on
# it stays; the sheet keeps its size.
# A tap of the eraser on an image removes it, by anyone (2026-10-18;
# the host, a co-host or whoever added it until then); a sweep
# across it does not. Writing on it stays; the sheet keeps its
# size.
firefox.run("document.getElementById('new_page').click();")
number = self.wait_until(lambda s: not s["images"], "another new page")["page"]["number"]
firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, False)
@@ -3571,18 +3578,19 @@ class EndToEndTest(unittest.TestCase):
firefox.switch_to(student_tab)
self.wait_until(lambda s: len(s["images"]) == 2 and all(i["ready"] for i in s["images"]),
"the two images to reach the student")
# Not the student, who has no right and did not add it.
firefox.run(ERASER_AT, 1050, 1500, 5)
# Not by moving the eraser across it: the student's sweep leaves it.
firefox.run(ERASER_AT, 1050, 1500, 100)
firefox.run(PEN_WAVE, 0.3, 0.5)
firefox.switch_to(teacher_tab)
self.wait_until(lambda s: len(s["layers"].get("Student", [])) == 1 and s["layers"]["Student"][0]["done"],
"the student's writing on the image")
# Not by moving the eraser across it.
firefox.run(ERASER_AT, 1050, 1500, 100)
time.sleep(0.3)
self.assertEqual(rectangles(self.state()), rectangles(two))
# A tap by the teacher removes it, written on or not.
# A tap by the student, who did not add it, removes it, written on
# or not; the teacher sees it go.
firefox.switch_to(student_tab)
firefox.run(ERASER_AT, 1050, 1500, 5)
self.wait_until(lambda s: len(s["images"]) == 1, "the image removed by the student")
firefox.switch_to(teacher_tab)
one = self.wait_until(lambda s: len(s["images"]) == 1, "the image to be removed")
self.assertEqual(one["page"], {"number": number, "width": 2970, "height": 3250})
self.assertEqual(rectangles(one), [[1335, 50, 300, 400]])