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
+215
-354
No files matched your search
+7
-7
@@ -82,12 +82,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="group after_settings" role="radiogroup" aria-label="Hide, Arrange and Eraser">
|
||||
<button type="button" data-tool="hide" aria-pressed="false" hidden
|
||||
<button type="button" data-tool="hide" aria-pressed="false"
|
||||
data-help="Hide part of the page for everyone: drag open a rectangle, and what lies under it is covered in white, marked by blue corners. Rest the pen on it to move it, or near a corner to resize it, or use Arrange; a tap of the eraser removes it.">Hide</button>
|
||||
<button type="button" data-tool="arrange" aria-pressed="false"
|
||||
data-help="Move and resize: the corners of every image are marked in blue, as those of Hides and text rectangles always are. Drag inside an image, a Hide or a text rectangle of yours to move it, drag a corner to resize it (an image keeps its shape). A press elsewhere does nothing. With any other tool, resting the pen on one of them for half a second does the same.">Arrange</button>
|
||||
<button type="button" data-tool="eraser" aria-pressed="false"
|
||||
data-help="Erase your own strokes and text by moving over them. A tap on an image, or on a Hide rectangle, removes it, if you are the host or a co-host (or added the image yourself). Two quick taps on empty paper, away from anything the eraser would remove, undo your latest mark; again, and the one before.">Eraser</button>
|
||||
data-help="Erase your own strokes and text by moving over them. A tap on an image, or on a Hide rectangle, removes it for everyone. Two quick taps on empty paper, away from anything the eraser would remove, undo your latest mark; again, and the one before.">Eraser</button>
|
||||
</div>
|
||||
<button id="undo" type="button" disabled
|
||||
data-help="Take back your latest mark: a stroke, an erasure, a text block typed or moved, a Hide rectangle. Again for the one before. Also Ctrl+Z (Command+Z on a Mac), or two quick taps of the eraser on empty paper. Only your own marks, and only on this page since you opened it.">Undo</button>
|
||||
@@ -99,14 +99,14 @@
|
||||
<div id="page_menu" class="popup menu" role="menu" hidden>
|
||||
<div class="menu_title">Change the display of the page</div>
|
||||
<button id="zoom_fit" type="button" class="menu_item" role="menuitem"
|
||||
data-help="Show the whole page in the window. Pressed by the host, it does so on every screen. Ctrl (Command on a Mac) with the scroll wheel, or with a pen stroke up or down, zooms about the pointer.">Fit to the window</button>
|
||||
data-help="Show the whole page in the window, on your screen only. Ctrl (Command on a Mac) with the scroll wheel, or with a pen stroke up or down, zooms about the pointer.">Fit to the window</button>
|
||||
<button id="zoom_sheet" type="button" class="menu_item" role="menuitem"
|
||||
data-help="Show the page at the scale a new page has, one full page filling the window. A shrunk page is then seen at its own size rather than enlarged. Pressed by the host, it does so on every screen.">Normal scale</button>
|
||||
data-help="Show the page at the scale a new page has, one full page filling the window. A shrunk page is then seen at its own size rather than enlarged. On your screen only.">Normal scale</button>
|
||||
<hr>
|
||||
<div class="menu_title">Change the size of the page</div>
|
||||
<button id="zoom_expand" type="button" class="menu_item" role="menuitem" hidden
|
||||
<button id="zoom_expand" type="button" class="menu_item" role="menuitem"
|
||||
data-help="Make the page fill the window, for everyone, at this zoom, in both directions. Nothing on it moves.">Expand to the window</button>
|
||||
<button id="zoom_shrink" type="button" class="menu_item" role="menuitem" hidden
|
||||
<button id="zoom_shrink" type="button" class="menu_item" role="menuitem"
|
||||
data-help="Make the page, for everyone, just large enough for what is on it, with the margin around. Nothing on it moves.">Shrink to the contents</button>
|
||||
<hr>
|
||||
<label class="menu_item toggle" role="menuitemcheckbox"
|
||||
@@ -119,7 +119,7 @@
|
||||
<button id="load_image" type="button"
|
||||
data-help="Put an image file, such as a scanned exercise, on the page for everyone: below the images already there, centred, at its own size. Rest the pen on an image to move it, or near a corner to scale it; it comes to the front. A tap of the eraser on an image removes it; writing on it stays.">Image</button>
|
||||
<button id="load_notebook_page" type="button"
|
||||
data-help="Your notebooks and the pages kept in them, to look at or to delete. The host and co-hosts can start a new page for everyone from a kept page, with everything on it; the current page is then kept in each notebook.">Notebooks</button>
|
||||
data-help="Your notebooks and the pages kept in them, to look at or to delete. A kept page starts a new page for everyone, with everything on it; the current page is then kept in each notebook.">Notebooks</button>
|
||||
</div>
|
||||
<div class="group" aria-label="Notebook">
|
||||
<select id="session_notebook" class="notebook_pick" aria-label="Current notebook"
|
||||
|
||||
+4
-12
@@ -15,10 +15,9 @@
|
||||
* a Trash the entries are moved there, and the button says so;
|
||||
* in the browser they are gone for good, and the button says that. The
|
||||
* notebook being written in cannot be marked: its mark explains.
|
||||
* Everyone has the chooser (Andy, 2026-10-11), so that a guest can see
|
||||
* and clear what their notebook holds, in a browser above all; loading
|
||||
* a page into the session is for the host and co-hosts, and a card says
|
||||
* so to anyone else.
|
||||
* Everyone has the chooser (Andy, 2026-10-11), so that anyone can see
|
||||
* and clear what their notebook holds, in a browser above all, and
|
||||
* since 2026-10-18 anyone may load a page into the session from it.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -163,7 +162,6 @@ export class NotebookChooser {
|
||||
/** The notebook being written in, which cannot be deleted while it is. */
|
||||
this.writing = '';
|
||||
/** Whether this participant may load a page into the session. */
|
||||
this.mayLoad = true;
|
||||
/** Whether deletions go to a Trash (the local program's) or are for good. */
|
||||
this.trash = false;
|
||||
/** Counts openings, so that pictures fetched for an earlier one are dropped. */
|
||||
@@ -188,12 +186,10 @@ export class NotebookChooser {
|
||||
/**
|
||||
* Show the notebooks, or the pages of the only notebook.
|
||||
* @param {string} [notebook] Open on this notebook: the one being written in.
|
||||
* @param {boolean} [mayLoad] Whether a page may be loaded into the session.
|
||||
*/
|
||||
async open(notebook, mayLoad = true) {
|
||||
async open(notebook) {
|
||||
this.opening += 1;
|
||||
this.writing = notebook ?? '';
|
||||
this.mayLoad = mayLoad;
|
||||
this.clearMarks();
|
||||
this.dialog.showModal();
|
||||
element('chooser_message').textContent = '';
|
||||
@@ -289,10 +285,6 @@ export class NotebookChooser {
|
||||
title.textContent = pageTitle(pageName);
|
||||
card.append(picture, title);
|
||||
card.addEventListener('click', async () => {
|
||||
if (!this.mayLoad) {
|
||||
this.say('Only the host and co-hosts can start a page for everyone from a kept page.');
|
||||
return;
|
||||
}
|
||||
const text = await readPage(notebook, pageName);
|
||||
if (text === null) return;
|
||||
this.dialog.close();
|
||||
|
||||
+4
-1
@@ -83,7 +83,10 @@ export function randomId(byteCount = 9) {
|
||||
* @typedef {object} Invitation
|
||||
* @property {string} relay Address of the relay, ws:// or wss://.
|
||||
* @property {string} secret The session secret.
|
||||
* @property {string} hostId Participant id of the host.
|
||||
* @property {string} hostId Participant id of whoever started the
|
||||
* session (named the host until 2026-10-18; the field keeps its name
|
||||
* so that invitations and page files are unchanged). It gives no
|
||||
* right.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
+32
-82
@@ -230,7 +230,7 @@ const CONNECTION_GRACE_MS = 1500;
|
||||
* The notice line between the toolbar and the page. It says nothing
|
||||
* while all is well. A STANDING notice, kept by its source until that
|
||||
* source clears it, is a state: not connected, the page not being
|
||||
* saved, no page from the host yet. A PASSING notice is a report of
|
||||
* saved, no page from the others yet. A PASSING notice is a report of
|
||||
* one event, kept for a few seconds: a page kept or exported, a file
|
||||
* that was not an image. Standing notices come before a passing one.
|
||||
*/
|
||||
@@ -470,7 +470,6 @@ async function enterSession(invitationText, name, notebook) {
|
||||
input('invitation').value = config.site ? formatInvitationLink(config.site, plainInvitation) : plainInvitation;
|
||||
showInvitationCode(config.site ? input('invitation').value : '');
|
||||
|
||||
const isHost = identity.id === invitation.hostId;
|
||||
let tool = /** @type {Tool} */ ('pen');
|
||||
/** The Shape tool's current shape, remembered in this browser (Andy, 2026-10-14). */
|
||||
let shape = shapeNamed(localStorage.getItem(SHAPE_KEY));
|
||||
@@ -501,7 +500,7 @@ async function enterSession(invitationText, name, notebook) {
|
||||
function showPageNumber() {
|
||||
const waiting = session.page.number === 0;
|
||||
element('page_number').textContent = waiting ? 'No page yet' : `Page ${session.page.number}`;
|
||||
standingNotice('page', waiting ? 'Waiting for the host’s page' : '', false);
|
||||
standingNotice('page', waiting ? 'Waiting for the others’ page; New starts one' : '', false);
|
||||
}
|
||||
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
||||
let connectionNoticeTimer;
|
||||
@@ -568,18 +567,6 @@ async function enterSession(invitationText, name, notebook) {
|
||||
textEditor?.layout();
|
||||
writer.changed();
|
||||
},
|
||||
onViewCommand: (command) => {
|
||||
log(`The host's view: ${command === 'fit' ? 'Fit' : 'Normal scale'}`);
|
||||
if (command === 'fit') renderer.fit();
|
||||
else renderer.sheetScale();
|
||||
},
|
||||
onPermissionsChanged: () => {
|
||||
log(`May start pages: ${session.mayMakePages() ? 'yes' : 'no'}` +
|
||||
(session.isHost ? `; guests allowed: ${session.permitted.size}` : ''));
|
||||
showHostTools();
|
||||
showParticipants(session, renderer);
|
||||
refreshArrangeCues();
|
||||
},
|
||||
onParticipantsChanged: () => {
|
||||
syncLayers();
|
||||
showParticipants(session, renderer);
|
||||
@@ -846,12 +833,11 @@ async function enterSession(invitationText, name, notebook) {
|
||||
/**
|
||||
* With the Arrange tool chosen, mark everything a press can move or
|
||||
* resize with a blue box and corners (Andy, 2026-10-14): the images
|
||||
* and Hides for the host and co-hosts, one's own text blocks and
|
||||
* shapes; free text, which has no width to resize, gets the box without
|
||||
* the corners. The one being held is left out, the green grip standing
|
||||
* in its place until the pointer lifts. Called whenever the images,
|
||||
* the Hides, one's own text or shapes, the right, or the hold may have
|
||||
* changed.
|
||||
* and Hides, one's own text blocks and shapes; free text, which has
|
||||
* no width to resize, gets the box without the corners. The one being
|
||||
* held is left out, the green grip standing in its place until the
|
||||
* pointer lifts. Called whenever the images, the Hides, one's own
|
||||
* text or shapes, or the hold may have changed.
|
||||
*/
|
||||
function refreshArrangeCues() {
|
||||
if (tool !== 'arrange') {
|
||||
@@ -861,16 +847,14 @@ async function enterSession(invitationText, name, notebook) {
|
||||
/** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]} */
|
||||
const cues = [];
|
||||
const page = session.page;
|
||||
if (session.mayMakePages()) {
|
||||
page.images.forEach((im, index) => {
|
||||
if (held?.kind === 'image' && held.index === index) return;
|
||||
cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height }, corners: true });
|
||||
});
|
||||
page.hides.forEach((h, index) => {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
page.images.forEach((im, index) => {
|
||||
if (held?.kind === 'image' && held.index === index) return;
|
||||
cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height }, corners: true });
|
||||
});
|
||||
page.hides.forEach((h, index) => {
|
||||
if (held?.kind === 'hide' && held.index === index) return;
|
||||
cues.push({ kind: 'hide', rect: { left: h.x, top: h.y, width: h.width, height: h.height }, corners: true });
|
||||
});
|
||||
for (const block of session.ownLayer.texts.values()) {
|
||||
if (held?.kind === 'text' && held.id === block.id) continue;
|
||||
cues.push({ kind: 'text', rect: textBounds(block), corners: block.height !== null });
|
||||
@@ -1110,13 +1094,13 @@ async function enterSession(invitationText, name, notebook) {
|
||||
if (rects.length) return rects;
|
||||
const page = session.page;
|
||||
const hide = page.hideAt(x, y, 0);
|
||||
if (hide && session.mayMakePages()) {
|
||||
if (hide) {
|
||||
const h = page.hides[hide.index];
|
||||
return [{ left: h.x, top: h.y, width: h.width, height: h.height }];
|
||||
}
|
||||
const found = page.imageAt(x, y, 0);
|
||||
const image = found && page.images[found.index];
|
||||
if (image && (session.mayMakePages() || image.by === session.identity.id)) {
|
||||
if (image) {
|
||||
return [{ left: image.x, top: image.y, width: image.width, height: image.height }];
|
||||
}
|
||||
return [];
|
||||
@@ -1143,15 +1127,15 @@ async function enterSession(invitationText, name, notebook) {
|
||||
zoomTo: (pixelsUp) => renderer.zoomDragTo(pixelsUp),
|
||||
endZoom: () => renderer.endZoomDrag(),
|
||||
// A hold moves what is under the pen, or resizes it from a corner: a
|
||||
// Hide rectangle or an image (for the host and co-hosts), or a
|
||||
// text block of our own. The dot drawn meanwhile goes.
|
||||
// Hide rectangle, an image, or a text block of our own. The dot
|
||||
// drawn meanwhile goes.
|
||||
beginHold: (clientX, clientY) => {
|
||||
const [x, y] = renderer.pageFromClient(clientX, clientY);
|
||||
const reach = renderer.cornerReach();
|
||||
const page = session.page;
|
||||
/** @type {Held | null} */
|
||||
let found = null;
|
||||
const hide = session.mayMakePages() ? page.hideAt(x, y, reach) : null;
|
||||
const hide = page.hideAt(x, y, reach);
|
||||
if (hide) {
|
||||
const h = page.hides[hide.index];
|
||||
found = hold('hide', hide.index, h.id, { left: h.x, top: h.y, width: h.width, height: h.height }, hide.corner, x, y);
|
||||
@@ -1190,7 +1174,7 @@ async function enterSession(invitationText, name, notebook) {
|
||||
const stroke = shapes[shapeAt.index];
|
||||
const box = /** @type {import('./page_model.js').Rectangle} */ (strokeBox(stroke));
|
||||
if (session.beginShapeEdit(stroke.id)) found = hold('shape', shapeAt.index, stroke.id, box, shapeAt.corner, x, y);
|
||||
} else if (session.mayMakePages()) {
|
||||
} else {
|
||||
const image = page.imageAt(x, y, reach);
|
||||
if (image) {
|
||||
const index = session.raiseImage(image.index);
|
||||
@@ -1277,8 +1261,8 @@ async function enterSession(invitationText, name, notebook) {
|
||||
lastEmptyTap = 0;
|
||||
return;
|
||||
}
|
||||
// A tap of the eraser on a Hide rectangle or an image removes it
|
||||
// (the right to make pages, or having added the image).
|
||||
// A tap of the eraser on a Hide rectangle or an image removes it,
|
||||
// for everyone (by anyone since 2026-10-18).
|
||||
let took = erased.strokes + erased.texts > 0;
|
||||
if (!took) {
|
||||
const hide = session.page.hideAt(x, y, 0);
|
||||
@@ -1458,8 +1442,8 @@ async function enterSession(invitationText, name, notebook) {
|
||||
textEditor?.layout();
|
||||
viewChanged();
|
||||
};
|
||||
// The host's Fit and Normal scale ("A4" until 2026-10-14) reach everyone (Andy, 2026-10-08); a guest's
|
||||
// are the guest's own.
|
||||
// Fit and Normal scale ("A4" until 2026-10-14) are each person's own
|
||||
// (2026-10-18; the host's reached everyone from 2026-10-08 until then).
|
||||
// The page and view commands live in the Page menu (Andy, 2026-10-14:
|
||||
// seldom used, and the one row is precious).
|
||||
attachPopup(element('page_menu_button'), element('page_menu'));
|
||||
@@ -1505,14 +1489,8 @@ async function enterSession(invitationText, name, notebook) {
|
||||
log(`The manual could not be copied: ${error}`);
|
||||
}
|
||||
});
|
||||
element('zoom_fit').addEventListener('click', () => {
|
||||
renderer.fit();
|
||||
session.sendViewCommand('fit');
|
||||
});
|
||||
element('zoom_sheet').addEventListener('click', () => {
|
||||
renderer.sheetScale();
|
||||
session.sendViewCommand('sheet');
|
||||
});
|
||||
element('zoom_fit').addEventListener('click', () => renderer.fit());
|
||||
element('zoom_sheet').addEventListener('click', () => renderer.sheetScale());
|
||||
// "Expand" makes the sheet itself fill the window at the present zoom,
|
||||
// in both directions; nothing on it moves (Andy, 2026-10-08 for the
|
||||
// width, both ways 2026-10-11).
|
||||
@@ -1541,21 +1519,9 @@ async function enterSession(invitationText, name, notebook) {
|
||||
element('desk').addEventListener('scroll', viewChanged, { passive: true });
|
||||
window.addEventListener('pagehide', saveView);
|
||||
|
||||
// The host drives: the host starts new pages, and may let a guest do so.
|
||||
function showHostTools() {
|
||||
// New and Image are the host's; Notebooks, in the same group so that
|
||||
// nothing moves for the host, is everyone's (the chooser reserves
|
||||
// loading).
|
||||
for (const id of ['new_page', 'load_image']) element(id).hidden = !session.mayMakePages();
|
||||
// The Hide tool is for the host and co-hosts; losing the right
|
||||
// while it is chosen puts the pen back in hand.
|
||||
const hideTool = /** @type {HTMLElement} */ (document.querySelector('[data-tool=hide]'));
|
||||
hideTool.hidden = !session.mayMakePages();
|
||||
if (hideTool.hidden && tool === 'hide') /** @type {HTMLElement} */ (document.querySelector('[data-tool=pen]')).click();
|
||||
element('zoom_expand').hidden = !session.mayMakePages();
|
||||
element('zoom_shrink').hidden = !session.mayMakePages();
|
||||
}
|
||||
showHostTools();
|
||||
// Everyone has every button (Andy and his teacher, 2026-10-18; until
|
||||
// then New, Image, Hide, Expand and Shrink were the host's and the
|
||||
// co-hosts').
|
||||
element('new_page').addEventListener('click', () => {
|
||||
log('New page asked for');
|
||||
session.newPage();
|
||||
@@ -1567,7 +1533,7 @@ async function enterSession(invitationText, name, notebook) {
|
||||
* @param {Blob} file
|
||||
*/
|
||||
async function loadImage(file) {
|
||||
if (!session.mayMakePages() || session.page.number === 0) return;
|
||||
if (session.page.number === 0) return;
|
||||
try {
|
||||
if (!session.addImage(await prepareImage(file))) {
|
||||
throw new Error('The page has no room for another image.');
|
||||
@@ -1588,7 +1554,7 @@ async function enterSession(invitationText, name, notebook) {
|
||||
// A deleted notebook leaves the toolbar's list.
|
||||
showNotebooks(sessionPicker).catch(() => {});
|
||||
});
|
||||
element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook, session.mayMakePages()); });
|
||||
element('load_notebook_page').addEventListener('click', () => { chooser.open(writer.notebook); });
|
||||
|
||||
// Image files are chosen, and pictures of the page exported, through a
|
||||
// chooser of our own, which begins where the notebooks are.
|
||||
@@ -2056,20 +2022,6 @@ function showParticipants(session, renderer) {
|
||||
const present = participant.self || participant.peer !== null;
|
||||
item.classList.toggle('absent', !present);
|
||||
|
||||
if (session.isHost && !participant.self) {
|
||||
// A checkbox with its label to the left, like Extend (Andy, 2026-10-09).
|
||||
const allowLine = document.createElement('label');
|
||||
allowLine.className = 'allow_line';
|
||||
allowLine.setAttribute('data-help', 'Make this participant a co-host: they may then start pages, put images on the page and arrange them, load notebook pages, expand, shrink and hide, as you can.');
|
||||
const allow = document.createElement('input');
|
||||
allow.type = 'checkbox';
|
||||
allow.className = 'allow';
|
||||
allow.checked = session.permitted.has(participant.id);
|
||||
allow.addEventListener('change', () => session.setPermitted(participant.id, allow.checked));
|
||||
allowLine.append('Co-host ', allow);
|
||||
item.append(allowLine);
|
||||
}
|
||||
|
||||
const label = document.createElement('label');
|
||||
const visible = document.createElement('input');
|
||||
visible.type = 'checkbox';
|
||||
@@ -2082,8 +2034,6 @@ function showParticipants(session, renderer) {
|
||||
|
||||
const roles = [];
|
||||
if (participant.self) roles.push('you');
|
||||
if (participant.host) roles.push('host');
|
||||
else if (!session.isHost && session.permitted.has(participant.id)) roles.push('co-host');
|
||||
if (!present) roles.push('away');
|
||||
if (roles.length) {
|
||||
const role = document.createElement('span');
|
||||
|
||||
@@ -165,7 +165,7 @@ function strokeShapes(stroke) {
|
||||
* @param {object} options
|
||||
* @param {string} options.session The session the page belongs to (its
|
||||
* room id), so that returning to the session continues the page.
|
||||
* @param {string} options.hostId
|
||||
* @param {string} options.hostId Who started the session (`data-host`).
|
||||
* @param {Set<string>} [options.hidden] Layers to leave out: for a picture
|
||||
* of the page as someone shows it, never for the page file itself.
|
||||
* @returns {string} The page file.
|
||||
|
||||
@@ -338,8 +338,8 @@ function withoutData(image) {
|
||||
}
|
||||
|
||||
/**
|
||||
* What identifies a page and gives its size. The host sends it to the
|
||||
* others when the page changes.
|
||||
* What identifies a page and gives its size. Whoever starts or changes
|
||||
* a page sends it to the others.
|
||||
* @typedef {object} PageDescriptor
|
||||
* @property {string} id
|
||||
* @property {number} number Counts the pages of a session from 1.
|
||||
@@ -504,8 +504,8 @@ export class Page {
|
||||
/**
|
||||
* @param {number} [width]
|
||||
* @param {number} [height]
|
||||
* @param {string} [id] Empty for the placeholder a guest shows
|
||||
* before the host's page arrives.
|
||||
* @param {string} [id] Empty for the placeholder someone joining
|
||||
* shows before a page arrives.
|
||||
* @param {number} [number] 0 for the placeholder.
|
||||
*/
|
||||
constructor(width = BLANK_PAGE_WIDTH, height = BLANK_PAGE_HEIGHT, id = '', number = 0) {
|
||||
@@ -781,9 +781,10 @@ export class Page {
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer ids from bottom to top. The host's layer is on top so that
|
||||
* corrections lie over the writing they correct.
|
||||
* @param {string} hostId
|
||||
* Layer ids from bottom to top. The layer of whoever started the
|
||||
* session is on top, so that a teacher's corrections lie over the
|
||||
* writing they correct (a drawing order, not a right).
|
||||
* @param {string} hostId Who started the session.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
layerOrder(hostId) {
|
||||
|
||||
+38
-129
@@ -46,7 +46,6 @@ import { trimTrailingBlankLines } from './text_layout.js';
|
||||
* @property {string} name
|
||||
* @property {number | null} peer Relay peer number; null when not connected.
|
||||
* @property {boolean} self
|
||||
* @property {boolean} host
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -64,11 +63,6 @@ import { trimTrailingBlankLines } from './text_layout.js';
|
||||
* @property {(byImages: boolean) => void} onPageResized
|
||||
* The page grew (or shrank) around its contents; `byImages` if an
|
||||
* image was added or taken back, not if writing reached an edge.
|
||||
* @property {() => void} onPermissionsChanged
|
||||
* Who may start pages changed.
|
||||
* @property {(command: 'fit' | 'sheet') => void} onViewCommand
|
||||
* The host pressed "Fit" or "Normal scale": everyone's view follows (Andy,
|
||||
* 2026-10-08), each in their own window.
|
||||
* @property {() => void} onParticipantsChanged
|
||||
* @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus
|
||||
* @property {() => void} [onUndoChanged]
|
||||
@@ -81,7 +75,9 @@ import { trimTrailingBlankLines } from './text_layout.js';
|
||||
* @property {string} roomId
|
||||
* @property {CryptoKey} key
|
||||
* @property {Identity} identity
|
||||
* @property {string} hostId
|
||||
* @property {string} hostId Who started the session, the id in the
|
||||
* invitation: the page file records it and that layer is drawn on
|
||||
* top; it gives no right (everyone has the same, Andy, 2026-10-18).
|
||||
* @property {string} name Our display name.
|
||||
* @property {Page} [page] The page as last saved, when returning to
|
||||
* the session.
|
||||
@@ -146,13 +142,11 @@ export class Session {
|
||||
this.hideEditing = null;
|
||||
this.events = events;
|
||||
|
||||
this.isHost = this.identity.id === this.hostId;
|
||||
/** @type {Set<string>} Guests the host allows to start pages. */
|
||||
this.permitted = new Set();
|
||||
// The host begins a session with page 1. A guest shows a placeholder
|
||||
// until the host's page arrives.
|
||||
// Whoever starts a session begins on page 1. Someone joining shows
|
||||
// a placeholder until a page arrives from the others (or starts one
|
||||
// with New, if nobody is there).
|
||||
this.page = options.page ??
|
||||
(this.isHost ? new Page(undefined, undefined, randomId(), 1) : new Page());
|
||||
(this.identity.id === this.hostId ? new Page(undefined, undefined, randomId(), 1) : new Page());
|
||||
this.ownLayer = this.page.layer(this.identity.id, this.name);
|
||||
this.page.recomputeStrokeBounds();
|
||||
this.page.updateExtent();
|
||||
@@ -161,13 +155,11 @@ export class Session {
|
||||
this.participants = new Map();
|
||||
this.participants.set(this.identity.id, {
|
||||
id: this.identity.id, name: this.name, peer: null, self: true,
|
||||
host: this.identity.id === this.hostId,
|
||||
});
|
||||
for (const layer of this.page.layers.values()) {
|
||||
if (layer.id === this.identity.id) continue;
|
||||
this.participants.set(layer.id, {
|
||||
id: layer.id, name: layer.name || 'Unnamed', peer: null, self: false,
|
||||
host: layer.id === this.hostId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -241,34 +233,10 @@ export class Session {
|
||||
return this.page.layerOrder(this.hostId);
|
||||
}
|
||||
|
||||
// Pages.
|
||||
|
||||
/** @returns {boolean} Whether we may start pages: the host, or a guest the host allows. */
|
||||
mayMakePages() {
|
||||
return this.isHost || this.permitted.has(this.identity.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's "Fit" or "Normal scale" goes to everyone. Only the host's does.
|
||||
* @param {'fit' | 'sheet'} command
|
||||
*/
|
||||
sendViewCommand(command) {
|
||||
if (!this.isHost) return;
|
||||
this.send(0, { type: 'view', command });
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow a guest to start pages, or stop allowing it. Only the host may.
|
||||
* @param {string} id
|
||||
* @param {boolean} allowed
|
||||
*/
|
||||
setPermitted(id, allowed) {
|
||||
if (!this.isHost || id === this.identity.id) return;
|
||||
if (allowed) this.permitted.add(id);
|
||||
else this.permitted.delete(id);
|
||||
this.send(0, { type: 'permissions', ids: [...this.permitted] });
|
||||
this.events.onPermissionsChanged();
|
||||
}
|
||||
// Pages. Everyone may start, load and change them (Andy and his
|
||||
// teacher, 2026-10-18: the host's rights and the co-host went; what
|
||||
// an accident costs is said in the documentation, and the page left
|
||||
// is kept in every notebook).
|
||||
|
||||
/**
|
||||
* The page's rectangle may have changed with its contents.
|
||||
@@ -279,11 +247,10 @@ export class Session {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new blank page for everyone. The host may; so may a guest the
|
||||
* host allows.
|
||||
* Start a new blank page for everyone. Someone with no page yet (the
|
||||
* others gone) starts page 1.
|
||||
*/
|
||||
newPage() {
|
||||
if (!this.mayMakePages()) return;
|
||||
const page = new Page(undefined, undefined, randomId(), this.page.number + 1);
|
||||
page.updateExtent();
|
||||
this.replacePage(page);
|
||||
@@ -292,7 +259,6 @@ export class Session {
|
||||
|
||||
/**
|
||||
* Add an image to the page for everyone, below the images already on it.
|
||||
* The host may; so may a guest the host allows.
|
||||
* @param {object} prepared
|
||||
* @param {PageImage['type']} prepared.type
|
||||
* @param {Uint8Array<ArrayBuffer>} prepared.data
|
||||
@@ -303,7 +269,7 @@ export class Session {
|
||||
*/
|
||||
addImage(prepared) {
|
||||
const page = this.page;
|
||||
if (!this.mayMakePages() || page.number === 0) return false;
|
||||
if (page.number === 0) return false;
|
||||
const place = placeImage(page, prepared.naturalWidth, prepared.naturalHeight);
|
||||
if (!page.hasRoomForImage(prepared.data.length, place.y + place.height)) return false;
|
||||
/** @type {PageImage} */
|
||||
@@ -333,7 +299,7 @@ export class Session {
|
||||
*/
|
||||
addHide(rect) {
|
||||
const page = this.page;
|
||||
if (!this.mayMakePages() || page.number === 0 || page.hides.length >= MAX_PAGE_HIDES) return false;
|
||||
if (page.number === 0 || page.hides.length >= MAX_PAGE_HIDES) return false;
|
||||
const hide = {
|
||||
id: randomId(), x: Math.round(rect.left), y: Math.round(rect.top),
|
||||
width: Math.round(rect.width), height: Math.round(rect.height),
|
||||
@@ -377,7 +343,7 @@ export class Session {
|
||||
placeHideByHand(index, rect) {
|
||||
const page = this.page;
|
||||
const hide = page.hides[index];
|
||||
if (!this.mayMakePages() || !hide) return;
|
||||
if (!hide) return;
|
||||
hide.x = Math.round(rect.left);
|
||||
hide.y = Math.round(rect.top);
|
||||
hide.width = Math.round(rect.width);
|
||||
@@ -394,7 +360,7 @@ export class Session {
|
||||
removeHide(index) {
|
||||
const page = this.page;
|
||||
const hide = page.hides[index];
|
||||
if (!this.mayMakePages() || !hide) return false;
|
||||
if (!hide) return false;
|
||||
page.hides.splice(index, 1);
|
||||
page.imagesRevision += 1;
|
||||
this.sendPageNow();
|
||||
@@ -424,7 +390,7 @@ export class Session {
|
||||
*/
|
||||
raiseImage(index) {
|
||||
const page = this.page;
|
||||
if (!this.mayMakePages() || index < 0 || index >= page.images.length) return index;
|
||||
if (index < 0 || index >= page.images.length) return index;
|
||||
if (index !== page.images.length - 1) {
|
||||
const [image] = page.images.splice(index, 1);
|
||||
page.images.push(image);
|
||||
@@ -443,7 +409,7 @@ export class Session {
|
||||
*/
|
||||
placeImageByHand(index, rect) {
|
||||
const page = this.page;
|
||||
if (!this.mayMakePages() || index < 0 || index >= page.images.length) return index;
|
||||
if (index < 0 || index >= page.images.length) return index;
|
||||
index = this.raiseImage(index);
|
||||
const image = page.images[index];
|
||||
image.x = Math.round(rect.left);
|
||||
@@ -496,7 +462,7 @@ export class Session {
|
||||
*/
|
||||
expandPage(width, height) {
|
||||
const page = this.page;
|
||||
if (!this.mayMakePages() || page.number === 0) return false;
|
||||
if (page.number === 0) return false;
|
||||
// Equally on both sides, and top and bottom, of the PAGE as it is,
|
||||
// writing included (the sheet may lie inside a page that writing has
|
||||
// grown; measured from the sheet, the page came out short, Andy,
|
||||
@@ -530,7 +496,7 @@ export class Session {
|
||||
*/
|
||||
shrinkPage() {
|
||||
const page = this.page;
|
||||
if (!this.mayMakePages() || page.number === 0) return false;
|
||||
if (page.number === 0) return false;
|
||||
// The blank lines at the end of one's own text blocks go first (Andy,
|
||||
// 2026-10-14); another's blocks are theirs, trimmed when they finish
|
||||
// typing. Each trim is a gesture of its own to undo; a block being
|
||||
@@ -564,7 +530,7 @@ export class Session {
|
||||
removeImage(index) {
|
||||
const page = this.page;
|
||||
const image = page.images[index];
|
||||
if (!image || !(this.mayMakePages() || image.by === this.identity.id)) return false;
|
||||
if (!image) return false;
|
||||
page.images.splice(index, 1);
|
||||
page.imagesRevision += 1;
|
||||
this.imageTransfers.delete(image.hash);
|
||||
@@ -581,7 +547,6 @@ export class Session {
|
||||
* @param {Page} kept As read from the page file.
|
||||
*/
|
||||
loadPage(kept) {
|
||||
if (!this.mayMakePages()) return;
|
||||
const page = new Page(kept.baseWidth, kept.baseHeight, randomId(), this.page.number + 1);
|
||||
page.baseLeft = kept.baseLeft;
|
||||
page.baseTop = kept.baseTop;
|
||||
@@ -595,9 +560,7 @@ export class Session {
|
||||
}
|
||||
for (const block of layer.texts.values()) copy.texts.set(block.id, { ...block, lines: block.lines.slice() });
|
||||
if (!this.participants.has(id)) {
|
||||
this.participants.set(id, {
|
||||
id, name: layer.name || 'Unnamed', peer: null, self: false, host: id === this.hostId,
|
||||
});
|
||||
this.participants.set(id, { id, name: layer.name || 'Unnamed', peer: null, self: false });
|
||||
}
|
||||
}
|
||||
page.recomputeStrokeBounds();
|
||||
@@ -744,15 +707,18 @@ export class Session {
|
||||
|
||||
/**
|
||||
* Move to a page that another participant is on, if it is newer than
|
||||
* ours. Pages are numbered by the host, so the highest number is the
|
||||
* session's current page; between two pages of the same number, the
|
||||
* host's is taken. If it is the page we are on, it may have gained
|
||||
* ours. Whoever presses New numbers the page one past their own, so
|
||||
* the highest number is the session's current page; between two
|
||||
* different pages of the same number (two people pressed New at the
|
||||
* same moment, or apart while disconnected) the one with the greater
|
||||
* id is taken, the same choice on every machine, and the loser's page
|
||||
* is kept in its notebook like any page left (2026-10-18; the host's
|
||||
* won until then). If it is the page we are on, it may have gained
|
||||
* images.
|
||||
* @param {unknown} received A page descriptor.
|
||||
* @param {boolean} fromHost
|
||||
* @param {number} peer Who told us.
|
||||
*/
|
||||
considerPage(received, fromHost, peer) {
|
||||
considerPage(received, peer) {
|
||||
const descriptor = cleanPageDescriptor(received);
|
||||
if (!descriptor) return;
|
||||
if (descriptor.id === this.page.id) {
|
||||
@@ -760,44 +726,12 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
const newer = descriptor.number > this.page.number ||
|
||||
(descriptor.number === this.page.number && fromHost);
|
||||
(descriptor.number === this.page.number && descriptor.id > this.page.id);
|
||||
if (!newer) return;
|
||||
this.replacePage(Page.from(descriptor));
|
||||
this.pageSourcePeer = peer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a page descriptor from a participant without the right to
|
||||
* make pages is one they may still send: our page, the sheet as it is,
|
||||
* and the images ours with only images they added left out (Andy,
|
||||
* 2026-10-08: whoever added an image may remove it, right or no right).
|
||||
* @param {any} received
|
||||
* @param {string} id The sender.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
removesOnlyOwnImages(received, id) {
|
||||
const descriptor = cleanPageDescriptor(received);
|
||||
const page = this.page;
|
||||
if (!descriptor || descriptor.id !== page.id || descriptor.left !== page.baseLeft ||
|
||||
descriptor.top !== page.baseTop || descriptor.width !== page.baseWidth ||
|
||||
descriptor.height !== page.baseHeight ||
|
||||
JSON.stringify(descriptor.hides) !== JSON.stringify(page.hides)) return false;
|
||||
/** @param {Omit<PageImage, 'data'>} image */
|
||||
const key = (image) => `${image.hash} ${image.x} ${image.y} ${image.width} ${image.height} ${image.by}`;
|
||||
let at = 0;
|
||||
let removed = 0;
|
||||
for (const ours of page.images) {
|
||||
if (at < descriptor.images.length && key(descriptor.images[at]) === key(ours)) {
|
||||
at += 1;
|
||||
} else if (ours.by === id) {
|
||||
removed += 1;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return at === descriptor.images.length && removed > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Another participant's list of the images on the page we are on. The
|
||||
* list with the higher revision is the later one. Of two different
|
||||
@@ -1116,7 +1050,6 @@ export class Session {
|
||||
return 'a move of a shape';
|
||||
case 'hide': {
|
||||
const page = this.page;
|
||||
if (!this.mayMakePages()) return null;
|
||||
const index = page.hides.findIndex((one) => one.id === entry.id);
|
||||
if (entry.before === null) {
|
||||
if (index < 0) return null;
|
||||
@@ -1197,7 +1130,6 @@ export class Session {
|
||||
type: 'hello', reply, name: this.name, publicKey: this.identity.publicKey,
|
||||
signature: await signClaim(this.identity, this.roomId, peer),
|
||||
page: this.page.number > 0 ? this.page.descriptor() : null,
|
||||
permitted: this.isHost ? [...this.permitted] : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1305,9 +1237,8 @@ export class Session {
|
||||
const previous = this.participants.get(id);
|
||||
if (previous && previous.peer !== null) this.peerIds.delete(previous.peer);
|
||||
this.peerIds.set(peer, id);
|
||||
this.participants.set(id, { id, name, peer, self: false, host: id === this.hostId });
|
||||
if (id === this.hostId) this.receivedPermissions(message.permitted);
|
||||
this.considerPage(message.page, id === this.hostId, peer);
|
||||
this.participants.set(id, { id, name, peer, self: false });
|
||||
this.considerPage(message.page, peer);
|
||||
// This peer is on our page: it can give us the images we lack.
|
||||
if (message.page?.id === this.page.id) this.requestImages(peer);
|
||||
this.page.layer(id, name);
|
||||
@@ -1329,20 +1260,12 @@ export class Session {
|
||||
const id = this.peerIds.get(peer);
|
||||
if (!id) return;
|
||||
if (message.type === 'page') {
|
||||
if (id === this.hostId || this.permitted.has(id)) this.considerPage(message.page, true, peer);
|
||||
else if (this.removesOnlyOwnImages(message.page, id)) this.considerPage(message.page, false, peer);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'permissions') {
|
||||
if (id === this.hostId) this.receivedPermissions(message.ids);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'view') {
|
||||
if (id === this.hostId && (message.command === 'fit' || message.command === 'sheet')) {
|
||||
this.events.onViewCommand(message.command);
|
||||
}
|
||||
this.considerPage(message.page, peer);
|
||||
return;
|
||||
}
|
||||
// 'permissions' and 'view' came from a host, until 2026-10-18; an
|
||||
// older client's are ignored.
|
||||
if (message.type === 'permissions' || message.type === 'view') return;
|
||||
if (message.type === 'image') {
|
||||
this.receivedImagePiece(message);
|
||||
return;
|
||||
@@ -1482,19 +1405,6 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The host said who may start pages.
|
||||
* @param {unknown} ids
|
||||
*/
|
||||
receivedPermissions(ids) {
|
||||
if (!Array.isArray(ids)) return;
|
||||
const permitted = new Set(ids.filter(validId));
|
||||
const same = permitted.size === this.permitted.size && [...permitted].every((id) => this.permitted.has(id));
|
||||
if (same) return;
|
||||
this.permitted = permitted;
|
||||
this.events.onPermissionsChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to accept a snapshot of a layer from a peer. A participant may
|
||||
* always send their own layer. The peer that moved us to this page may
|
||||
@@ -1543,7 +1453,6 @@ export class Session {
|
||||
if (!this.participants.has(layerId)) {
|
||||
this.participants.set(layerId, {
|
||||
id: layerId, name: snapshot.name || 'Unnamed', peer: null, self: false,
|
||||
host: layerId === this.hostId,
|
||||
});
|
||||
this.events.onParticipantsChanged();
|
||||
}
|
||||
|
||||
@@ -529,8 +529,6 @@ h2 {
|
||||
.session_body li.absent { color: var(--muted); }
|
||||
.session_body li label { flex: 1; }
|
||||
.session_body li .role { font-size: 0.8rem; color: var(--muted); }
|
||||
.session_body li .allow_line { order: 1; display: flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--muted); cursor: pointer; }
|
||||
.session_body li .allow_line input { margin: 0; }
|
||||
.invitation_line { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.invitation_line input { flex: 1; min-width: 0; }
|
||||
/* The invitation as a QR code, for the camera of a phone or a tablet. */
|
||||
|
||||
Reference in new issue
Block a user