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:
1 parent
5d63a0f758
commit
60ad747e32
10 files changed
+207
-346
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+24
-74
@@ -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,7 +847,6 @@ 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 });
|
||||||
@@ -870,7 +855,6 @@ async function enterSession(invitationText, name, notebook) {
|
|||||||
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');
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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]])
|
||||||
|
|||||||
Reference in new issue
Block a user