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

+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);
}
/**