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> <div id="session" hidden>
<header class="toolbar"> <header class="toolbar">
<div id="drawing" class="drawing" aria-label="Drawing">
<div class="group" role="radiogroup" aria-label="Tool"> <div class="group" role="radiogroup" aria-label="Tool">
<button type="button" data-tool="pen" aria-pressed="true" <button type="button" data-tool="pen" aria-pressed="true"
data-help="Write on the page. Only your own writing can be erased.">Pen</button> data-help="Write on the page. Only your own writing can be erased.">Pen</button>
<button type="button" data-tool="text" aria-pressed="false" <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> 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>
<div id="settings" class="settings">
<div class="group" id="colors" role="radiogroup" aria-label="Color"> <div class="group" id="colors" role="radiogroup" aria-label="Color">
<button id="more_colors" type="button" class="swatch more" <button id="more_colors" type="button" class="swatch more"
data-help="Choose another color. It is added here.">+</button> 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" <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."> aria-label="Text size in points" data-help="Text size in points.">
</div> </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" <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> 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" <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 { pageToPng } from './page_picture.js';
import { TextEditor } from './text_editor.js'; import { TextEditor } from './text_editor.js';
import { enableTooltips } from './tooltip.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'; 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. */ /** With the text tool or the eraser, a press that moves less than this (3 mm) is a tap. */
const TAP_DISTANCE = 30; 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 * 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(); syncLayers();
showParticipants(session, renderer); showParticipants(session, renderer);
}, },
onUndoChanged: () => {
/** @type {HTMLButtonElement} */ (element('undo')).disabled = !session.canUndo();
},
onStatus: (status, detail) => { onStatus: (status, detail) => {
const text = { connecting: 'Connecting…', connected: 'Connected', failed: detail ?? 'Disconnected' }; const text = { connecting: 'Connecting…', connected: 'Connected', failed: detail ?? 'Disconnected' };
log(`Relay: ${text[status]}`); log(`Relay: ${text[status]}`);
@@ -616,16 +625,54 @@ async function enterSession(invitationText, name, notebook) {
}; };
new ResizeObserver(watchToolbar).observe(toolbar); new ResizeObserver(watchToolbar).observe(toolbar);
watchToolbar(); 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, { textEditor = new TextEditor(pageElement, renderer, {
setText: (block) => session.setText(block), setText: (block) => session.setText(block),
removeText: (id) => session.removeTexts([id]), 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. // A hold on the field being typed in moves its block, as on the page.
beginHold: (clientX, clientY) => penTarget.beginHold?.(clientX, clientY) ?? false, beginHold: (clientX, clientY) => penTarget.beginHold?.(clientX, clientY) ?? false,
holdMove: (clientX, clientY) => penTarget.holdMove?.(clientX, clientY), holdMove: (clientX, clientY) => penTarget.holdMove?.(clientX, clientY),
holdEnd: () => penTarget.holdEnd?.(), 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). // Whether one's own writing may grow the page (the "Extend" box).
let extend = localStorage.getItem(EXTEND_KEY) !== 'no'; let extend = localStorage.getItem(EXTEND_KEY) !== 'no';
const extendBox = input('extend'); const extendBox = input('extend');
@@ -671,6 +718,8 @@ async function enterSession(invitationText, name, notebook) {
*/ */
/** @type {Held | null} */ /** @type {Held | null} */
let 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. */ /** @type {import('./page_model.js').Stroke | null} The stroke a hold began as. */
let strokeInProgress = null; let strokeInProgress = null;
@@ -765,6 +814,7 @@ async function enterSession(invitationText, name, notebook) {
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);
session.beginHideEdit(h.id);
} else { } else {
const blocks = [...session.ownLayer.texts.values()]; const blocks = [...session.ownLayer.texts.values()];
const at = rectAt(blocks.map(textBounds), x, y, reach); 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. // Free text has no corners to resize by.
const corner = block.height === null ? null : at.corner; const corner = block.height === null ? null : at.corner;
found = hold('text', at.index, block.id, textBounds(block), corner, x, y); found = hold('text', at.index, block.id, textBounds(block), corner, x, y);
session.beginTextEdit(block.id, 'hold');
} else if (session.mayMakePages()) { } else if (session.mayMakePages()) {
const image = page.imageAt(x, y, reach); const image = page.imageAt(x, y, reach);
if (image) { if (image) {
@@ -786,7 +837,7 @@ async function enterSession(invitationText, name, notebook) {
if (strokeInProgress) { if (strokeInProgress) {
const begun = strokeInProgress; const begun = strokeInProgress;
strokeInProgress = null; strokeInProgress = null;
session.endStroke(); session.endStroke(false);
session.erase([begun.id]); session.erase([begun.id]);
} }
textEditor?.hidePreview(); textEditor?.hidePreview();
@@ -826,11 +877,14 @@ async function enterSession(invitationText, name, notebook) {
const r = currentHeldRect(held); 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 (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.sendPageNow();
if (held.kind === 'text') session.endTextEdit(held.id, 'hold');
if (held.kind === 'hide') session.endHideEdit();
held = null; held = null;
renderer.showGrip(null); renderer.showGrip(null);
}, },
eraseAlong: (x0, y0, x1, y1) => { eraseAlong: (x0, y0, x1, y1) => {
textEditor?.stop(); textEditor?.stop();
session.beginErasing();
const radius = renderer.pageLength(ERASER_RADIUS); const radius = renderer.pageLength(ERASER_RADIUS);
const strokes = strokesTouched(session.ownLayer, x0, y0, x1, y1, radius); const strokes = strokesTouched(session.ownLayer, x0, y0, x1, y1, radius);
const texts = textsTouched(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 }; return { strokes: strokes.length, texts: texts.length };
}, },
eraseEnded: (x, y, reach) => { eraseEnded: (x, y, reach) => {
// A tap of the eraser on a Hide rectangle or an image removes it // What the movement erased is one gesture to undo.
// (the right to make pages, or having added the image). const erased = session.endErasing();
if (reach >= TAP_DISTANCE) return; if (reach >= TAP_DISTANCE) {
const hide = session.page.hideAt(x, y, 0); lastEmptyTap = 0;
if (hide && session.removeHide(hide.index)) {
log(`Hide ${hide.index + 1} removed`);
return; return;
} }
const found = session.page.imageAt(x, y, 0); // A tap of the eraser on a Hide rectangle or an image removes it
if (found && session.removeImage(found.index)) log(`Image ${found.index + 1} removed`); // (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) => { previewRect: (x0, y0, x1, y1) => {
if (Math.abs(x1 - x0) >= TAP_DISTANCE || Math.abs(y1 - y0) >= TAP_DISTANCE) { if (Math.abs(x1 - x0) >= TAP_DISTANCE || Math.abs(y1 - y0) >= TAP_DISTANCE) {
@@ -1170,7 +1248,12 @@ function press(group, chosen) {
function buildToolbar(options) { function buildToolbar(options) {
for (const button of document.querySelectorAll('[data-tool]')) { for (const button of document.querySelectorAll('[data-tool]')) {
button.addEventListener('click', () => { 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'); const chosen = button.getAttribute('data-tool');
options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : chosen === 'hide' ? 'hide' : 'pen'); 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. * Erases one's own strokes and text blocks along the movement and says how many.
* @property {(x: number, y: number, reach: number) => void} [eraseEnded] * @property {(x: number, y: number, reach: number) => void} [eraseEnded]
* The eraser went down at (x, y) and has come up again, having moved * 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 * @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 * 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. * 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); if (event.type === 'pointerup') target.placeRect(textStartX, textStartY, x, y);
} else if (erasing) { } else if (erasing) {
eraseFromPress(); 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 { } else {
target.endStroke(); target.endStroke();
} }
+1 -1
View File
@@ -46,7 +46,7 @@ function describe(node) {
} }
/** Whether the selection belongs to a focused text field. */ /** Whether the selection belongs to a focused text field. */
function inField() { export function inField() {
const active = document.activeElement; const active = document.activeElement;
return active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement || return active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement ||
(active instanceof HTMLElement && active.isContentEditable); (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').Stroke} Stroke */
/** @typedef {import('./page_model.js').PageImage} PageImage */ /** @typedef {import('./page_model.js').PageImage} PageImage */
/** @typedef {import('./page_model.js').TextBlock} TextBlock */ /** @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 * @typedef {object} Participant
@@ -55,6 +69,8 @@ import { RelayConnection } from './relay_connection.js';
* 2026-10-08), each in their own window. * 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]
* 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. */ /** Approximate size at which a layer snapshot is split into messages. */
const SNAPSHOT_CHUNK_CHARACTERS = 100000; 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. */ /** An image is sent in pieces of this many bytes. */
const IMAGE_CHUNK_BYTES = 96 * 1024; const IMAGE_CHUNK_BYTES = 96 * 1024;
@@ -106,6 +125,19 @@ export class Session {
/** @type {ReturnType<typeof setTimeout> | null} */ /** @type {ReturnType<typeof setTimeout> | null} */
this.pointSendTimer = null; this.pointSendTimer = null;
this.lastPointSend = 0; 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.events = events;
this.isHost = this.identity.id === this.hostId; this.isHost = this.identity.id === this.hostId;
@@ -296,18 +328,41 @@ 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 (!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), 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),
}); };
page.hides.push(hide);
this.sheetAround(rect); this.sheetAround(rect);
page.imagesRevision += 1; page.imagesRevision += 1;
this.sendPageNow(); this.sendPageNow();
this.events.onImagesChanged(); this.events.onImagesChanged();
this.extentChanged(true); this.extentChanged(true);
this.pushUndo({ kind: 'hide', id: hide.id, before: null });
return true; 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. * Give a Hide rectangle a new rectangle: moved or resized by hand.
* @param {number} index * @param {number} index
@@ -332,12 +387,14 @@ export class Session {
*/ */
removeHide(index) { removeHide(index) {
const page = this.page; 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.hides.splice(index, 1);
page.imagesRevision += 1; page.imagesRevision += 1;
this.sendPageNow(); this.sendPageNow();
this.events.onImagesChanged(); this.events.onImagesChanged();
this.extentChanged(true); this.extentChanged(true);
this.pushUndo({ kind: 'hide', id: hide.id, before: { ...hide } });
return true; return true;
} }
@@ -550,10 +607,44 @@ export class Session {
this.extentChanged(); 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. */ /** @param {string[]} ids Text blocks of our own layer. */
removeTexts(ids) { 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)); const removed = ids.filter((id) => this.ownLayer.texts.delete(id));
if (removed.length === 0) return; 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.send(0, { type: 'text_remove', page: this.page.id, ids: removed });
this.page.recomputeStrokeBounds(); this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(this.identity.id); this.events.onLayerReplaced(this.identity.id);
@@ -727,6 +818,11 @@ export class Session {
this.endStroke(); this.endStroke();
const old = this.page; const old = this.page;
this.page = 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); this.ownLayer = page.layer(this.identity.id, this.name);
for (const participant of this.participants.values()) { for (const participant of this.participants.values()) {
if (participant.peer !== null) page.layer(participant.id, participant.name); 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 }); 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; const stroke = this.currentStroke;
if (!stroke) return; if (!stroke) return;
this.currentStroke = null; this.currentStroke = null;
@@ -819,18 +919,125 @@ export class Session {
this.sendPendingPoints(); this.sendPendingPoints();
this.send(0, { type: 'stroke_end', id: stroke.id }); this.send(0, { type: 'stroke_end', id: stroke.id });
this.events.onStrokeProgress(this.identity.id, stroke); 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. */ /** @param {string[]} ids Strokes of our own layer. */
erase(ids) { 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)); const erased = ids.filter((id) => this.ownLayer.strokes.delete(id));
if (erased.length === 0) return; 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.send(0, { type: 'erase', page: this.page.id, ids: erased });
this.page.recomputeStrokeBounds(); this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(this.identity.id); this.events.onLayerReplaced(this.identity.id);
this.extentChanged(); 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. // Sending.
/** /**
@@ -1080,6 +1287,22 @@ export class Session {
this.events.onStrokeProgress(id, stroke); this.events.onStrokeProgress(id, stroke);
break; 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': { case 'erase': {
if (message.page !== this.page.id || !Array.isArray(message.ids)) break; if (message.page !== this.page.id || !Array.isArray(message.ids)) break;
let erased = false; let erased = false;
@@ -1245,3 +1468,27 @@ export class Session {
this.send(peer, { type: 'state_end' }); 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 {object} target
* @param {(block: TextBlock) => void} target.setText The block changed. * @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.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] * @param {(clientX: number, clientY: number) => boolean} [target.beginHold]
* The pen rested on the field for HOLD_MS: a hold, as on the page * 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 * (Andy, 2026-10-09: the box being typed in can be moved without
@@ -185,6 +187,7 @@ export class TextEditor {
*/ */
edit(block) { edit(block) {
this.stop(); this.stop();
this.target.beginEdit?.(block.id);
this.block = block; this.block = block;
this.renderer.editingText = block.id; this.renderer.editingText = block.id;
this.field.value = block.text; this.field.value = block.text;
@@ -207,6 +210,7 @@ export class TextEditor {
this.field.value = ''; this.field.value = '';
if (block.text.trim() === '') this.target.removeText(block.id); if (block.text.trim() === '') this.target.removeText(block.id);
else this.target.setText(block); else this.target.setText(block);
this.target.endEdit?.(block.id);
} }
/** /**
+29 -5
View File
@@ -102,13 +102,35 @@ h2 {
background: var(--panel); 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 /* 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 instead of standing at the two ends (Andy, 2026-10-09; main.js sets
the class by measuring). */ the class by measuring). */
.toolbar.wrapped { justify-content: center; } .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.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 { display: flex; align-items: center; gap: 0.3rem; cursor: pointer; }
.toolbar .toggle input { margin: 0; } .toolbar .toggle input { margin: 0; }
@@ -125,14 +147,16 @@ h2 {
/* The same height as the buttons, so the toolbar does not change height; /* 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 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 { .toolbar button:not(.swatch), #text_size, #text_font {
box-sizing: border-box; box-sizing: border-box;
height: 2rem; height: calc(2rem - 2px);
padding-top: 0; padding-top: 0;
padding-bottom: 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 { #text_size, #text_font {
margin: 0; margin: 0;
@@ -196,7 +220,7 @@ h2 {
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.5rem 0.55rem; 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; min-width: 0;
} }
+23 -5
View File
@@ -48,10 +48,11 @@ place in it waits for the host to return.
## The screen ## The screen
Across the top is the toolbar, one row holding everything: the tools Across the top is the toolbar, one row holding everything in three
and their settings, the view, the host's page buttons, your notebook, groups: on the left the tools, their settings and **Undo**; in the
and **Session**, which opens a window with the participants and the middle the page and the view; on the right the host's page buttons,
invitation. Below it the page has the whole width of the window. Every button 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 explains itself: rest the pointer on it for two seconds and its
explanation appears beneath it. 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 **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 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 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 ### 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 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 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 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 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 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. 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 connection sends its whole layer again, so that nothing drawn meanwhile
is lost. 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 ## The page file
A notebook is a directory. The page being written is `Current.svg` in 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, 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, `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, `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 (`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 screen (`TEXT_FRAME_STYLE` in `renderer.js`; the file and the export
always hold the whole frame). always hold the whole frame).
+167
View File
@@ -368,6 +368,37 @@ ERASE_THROUGH = """
page.setPointerCapture = capture; 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(): def free_port():
with socket.socket() as listener: with socket.socket() as listener:
@@ -467,6 +498,12 @@ class Firefox:
"actions": [{"type": "pointerMove", "duration": 0, "origin": "viewport", "actions": [{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(box[0]), "y": round(box[1])}]}]}) "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): def set_window(self, width, height):
self.in_session("POST", "/window/rect", {"width": width, "height": 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.switch_to(host_tab)
firefox.go("about:blank") 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): def test_images_are_moved_and_scaled_by_hand(self):
"""The teacher's requests of 2026-10-08: composition mode, and """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.""" images moved, scaled and brought to the front by a hold of the pen."""