Keep Draw rows in place across board changes, and frame lines across the antimeridian

The shape list rebuilt every row on each board change. A name being typed was
lost when a voice mark landed or expired, and a click right after typing only
committed the name, because the button under the pointer was replaced. Rows
are now updated in place, a re-entrant render is deferred, picking a colour or
Esc returns focus to the swatch, and keys on the list no longer reach Draw.

Fly-to on a line across the antimeridian flew to longitude 0. A drawn mark
and a voice mark are no longer deduplicated into one another, so a voice TTL
cannot expire a drawn pin, and a new pin on a hidden one is drawn.

The engine tests now check effects (renderer calls, alpha, notifications on
clear and expiry, the framed centre), not only return values.
This commit is contained in:
Said Turksever committed 2026-10-30 15:23:56 +00:00
1 parent 94470ff00e
commit bdbbc3d767
6 files changed
+428 -108

No files matched your search

+2 -2
View File
@@ -5420,8 +5420,8 @@ request viewbox and continues searching without that geographic preference.
### Manual Whiteboard Drawing (September 2026)
- UTILITIES ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (icon buttons for Area, Line and Pin) and a colour swatch for the next shape, click the vertices on the real world, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
- **Drawn shapes are listed under Draw like Import's files** (`src/annotations/drawMarkList.js`): one row per hand-drawn mark (`manual: true`; voice marks are not listed) with its colour swatch, its name, and show/hide, fly-to and remove. The name is typed in the row (Enter or leaving the field saves it, Esc restores it and stays in the field); an unnamed shape shows its kind, and an unnamed line its length. The swatch opens the five colours in the row. The list reads the engine's board and redraws on `onChange`; it asks for changes through the engine's per-mark `remove`, `setHidden`, `frame` and `restyle`. A hidden mark stays on the board at alpha 0, so it keeps its place and comes back at once; `restyle` re-adds the mark with the new name or colour, and a renamed line keeps its length (`Name — 1.2 km`). Fly-to frames a line by its whole path, not its first point.
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its markup lives in the UTILITIES panel's component template (`src/ui/templates/layer-panels.html`), assembled into `index.html` at build time. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `diagnostics`) is the no-mouse test seam.
- **Drawn shapes are listed under Draw like Import's files** (`src/annotations/drawMarkList.js`): one row per hand-drawn mark (`manual: true`; voice marks are not listed) with its colour swatch, its name, and show/hide, fly-to and remove. The name is typed in the row (Enter or leaving the field saves it, Esc restores it and leaves the field without leaving Draw); an unnamed shape shows its kind, and an unnamed line its length. The swatch opens the five colours in the row; choosing one, or Esc, closes them and returns focus to the swatch. The list reads the engine's board on `onChange` and updates its rows in place rather than rebuilding them, so a click that is half done, or a name being typed, survives a board change; keys on the list's controls are the list's, not the drawing's. it asks for changes through the engine's per-mark `remove`, `setHidden`, `frame` and `restyle`. A hidden mark stays on the board at alpha 0, so it keeps its place and comes back at once; `restyle` re-adds the mark with the new name or colour, and a renamed line keeps its length (`Name — 1.2 km`). Fly-to frames a line by its whole path, not its first point, and centres a line across the antimeridian there. A drawn mark and a voice mark are never deduplicated into one another, and a hidden mark is never refreshed by a new one.
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its markup lives in the UTILITIES panel's component template (`src/ui/templates/layer-panels.html`), assembled into `index.html` at build time. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam.
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, de-dup, appear in `.list()`, and clear with `clear_annotations` / `.clear()`.
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen".
+12 -2
View File
@@ -828,6 +828,10 @@ export function createAnnotationEngine({
};
for (const ex of annotations.values()) {
if (ex.type !== anno.type || !ex.anchor) continue;
// A shape drawn by hand and a voice mark are never the same mark: a
// match would let a voice TTL expire the drawn one, or replace it. A
// hidden mark is not refreshed either; a new shape there is new.
if (Boolean(ex.manual) !== Boolean(anno.manual) || ex.hidden) continue;
// Identity is GEOMETRY, not label: "Marina" and "Marina District" resolve to the SAME
// polygon/anchor and must collapse to one mark (the caller replaces label/color in place
// so the latest caption wins). Resolution is deterministic, so the same place re-resolves
@@ -1054,15 +1058,21 @@ export function createAnnotationEngine({
if (!anno) return false;
if (anno.type === 'route' && anno.path?.length) {
const ring = anno.path.map((p) => [p.lon, p.lat]);
const lons = ring.map(([lon]) => lon);
// Unwrap around the first vertex, as ringRange does, so a line across
// the antimeridian is centred on it, not on the far side of the globe.
const ref = ring[0][0];
const lons = ring.map(
([lon]) => lon - 360 * Math.round((lon - ref) / 360),
);
const lats = ring.map(([, lat]) => lat);
const midLon = (Math.min(...lons) + Math.max(...lons)) / 2;
frameAnnotation({
...anno,
to: null,
ring,
polygons: null,
anchor: {
lon: (Math.min(...lons) + Math.max(...lons)) / 2,
lon: ((((midLon + 180) % 360) + 360) % 360) - 180,
lat: (Math.min(...lats) + Math.max(...lats)) / 2,
height: 0,
},
+168
View File
@@ -12,6 +12,7 @@ import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
//
// Run with: npm test (node --test)
import { test } from 'node:test';
import * as Cesium from 'cesium';
import assert from 'node:assert/strict';
import {
createAnnotationEngine,
@@ -787,6 +788,173 @@ test('hand-drawn marks: one mark can be hidden, renamed, recoloured, framed and
assert.equal(changes, seen, 'an unsubscribed listener hears nothing');
});
// The list trusts these calls to change the globe, not just the list: each
// is checked by its effect (renderer calls, alpha, camera, notifications).
test('hand-drawn marks: remove, hide, clear, expiry and framing have their effect', async (t) => {
const frames = [];
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = (callback) => frames.push(callback);
globalThis.cancelAnimationFrame = () => {};
let now = 1_000_000;
t.mock.method(performance, 'now', () => now);
_resetRenderGovernorForTest();
t.after(() => {
globalThis.requestAnimationFrame = originalRequestAnimationFrame;
globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
_resetRenderGovernorForTest();
});
const runFrames = () => {
for (let i = 0; i < 5 && frames.length; i += 1) frames.shift()();
};
const { renderer, calls } = fakeRenderer();
const flights = [];
const engine = createAnnotationEngine({
viewer: {
camera: {
heading: 0,
flyToBoundingSphere: (sphere) => flights.push(sphere),
},
},
renderer,
});
let changes = 0;
engine.onChange(() => {
changes += 1;
});
await engine.annotate(
[
{
type: 'pin',
manual: true,
latitude: 30.27,
longitude: -97.74,
label: 'Camp',
},
{
type: 'pin',
manual: true,
latitude: 30.3,
longitude: -97.7,
label: 'Base',
},
],
{ persist: true },
);
now += 1000;
runFrames();
const [camp, base] = engine.list();
assert.equal(camp.alpha, 1);
engine.setHidden(camp.id, true);
runFrames();
assert.equal(camp.alpha, 0, 'a hidden mark is drawn at alpha 0');
assert.ok(engine.list().includes(camp), 'and stays on the board');
engine.setHidden(camp.id, false);
runFrames();
assert.equal(camp.alpha, 1, 'showing it again is immediate, with no fade-in');
const removes = calls.remove;
let seen = changes;
engine.remove(base.id);
assert.equal(calls.remove, removes + 1, 'remove takes it off the globe');
assert.equal(changes, seen + 1, 'and tells the list');
seen = changes;
engine.clear();
assert.equal(changes, seen + 1, 'a clear tells the list');
// A voice mark with a TTL: its expiry must reach the list too.
await engine.annotate(
[{ type: 'pin', latitude: 1, longitude: 1, label: 'Brief' }],
{
persist: false,
ttlMs: 2000,
},
);
seen = changes;
now += 60_000;
runFrames();
assert.equal(engine.list().length, 0, 'the mark expired');
assert.equal(changes, seen + 1, 'expiry tells the list');
// A line across the antimeridian is framed there, not on the far side.
await engine.annotate(
[
{
type: 'route',
manual: true,
path: [
[179.9, -17],
[-179.9, -17.1],
],
label: '',
},
],
{ persist: true },
);
const [line] = engine.list();
engine.frame(line.id);
const centre = Cesium.Cartographic.fromCartesian(flights.at(-1).center);
assert.ok(
Math.abs(Math.abs(Cesium.Math.toDegrees(centre.longitude)) - 180) < 0.5,
`framed at ${Cesium.Math.toDegrees(centre.longitude)}°, not near 180°`,
);
});
test('hand-drawn marks: a voice mark or a hidden mark is not taken as their duplicate', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({ viewer: {}, renderer });
await engine.annotate(
[
{
type: 'pin',
manual: true,
latitude: 30.27,
longitude: -97.74,
label: 'Camp',
},
],
{ persist: true },
);
const [drawn] = engine.list();
// The agent marks the same spot with the same label, briefly.
await engine.annotate(
[{ type: 'pin', latitude: 30.27, longitude: -97.74, label: 'Camp' }],
{
persist: false,
},
);
assert.equal(engine.list().length, 2, 'the voice mark is its own mark');
assert.equal(drawn.ttlMs, null, 'the drawn pin keeps persisting');
assert.equal(drawn.manual, true);
engine.setHidden(drawn.id, true);
const again = await engine.annotate(
[
{
type: 'pin',
manual: true,
latitude: 30.27,
longitude: -97.74,
label: 'Camp',
},
],
{ persist: true },
);
assert.equal(again.drawn, 1);
const visible = engine.list().filter((mark) => mark.manual && !mark.hidden);
assert.equal(
visible.length,
1,
'a pin dropped on a hidden one is a new, visible pin',
);
});
test('hand-drawn marks: only a manual mark can be restyled', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
+140 -97
View File
@@ -60,36 +60,20 @@ export function createDrawMarkList({ listEl, annotations, listen }) {
const doc = listEl.ownerDocument;
let destroyed = false;
let picking = null; // the id whose colour picker is open
/** @type {Map<string, object>} each listed mark's row nodes */
const rows = new Map();
let rendering = false;
let rerender = false;
const marks = () =>
(annotations.list?.() || []).filter((mark) => mark?.manual === true);
const find = (id) => marks().find((mark) => mark.id === id) || null;
/** Which row control has focus, so a re-render can give it back. */
function focusedControl() {
const active = doc.activeElement;
if (!active || !listEl.contains?.(active)) return null;
const row = active.closest?.('[data-mark-id]');
return row
? { id: row.dataset.markId, control: active.dataset?.control }
: null;
}
function restoreFocus(was) {
if (!was) return;
const row = [...listEl.querySelectorAll('[data-mark-id]')].find(
(node) => node.dataset.markId === was.id,
);
row?.querySelector?.(`[data-control="${was.control}"]`)?.focus?.();
}
function actionButton(icon, label, control) {
function actionButton(icon, control) {
const button = doc.createElement('button');
button.type = 'button';
button.className = 'geo-import-action';
button.dataset.control = control;
button.title = label;
button.setAttribute('aria-label', label);
const glyph = doc.createElement('span');
glyph.className = 'material-symbols-outlined';
glyph.setAttribute('aria-hidden', 'true');
@@ -98,25 +82,24 @@ export function createDrawMarkList({ listEl, annotations, listen }) {
return button;
}
function colorPicker(mark) {
function setLabel(node, label) {
node.title = label;
node.setAttribute('aria-label', label);
}
function colorPicker() {
const picker = doc.createElement('div');
picker.className = 'draw-colors draw-mark-colors';
picker.dataset.control = 'picker';
picker.setAttribute('role', 'radiogroup');
picker.setAttribute(
'aria-label',
t('Colour of {name}', { name: label(mark) }),
);
for (const color of DRAW_COLORS) {
const swatch = doc.createElement('button');
swatch.type = 'button';
swatch.className = `draw-color${color === mark.color ? ' active' : ''}`;
swatch.className = 'draw-color';
swatch.dataset.color = color;
swatch.dataset.control = `color-${color}`;
swatch.setAttribute('role', 'radio');
swatch.setAttribute('aria-checked', String(color === mark.color));
const name = t(COLOR_NAMES[color]);
swatch.title = name;
swatch.setAttribute('aria-label', name);
setLabel(swatch, t(COLOR_NAMES[color]));
picker.append(swatch);
}
return picker;
@@ -124,75 +107,118 @@ export function createDrawMarkList({ listEl, annotations, listen }) {
const label = (mark) => mark.name || drawMarkFallbackName(mark);
/** One row's nodes, built once and kept while its mark is on the board. */
function createRow(id) {
const row = doc.createElement('li');
row.className = 'geo-import-row draw-mark-row';
row.dataset.markId = id;
const swatch = doc.createElement('button');
swatch.type = 'button';
swatch.className = 'geo-import-swatch draw-mark-swatch';
swatch.dataset.control = 'swatch';
// textContent and value only, never markup: the name is typed text.
const name = doc.createElement('input');
name.type = 'text';
name.className = 'geo-import-name draw-mark-name';
name.dataset.control = 'name';
name.maxLength = 120;
name.autocomplete = 'off';
name.spellcheck = false;
const toggle = actionButton('visibility', 'toggle');
const fly = actionButton('my_location', 'fly');
const remove = actionButton('close', 'remove');
row.append(swatch, name, toggle, fly, remove);
return { row, swatch, name, toggle, fly, remove, picker: null };
}
function updateRow(nodes, mark) {
const { row, swatch, name, toggle, fly, remove } = nodes;
const title = label(mark);
row.classList.toggle('hidden-mark', Boolean(mark.hidden));
swatch.style.background = DRAW_COLOR_CSS[mark.color] || '';
setLabel(swatch, t('Change the colour of {name}', { name: title }));
swatch.setAttribute('aria-expanded', String(picking === mark.id));
// A name being typed is the person's draft: a board change elsewhere
// must not overwrite it.
if (doc.activeElement !== name) name.value = mark.name || '';
name.placeholder = drawMarkFallbackName(mark);
name.setAttribute('aria-label', t('Name of {name}', { name: title }));
const visible = !mark.hidden;
toggle.children[0].textContent = visible ? 'visibility' : 'visibility_off';
setLabel(
toggle,
visible
? t('Hide {name}', { name: title })
: t('Show {name}', { name: title }),
);
toggle.setAttribute('aria-pressed', String(visible));
setLabel(fly, t('Fly to {name}', { name: title }));
setLabel(remove, t('Remove {name}', { name: title }));
if (picking === mark.id) {
nodes.picker ||= colorPicker();
nodes.picker.setAttribute(
'aria-label',
t('Colour of {name}', { name: title }),
);
for (const button of nodes.picker.children) {
const on = button.dataset.color === mark.color;
button.classList.toggle('active', on);
button.setAttribute('aria-checked', String(on));
}
if (nodes.picker.parentNode !== row) row.append(nodes.picker);
} else if (nodes.picker) {
nodes.picker.remove();
nodes.picker = null;
}
}
/**
* Bring the rows in line with the board. Rows are updated in place, never
* rebuilt: a rebuild would detach the button under a click that is half
* done, and the name field someone is typing in.
*/
function render() {
if (destroyed) return;
const keep = focusedControl();
if (rendering) {
rerender = true;
return;
}
rendering = true;
try {
do {
rerender = false;
reconcile();
} while (rerender && !destroyed);
} finally {
rendering = false;
}
}
function reconcile() {
const list = marks();
if (picking && !list.some((mark) => mark.id === picking)) picking = null;
listEl.replaceChildren(
...list.map((mark) => {
const row = doc.createElement('li');
row.className = 'geo-import-row draw-mark-row';
row.dataset.markId = mark.id;
if (mark.hidden) row.classList.add('hidden-mark');
const swatch = doc.createElement('button');
swatch.type = 'button';
swatch.className = 'geo-import-swatch draw-mark-swatch';
swatch.dataset.control = 'swatch';
swatch.style.background = DRAW_COLOR_CSS[mark.color] || '';
const swatchLabel = t('Change the colour of {name}', {
name: label(mark),
});
swatch.title = swatchLabel;
swatch.setAttribute('aria-label', swatchLabel);
swatch.setAttribute('aria-expanded', String(picking === mark.id));
// textContent and value only, never markup: the name is typed text.
const name = doc.createElement('input');
name.type = 'text';
name.className = 'geo-import-name draw-mark-name';
name.dataset.control = 'name';
name.maxLength = 120;
name.autocomplete = 'off';
name.spellcheck = false;
name.value = mark.name || '';
name.placeholder = drawMarkFallbackName(mark);
name.setAttribute(
'aria-label',
t('Name of {name}', { name: label(mark) }),
);
const visible = !mark.hidden;
const toggle = actionButton(
visible ? 'visibility' : 'visibility_off',
visible
? t('Hide {name}', { name: label(mark) })
: t('Show {name}', { name: label(mark) }),
'toggle',
);
toggle.setAttribute('aria-pressed', String(visible));
row.append(
swatch,
name,
toggle,
actionButton(
'my_location',
t('Fly to {name}', { name: label(mark) }),
'fly',
),
actionButton(
'close',
t('Remove {name}', { name: label(mark) }),
'remove',
),
);
if (picking === mark.id) row.append(colorPicker(mark));
return row;
}),
);
const live = new Set(list.map((mark) => mark.id));
for (const [id, nodes] of rows) {
if (live.has(id)) continue;
nodes.row.remove();
rows.delete(id);
}
list.forEach((mark, index) => {
let nodes = rows.get(mark.id);
if (!nodes) {
nodes = createRow(mark.id);
rows.set(mark.id, nodes);
}
updateRow(nodes, mark);
// Move a row only when it is out of place, so a focused one stays put.
if (listEl.children[index] !== nodes.row)
listEl.insertBefore(nodes.row, listEl.children[index] || null);
});
listEl.classList.toggle('visible', list.length > 0);
restoreFocus(keep);
}
const rowId = (node) => node?.closest?.('[data-mark-id]')?.dataset.markId;
@@ -203,10 +229,18 @@ export function createDrawMarkList({ listEl, annotations, listen }) {
const mark = id && find(id);
if (!mark) return;
const next = input.value.trim();
input.value = next;
if (next === (mark.name || '')) return;
annotations.restyle(id, { name: next });
}
/** Close a row's colour picker and give focus back to its swatch. */
function closePicker(id) {
picking = null;
render();
rows.get(id)?.swatch.focus?.();
}
listen(listEl, 'click', (event) => {
const control = event.target?.closest?.('[data-control]');
const id = rowId(control);
@@ -216,15 +250,23 @@ export function createDrawMarkList({ listEl, annotations, listen }) {
picking = picking === id ? null : id;
render();
} else if (kind.startsWith('color-')) {
const color = control.dataset.color;
picking = null;
annotations.restyle(id, { color: control.dataset.color });
render();
if (find(id)?.color !== color) annotations.restyle(id, { color });
closePicker(id);
} else if (kind === 'toggle') annotations.setHidden(id, !find(id)?.hidden);
else if (kind === 'fly') annotations.frame(id);
else if (kind === 'remove') annotations.remove(id);
});
listen(listEl, 'keydown', (event) => {
const input = event.target;
if (event.key === 'Escape' && picking && rowId(input) === picking) {
// Escape closes the picker; it must not also leave Draw.
event.preventDefault();
event.stopPropagation();
closePicker(picking);
return;
}
if (input?.dataset?.control !== 'name') return;
if (event.key === 'Enter') {
event.preventDefault();
@@ -252,6 +294,7 @@ export function createDrawMarkList({ listEl, annotations, listen }) {
if (destroyed) return;
destroyed = true;
unsubscribe();
rows.clear();
listEl.replaceChildren();
listEl.classList.remove('visible');
},
+103 -7
View File
@@ -47,14 +47,31 @@ function fakeDocument() {
getAttribute(key) {
return this.attributes.get(key) ?? null;
},
get parentNode() {
return this.parent;
},
append(...nodes) {
for (const child of nodes) {
child.parent = this;
this.children.push(child);
}
for (const child of nodes) this.insertBefore(child, null);
},
insertBefore(child, before) {
child.remove();
child.parent = this;
const at = before ? this.children.indexOf(before) : -1;
if (at < 0) this.children.push(child);
else this.children.splice(at, 0, child);
},
remove() {
// Like a browser, taking the focused node out of the page blurs it.
if (doc.activeElement && this.contains(doc.activeElement))
doc.activeElement = null;
if (this.parent)
this.parent.children = this.parent.children.filter(
(node) => node !== this,
);
this.parent = null;
},
replaceChildren(...nodes) {
this.children = [];
for (const child of [...this.children]) child.remove();
this.append(...nodes);
},
closest(selector) {
@@ -136,7 +153,22 @@ function harness(initial) {
const rows = () => listEl.children;
const control = (row, name) => row.querySelector(`[data-control="${name}"]`);
const click = (target) => listeners.click({ target });
return { list, listEl, listeners, rows, control, click, calls, changed };
const addMark = (mark) => {
marks = [...marks, { hidden: false, ...mark }];
changed();
};
return {
list,
listEl,
listeners,
rows,
control,
click,
calls,
changed,
addMark,
changeListeners,
};
}
const LINE = {
@@ -251,11 +283,14 @@ test('a name is committed on Enter or blur, and Escape undoes the edit without l
});
test('a re-render keeps focus on the same control, and destroy stops listening', () => {
const { rows, control, list, listEl, changed } = harness([LINE]);
const { rows, control, list, listEl, changed, changeListeners } = harness([
LINE,
]);
control(rows()[0], 'fly').focus();
changed();
assert.equal(listEl.ownerDocument.activeElement, control(rows()[0], 'fly'));
list.destroy();
assert.equal(changeListeners.size, 0, 'destroy unsubscribes from the board');
assert.equal(rows().length, 0);
assert.ok(!listEl.classList.contains('visible'));
changed();
@@ -278,3 +313,64 @@ test('an unnamed shape is called by its kind', () => {
'Line',
);
});
test('a board change while a name is being typed keeps the draft and the row', () => {
const { rows, control, listEl, addMark, calls } = harness([LINE, PIN]);
const name = control(rows()[0], 'name');
name.focus();
name.value = 'Half-typ';
// A voice mark lands, and another hand-drawn one: neither may wipe the draft.
addMark(VOICE);
addMark({ ...PIN, id: 'a4', name: null, label: '' });
assert.equal(control(rows()[0], 'name'), name, 'the row is not rebuilt');
assert.equal(name.value, 'Half-typ', 'the typed text survives');
assert.equal(listEl.ownerDocument.activeElement, name, 'focus stays');
assert.equal(calls.length, 0, 'nothing is committed early');
assert.deepEqual(
rows().map((row) => row.dataset.markId),
['a1', 'a2', 'a4'],
);
});
test('a click right after typing a name still reaches its button', () => {
const { rows, control, listeners, click, calls } = harness([LINE, PIN]);
const name = control(rows()[0], 'name');
name.focus();
name.value = 'Home';
const hide = control(rows()[1], 'toggle');
const remove = control(rows()[0], 'remove');
// mousedown on a button blurs the field first, which commits the name.
name.blur();
listeners.focusout({ target: name });
assert.deepEqual(calls.at(-1), ['restyle', 'a1', { name: 'Home' }]);
assert.equal(control(rows()[1], 'toggle'), hide, 'the button is still there');
click(hide);
assert.deepEqual(calls.at(-1), ['hide', 'a2', true]);
click(remove);
assert.deepEqual(calls.at(-1), ['remove', 'a1']);
});
test('picking a colour returns focus to the swatch; Escape closes the picker', () => {
const { rows, control, click, listeners, listEl, calls } = harness([LINE]);
const doc = listEl.ownerDocument;
click(control(rows()[0], 'swatch'));
click(control(rows()[0], 'color-primary'));
assert.equal(calls.length, 0, 'the colour it already has costs nothing');
assert.equal(doc.activeElement, control(rows()[0], 'swatch'));
click(control(rows()[0], 'swatch'));
const amber = control(rows()[0], 'color-amber');
amber.focus();
let stopped = false;
listeners.keydown({
key: 'Escape',
target: amber,
preventDefault() {},
stopPropagation() {
stopped = true;
},
});
assert.ok(stopped, 'Escape does not reach Draw');
assert.equal(control(rows()[0], 'color-amber'), null, 'the picker closed');
assert.equal(doc.activeElement, control(rows()[0], 'swatch'));
});
+3
View File
@@ -310,6 +310,9 @@ export function initDrawTool({ viewer, annotations }) {
};
const onKey = (event) => {
if (!active || destroyed || typingElsewhere(event)) return;
// Keys on the shape list's own controls are the list's (Esc closes a
// colour picker there), not the drawing's.
if (markListEl?.contains?.(event.target)) return;
// Enter goes to finish() once a shape has been started; finish() owns the
// decision and says WHY it refused. Swallowing the key on a degenerate
// shape left the person pressing Enter at a silent panel. A focused button