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:
1 parent
a139361e8d
commit
4e3a281d65
11 files changed
+602
-35
No files matched your search
+251
-4
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user