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
+597
-30
No files matched your search
+13
-5
@@ -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"
|
||||||
|
|||||||
+89
-6
@@ -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) => {
|
||||||
|
// What the movement erased is one gesture to undo.
|
||||||
|
const erased = session.endErasing();
|
||||||
|
if (reach >= TAP_DISTANCE) {
|
||||||
|
lastEmptyTap = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
// A tap of the eraser on a Hide rectangle or an image removes it
|
// A tap of the eraser on a Hide rectangle or an image removes it
|
||||||
// (the right to make pages, or having added the image).
|
// (the right to make pages, or having added the image).
|
||||||
if (reach >= TAP_DISTANCE) return;
|
let took = erased.strokes + erased.texts > 0;
|
||||||
|
if (!took) {
|
||||||
const hide = session.page.hideAt(x, y, 0);
|
const hide = session.page.hideAt(x, y, 0);
|
||||||
if (hide && session.removeHide(hide.index)) {
|
if (hide && session.removeHide(hide.index)) {
|
||||||
log(`Hide ${hide.index + 1} removed`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const found = session.page.imageAt(x, y, 0);
|
// A tap on empty paper: the second within DOUBLE_TAP_MS undoes.
|
||||||
if (found && session.removeImage(found.index)) log(`Image ${found.index + 1} removed`);
|
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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
Reference in new issue
Block a user