mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
94470ff00e
commit
bdbbc3d767
6 files changed
+428
-108
No files matched your search
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
@@ -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');
|
||||
},
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user