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
+215 -354

No files matched your search

+7 -7
View File
@@ -13,24 +13,24 @@ may be used.
## Documentation ## Documentation
The user documentation (an introduction, Kokkoja in five minutes, a The user documentation (an introduction, Kokkoja in five minutes,
guest's Kokkoja, a host's Kokkoja, and how Kokkoja works) is being moved using Kokkoja, and how Kokkoja works) is being moved
to a new format and is not in this repository at the moment. It will to a new format and is not in this repository at the moment. It will
return with a later release. return with a later release.
## In short ## In short
A guest needs nothing but a web browser: open the invitation link, enter Joining 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, a name, join. Anyone can start a session from the same web site,
`https://app.kokkoja.com/`, with the notebooks kept in the `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 python3 kokkoja.py --relay wss://app.kokkoja.com/relay
opens Kokkoja in the browser; *Start a session* gives an invitation to 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. Documents directory.
## What is here ## What is here
+7 -7
View File
@@ -82,12 +82,12 @@
</div> </div>
</div> </div>
<div class="group after_settings" role="radiogroup" aria-label="Hide, Arrange and Eraser"> <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> 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" <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> 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" <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> </div>
<button id="undo" type="button" disabled <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> 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 id="page_menu" class="popup menu" role="menu" hidden>
<div class="menu_title">Change the display of the page</div> <div class="menu_title">Change the display of the page</div>
<button id="zoom_fit" type="button" class="menu_item" role="menuitem" <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" <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> <hr>
<div class="menu_title">Change the size of the page</div> <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> 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> 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> <hr>
<label class="menu_item toggle" role="menuitemcheckbox" <label class="menu_item toggle" role="menuitemcheckbox"
@@ -119,7 +119,7 @@
<button id="load_image" type="button" <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> 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" <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>
<div class="group" aria-label="Notebook"> <div class="group" aria-label="Notebook">
<select id="session_notebook" class="notebook_pick" aria-label="Current 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; * 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 * in the browser they are gone for good, and the button says that. The
* notebook being written in cannot be marked: its mark explains. * notebook being written in cannot be marked: its mark explains.
* Everyone has the chooser (Andy, 2026-10-11), so that a guest can see * Everyone has the chooser (Andy, 2026-10-11), so that anyone can see
* and clear what their notebook holds, in a browser above all; loading * and clear what their notebook holds, in a browser above all, and
* a page into the session is for the host and co-hosts, and a card says * since 2026-10-18 anyone may load a page into the session from it.
* so to anyone else.
*/ */
import { import {
@@ -163,7 +162,6 @@ export class NotebookChooser {
/** The notebook being written in, which cannot be deleted while it is. */ /** The notebook being written in, which cannot be deleted while it is. */
this.writing = ''; this.writing = '';
/** Whether this participant may load a page into the session. */ /** 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. */ /** Whether deletions go to a Trash (the local program's) or are for good. */
this.trash = false; this.trash = false;
/** Counts openings, so that pictures fetched for an earlier one are dropped. */ /** 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. * Show the notebooks, or the pages of the only notebook.
* @param {string} [notebook] Open on this notebook: the one being written in. * @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.opening += 1;
this.writing = notebook ?? ''; this.writing = notebook ?? '';
this.mayLoad = mayLoad;
this.clearMarks(); this.clearMarks();
this.dialog.showModal(); this.dialog.showModal();
element('chooser_message').textContent = ''; element('chooser_message').textContent = '';
@@ -289,10 +285,6 @@ export class NotebookChooser {
title.textContent = pageTitle(pageName); title.textContent = pageTitle(pageName);
card.append(picture, title); card.append(picture, title);
card.addEventListener('click', async () => { 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); const text = await readPage(notebook, pageName);
if (text === null) return; if (text === null) return;
this.dialog.close(); this.dialog.close();
+4 -1
View File
@@ -83,7 +83,10 @@ export function randomId(byteCount = 9) {
* @typedef {object} Invitation * @typedef {object} Invitation
* @property {string} relay Address of the relay, ws:// or wss://. * @property {string} relay Address of the relay, ws:// or wss://.
* @property {string} secret The session secret. * @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.
*/ */
/** /**
+32 -82
View File
@@ -230,7 +230,7 @@ const CONNECTION_GRACE_MS = 1500;
* The notice line between the toolbar and the page. It says nothing * 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 * 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 * 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 * 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. * 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; input('invitation').value = config.site ? formatInvitationLink(config.site, plainInvitation) : plainInvitation;
showInvitationCode(config.site ? input('invitation').value : ''); showInvitationCode(config.site ? input('invitation').value : '');
const isHost = identity.id === invitation.hostId;
let tool = /** @type {Tool} */ ('pen'); let tool = /** @type {Tool} */ ('pen');
/** The Shape tool's current shape, remembered in this browser (Andy, 2026-10-14). */ /** The Shape tool's current shape, remembered in this browser (Andy, 2026-10-14). */
let shape = shapeNamed(localStorage.getItem(SHAPE_KEY)); let shape = shapeNamed(localStorage.getItem(SHAPE_KEY));
@@ -501,7 +500,7 @@ async function enterSession(invitationText, name, notebook) {
function showPageNumber() { function showPageNumber() {
const waiting = session.page.number === 0; const waiting = session.page.number === 0;
element('page_number').textContent = waiting ? 'No page yet' : `Page ${session.page.number}`; 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} */ /** @type {ReturnType<typeof setTimeout> | undefined} */
let connectionNoticeTimer; let connectionNoticeTimer;
@@ -568,18 +567,6 @@ async function enterSession(invitationText, name, notebook) {
textEditor?.layout(); textEditor?.layout();
writer.changed(); 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: () => { onParticipantsChanged: () => {
syncLayers(); syncLayers();
showParticipants(session, renderer); 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 * 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 * 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 * and Hides, one's own text blocks and shapes; free text, which has
* shapes; free text, which has no width to resize, gets the box without * no width to resize, gets the box without the corners. The one being
* the corners. The one being held is left out, the green grip standing * held is left out, the green grip standing in its place until the
* in its place until the pointer lifts. Called whenever the images, * pointer lifts. Called whenever the images, the Hides, one's own
* the Hides, one's own text or shapes, the right, or the hold may have * text or shapes, or the hold may have changed.
* changed.
*/ */
function refreshArrangeCues() { function refreshArrangeCues() {
if (tool !== 'arrange') { if (tool !== 'arrange') {
@@ -861,16 +847,14 @@ async function enterSession(invitationText, name, notebook) {
/** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]} */ /** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]} */
const cues = []; const cues = [];
const page = session.page; const page = session.page;
if (session.mayMakePages()) { page.images.forEach((im, index) => {
page.images.forEach((im, index) => { if (held?.kind === 'image' && held.index === index) return;
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 });
cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height }, corners: true }); });
}); page.hides.forEach((h, index) => {
page.hides.forEach((h, index) => { if (held?.kind === 'hide' && held.index === index) return;
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 });
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()) { for (const block of session.ownLayer.texts.values()) {
if (held?.kind === 'text' && held.id === block.id) continue; if (held?.kind === 'text' && held.id === block.id) continue;
cues.push({ kind: 'text', rect: textBounds(block), corners: block.height !== null }); 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; if (rects.length) return rects;
const page = session.page; const page = session.page;
const hide = page.hideAt(x, y, 0); const hide = page.hideAt(x, y, 0);
if (hide && session.mayMakePages()) { if (hide) {
const h = page.hides[hide.index]; const h = page.hides[hide.index];
return [{ left: h.x, top: h.y, width: h.width, height: h.height }]; return [{ left: h.x, top: h.y, width: h.width, height: h.height }];
} }
const found = page.imageAt(x, y, 0); const found = page.imageAt(x, y, 0);
const image = found && page.images[found.index]; 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 [{ left: image.x, top: image.y, width: image.width, height: image.height }];
} }
return []; return [];
@@ -1143,15 +1127,15 @@ async function enterSession(invitationText, name, notebook) {
zoomTo: (pixelsUp) => renderer.zoomDragTo(pixelsUp), zoomTo: (pixelsUp) => renderer.zoomDragTo(pixelsUp),
endZoom: () => renderer.endZoomDrag(), endZoom: () => renderer.endZoomDrag(),
// A hold moves what is under the pen, or resizes it from a corner: a // 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 // Hide rectangle, an image, or a text block of our own. The dot
// text block of our own. The dot drawn meanwhile goes. // drawn meanwhile goes.
beginHold: (clientX, clientY) => { beginHold: (clientX, clientY) => {
const [x, y] = renderer.pageFromClient(clientX, clientY); const [x, y] = renderer.pageFromClient(clientX, clientY);
const reach = renderer.cornerReach(); const reach = renderer.cornerReach();
const page = session.page; const page = session.page;
/** @type {Held | null} */ /** @type {Held | null} */
let found = null; let found = null;
const hide = session.mayMakePages() ? page.hideAt(x, y, reach) : null; const hide = page.hideAt(x, y, reach);
if (hide) { if (hide) {
const h = page.hides[hide.index]; 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); 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 stroke = shapes[shapeAt.index];
const box = /** @type {import('./page_model.js').Rectangle} */ (strokeBox(stroke)); 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); 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); const image = page.imageAt(x, y, reach);
if (image) { if (image) {
const index = session.raiseImage(image.index); const index = session.raiseImage(image.index);
@@ -1277,8 +1261,8 @@ async function enterSession(invitationText, name, notebook) {
lastEmptyTap = 0; lastEmptyTap = 0;
return; return;
} }
// A tap of the eraser on a Hide rectangle or an image removes it // A tap of the eraser on a Hide rectangle or an image removes it,
// (the right to make pages, or having added the image). // for everyone (by anyone since 2026-10-18).
let took = erased.strokes + erased.texts > 0; let took = erased.strokes + erased.texts > 0;
if (!took) { if (!took) {
const hide = session.page.hideAt(x, y, 0); const hide = session.page.hideAt(x, y, 0);
@@ -1458,8 +1442,8 @@ async function enterSession(invitationText, name, notebook) {
textEditor?.layout(); textEditor?.layout();
viewChanged(); viewChanged();
}; };
// The host's Fit and Normal scale ("A4" until 2026-10-14) reach everyone (Andy, 2026-10-08); a guest's // Fit and Normal scale ("A4" until 2026-10-14) are each person's own
// are the guest'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: // The page and view commands live in the Page menu (Andy, 2026-10-14:
// seldom used, and the one row is precious). // seldom used, and the one row is precious).
attachPopup(element('page_menu_button'), element('page_menu')); 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}`); log(`The manual could not be copied: ${error}`);
} }
}); });
element('zoom_fit').addEventListener('click', () => { element('zoom_fit').addEventListener('click', () => renderer.fit());
renderer.fit(); element('zoom_sheet').addEventListener('click', () => renderer.sheetScale());
session.sendViewCommand('fit');
});
element('zoom_sheet').addEventListener('click', () => {
renderer.sheetScale();
session.sendViewCommand('sheet');
});
// "Expand" makes the sheet itself fill the window at the present zoom, // "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 // in both directions; nothing on it moves (Andy, 2026-10-08 for the
// width, both ways 2026-10-11). // width, both ways 2026-10-11).
@@ -1541,21 +1519,9 @@ async function enterSession(invitationText, name, notebook) {
element('desk').addEventListener('scroll', viewChanged, { passive: true }); element('desk').addEventListener('scroll', viewChanged, { passive: true });
window.addEventListener('pagehide', saveView); window.addEventListener('pagehide', saveView);
// The host drives: the host starts new pages, and may let a guest do so. // Everyone has every button (Andy and his teacher, 2026-10-18; until
function showHostTools() { // then New, Image, Hide, Expand and Shrink were the host's and the
// New and Image are the host's; Notebooks, in the same group so that // co-hosts').
// 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();
element('new_page').addEventListener('click', () => { element('new_page').addEventListener('click', () => {
log('New page asked for'); log('New page asked for');
session.newPage(); session.newPage();
@@ -1567,7 +1533,7 @@ async function enterSession(invitationText, name, notebook) {
* @param {Blob} file * @param {Blob} file
*/ */
async function loadImage(file) { async function loadImage(file) {
if (!session.mayMakePages() || session.page.number === 0) return; if (session.page.number === 0) return;
try { try {
if (!session.addImage(await prepareImage(file))) { if (!session.addImage(await prepareImage(file))) {
throw new Error('The page has no room for another image.'); 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. // A deleted notebook leaves the toolbar's list.
showNotebooks(sessionPicker).catch(() => {}); 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 // Image files are chosen, and pictures of the page exported, through a
// chooser of our own, which begins where the notebooks are. // 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; const present = participant.self || participant.peer !== null;
item.classList.toggle('absent', !present); 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 label = document.createElement('label');
const visible = document.createElement('input'); const visible = document.createElement('input');
visible.type = 'checkbox'; visible.type = 'checkbox';
@@ -2082,8 +2034,6 @@ function showParticipants(session, renderer) {
const roles = []; const roles = [];
if (participant.self) roles.push('you'); 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 (!present) roles.push('away');
if (roles.length) { if (roles.length) {
const role = document.createElement('span'); const role = document.createElement('span');
+1 -1
View File
@@ -165,7 +165,7 @@ function strokeShapes(stroke) {
* @param {object} options * @param {object} options
* @param {string} options.session The session the page belongs to (its * @param {string} options.session The session the page belongs to (its
* room id), so that returning to the session continues the page. * 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 * @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. * of the page as someone shows it, never for the page file itself.
* @returns {string} The page file. * @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 * What identifies a page and gives its size. Whoever starts or changes
* others when the page changes. * a page sends it to the others.
* @typedef {object} PageDescriptor * @typedef {object} PageDescriptor
* @property {string} id * @property {string} id
* @property {number} number Counts the pages of a session from 1. * @property {number} number Counts the pages of a session from 1.
@@ -504,8 +504,8 @@ export class Page {
/** /**
* @param {number} [width] * @param {number} [width]
* @param {number} [height] * @param {number} [height]
* @param {string} [id] Empty for the placeholder a guest shows * @param {string} [id] Empty for the placeholder someone joining
* before the host's page arrives. * shows before a page arrives.
* @param {number} [number] 0 for the placeholder. * @param {number} [number] 0 for the placeholder.
*/ */
constructor(width = BLANK_PAGE_WIDTH, height = BLANK_PAGE_HEIGHT, id = '', number = 0) { 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 * Layer ids from bottom to top. The layer of whoever started the
* corrections lie over the writing they correct. * session is on top, so that a teacher's corrections lie over the
* @param {string} hostId * writing they correct (a drawing order, not a right).
* @param {string} hostId Who started the session.
* @returns {string[]} * @returns {string[]}
*/ */
layerOrder(hostId) { layerOrder(hostId) {
+38 -129
View File
@@ -46,7 +46,6 @@ import { trimTrailingBlankLines } from './text_layout.js';
* @property {string} name * @property {string} name
* @property {number | null} peer Relay peer number; null when not connected. * @property {number | null} peer Relay peer number; null when not connected.
* @property {boolean} self * @property {boolean} self
* @property {boolean} host
*/ */
/** /**
@@ -64,11 +63,6 @@ import { trimTrailingBlankLines } from './text_layout.js';
* @property {(byImages: boolean) => void} onPageResized * @property {(byImages: boolean) => void} onPageResized
* The page grew (or shrank) around its contents; `byImages` if an * The page grew (or shrank) around its contents; `byImages` if an
* image was added or taken back, not if writing reached an edge. * 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 {() => void} onParticipantsChanged
* @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus * @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus
* @property {() => void} [onUndoChanged] * @property {() => void} [onUndoChanged]
@@ -81,7 +75,9 @@ import { trimTrailingBlankLines } from './text_layout.js';
* @property {string} roomId * @property {string} roomId
* @property {CryptoKey} key * @property {CryptoKey} key
* @property {Identity} identity * @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 {string} name Our display name.
* @property {Page} [page] The page as last saved, when returning to * @property {Page} [page] The page as last saved, when returning to
* the session. * the session.
@@ -146,13 +142,11 @@ export class Session {
this.hideEditing = null; this.hideEditing = null;
this.events = events; this.events = events;
this.isHost = this.identity.id === this.hostId; // Whoever starts a session begins on page 1. Someone joining shows
/** @type {Set<string>} Guests the host allows to start pages. */ // a placeholder until a page arrives from the others (or starts one
this.permitted = new Set(); // with New, if nobody is there).
// The host begins a session with page 1. A guest shows a placeholder
// until the host's page arrives.
this.page = options.page ?? 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.ownLayer = this.page.layer(this.identity.id, this.name);
this.page.recomputeStrokeBounds(); this.page.recomputeStrokeBounds();
this.page.updateExtent(); this.page.updateExtent();
@@ -161,13 +155,11 @@ export class Session {
this.participants = new Map(); this.participants = new Map();
this.participants.set(this.identity.id, { this.participants.set(this.identity.id, {
id: this.identity.id, name: this.name, peer: null, self: true, 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()) { for (const layer of this.page.layers.values()) {
if (layer.id === this.identity.id) continue; if (layer.id === this.identity.id) continue;
this.participants.set(layer.id, { this.participants.set(layer.id, {
id: layer.id, name: layer.name || 'Unnamed', peer: null, self: false, 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); return this.page.layerOrder(this.hostId);
} }
// Pages. // 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
/** @returns {boolean} Whether we may start pages: the host, or a guest the host allows. */ // an accident costs is said in the documentation, and the page left
mayMakePages() { // is kept in every notebook).
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();
}
/** /**
* The page's rectangle may have changed with its contents. * 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 * Start a new blank page for everyone. Someone with no page yet (the
* host allows. * others gone) starts page 1.
*/ */
newPage() { newPage() {
if (!this.mayMakePages()) return;
const page = new Page(undefined, undefined, randomId(), this.page.number + 1); const page = new Page(undefined, undefined, randomId(), this.page.number + 1);
page.updateExtent(); page.updateExtent();
this.replacePage(page); this.replacePage(page);
@@ -292,7 +259,6 @@ export class Session {
/** /**
* Add an image to the page for everyone, below the images already on it. * 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 {object} prepared
* @param {PageImage['type']} prepared.type * @param {PageImage['type']} prepared.type
* @param {Uint8Array<ArrayBuffer>} prepared.data * @param {Uint8Array<ArrayBuffer>} prepared.data
@@ -303,7 +269,7 @@ export class Session {
*/ */
addImage(prepared) { addImage(prepared) {
const page = this.page; 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); const place = placeImage(page, prepared.naturalWidth, prepared.naturalHeight);
if (!page.hasRoomForImage(prepared.data.length, place.y + place.height)) return false; if (!page.hasRoomForImage(prepared.data.length, place.y + place.height)) return false;
/** @type {PageImage} */ /** @type {PageImage} */
@@ -333,7 +299,7 @@ export class Session {
*/ */
addHide(rect) { addHide(rect) {
const page = this.page; 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 = { const hide = {
id: randomId(), x: Math.round(rect.left), y: Math.round(rect.top), id: randomId(), x: Math.round(rect.left), y: Math.round(rect.top),
width: Math.round(rect.width), height: Math.round(rect.height), width: Math.round(rect.width), height: Math.round(rect.height),
@@ -377,7 +343,7 @@ export class Session {
placeHideByHand(index, rect) { placeHideByHand(index, rect) {
const page = this.page; const page = this.page;
const hide = page.hides[index]; const hide = page.hides[index];
if (!this.mayMakePages() || !hide) return; if (!hide) return;
hide.x = Math.round(rect.left); hide.x = Math.round(rect.left);
hide.y = Math.round(rect.top); hide.y = Math.round(rect.top);
hide.width = Math.round(rect.width); hide.width = Math.round(rect.width);
@@ -394,7 +360,7 @@ export class Session {
removeHide(index) { removeHide(index) {
const page = this.page; const page = this.page;
const hide = page.hides[index]; const hide = page.hides[index];
if (!this.mayMakePages() || !hide) return false; if (!hide) return false;
page.hides.splice(index, 1); page.hides.splice(index, 1);
page.imagesRevision += 1; page.imagesRevision += 1;
this.sendPageNow(); this.sendPageNow();
@@ -424,7 +390,7 @@ export class Session {
*/ */
raiseImage(index) { raiseImage(index) {
const page = this.page; 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) { if (index !== page.images.length - 1) {
const [image] = page.images.splice(index, 1); const [image] = page.images.splice(index, 1);
page.images.push(image); page.images.push(image);
@@ -443,7 +409,7 @@ export class Session {
*/ */
placeImageByHand(index, rect) { placeImageByHand(index, rect) {
const page = this.page; 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); index = this.raiseImage(index);
const image = page.images[index]; const image = page.images[index];
image.x = Math.round(rect.left); image.x = Math.round(rect.left);
@@ -496,7 +462,7 @@ export class Session {
*/ */
expandPage(width, height) { expandPage(width, height) {
const page = this.page; 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, // 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 // writing included (the sheet may lie inside a page that writing has
// grown; measured from the sheet, the page came out short, Andy, // grown; measured from the sheet, the page came out short, Andy,
@@ -530,7 +496,7 @@ export class Session {
*/ */
shrinkPage() { shrinkPage() {
const page = this.page; 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, // 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 // 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 // typing. Each trim is a gesture of its own to undo; a block being
@@ -564,7 +530,7 @@ export class Session {
removeImage(index) { removeImage(index) {
const page = this.page; const page = this.page;
const image = page.images[index]; 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.images.splice(index, 1);
page.imagesRevision += 1; page.imagesRevision += 1;
this.imageTransfers.delete(image.hash); this.imageTransfers.delete(image.hash);
@@ -581,7 +547,6 @@ export class Session {
* @param {Page} kept As read from the page file. * @param {Page} kept As read from the page file.
*/ */
loadPage(kept) { loadPage(kept) {
if (!this.mayMakePages()) return;
const page = new Page(kept.baseWidth, kept.baseHeight, randomId(), this.page.number + 1); const page = new Page(kept.baseWidth, kept.baseHeight, randomId(), this.page.number + 1);
page.baseLeft = kept.baseLeft; page.baseLeft = kept.baseLeft;
page.baseTop = kept.baseTop; 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() }); for (const block of layer.texts.values()) copy.texts.set(block.id, { ...block, lines: block.lines.slice() });
if (!this.participants.has(id)) { if (!this.participants.has(id)) {
this.participants.set(id, { this.participants.set(id, { id, name: layer.name || 'Unnamed', peer: null, self: false });
id, name: layer.name || 'Unnamed', peer: null, self: false, host: id === this.hostId,
});
} }
} }
page.recomputeStrokeBounds(); page.recomputeStrokeBounds();
@@ -744,15 +707,18 @@ export class Session {
/** /**
* Move to a page that another participant is on, if it is newer than * 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 * ours. Whoever presses New numbers the page one past their own, so
* session's current page; between two pages of the same number, the * the highest number is the session's current page; between two
* host's is taken. If it is the page we are on, it may have gained * 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. * images.
* @param {unknown} received A page descriptor. * @param {unknown} received A page descriptor.
* @param {boolean} fromHost
* @param {number} peer Who told us. * @param {number} peer Who told us.
*/ */
considerPage(received, fromHost, peer) { considerPage(received, peer) {
const descriptor = cleanPageDescriptor(received); const descriptor = cleanPageDescriptor(received);
if (!descriptor) return; if (!descriptor) return;
if (descriptor.id === this.page.id) { if (descriptor.id === this.page.id) {
@@ -760,44 +726,12 @@ export class Session {
return; return;
} }
const newer = descriptor.number > this.page.number || 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; if (!newer) return;
this.replacePage(Page.from(descriptor)); this.replacePage(Page.from(descriptor));
this.pageSourcePeer = peer; 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 * 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 * 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'; return 'a move of a shape';
case 'hide': { case 'hide': {
const page = this.page; const page = this.page;
if (!this.mayMakePages()) return null;
const index = page.hides.findIndex((one) => one.id === entry.id); const index = page.hides.findIndex((one) => one.id === entry.id);
if (entry.before === null) { if (entry.before === null) {
if (index < 0) return null; if (index < 0) return null;
@@ -1197,7 +1130,6 @@ export class Session {
type: 'hello', reply, name: this.name, publicKey: this.identity.publicKey, type: 'hello', reply, name: this.name, publicKey: this.identity.publicKey,
signature: await signClaim(this.identity, this.roomId, peer), signature: await signClaim(this.identity, this.roomId, peer),
page: this.page.number > 0 ? this.page.descriptor() : null, 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); const previous = this.participants.get(id);
if (previous && previous.peer !== null) this.peerIds.delete(previous.peer); if (previous && previous.peer !== null) this.peerIds.delete(previous.peer);
this.peerIds.set(peer, id); this.peerIds.set(peer, id);
this.participants.set(id, { id, name, peer, self: false, host: id === this.hostId }); this.participants.set(id, { id, name, peer, self: false });
if (id === this.hostId) this.receivedPermissions(message.permitted); this.considerPage(message.page, peer);
this.considerPage(message.page, id === this.hostId, peer);
// This peer is on our page: it can give us the images we lack. // This peer is on our page: it can give us the images we lack.
if (message.page?.id === this.page.id) this.requestImages(peer); if (message.page?.id === this.page.id) this.requestImages(peer);
this.page.layer(id, name); this.page.layer(id, name);
@@ -1329,20 +1260,12 @@ export class Session {
const id = this.peerIds.get(peer); const id = this.peerIds.get(peer);
if (!id) return; if (!id) return;
if (message.type === 'page') { if (message.type === 'page') {
if (id === this.hostId || this.permitted.has(id)) this.considerPage(message.page, true, peer); this.considerPage(message.page, 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; 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') { if (message.type === 'image') {
this.receivedImagePiece(message); this.receivedImagePiece(message);
return; 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 * 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 * 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)) { if (!this.participants.has(layerId)) {
this.participants.set(layerId, { this.participants.set(layerId, {
id: layerId, name: snapshot.name || 'Unnamed', peer: null, self: false, id: layerId, name: snapshot.name || 'Unnamed', peer: null, self: false,
host: layerId === this.hostId,
}); });
this.events.onParticipantsChanged(); this.events.onParticipantsChanged();
} }
-2
View File
@@ -529,8 +529,6 @@ h2 {
.session_body li.absent { color: var(--muted); } .session_body li.absent { color: var(--muted); }
.session_body li label { flex: 1; } .session_body li label { flex: 1; }
.session_body li .role { font-size: 0.8rem; color: var(--muted); } .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 { display: flex; gap: 0.5rem; align-items: center; }
.invitation_line input { flex: 1; min-width: 0; } .invitation_line input { flex: 1; min-width: 0; }
/* The invitation as a QR code, for the camera of a phone or a tablet. */ /* 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, 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})), ready: one.data !== null, shown: i < shown.length && !shown[i].hidden})),
participants: session.participantList().map( 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, layers,
texts, texts,
}; };
@@ -265,14 +265,6 @@ PEN_WAVE = """
page.setPointerCapture = capture; 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 # 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 # 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, # ('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): def test_browser_guest(self):
"""A guest with nothing installed: the host's invitation is a link to """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 firefox = self.firefox
today = time.strftime("%Y_%m_%d") today = time.strftime("%Y_%m_%d")
@@ -959,14 +954,11 @@ class EndToEndTest(unittest.TestCase):
self.assertTrue(picture.startswith(b"\x89PNG")) self.assertTrue(picture.startswith(b"\x89PNG"))
self.assertIn("downloads", firefox.run("return document.getElementById('notice').textContent;")) self.assertIn("downloads", firefox.run("return document.getElementById('notice').textContent;"))
# Allowed by the host, the guest puts an image on the page (the # The guest puts an image on the page (the file would come from the
# file would come from the browser's own chooser). # browser's own chooser); nobody's leave is needed (2026-10-18).
self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;")) self.assertFalse(firefox.run("return document.getElementById('new_page').hidden;"))
firefox.switch_to(host_tab) self.assertFalse(firefox.run("return document.getElementById('load_image').hidden;"))
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Guest"), "true") self.assertIsNone(firefox.run("return document.querySelector('#participants .allow');"), "no Co-host box")
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")
firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, False) 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") guest = self.wait_until(lambda s: s["image"] and s["image"]["ready"], "the guest's image")
firefox.switch_to(host_tab) firefox.switch_to(host_tab)
@@ -985,9 +977,34 @@ class EndToEndTest(unittest.TestCase):
self.assertEqual(reloaded["layers"]["Guest"], guest["layers"]["Guest"]) self.assertEqual(reloaded["layers"]["Guest"], guest["layers"]["Guest"])
self.assertEqual(len(reloaded["participants"]), 2) 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.in_session("DELETE", "/window")
firefox.switch_to(host_tab) 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") firefox.go("about:blank")
def test_tooltips(self): def test_tooltips(self):
@@ -1086,7 +1103,8 @@ class EndToEndTest(unittest.TestCase):
def test_hide_rectangles(self): def test_hide_rectangles(self):
"""The teacher's requests of 2026-10-08: a Hide rectangle covers part """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 firefox = self.firefox
host_tab = firefox.current_tab() host_tab = firefox.current_tab()
firefox.go(self.address("Late")) firefox.go(self.address("Late"))
@@ -1096,7 +1114,7 @@ class EndToEndTest(unittest.TestCase):
firefox.go(self.address("Student", join=invitation)) firefox.go(self.address("Student", join=invitation))
self.enter_name("Watcher", "join_session") self.enter_name("Watcher", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the watcher") 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) firefox.switch_to(host_tab)
self.assertFalse(firefox.run("return document.querySelector('[data-tool=hide]').hidden;")) 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)) 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 # The watcher sees it; hiding the host's layer on the watcher's
# screen does not uncover it; the watcher can neither move nor # screen does not uncover it. The watcher moves it by a hold, as
# remove it. # anyone may (2026-10-18), and takes the move back with Undo; the
# host sees both.
firefox.switch_to(guest_tab) firefox.switch_to(guest_tab)
self.wait_until(lambda s: s["hides"] == made["hides"], "the Hide to reach the watcher") self.wait_until(lambda s: s["hides"] == made["hides"], "the Hide to reach the watcher")
firefox.run(""" firefox.run("""
@@ -1134,9 +1153,16 @@ class EndToEndTest(unittest.TestCase):
origin = self.state()["origin"] origin = self.state()["origin"]
firefox.run_until_done(PEN_HOLD, (hide[0] + 50 - origin[0]) * vscale, (hide[1] + 50 - origin[1]) * vscale, firefox.run_until_done(PEN_HOLD, (hide[0] + 50 - origin[0]) * vscale, (hide[1] + 50 - origin[1]) * vscale,
100 * vscale, 0, 800) 100 * vscale, 0, 800)
firefox.run(ERASER_AT, hide[0] + 50, hide[1] + 50, 5) moved_by_watcher = [hide[0] + 100, *hide[1:]]
time.sleep(0.5) self.wait_until(lambda s: s["hides"] == [moved_by_watcher], "the Hide moved by the watcher")
self.assertEqual(self.state()["hides"], made["hides"]) 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, # The host moves it by a hold, resizes it freely from a corner,
# and the file carries it above every layer. # and the file carries it above every layer.
@@ -1409,43 +1435,36 @@ class EndToEndTest(unittest.TestCase):
firefox.run("document.querySelector('[data-tool=pen]').click();") firefox.run("document.querySelector('[data-tool=pen]').click();")
scaled = erased_not scaled = erased_not
# A guest given the right adds an image, keeps the power to remove it # The guest adds an image and removes it with a tap of the eraser,
# after the right is withdrawn, and cannot remove the host's. # nobody's leave being needed (2026-10-18).
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Viewer"), "true")
firefox.switch_to(guest_tab) firefox.switch_to(guest_tab)
self.wait_until(lambda s: not firefox.run("return document.getElementById('new_page').hidden;"), self.assertFalse(firefox.run("return document.getElementById('new_page').hidden;"))
"the viewer to be allowed")
firefox.run_until_done(LOAD_IMAGE, 200, 100, "png", 0, False) 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") added = self.wait_until(lambda s: len(s["images"]) == 3, "the viewer's image")
firefox.switch_to(host_tab) firefox.switch_to(host_tab)
self.wait_until(lambda s: len(s["images"]) == 3, "the viewer's image to reach the host") 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) 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] own = added["images"][2]
firefox.run(ERASER_AT, own["x"] + 100, own["y"] + 50, 5) 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") 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) firefox.switch_to(host_tab)
self.wait_until(lambda s: len(s["images"]) == 2, "the removal to reach the host") self.wait_until(lambda s: len(s["images"]) == 2, "the removal to reach the host")
firefox.switch_to(guest_tab) firefox.switch_to(guest_tab)
# The viewer sees the same, and cannot move an image: a hold there # The viewer sees the same, and moves the host's image by a hold,
# draws a dot instead. # which draws no dot; the host sees the move.
firefox.switch_to(guest_tab)
viewer = self.wait_until(lambda s: s["images"] and rect(s, 1) == rect(scaled, 1), "the viewer's copy") 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"]) self.assertEqual(viewer["images"][1]["hash"], scaled["images"][1]["hash"])
vscale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") 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, 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) (viewer["images"][1]["y"] + 50 - viewer["origin"][1]) * vscale, 60 * vscale, 0, 800)
time.sleep(0.5) after = self.wait_until(lambda s: s["images"][1]["x"] == scaled["images"][1]["x"] + 60, "the image moved by the viewer")
after = self.state() self.assertEqual(rect(after, 1)[1:], rect(scaled, 1)[1:])
self.assertEqual(rect(after, 1), rect(scaled, 1)) self.assertEqual(len(after["layers"].get("Viewer", [])), viewer_strokes)
self.assertEqual(len(after["layers"].get("Viewer", [])), 1) 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.in_session("DELETE", "/window")
firefox.switch_to(host_tab) 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, image (and, until 2026-10-14, every Hide and own text rectangle,
which carry blue corners at all times since), a drag inside one 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 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 press elsewhere does nothing. A guest sees the same cues on the
cues, their own text rectangle's corners being drawn anyway.""" image and the Hide (2026-10-18; none before), and one on their
own text."""
firefox = self.firefox firefox = self.firefox
host_tab = firefox.current_tab() host_tab = firefox.current_tab()
firefox.go(self.address("Late")) firefox.go(self.address("Late"))
@@ -1640,17 +1660,17 @@ class EndToEndTest(unittest.TestCase):
firefox.pointer_actions([{"type": "pointerUp", "button": 0}]) firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
self.assertEqual(cues(), ["image", "hide", "text", "text"]) self.assertEqual(cues(), ["image", "hide", "text", "text"])
# The watcher, without the right, is shown no cue on the image or # The watcher is shown the cues on the image and the Hide too, and
# the Hide, only on their own text. # on their own text.
firefox.switch_to(guest_tab) firefox.switch_to(guest_tab)
self.wait_until(lambda s: len(s["images"]) == 1 and len(s["hides"]) == 1, "the page at the watcher") self.wait_until(lambda s: len(s["images"]) == 1 and len(s["hides"]) == 1, "the page at the watcher")
choose("arrange") choose("arrange")
self.assertEqual(cues(), []) self.assertEqual(cues(), ["image", "hide"])
choose("text") choose("text")
firefox.run(TEXT_GESTURE, 0.1, 0.8, 0.3, 0.9) firefox.run(TEXT_GESTURE, 0.1, 0.8, 0.3, 0.9)
firefox.type_into(".text_field", "Mine") firefox.type_into(".text_field", "Mine")
choose("arrange") 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 = 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] mine = mine["texts"]["Watcher"][0]
self.assert_hover_cue(mine["x"] + 20, mine["y"] + 20, [mine["x"], mine["y"], mine["width"], mine["height"]], "rect") 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.assertNotIn("Copy", names)
self.assertEqual(len(names), len(set(names)), "no two entries of one name") 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("\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(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") 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 # 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") "the student to receive the teacher's layer")
self.assertEqual(student["layers"]["Teacher"], teacher["layers"]["Teacher"]) self.assertEqual(student["layers"]["Teacher"], teacher["layers"]["Teacher"])
teacher_first_page = {"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. # The student writes with a pen; the teacher sees the same strokes.
for fx in (0.15, 0.4, 0.65): for fx in (0.15, 0.4, 0.65):
@@ -2545,12 +2564,11 @@ class EndToEndTest(unittest.TestCase):
self.assertIn("Already kept", firefox.run( self.assertIn("Already kept", firefox.run(
"return document.getElementById('notice').textContent;")) "return document.getElementById('notice').textContent;"))
# Only the host can start a new page. # Everyone has New (2026-10-18; the host alone until then).
self.assertTrue(firefox.run("return document.getElementById('new_page').hidden;"))
firefox.switch_to(teacher_tab)
self.assertFalse(firefox.run("return document.getElementById('new_page').hidden;")) 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 # old page is kept without anyone asking: the teacher's now, the
# student's already. # student's already.
firefox.run("document.getElementById('new_page').click();") firefox.run("document.getElementById('new_page').click();")
@@ -2624,8 +2642,10 @@ class EndToEndTest(unittest.TestCase):
self.assertEqual(doubled["strokes"], before["strokes"]) self.assertEqual(doubled["strokes"], before["strokes"])
firefox.run(CTRL_DRAG, 0.5, 0.3, 0.5, 0.3, 104) firefox.run(CTRL_DRAG, 0.5, 0.3, 0.5, 0.3, 104)
self.assertAlmostEqual(zoom()["width"], before["width"], delta=2) self.assertAlmostEqual(zoom()["width"], before["width"], delta=2)
# The host's Fit and Normal scale reach the guest, who then fits the page to # Fit and Normal scale are each person's own (2026-10-18; the
# the guest's own window; the guest's own Fit stays the guest's. # 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.switch_to(student_tab)
firefox.run(""" firefox.run("""
document.getElementById('desk').dispatchEvent(new WheelEvent('wheel', document.getElementById('desk').dispatchEvent(new WheelEvent('wheel',
@@ -2634,12 +2654,13 @@ class EndToEndTest(unittest.TestCase):
guest_zoomed = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") guest_zoomed = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
firefox.switch_to(teacher_tab) firefox.switch_to(teacher_tab)
firefox.run("document.getElementById('zoom_fit').click();") 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;") 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) time.sleep(0.5)
self.assertEqual(firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;"), host_scale) 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"]) self.assertGreater(wide_page["height"], teacher["page"]["height"])
firefox.switch_to(student_tab) firefox.switch_to(student_tab)
self.wait_until(lambda s: s["page"] == wide_page, "the expanded page to reach the student") 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;"), self.assertEqual(firefox.run("return window.kokkoja.session.page.baseLeft;"),
-((wide_page["width"] - teacher["page"]["width"]) // 2)) -((wide_page["width"] - teacher["page"]["width"]) // 2))
# The page file carries the sheet's edges and reads back with them. # 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["page"], teacher["page"])
self.assertEqual(alone["image"], teacher["image"]) self.assertEqual(alone["image"], teacher["image"])
self.assertEqual(alone["participants"], [ self.assertEqual(alone["participants"], [
{"name": "Teacher", "host": True, "present": True}, {"name": "Teacher", "present": True},
{"name": "Student", "host": False, "present": False}]) {"name": "Student", "present": False}])
firefox.run("document.getElementById('zoom_fit').click();") # The view came back scrolled. firefox.run("document.getElementById('zoom_fit').click();") # The view came back scrolled.
firefox.drag("#page", 100, 500, 300, 520) firefox.drag("#page", 100, 500, 300, 520)
self.current_page("Teacher notebook", {"Teacher": 1, "Student": 1}, number=3) self.current_page("Teacher notebook", {"Teacher": 1, "Student": 1}, number=3)
@@ -3082,22 +3103,14 @@ class EndToEndTest(unittest.TestCase):
return [shown.naturalWidth, shown.naturalHeight]; return [shown.naturalWidth, shown.naturalHeight];
"""), [2400, 1600]) """), [2400, 1600])
# The student joins the new session. The host lets the student # The student joins the new session and starts a page with an
# start pages, the student loads an image, and the host withdraws # image on it, as anyone may (2026-10-18).
# the permission.
invitation = firefox.run("return document.getElementById('invitation').value;") invitation = firefox.run("return document.getElementById('invitation').value;")
firefox.switch_to(student_tab) firefox.switch_to(student_tab)
firefox.go(self.address("Student", join=invitation)) firefox.go(self.address("Student", join=invitation))
self.enter_name("Student", "join_session") self.enter_name("Student", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 2 and s["image"] and s["image"]["ready"], self.wait_until(lambda s: s["page"]["number"] == 2 and s["image"] and s["image"]["ready"],
"the student to reach the large image page") "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) 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") 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}) 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"], lambda s: s["page"]["number"] == 3 and s["image"] and s["image"]["ready"],
"the student's image to reach the teacher") "the student's image to reach the teacher")
self.assertEqual(teacher["image"], student["image"]) self.assertEqual(teacher["image"], student["image"])
self.assertEqual(firefox.run(TOGGLE_PERMISSION, "Student"), "false")
firefox.switch_to(student_tab) 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 # Writing past the edge of the page makes the page grow, for
# everyone, in steps of 5 mm. # 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") """), lambda names: "Other notebook" not in names, "the toolbar's list to lose the notebook")
firefox.run("document.getElementById('chooser_close').click();") 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) firefox.switch_to(teacher_tab)
# A page that its images have made longer than a sheet is first # A page that its images have made longer than a sheet is first
# shown at the scale at which one sheet fits the window. # 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") "the loaded page of three images to reach the student")
self.assertEqual(student["images"], stacked["images"]) self.assertEqual(student["images"], stacked["images"])
self.assertEqual(firefox.run(VIEW), long_view) 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) 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 # Five colours and four widths in the toolbar. "+" among the
# colours opens a palette of our own, in which the colours already # 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) 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();") firefox.run("document.querySelector('#participants input[type=checkbox]').click();")
# A tap of the eraser on an image removes it, for whoever may make # A tap of the eraser on an image removes it, by anyone (2026-10-18;
# pages or added the image; a sweep across it does not. Writing on # the host, a co-host or whoever added it until then); a sweep
# it stays; the sheet keeps its size. # across it does not. Writing on it stays; the sheet keeps its
# size.
firefox.run("document.getElementById('new_page').click();") firefox.run("document.getElementById('new_page').click();")
number = self.wait_until(lambda s: not s["images"], "another new page")["page"]["number"] 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) firefox.run_until_done(LOAD_IMAGE, 300, 400, "png", 0, False)
@@ -3571,18 +3578,19 @@ class EndToEndTest(unittest.TestCase):
firefox.switch_to(student_tab) firefox.switch_to(student_tab)
self.wait_until(lambda s: len(s["images"]) == 2 and all(i["ready"] for i in s["images"]), 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") "the two images to reach the student")
# Not the student, who has no right and did not add it. # Not by moving the eraser across it: the student's sweep leaves it.
firefox.run(ERASER_AT, 1050, 1500, 5) firefox.run(ERASER_AT, 1050, 1500, 100)
firefox.run(PEN_WAVE, 0.3, 0.5) firefox.run(PEN_WAVE, 0.3, 0.5)
firefox.switch_to(teacher_tab) firefox.switch_to(teacher_tab)
self.wait_until(lambda s: len(s["layers"].get("Student", [])) == 1 and s["layers"]["Student"][0]["done"], self.wait_until(lambda s: len(s["layers"].get("Student", [])) == 1 and s["layers"]["Student"][0]["done"],
"the student's writing on the image") "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)) 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) 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") 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(one["page"], {"number": number, "width": 2970, "height": 3250})
self.assertEqual(rectangles(one), [[1335, 50, 300, 400]]) self.assertEqual(rectangles(one), [[1335, 50, 300, 400]])