Undo, and the toolbar in three groups

Undo takes back one's own latest mark, by the button, Ctrl+Z or two
quick taps of the eraser; the toolbar's drawing tools, the page and
view, and the session stand in three groups that do not move when the
tool changes; the buttons are a little smaller.
This commit is contained in:
ack committed 2026-10-11 13:17:22 +02:00
1 parent a139361e8d
commit 4e3a281d65
11 files changed
+602 -35

No files matched your search

+13 -5
View File
@@ -40,16 +40,14 @@
<div id="session" hidden>
<header class="toolbar">
<div id="drawing" class="drawing" aria-label="Drawing">
<div class="group" role="radiogroup" aria-label="Tool">
<button type="button" data-tool="pen" aria-pressed="true"
data-help="Write on the page. Only your own writing can be erased.">Pen</button>
<button type="button" data-tool="text" aria-pressed="false"
data-help="Type on the page. Tap where the text should begin, or drag open a rectangle: a red box, white inside, in which the text wraps and which grows downward as you type. Tap your own text to change it; rest the pen on it to move it, or near a corner of a box to resize it.">Text</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).">Eraser</button>
<button type="button" data-tool="hide" aria-pressed="false" hidden
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; a tap of the eraser removes it.">Hide</button>
</div>
<div id="settings" class="settings">
<div class="group" id="colors" role="radiogroup" aria-label="Color">
<button id="more_colors" type="button" class="swatch more"
data-help="Choose another color. It is added here.">+</button>
@@ -65,7 +63,17 @@
<input id="text_size" type="number" min="6" max="100" step="1" value="14"
aria-label="Text size in points" data-help="Text size in points.">
</div>
<div class="group" aria-label="View">
</div>
<div class="group after_settings" role="radiogroup" aria-label="Hide and Eraser">
<button type="button" data-tool="hide" aria-pressed="false" hidden
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; a tap of the eraser removes it.">Hide</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>
</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>
</div>
<div class="group page_view" aria-label="Page and view">
<button id="zoom_fit" type="button"
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</button>
<button id="zoom_sheet" type="button"
+94 -11
View File
@@ -33,7 +33,7 @@ import { writePicture } from './files.js';
import { pageToPng } from './page_picture.js';
import { TextEditor } from './text_editor.js';
import { enableTooltips } from './tooltip.js';
import { guardSelection } from './selection_guard.js';
import { guardSelection, inField } from './selection_guard.js';
import { QUIET_ZONE, qrModules, qrPath } from './qr_code.js';
/**
@@ -56,6 +56,12 @@ const ERASER_RADIUS = 10;
/** With the text tool or the eraser, a press that moves less than this (3 mm) is a tap. */
const TAP_DISTANCE = 30;
/**
* Two taps of the eraser within this, both finding nothing to erase, undo
* the latest gesture (Andy, 2026-10-11). A tap that erases something is
* never half of a double tap, so the eraser's own taps are unaffected.
*/
const DOUBLE_TAP_MS = 400;
/**
* An image file is shared as it is if it is no larger than this, in bytes
@@ -551,6 +557,9 @@ async function enterSession(invitationText, name, notebook) {
syncLayers();
showParticipants(session, renderer);
},
onUndoChanged: () => {
/** @type {HTMLButtonElement} */ (element('undo')).disabled = !session.canUndo();
},
onStatus: (status, detail) => {
const text = { connecting: 'Connecting…', connected: 'Connected', failed: detail ?? 'Disconnected' };
log(`Relay: ${text[status]}`);
@@ -616,16 +625,54 @@ async function enterSession(invitationText, name, notebook) {
};
new ResizeObserver(watchToolbar).observe(toolbar);
watchToolbar();
// The drawing group keeps the width of its widest state, the pen's
// colours and widths, so that the page-and-view group, centred between
// it and the session group, stays put when the tool changes (Andy,
// 2026-10-11). The floor rises if the group ever grows, as when a
// colour is added.
// The settings (colours, widths, font and size) keep their widest
// room likewise, so that Eraser, Hide and Undo, which stand after them
// since 2026-10-11, do not jump when a tool shows fewer settings.
for (const part of [element('drawing'), element('settings')]) {
let widest = 0;
new ResizeObserver(() => {
const width = part.getBoundingClientRect().width;
if (width > widest) {
widest = width;
part.style.minWidth = `${width}px`;
}
}).observe(part);
}
textEditor = new TextEditor(pageElement, renderer, {
setText: (block) => session.setText(block),
removeText: (id) => session.removeTexts([id]),
beginEdit: (id) => session.beginTextEdit(id, 'editor'),
endEdit: (id) => session.endTextEdit(id, 'editor'),
// A hold on the field being typed in moves its block, as on the page.
beginHold: (clientX, clientY) => penTarget.beginHold?.(clientX, clientY) ?? false,
holdMove: (clientX, clientY) => penTarget.holdMove?.(clientX, clientY),
holdEnd: () => penTarget.holdEnd?.(),
});
// Undo: one's own latest gesture, then the one before (Andy,
// 2026-10-11): the button, Ctrl+Z (Command+Z on a Mac) outside a field,
// and two quick taps of the eraser on empty paper (eraseEnded below).
// A block being typed is finished first, so that the undo is of it.
const undo = () => {
textEditor?.stop();
const what = session.undo();
if (what) log(`Undid ${what}`);
};
element('undo').addEventListener('click', undo);
document.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey &&
event.key.toLowerCase() === 'z' && !inField()) {
event.preventDefault();
undo();
}
});
// Whether one's own writing may grow the page (the "Extend" box).
let extend = localStorage.getItem(EXTEND_KEY) !== 'no';
const extendBox = input('extend');
@@ -671,6 +718,8 @@ async function enterSession(invitationText, name, notebook) {
*/
/** @type {Held | null} */
let held = null;
/** When the eraser last tapped empty paper, by performance.now(); 0 if not lately. */
let lastEmptyTap = 0;
/** @type {import('./page_model.js').Stroke | null} The stroke a hold began as. */
let strokeInProgress = null;
@@ -765,6 +814,7 @@ async function enterSession(invitationText, name, notebook) {
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);
session.beginHideEdit(h.id);
} else {
const blocks = [...session.ownLayer.texts.values()];
const at = rectAt(blocks.map(textBounds), x, y, reach);
@@ -773,6 +823,7 @@ async function enterSession(invitationText, name, notebook) {
// Free text has no corners to resize by.
const corner = block.height === null ? null : at.corner;
found = hold('text', at.index, block.id, textBounds(block), corner, x, y);
session.beginTextEdit(block.id, 'hold');
} else if (session.mayMakePages()) {
const image = page.imageAt(x, y, reach);
if (image) {
@@ -786,7 +837,7 @@ async function enterSession(invitationText, name, notebook) {
if (strokeInProgress) {
const begun = strokeInProgress;
strokeInProgress = null;
session.endStroke();
session.endStroke(false);
session.erase([begun.id]);
}
textEditor?.hidePreview();
@@ -826,11 +877,14 @@ async function enterSession(invitationText, name, notebook) {
const r = currentHeldRect(held);
if (r) log(`${held.kind} placed at ${r.left / 100}, ${r.top / 100} cm, ${r.width / 100} by ${r.height / 100} cm`);
if (held.kind !== 'text') session.sendPageNow();
if (held.kind === 'text') session.endTextEdit(held.id, 'hold');
if (held.kind === 'hide') session.endHideEdit();
held = null;
renderer.showGrip(null);
},
eraseAlong: (x0, y0, x1, y1) => {
textEditor?.stop();
session.beginErasing();
const radius = renderer.pageLength(ERASER_RADIUS);
const strokes = strokesTouched(session.ownLayer, x0, y0, x1, y1, radius);
const texts = textsTouched(session.ownLayer, x0, y0, x1, y1, radius);
@@ -839,16 +893,40 @@ async function enterSession(invitationText, name, notebook) {
return { strokes: strokes.length, texts: texts.length };
},
eraseEnded: (x, y, reach) => {
// A tap of the eraser on a Hide rectangle or an image removes it
// (the right to make pages, or having added the image).
if (reach >= TAP_DISTANCE) return;
const hide = session.page.hideAt(x, y, 0);
if (hide && session.removeHide(hide.index)) {
log(`Hide ${hide.index + 1} removed`);
// What the movement erased is one gesture to undo.
const erased = session.endErasing();
if (reach >= TAP_DISTANCE) {
lastEmptyTap = 0;
return;
}
const found = session.page.imageAt(x, y, 0);
if (found && session.removeImage(found.index)) log(`Image ${found.index + 1} removed`);
// A tap of the eraser on a Hide rectangle or an image removes it
// (the right to make pages, or having added the image).
let took = erased.strokes + erased.texts > 0;
if (!took) {
const hide = session.page.hideAt(x, y, 0);
if (hide && session.removeHide(hide.index)) {
log(`Hide ${hide.index + 1} removed`);
took = true;
} else {
const found = session.page.imageAt(x, y, 0);
if (found && session.removeImage(found.index)) {
log(`Image ${found.index + 1} removed`);
took = true;
}
}
}
if (took) {
lastEmptyTap = 0;
return;
}
// A tap on empty paper: the second within DOUBLE_TAP_MS undoes.
const now = performance.now();
if (now - lastEmptyTap <= DOUBLE_TAP_MS) {
lastEmptyTap = 0;
undo();
} else {
lastEmptyTap = now;
}
},
previewRect: (x0, y0, x1, y1) => {
if (Math.abs(x1 - x0) >= TAP_DISTANCE || Math.abs(y1 - y0) >= TAP_DISTANCE) {
@@ -1170,7 +1248,12 @@ function press(group, chosen) {
function buildToolbar(options) {
for (const button of document.querySelectorAll('[data-tool]')) {
button.addEventListener('click', () => {
press(/** @type {Element} */ (button.parentElement), button);
// The tool buttons stand in two groups since 2026-10-11 (Pen and
// Text before the colours they use, Eraser and Hide after), so the
// pressed one is marked among all of them, not among siblings.
for (const other of document.querySelectorAll('[data-tool]')) {
other.setAttribute('aria-pressed', String(other === button));
}
const chosen = button.getAttribute('data-tool');
options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : chosen === 'hide' ? 'hide' : 'pen');
});
+3 -2
View File
@@ -29,7 +29,7 @@ const NOMINAL_PRESSURE = 0.4;
* Erases one's own strokes and text blocks along the movement and says how many.
* @property {(x: number, y: number, reach: number) => void} [eraseEnded]
* The eraser went down at (x, y) and has come up again, having moved
* no farther than `reach` from there.
* no farther than `reach` from there; Infinity if the press was cancelled.
* @property {(x0: number, y0: number, x1: number, y1: number) => void} placeRect
* With the text or Hide tool, the pointer went down at (x0, y0) and up
* at (x1, y1): a tap, or a rectangle dragged open.
@@ -292,7 +292,8 @@ export function attachPenInput(surface, target) {
if (event.type === 'pointerup') target.placeRect(textStartX, textStartY, x, y);
} else if (erasing) {
eraseFromPress();
if (event.type === 'pointerup') target.eraseEnded?.(eraseStartX, eraseStartY, eraseReach);
// A cancelled press ends the gesture too, with no tap in it.
target.eraseEnded?.(eraseStartX, eraseStartY, event.type === 'pointerup' ? eraseReach : Infinity);
} else {
target.endStroke();
}
+1 -1
View File
@@ -46,7 +46,7 @@ function describe(node) {
}
/** Whether the selection belongs to a focused text field. */
function inField() {
export function inField() {
const active = document.activeElement;
return active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement ||
(active instanceof HTMLElement && active.isContentEditable);
+251 -4
View File
@@ -23,6 +23,20 @@ import { RelayConnection } from './relay_connection.js';
/** @typedef {import('./page_model.js').Stroke} Stroke */
/** @typedef {import('./page_model.js').PageImage} PageImage */
/** @typedef {import('./page_model.js').TextBlock} TextBlock */
/** @typedef {import('./page_model.js').HideRect} HideRect */
/**
* One gesture of ours, with what is needed to take it back: a stroke
* drawn; strokes and text blocks erased by one movement of the eraser;
* a text block edited (made, typed into, moved or resized), with the
* block as it was before, or null if it was new; a Hide rectangle made,
* moved or removed, likewise. Images are not here: Andy, 2026-10-11,
* the undo set is what is cheap, and an image's data is not kept.
* @typedef {{kind: 'stroke', id: string}
* | {kind: 'erase', strokes: Stroke[], texts: TextBlock[]}
* | {kind: 'text', id: string, before: TextBlock | null}
* | {kind: 'hide', id: string, before: HideRect | null}} UndoEntry
*/
/**
* @typedef {object} Participant
@@ -55,6 +69,8 @@ import { RelayConnection } from './relay_connection.js';
* 2026-10-08), each in their own window.
* @property {() => void} onParticipantsChanged
* @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus
* @property {() => void} [onUndoChanged]
* Whether there is something of ours to undo may have changed.
*/
/**
@@ -72,6 +88,9 @@ import { RelayConnection } from './relay_connection.js';
/** Approximate size at which a layer snapshot is split into messages. */
const SNAPSHOT_CHUNK_CHARACTERS = 100000;
/** How many of one's own gestures can be undone, the latest first. */
export const UNDO_LIMIT = 100;
/** An image is sent in pieces of this many bytes. */
const IMAGE_CHUNK_BYTES = 96 * 1024;
@@ -106,6 +125,19 @@ export class Session {
/** @type {ReturnType<typeof setTimeout> | null} */
this.pointSendTimer = null;
this.lastPointSend = 0;
/**
* Our gestures on this page, the latest last (Andy, 2026-10-11: the
* latest gesture is undone, then the one before). Cleared with the
* page; a reloaded tab starts with none.
* @type {UndoEntry[]}
*/
this.undoStack = [];
/** @type {{strokes: Stroke[], texts: TextBlock[]} | null} The eraser's movement in progress. */
this.erasing = null;
/** @type {{id: string, owner: string, before: TextBlock | null} | null} A text block being edited. */
this.textEditing = null;
/** @type {{id: string, before: HideRect} | null} A Hide rectangle being moved. */
this.hideEditing = null;
this.events = events;
this.isHost = this.identity.id === this.hostId;
@@ -296,18 +328,41 @@ export class Session {
addHide(rect) {
const page = this.page;
if (!this.mayMakePages() || page.number === 0 || page.hides.length >= MAX_PAGE_HIDES) return false;
page.hides.push({
const hide = {
id: randomId(), x: Math.round(rect.left), y: Math.round(rect.top),
width: Math.round(rect.width), height: Math.round(rect.height),
});
};
page.hides.push(hide);
this.sheetAround(rect);
page.imagesRevision += 1;
this.sendPageNow();
this.events.onImagesChanged();
this.extentChanged(true);
this.pushUndo({ kind: 'hide', id: hide.id, before: null });
return true;
}
/**
* A Hide rectangle is about to be moved or resized by hand: remember
* it as it is, so that the whole movement can be undone as one.
* @param {string} id
*/
beginHideEdit(id) {
const hide = this.page.hides.find((one) => one.id === id);
if (hide) this.hideEditing = { id, before: { ...hide } };
}
/** The movement ended: record it if the rectangle changed. */
endHideEdit() {
const editing = this.hideEditing;
this.hideEditing = null;
if (!editing) return;
const hide = this.page.hides.find((one) => one.id === editing.id);
if (hide && !sameRect(hide, editing.before)) {
this.pushUndo({ kind: 'hide', id: editing.id, before: editing.before });
}
}
/**
* Give a Hide rectangle a new rectangle: moved or resized by hand.
* @param {number} index
@@ -332,12 +387,14 @@ export class Session {
*/
removeHide(index) {
const page = this.page;
if (!this.mayMakePages() || !page.hides[index]) return false;
const hide = page.hides[index];
if (!this.mayMakePages() || !hide) return false;
page.hides.splice(index, 1);
page.imagesRevision += 1;
this.sendPageNow();
this.events.onImagesChanged();
this.extentChanged(true);
this.pushUndo({ kind: 'hide', id: hide.id, before: { ...hide } });
return true;
}
@@ -550,10 +607,44 @@ export class Session {
this.extentChanged();
}
/**
* A text block of ours is about to be edited, as a whole gesture: typed
* into until the editing ends, or moved by hand until the pen lifts.
* The block as it is now is what undo restores; null for a new block.
* Only one gesture at a time: a hold on the block being typed in is part
* of the typing (Andy, 2026-10-09: a hold on the field moves the block).
* @param {string} id
* @param {string} owner Who began it: 'editor' or 'hold'.
* @returns {boolean} Whether this call began the gesture.
*/
beginTextEdit(id, owner) {
if (this.textEditing) return false;
const block = this.ownLayer.texts.get(id);
this.textEditing = { id, owner, before: block ? copyText(block) : null };
return true;
}
/**
* The gesture ended: record it if the block changed.
* @param {string} id
* @param {string} owner
*/
endTextEdit(id, owner) {
const editing = this.textEditing;
if (!editing || editing.id !== id || editing.owner !== owner) return;
this.textEditing = null;
const now = this.ownLayer.texts.get(id) ?? null;
if (JSON.stringify(now) !== JSON.stringify(editing.before)) {
this.pushUndo({ kind: 'text', id, before: editing.before });
}
}
/** @param {string[]} ids Text blocks of our own layer. */
removeTexts(ids) {
const removedBlocks = ids.map((id) => this.ownLayer.texts.get(id)).filter((block) => block !== undefined);
const removed = ids.filter((id) => this.ownLayer.texts.delete(id));
if (removed.length === 0) return;
if (this.erasing) this.erasing.texts.push(...removedBlocks);
this.send(0, { type: 'text_remove', page: this.page.id, ids: removed });
this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(this.identity.id);
@@ -727,6 +818,11 @@ export class Session {
this.endStroke();
const old = this.page;
this.page = page;
this.undoStack = [];
this.erasing = null;
this.textEditing = null;
this.hideEditing = null;
this.events.onUndoChanged?.();
this.ownLayer = page.layer(this.identity.id, this.name);
for (const participant of this.participants.values()) {
if (participant.peer !== null) page.layer(participant.id, participant.name);
@@ -811,7 +907,11 @@ export class Session {
this.send(0, { type: 'stroke_points', id: pending.id, at: pending.at, points: pending.points });
}
endStroke() {
/**
* @param {boolean} [record] Whether the stroke is a gesture to undo;
* not when it is about to be dropped, as a hold's beginning is.
*/
endStroke(record = true) {
const stroke = this.currentStroke;
if (!stroke) return;
this.currentStroke = null;
@@ -819,18 +919,125 @@ export class Session {
this.sendPendingPoints();
this.send(0, { type: 'stroke_end', id: stroke.id });
this.events.onStrokeProgress(this.identity.id, stroke);
if (record && pointCount(stroke) > 0) this.pushUndo({ kind: 'stroke', id: stroke.id });
}
/**
* The eraser went down: what it removes until it lifts is one gesture.
* Harmless when already begun.
*/
beginErasing() {
if (!this.erasing) this.erasing = { strokes: [], texts: [] };
}
/**
* The eraser lifted: the gesture is recorded if it removed anything.
* @returns {{strokes: number, texts: number}} What it removed.
*/
endErasing() {
const erasing = this.erasing;
this.erasing = null;
if (!erasing) return { strokes: 0, texts: 0 };
if (erasing.strokes.length || erasing.texts.length) {
this.pushUndo({ kind: 'erase', strokes: erasing.strokes, texts: erasing.texts });
}
return { strokes: erasing.strokes.length, texts: erasing.texts.length };
}
/** @param {string[]} ids Strokes of our own layer. */
erase(ids) {
const erasedStrokes = ids.map((id) => this.ownLayer.strokes.get(id)).filter((stroke) => stroke !== undefined);
const erased = ids.filter((id) => this.ownLayer.strokes.delete(id));
if (erased.length === 0) return;
if (this.erasing) this.erasing.strokes.push(...erasedStrokes);
this.send(0, { type: 'erase', page: this.page.id, ids: erased });
this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(this.identity.id);
this.extentChanged();
}
/**
* Put erased strokes of ours back, whole, for everyone.
* @param {Stroke[]} strokes
*/
restoreStrokes(strokes) {
for (const stroke of strokes) this.ownLayer.strokes.set(stroke.id, stroke);
/** @type {Stroke[]} */
let chunk = [];
let size = 0;
for (const stroke of strokes) {
chunk.push(stroke);
size += 100 + stroke.points.length * 8;
if (size >= SNAPSHOT_CHUNK_CHARACTERS) {
this.send(0, { type: 'stroke_restore', page: this.page.id, strokes: chunk });
chunk = [];
size = 0;
}
}
if (chunk.length) this.send(0, { type: 'stroke_restore', page: this.page.id, strokes: chunk });
this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(this.identity.id);
this.extentChanged();
}
// Undo: one's own gestures, the latest first (Andy, 2026-10-11).
/** @param {UndoEntry} entry */
pushUndo(entry) {
this.undoStack.push(entry);
if (this.undoStack.length > UNDO_LIMIT) this.undoStack.shift();
this.events.onUndoChanged?.();
}
canUndo() {
return this.undoStack.length > 0;
}
/**
* Take back our latest gesture.
* @returns {string | null} What was undone, for the log; null if nothing.
*/
undo() {
const entry = this.undoStack.pop();
if (!entry) return null;
this.events.onUndoChanged?.();
switch (entry.kind) {
case 'stroke':
this.erase([entry.id]);
return 'a stroke';
case 'erase':
this.restoreStrokes(entry.strokes);
for (const block of entry.texts) this.setText(block);
return `an erasure of ${entry.strokes.length} strokes and ${entry.texts.length} texts`;
case 'text':
if (entry.before) this.setText(entry.before);
else this.removeTexts([entry.id]);
return entry.before ? 'a change of text' : 'a text block';
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;
page.hides.splice(index, 1);
} else if (index < 0) {
page.hides.push({ ...entry.before });
this.sheetAround(rectOf(entry.before));
} else {
Object.assign(page.hides[index], entry.before);
this.sheetAround(rectOf(entry.before));
}
page.imagesRevision += 1;
this.sendPageNow();
this.events.onImagesChanged();
this.extentChanged(true);
return entry.before === null ? 'a Hide rectangle' : index < 0 ? 'the removal of a Hide rectangle' : 'a move of a Hide rectangle';
}
default:
return null;
}
}
// Sending.
/**
@@ -1080,6 +1287,22 @@ export class Session {
this.events.onStrokeProgress(id, stroke);
break;
}
case 'stroke_restore': {
if (message.page !== this.page.id || !Array.isArray(message.strokes)) break;
let restored = false;
for (const received of message.strokes) {
const stroke = cleanStroke({ ...received, done: true });
if (stroke) {
layer.strokes.set(stroke.id, stroke);
restored = true;
}
}
if (!restored) break;
this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(id);
this.extentChanged();
break;
}
case 'erase': {
if (message.page !== this.page.id || !Array.isArray(message.ids)) break;
let erased = false;
@@ -1245,3 +1468,27 @@ export class Session {
this.send(peer, { type: 'state_end' });
}
}
/**
* @param {TextBlock} block
* @returns {TextBlock}
*/
function copyText(block) {
return { ...block, lines: block.lines.slice() };
}
/**
* @param {HideRect} a
* @param {HideRect} b
*/
function sameRect(a, b) {
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
}
/**
* @param {HideRect} hide
* @returns {import('./page_model.js').Rectangle}
*/
function rectOf(hide) {
return { left: hide.x, top: hide.y, width: hide.width, height: hide.height };
}
+4
View File
@@ -30,6 +30,8 @@ export class TextEditor {
* @param {object} target
* @param {(block: TextBlock) => void} target.setText The block changed.
* @param {(id: string) => void} target.removeText The block was left empty.
* @param {(id: string) => void} [target.beginEdit] Editing of the block begins: one gesture until endEdit.
* @param {(id: string) => void} [target.endEdit] The editing ended.
* @param {(clientX: number, clientY: number) => boolean} [target.beginHold]
* The pen rested on the field for HOLD_MS: a hold, as on the page
* (Andy, 2026-10-09: the box being typed in can be moved without
@@ -185,6 +187,7 @@ export class TextEditor {
*/
edit(block) {
this.stop();
this.target.beginEdit?.(block.id);
this.block = block;
this.renderer.editingText = block.id;
this.field.value = block.text;
@@ -207,6 +210,7 @@ export class TextEditor {
this.field.value = '';
if (block.text.trim() === '') this.target.removeText(block.id);
else this.target.setText(block);
this.target.endEdit?.(block.id);
}
/**
+29 -5
View File
@@ -102,13 +102,35 @@ h2 {
background: var(--panel);
}
/* Three groups (Andy, 2026-10-11): the drawing tools with their settings
and Undo on the left; the page and the view in the middle, centred
between the other two; the session's buttons flush right. The
drawing group keeps the width of its widest state (main.js sets its
min-width by measuring), so that the middle group stays put when the
tool changes. */
.drawing, .settings { display: flex; align-items: center; gap: 0.5rem 0.55rem; }
/* Twice the gap before Eraser, Hide and Undo, so that with the pen the
widest line and the Eraser read as two groups (Andy, 2026-10-11). */
.drawing .after_settings { margin-left: 0.55rem; }
.toolbar .page_view { margin-left: auto; margin-right: auto; }
/* When the window is too narrow for one row, the tools are centred
instead of standing at the two ends (Andy, 2026-10-09; main.js sets
the class by measuring). */
.toolbar.wrapped { justify-content: center; }
.toolbar.wrapped .page_view { margin-left: 0; margin-right: 0; }
.toolbar.wrapped .toolbar_right { margin-left: 0; justify-content: center; }
.toolbar .notebook_pick { width: 9rem; }
/* 9rem wide until 2026-10-11: room for Undo. The buttons' height, else
this select, 36 px tall by its own padding, sets the row's height and
the buttons' trimming gains nothing (measured 2026-10-11). */
.toolbar .notebook_pick, .toolbar #session_notebook_name {
width: 7.2rem;
height: calc(2rem - 2px);
padding-top: 0;
padding-bottom: 0;
box-sizing: border-box;
}
.toolbar .toggle { display: flex; align-items: center; gap: 0.3rem; cursor: pointer; }
.toolbar .toggle input { margin: 0; }
@@ -125,14 +147,16 @@ h2 {
/* The same height as the buttons, so the toolbar does not change height;
narrow enough that a host's toolbar fits a 1920-pixel screen in one
row (measured 2026-10-09: about 1750 pixels with these settings). */
row (measured 2026-10-09: about 1750 pixels with these settings).
Since 2026-10-11 two pixels less in height and on each side than
2rem and 0.6rem, for room (Andy, in two steps of a pixel). */
.toolbar button:not(.swatch), #text_size, #text_font {
box-sizing: border-box;
height: 2rem;
height: calc(2rem - 2px);
padding-top: 0;
padding-bottom: 0;
}
.toolbar button:not(.swatch) { padding-left: 0.6rem; padding-right: 0.6rem; }
.toolbar button:not(.swatch) { padding-left: calc(0.6rem - 2px); padding-right: calc(0.6rem - 2px); }
#text_size, #text_font {
margin: 0;
@@ -196,7 +220,7 @@ h2 {
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.55rem;
margin-left: auto;
margin-left: 0; /* auto until 2026-10-11; the middle group's margins push it right now. */
min-width: 0;
}
+23 -5
View File
@@ -48,10 +48,11 @@ place in it waits for the host to return.
## The screen
Across the top is the toolbar, one row holding everything: the tools
and their settings, the view, the host's page buttons, your notebook,
and **Session**, which opens a window with the participants and the
invitation. Below it the page has the whole width of the window. Every button
Across the top is the toolbar, one row holding everything in three
groups: on the left the tools, their settings and **Undo**; in the
middle the page and the view; on the right the host's page buttons,
your notebook, and **Session**, which opens a window with the
participants and the invitation. Below it the page has the whole width of the window. Every button
explains itself: rest the pointer on it for two seconds and its
explanation appears beneath it.
@@ -117,7 +118,24 @@ and to the next ones.
**Eraser** removes your own writing. Move it across a stroke and the
whole stroke goes; across a text block and the block goes. It removes
nothing of anyone else's, and nobody else's eraser can touch yours. An
erased stroke is gone from the page for everyone.
erased stroke is gone from the page for everyone, until you undo the
erasure.
### Undo
**Undo**, the last button of the drawing tools, takes back your latest mark: a
stroke, what one movement of the eraser removed, a text block as you
last typed or moved it, a Hide rectangle made, moved or removed. Press
it again for the mark before that, and so on back to the beginning of
the page. Ctrl+Z (Command+Z on a Mac) does the same, and so do two
quick taps of the Eraser on empty paper, away from anything the eraser
would remove; a tap that erases something is never half of such a
double tap. Undo acts only on your own marks, since nobody can change
anyone else's, and only on marks made on this page since you opened it:
after a reload of the tab, or on a page loaded from a notebook, there is
nothing to undo although the marks are there. The button is grey when
there is nothing to undo. An image cannot be undone; put it back with
**Image** if a tap of the eraser removed it by mistake.
### The view
+2 -1
View File
@@ -195,7 +195,8 @@ anyone included, is covered in white, marked by blue corners that tell
the students something is there. Use it to cover a solution until the
student has tried, or a part of the exercise for later. Rest the pen on
it to move it, near a corner to resize it; a tap of the eraser removes
it and shows what was covered. Nothing under a Hide is lost, and a
it and shows what was covered, and **Undo** takes back any of these as
it does your writing. Nothing under a Hide is lost, and a
student cannot uncover it by hiding your writing on their screen: a Hide
belongs to the page, not to your writing. A Hide is white in an
exported picture and in the page file as other programs see it.
+15 -1
View File
@@ -151,6 +151,17 @@ layer from that participant. A client that reconnects after a dropped
connection sends its whole layer again, so that nothing drawn meanwhile
is lost.
Undo is personal, since nobody can change another's layer, and is a
stack of one's own gestures kept in the browser tab for the page being
written: a stroke; the strokes and text blocks one movement of the
eraser removed, kept whole; a text block as it was before an editing or
a move; a Hide rectangle as it was before it was made, moved or removed.
Undoing sends the ordinary messages, except that erased strokes come
back by a `stroke_restore` message carrying them whole. Images are not
in the stack, because their data is not kept. The stack is cleared when
the page changes and is not in the page file, so a reloaded tab starts
with nothing to undo.
## The page file
A notebook is a directory. The page being written is `Current.svg` in
@@ -264,7 +275,10 @@ by which the page grows (`page_model.js`), the tooltip delay
slack, `pen_input.js`), the reach of an image's corner (24 pixels,
`renderer.js`), the smallest an image can be scaled to (1 cm,
`page_model.js`), how often a dragged image's place is sent (40 ms,
`session.js`) and the points of a stroke (25 ms, `session.js`), the colour and width of a text rectangle's frame
`session.js`) and the points of a stroke (25 ms, `session.js`), the
interval of the eraser's double tap (400 ms, `DOUBLE_TAP_MS` in
`main.js`) and how many gestures can be undone (100, `UNDO_LIMIT` in
`session.js`), the colour and width of a text rectangle's frame
(`page_file.js`) and whether the frame is shown whole or as corners on
screen (`TEXT_FRAME_STYLE` in `renderer.js`; the file and the export
always hold the whole frame).
+167
View File
@@ -368,6 +368,37 @@ ERASE_THROUGH = """
page.setPointerCapture = capture;
"""
# Taps the eraser `count` times at (x, y) in page units, `gap` ms apart,
# and calls the last argument when done.
ERASER_TAPS = """
const [x, y, count, gap, done] = arguments;
const page = document.getElementById('page');
const box = page.getBoundingClientRect();
const extent = window.kokkoja.session.page;
const cx = box.left + (x - extent.left) / extent.width * box.width;
const cy = box.top + (y - extent.top) / extent.height * box.height;
const capture = page.setPointerCapture;
page.setPointerCapture = () => {};
const fire = (type, buttons) => page.dispatchEvent(
new PointerEvent(type, {pointerId: 7, pointerType: 'pen', isPrimary: true,
clientX: cx, clientY: cy, pressure: 0.5,
button: type === 'pointermove' ? -1 : 0, buttons, bubbles: true, cancelable: true}));
document.querySelector('[data-tool=eraser]').click();
let left = count;
const tap = () => {
fire('pointerdown', 1);
fire('pointerup', 0);
if (--left > 0) {
setTimeout(tap, gap);
} else {
document.querySelector('[data-tool=pen]').click();
page.setPointerCapture = capture;
done();
}
};
tap();
"""
def free_port():
with socket.socket() as listener:
@@ -467,6 +498,12 @@ class Firefox:
"actions": [{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(box[0]), "y": round(box[1])}]}]})
def chord(self, *keys):
"""Press keys together and release them, as a keyboard shortcut."""
actions = ([{"type": "keyDown", "value": key} for key in keys] +
[{"type": "keyUp", "value": key} for key in reversed(keys)])
self.in_session("POST", "/actions", {"actions": [{"type": "key", "id": "keyboard", "actions": actions}]})
def set_window(self, width, height):
self.in_session("POST", "/window/rect", {"width": width, "height": height})
@@ -914,6 +951,136 @@ class EndToEndTest(unittest.TestCase):
firefox.switch_to(host_tab)
firefox.go("about:blank")
def test_undo(self):
"""Andy, 2026-10-11: one's latest gesture is taken back, then the
one before, by the Undo button, Ctrl+Z, or two quick taps of the
eraser on empty paper; a stroke, an erasure, text, a Hide."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
self.enter_name("Writer", "start_session")
invitation = firefox.run("return document.getElementById('invitation').value;")
guest_tab = firefox.new_tab()
firefox.go(self.address("Student", join=invitation))
self.enter_name("Watcher", "join_session")
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the watcher")
firefox.switch_to(host_tab)
undo_disabled = lambda: firefox.run("return document.getElementById('undo').disabled;")
undo = lambda: firefox.run("document.getElementById('undo').click();")
self.assertTrue(undo_disabled(), "nothing to undo on a new page")
# Two strokes; Undo takes back the second, for everyone.
firefox.drag("#page", 200, 300, 500, 320)
firefox.drag("#page", 200, 400, 500, 420)
two = self.wait_until(lambda s: len(s["layers"]["Writer"]) == 2 and s["layers"]["Writer"][-1]["done"],
"two strokes")
self.assertFalse(undo_disabled())
undo()
one = self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1, "one stroke after the undo")
self.assertEqual(one["layers"]["Writer"][0]["id"], two["layers"]["Writer"][0]["id"])
firefox.switch_to(guest_tab)
self.wait_until(lambda s: len(s["layers"].get("Writer", [])) == 1, "the undo to reach the watcher")
firefox.switch_to(host_tab)
# An erasure is one gesture: undone, the stroke is back whole,
# with its id, on every screen.
stroke = one["layers"]["Writer"][0]
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.width, b.height];")
firefox.run(ERASE_THROUGH, 350 / page_box[0], 310 / page_box[1])
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 0, "the stroke erased")
self.assertFalse(undo_disabled())
undo()
back = self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1, "the stroke back")
self.assertEqual(back["layers"]["Writer"][0], stroke)
firefox.switch_to(guest_tab)
watcher = self.wait_until(lambda s: len(s["layers"].get("Writer", [])) == 1 and s["layers"]["Writer"][0]["done"],
"the restored stroke to reach the watcher")
self.assertEqual(watcher["layers"]["Writer"][0], stroke)
firefox.switch_to(host_tab)
# Ctrl+Z undoes the stroke itself, the gesture before the erasure;
# then there is nothing left to undo.
firefox.chord("\ue009", "z")
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 0, "the stroke gone by Ctrl+Z")
self.assertTrue(undo_disabled())
# Two quick taps of the eraser on empty paper undo; one tap, or two
# far apart, do nothing; a tap that erases is not half of a double
# tap, so the stroke it erased stays erased.
origin = self.state()["origin"]
vscale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
units = lambda cx, cy: (origin[0] + cx / vscale, origin[1] + cy / vscale)
empty = units(700, 150)
firefox.drag("#page", 200, 400, 500, 420)
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1 and s["layers"]["Writer"][0]["done"], "a stroke")
firefox.run_until_done(ERASER_TAPS, *empty, 1, 0)
time.sleep(0.6)
firefox.run_until_done(ERASER_TAPS, *empty, 1, 0)
time.sleep(0.2)
self.assertEqual(len(self.state()["layers"]["Writer"]), 1, "taps far apart undo nothing")
firefox.run_until_done(ERASER_TAPS, *empty, 2, 50)
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 0, "the stroke undone by a double tap")
firefox.drag("#page", 200, 400, 500, 420)
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1 and s["layers"]["Writer"][0]["done"], "a stroke again")
firefox.run_until_done(ERASER_TAPS, *units(350, 410), 1, 0)
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 0, "the stroke erased by a tap")
firefox.run_until_done(ERASER_TAPS, *empty, 1, 0)
time.sleep(0.2)
self.assertEqual(len(self.state()["layers"]["Writer"]), 0, "an erasing tap and an empty one are no double tap")
firefox.run_until_done(ERASER_TAPS, *empty, 2, 50)
self.wait_until(lambda s: len(s["layers"]["Writer"]) == 1, "the erasure undone by a double tap")
# Text: typing a block is one gesture, changing it another; undo
# restores the text as it was before the change, then removes the
# block.
firefox.run("document.querySelector('[data-tool=text]').click();")
firefox.run(TEXT_GESTURE, 0.6, 0.6, 0.6, 0.6)
firefox.type_into(".text_field", "Hallo")
firefox.run("document.querySelector('[data-tool=pen]').click();")
typed = self.wait_until(lambda s: len(s["texts"]["Writer"]) == 1 and s["texts"]["Writer"][0]["text"] == "Hallo",
"the text block")
block = typed["texts"]["Writer"][0]
state = self.state()
fx = (block["x"] + 10 - state["origin"][0]) / state["page"]["width"]
fy = (block["y"] + 10 - state["origin"][1]) / state["page"]["height"]
firefox.run("document.querySelector('[data-tool=text]').click();")
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
firefox.type_into(".text_field", " Welt")
firefox.run("document.querySelector('[data-tool=pen]').click();")
self.wait_until(lambda s: s["texts"]["Writer"][0]["text"] == "Hallo Welt", "the changed text")
undo()
self.wait_until(lambda s: s["texts"]["Writer"][0]["text"] == "Hallo", "the text as it was")
undo()
self.wait_until(lambda s: len(s["texts"]["Writer"]) == 0, "the block gone")
self.assertEqual(len(self.state()["layers"]["Writer"]), 1)
# A Hide rectangle made, and one removed by the eraser, are undone.
firefox.run("document.querySelector('[data-tool=hide]').click();")
firefox.run(TEXT_GESTURE, 0.05, 0.2, 0.35, 0.4)
made = self.wait_until(lambda s: len(s["hides"]) == 1, "the Hide rectangle")
undo()
self.wait_until(lambda s: not s["hides"], "the Hide undone")
firefox.run(TEXT_GESTURE, 0.05, 0.2, 0.35, 0.4)
made = self.wait_until(lambda s: len(s["hides"]) == 1, "the Hide rectangle again")
hide = made["hides"][0]
firefox.run(ERASER_AT, hide[0] + 50, hide[1] + 50, 5)
self.wait_until(lambda s: not s["hides"], "the Hide removed by the eraser")
undo()
self.wait_until(lambda s: s["hides"] == [hide], "the Hide back where it was")
firefox.switch_to(guest_tab)
self.wait_until(lambda s: s["hides"] == [hide], "the restored Hide to reach the watcher")
firefox.switch_to(host_tab)
# A new page starts with nothing to undo.
firefox.run("document.getElementById('new_page').click();")
self.wait_until(lambda s: s["page"]["number"] == 2, "the new page")
self.assertTrue(undo_disabled())
firefox.switch_to(guest_tab)
firefox.in_session("DELETE", "/window")
firefox.switch_to(host_tab)
firefox.go("about:blank")
def test_images_are_moved_and_scaled_by_hand(self):
"""The teacher's requests of 2026-10-08: composition mode, and
images moved, scaled and brought to the front by a hold of the pen."""