List drawn shapes under Draw, with icon shapes and colour swatches

Each shape drawn by hand gets a row under Draw, as Import's files do: its
colour swatch, its name, and show/hide, fly-to and remove. A shape is named
in its row, and its swatch opens the five colours there, so labelling and
colouring happen after a shape is drawn and can be seen, instead of in a
field filled in beforehand. Area, Line and Pin become icon buttons, named
for screen readers and tooltips, and the colour for the next shape is a row
of swatches instead of a dropdown.

The annotation engine gains per-mark remove, setHidden, restyle and frame,
and onChange so the list follows the board. A hidden mark stays on the board
at alpha 0, a renamed line keeps its length, and fly-to frames a line by its
whole path. Voice marks are not listed.

The Draw, form-field and segmented-control gates drive the new controls;
swatches are checked as chips, not segments.
This commit is contained in:
Said Turksever committed 2026-10-29 12:41:12 +00:00
1 parent 8543b7be43
commit 95ed686da5
23 files changed
+1061 -214

No files matched your search

+111
View File
@@ -144,6 +144,17 @@ export function createAnnotationEngine({
// task with the FINAL outcome — resolved or failed — so narration that said
// "tracing the boundary now" can later honestly confirm or correct itself.
const outlineListeners = new Set();
// Told whenever the set of marks, or one mark's look, changes (Draw's list).
const changeListeners = new Set();
const notifyChange = () => {
for (const listener of changeListeners) {
try {
listener();
} catch {
/* one listener's failure must not stop the others */
}
}
};
function emitOutlineEvent(evt) {
for (const listener of outlineListeners) {
@@ -219,6 +230,7 @@ export function createAnnotationEngine({
const now = performance.now();
let changed = false;
let pending = false; // any mark still fading in / out, or with a TTL fade ahead
let expired = false;
for (const anno of annotations.values()) {
const next = computeAlpha(anno, now);
if (next !== anno.alpha) {
@@ -229,6 +241,7 @@ export function createAnnotationEngine({
annotations.delete(anno.id);
renderer.remove(anno);
changed = true;
expired = true;
continue;
}
if (pendingAnimation(anno, now)) pending = true;
@@ -237,6 +250,7 @@ export function createAnnotationEngine({
renderer.sync(annotations);
syncAnnotationHold();
}
if (expired) notifyChange();
// Stop once the board is STABLE — persistent marks (persist=true is the default)
// finish fading in after ~260ms and never change again, so without this the loop
// would spin O(N) every frame forever. ensureTicking() restarts on the next
@@ -466,6 +480,7 @@ export function createAnnotationEngine({
if (ids.length) {
renderer.sync(annotations);
ensureTicking();
notifyChange();
}
if (opts.flyTo && firstAnchor) frameAnnotation(firstAnchor);
else if (ids.length) ensureMarksVisible(ids);
@@ -885,6 +900,11 @@ export function createAnnotationEngine({
alpha: 0, // animate in
bornAt: now,
expiring: false,
// Drawn by hand (drawTool.js): listed under Draw, renamed and recoloured
// there. `name` is the person's own label, before any distance is added.
manual: spec?.manual === true,
name: spec?.manual === true ? cleanLabel(spec?.label) || null : null,
hidden: false,
};
if (type === 'route') {
@@ -970,6 +990,85 @@ export function createAnnotationEngine({
annotations.clear();
renderer.sync(annotations);
syncAnnotationHold();
notifyChange();
}
/** Take one mark off the board at once. */
function remove(id) {
const anno = annotations.get(id);
if (!anno) return false;
annotations.delete(id);
renderer.remove(anno);
renderer.sync(annotations);
syncAnnotationHold();
notifyChange();
return true;
}
/** Hide or show one mark; a hidden mark stays on the board at alpha 0. */
function setHidden(id, hidden) {
const anno = annotations.get(id);
if (!anno) return false;
anno.hidden = Boolean(hidden);
// Skip the fade-in, so showing it again is immediate.
anno.bornAt = Math.min(anno.bornAt, performance.now() - 260);
ensureTicking();
notifyChange();
return true;
}
/**
* Rename or recolour a hand-drawn mark. The renderers build a mark's look
* when it is added, so the mark is added again with the new look.
*/
function restyle(id, { name, color } = {}) {
const anno = annotations.get(id);
if (!anno?.manual) return false;
if (name !== undefined) {
anno.name = cleanLabel(name) || null;
anno.label =
anno.type === 'route'
? composeRouteLabel(anno.name, anno.distanceM, null, 'manual', false)
: anno.name;
}
if (color !== undefined && COLORS.has(color)) anno.color = color;
renderer.remove(anno);
try {
renderer.add(anno);
} catch (error) {
rollbackRendererState(anno);
annotations.delete(id);
syncAnnotationHold();
notifyChange();
throw error;
}
renderer.sync(annotations);
ensureTicking();
notifyChange();
return true;
}
/** Fly the camera to one mark; a line is framed whole, not by its start. */
function frame(id) {
const anno = annotations.get(id);
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);
const lats = ring.map(([, lat]) => lat);
frameAnnotation({
...anno,
to: null,
ring,
polygons: null,
anchor: {
lon: (Math.min(...lons) + Math.max(...lons)) / 2,
lat: (Math.min(...lats) + Math.max(...lats)) / 2,
height: 0,
},
});
} else frameAnnotation(anno);
return true;
}
/** Begin a graceful fade-out of everything, then remove. */
@@ -1100,6 +1199,16 @@ export function createAnnotationEngine({
const engine = {
annotate,
clear,
remove,
setHidden,
restyle,
frame,
/** Subscribe to board changes; returns an unsubscribe fn. */
onChange(listener) {
if (destroyed || typeof listener !== 'function') return () => {};
changeListeners.add(listener);
return () => changeListeners.delete(listener);
},
destroy() {
if (destroyed) return;
destroyed = true;
@@ -1109,6 +1218,7 @@ export function createAnnotationEngine({
if (tickHandle != null) cancelAnimationFrame(tickHandle);
tickHandle = null;
outlineListeners.clear();
changeListeners.clear();
releaseContinuousRender('annotations');
renderer.destroy();
}
@@ -1364,6 +1474,7 @@ function normalizeType(type) {
}
function computeAlpha(anno, now) {
if (anno.hidden) return 0;
const inT = (now - anno.bornAt) / 260;
const fadeIn = inT >= 1 ? 1 : Math.max(0, inT);
+73
View File
@@ -730,3 +730,76 @@ test('only an explicit navigation request permits resolving distant annotation t
assert.deepEqual(received.map(options => options.allowDistant), [false, true]);
engine.destroy();
});
// Draw's list works one hand-drawn mark at a time: hide, rename, recolour,
// frame and remove it, and hear about every change to the board.
test('hand-drawn marks: one mark can be hidden, renamed, recoloured, framed and removed', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const { renderer, calls } = fakeRenderer();
const flights = [];
const engine = createAnnotationEngine({
viewer: { camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } },
renderer,
});
let changes = 0;
const unsubscribe = engine.onChange(() => { changes += 1; });
const drawn = await engine.annotate([
{ type: 'route', manual: true, path: [[-97.75, 30.26], [-97.73, 30.27]], label: '' },
{ type: 'pin', manual: true, latitude: 30.27, longitude: -97.74, label: 'Camp', color: 'amber' },
], { persist: true });
assert.equal(drawn.drawn, 2);
assert.equal(changes, 1, 'one annotate, one change');
const [line, pin] = engine.list();
assert.equal(line.manual, true);
assert.equal(line.name, null);
assert.match(line.label, /km|m$/, 'an unnamed line is labelled by its length');
assert.equal(pin.name, 'Camp');
assert.equal(engine.setHidden(pin.id, true), true);
assert.equal(pin.hidden, true);
assert.equal(changes, 2);
const addsBefore = calls.add;
assert.equal(engine.restyle(line.id, { name: 'Ridge walk', color: 'green' }), true);
assert.equal(line.name, 'Ridge walk');
assert.match(line.label, /^Ridge walk — /, 'a renamed line keeps its length');
assert.equal(line.color, 'green');
assert.equal(calls.add, addsBefore + 1, 'the new look is drawn again');
assert.equal(engine.restyle(line.id, { color: 'not-a-colour' }), true);
assert.equal(line.color, 'green', 'an unknown colour is ignored');
assert.equal(engine.frame(line.id), true);
assert.equal(flights.length, 1);
assert.equal(engine.frame('anno-missing'), false);
assert.equal(engine.remove(pin.id), true);
assert.deepEqual(engine.list().map(({ id }) => id), [line.id]);
assert.equal(engine.remove(pin.id), false);
engine.clear();
assert.equal(engine.list().length, 0);
const seen = changes;
unsubscribe();
await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true });
assert.equal(changes, seen, 'an unsubscribed listener hears nothing');
});
test('hand-drawn marks: only a manual mark can be restyled', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({
viewer: {},
renderer,
resolveTarget: async () => ({ ...FIXED_POINT }),
});
await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
const [voiceMark] = engine.list();
assert.equal(voiceMark.manual, false);
assert.equal(engine.restyle(voiceMark.id, { name: 'Renamed' }), false);
assert.equal(voiceMark.label, 'Capitol');
});
+259
View File
@@ -0,0 +1,259 @@
/**
* The shapes drawn by hand, listed under Draw the way Import lists its files:
* one row per shape with its colour, its name, and show/hide, fly-to and
* remove. The name is edited in place and the colour picked from the row, so
* a shape is labelled and coloured after it is drawn, when it can be seen.
*
* The board is the annotation engine's: this list only reads `list()`, asks
* for changes through `remove`, `setHidden`, `frame` and `restyle`, and
* re-renders on `onChange`. Voice marks are not listed; only `manual` ones.
*/
import { msg, t } from '../i18n/index.js';
export const DRAW_COLORS = Object.freeze([
'primary',
'amber',
'cyan',
'green',
'red',
]);
/** The swatch for each colour, matching the preview and the drawn shape. */
export const DRAW_COLOR_CSS = Object.freeze({
primary: '#8be9ff',
amber: '#ffb547',
cyan: '#39d0ff',
green: '#5dff9f',
red: '#ff6b6b',
});
const COLOR_NAMES = Object.freeze({
primary: msg('Primary'),
amber: msg('Amber'),
cyan: msg('Cyan'),
green: msg('Green'),
red: msg('Red'),
});
const SHAPE_NAMES = Object.freeze({
area: msg('Area'),
route: msg('Line'),
pin: msg('Pin'),
});
/** What a row calls a shape that has no name of its own. */
export function drawMarkFallbackName(mark) {
const shape = t(SHAPE_NAMES[mark?.type] || SHAPE_NAMES.pin);
// An unnamed line's label is its length; keep it, it is the useful part.
return mark?.type === 'route' && !mark.name && mark.label
? `${shape} · ${mark.label}`
: shape;
}
/**
* @param {{
* listEl: HTMLElement,
* annotations: {list: Function, remove: Function, setHidden: Function,
* frame: Function, restyle: Function, onChange?: Function},
* listen: (target: EventTarget, type: string, listener: Function, options?: object) => void,
* }} deps
* @returns {{render: Function, marks: Function, destroy: Function}}
*/
export function createDrawMarkList({ listEl, annotations, listen }) {
const doc = listEl.ownerDocument;
let destroyed = false;
let picking = null; // the id whose colour picker is open
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) {
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');
glyph.textContent = icon;
button.append(glyph);
return button;
}
function colorPicker(mark) {
const picker = doc.createElement('div');
picker.className = 'draw-colors draw-mark-colors';
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.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);
picker.append(swatch);
}
return picker;
}
const label = (mark) => mark.name || drawMarkFallbackName(mark);
function render() {
if (destroyed) return;
const keep = focusedControl();
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;
}),
);
listEl.classList.toggle('visible', list.length > 0);
restoreFocus(keep);
}
const rowId = (node) => node?.closest?.('[data-mark-id]')?.dataset.markId;
/** Commit a typed name; an unchanged one costs nothing. */
function commitName(input) {
const id = rowId(input);
const mark = id && find(id);
if (!mark) return;
const next = input.value.trim();
if (next === (mark.name || '')) return;
annotations.restyle(id, { name: next });
}
listen(listEl, 'click', (event) => {
const control = event.target?.closest?.('[data-control]');
const id = rowId(control);
if (!control || !id) return;
const kind = control.dataset.control;
if (kind === 'swatch') {
picking = picking === id ? null : id;
render();
} else if (kind.startsWith('color-')) {
picking = null;
annotations.restyle(id, { color: control.dataset.color });
render();
} 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 (input?.dataset?.control !== 'name') return;
if (event.key === 'Enter') {
event.preventDefault();
commitName(input);
input.blur?.();
} else if (event.key === 'Escape') {
// Escape undoes the edit; it must not also leave Draw or cancel a shape.
event.preventDefault();
event.stopPropagation();
input.value = find(rowId(input))?.name || '';
input.blur?.();
}
});
listen(listEl, 'focusout', (event) => {
if (event.target?.dataset?.control === 'name') commitName(event.target);
});
const unsubscribe = annotations.onChange?.(render) || (() => {});
render();
return {
render,
marks,
destroy() {
if (destroyed) return;
destroyed = true;
unsubscribe();
listEl.replaceChildren();
listEl.classList.remove('visible');
},
};
}
+280
View File
@@ -0,0 +1,280 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDrawMarkList, drawMarkFallbackName } from './drawMarkList.js';
/** Just enough DOM for the list: rows, inputs, buttons, attributes, focus. */
function fakeDocument() {
const doc = { activeElement: null };
const matches = (node, selector) => {
const attr = /^\[data-([a-z-]+)(?:="([^"]*)")?\]$/.exec(selector);
if (!attr) return false;
const key = attr[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase());
return attr[2] === undefined
? node.dataset[key] !== undefined
: node.dataset[key] === attr[2];
};
doc.createElement = (tag) => {
const classes = new Set();
const node = {
tagName: tag.toUpperCase(),
ownerDocument: doc,
parent: null,
children: [],
dataset: {},
style: {},
attributes: new Map(),
value: '',
placeholder: '',
title: '',
textContent: '',
classList: {
add: (name) => classes.add(name),
remove: (name) => classes.delete(name),
contains: (name) => classes.has(name),
toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)),
},
set className(value) {
classes.clear();
for (const name of String(value).split(/\s+/).filter(Boolean))
classes.add(name);
},
get className() {
return [...classes].join(' ');
},
setAttribute(key, value) {
this.attributes.set(key, String(value));
},
getAttribute(key) {
return this.attributes.get(key) ?? null;
},
append(...nodes) {
for (const child of nodes) {
child.parent = this;
this.children.push(child);
}
},
replaceChildren(...nodes) {
this.children = [];
this.append(...nodes);
},
closest(selector) {
let node = this;
while (node && !matches(node, selector)) node = node.parent;
return node || null;
},
querySelectorAll(selector) {
const found = [];
const walk = (node) => {
for (const child of node.children) {
if (matches(child, selector)) found.push(child);
walk(child);
}
};
walk(this);
return found;
},
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
},
contains(other) {
for (let node = other; node; node = node.parent)
if (node === this) return true;
return false;
},
focus() {
doc.activeElement = this;
},
blur() {
if (doc.activeElement === this) doc.activeElement = null;
},
};
return node;
};
return doc;
}
function harness(initial) {
const doc = fakeDocument();
const listEl = doc.createElement('ul');
const listeners = {};
const listen = (target, type, listener) => {
listeners[type] = listener;
};
let marks = initial.map((mark) => ({ hidden: false, ...mark }));
const changeListeners = new Set();
const changed = () => changeListeners.forEach((listener) => listener());
const calls = [];
const annotations = {
list: () => marks,
onChange: (listener) => {
changeListeners.add(listener);
return () => changeListeners.delete(listener);
},
remove(id) {
calls.push(['remove', id]);
marks = marks.filter((mark) => mark.id !== id);
changed();
},
setHidden(id, hidden) {
calls.push(['hide', id, hidden]);
marks.find((mark) => mark.id === id).hidden = hidden;
changed();
},
frame(id) {
calls.push(['frame', id]);
},
restyle(id, change) {
calls.push(['restyle', id, change]);
Object.assign(
marks.find((mark) => mark.id === id),
change,
);
changed();
},
};
const list = createDrawMarkList({ listEl, annotations, listen });
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 LINE = {
id: 'a1',
type: 'route',
manual: true,
name: null,
label: '1.2 km',
color: 'primary',
};
const PIN = {
id: 'a2',
type: 'pin',
manual: true,
name: 'Camp',
label: 'Camp',
color: 'amber',
};
const VOICE = {
id: 'a3',
type: 'area',
manual: false,
label: 'Texas',
color: 'primary',
};
test('lists only hand-drawn marks, named or by shape, with Import-style actions', () => {
const { rows, control, listEl } = harness([LINE, PIN, VOICE]);
assert.deepEqual(
rows().map((row) => row.dataset.markId),
['a1', 'a2'],
);
assert.ok(listEl.classList.contains('visible'));
const [line, pin] = rows();
assert.equal(control(line, 'name').value, '');
assert.equal(control(line, 'name').placeholder, 'Line · 1.2 km');
assert.equal(control(pin, 'name').value, 'Camp');
assert.equal(control(pin, 'swatch').style.background, '#ffb547');
assert.equal(control(pin, 'toggle').getAttribute('aria-pressed'), 'true');
assert.equal(control(pin, 'fly').getAttribute('aria-label'), 'Fly to Camp');
assert.equal(
control(pin, 'remove').getAttribute('aria-label'),
'Remove Camp',
);
});
test('show/hide, fly to and remove act on that one mark', () => {
const { rows, control, click, calls } = harness([LINE, PIN]);
click(control(rows()[1], 'toggle'));
assert.deepEqual(calls.at(-1), ['hide', 'a2', true]);
assert.equal(
control(rows()[1], 'toggle').getAttribute('aria-pressed'),
'false',
);
assert.ok(rows()[1].classList.contains('hidden-mark'));
click(control(rows()[0], 'fly'));
assert.deepEqual(calls.at(-1), ['frame', 'a1']);
click(control(rows()[0], 'remove'));
assert.deepEqual(calls.at(-1), ['remove', 'a1']);
assert.deepEqual(
rows().map((row) => row.dataset.markId),
['a2'],
);
});
test('the swatch opens a colour picker in the row; a colour restyles the mark', () => {
const { rows, control, click, calls } = harness([LINE]);
click(control(rows()[0], 'swatch'));
assert.equal(
control(rows()[0], 'swatch').getAttribute('aria-expanded'),
'true',
);
const red = control(rows()[0], 'color-red');
assert.ok(red, 'the picker offers every colour');
click(red);
assert.deepEqual(calls.at(-1), ['restyle', 'a1', { color: 'red' }]);
assert.equal(
control(rows()[0], 'color-red'),
null,
'choosing closes the picker',
);
assert.equal(control(rows()[0], 'swatch').style.background, '#ff6b6b');
});
test('a name is committed on Enter or blur, and Escape undoes the edit without leaving Draw', () => {
const { rows, control, listeners, calls } = harness([LINE, PIN]);
const name = control(rows()[0], 'name');
name.value = ' Ridge walk ';
listeners.keydown({ key: 'Enter', target: name, preventDefault() {} });
assert.deepEqual(calls.at(-1), ['restyle', 'a1', { name: 'Ridge walk' }]);
const pinName = control(rows()[1], 'name');
pinName.value = 'Base';
let stopped = false;
listeners.keydown({
key: 'Escape',
target: pinName,
preventDefault() {},
stopPropagation() {
stopped = true;
},
});
assert.equal(pinName.value, 'Camp', 'Escape restores the name');
assert.ok(stopped, 'Escape stays in the field');
const count = calls.length;
listeners.focusout({ target: control(rows()[1], 'name') });
assert.equal(calls.length, count, 'an unchanged name costs nothing');
control(rows()[1], 'name').value = 'Base';
listeners.focusout({ target: control(rows()[1], 'name') });
assert.deepEqual(calls.at(-1), ['restyle', 'a2', { name: 'Base' }]);
});
test('a re-render keeps focus on the same control, and destroy stops listening', () => {
const { rows, control, list, listEl, changed } = harness([LINE]);
control(rows()[0], 'fly').focus();
changed();
assert.equal(listEl.ownerDocument.activeElement, control(rows()[0], 'fly'));
list.destroy();
assert.equal(rows().length, 0);
assert.ok(!listEl.classList.contains('visible'));
changed();
assert.equal(rows().length, 0, 'a destroyed list does not re-render');
});
test('an unnamed shape is called by its kind', () => {
assert.equal(drawMarkFallbackName({ type: 'area' }), 'Area');
assert.equal(drawMarkFallbackName({ type: 'pin' }), 'Pin');
assert.equal(
drawMarkFallbackName({ type: 'route', label: '300 m' }),
'Line · 300 m',
);
assert.equal(
drawMarkFallbackName({
type: 'route',
name: 'Walk',
label: 'Walk — 300 m',
}),
'Line',
);
});
+36 -27
View File
@@ -3,7 +3,8 @@
*
* UTILITIES ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
* shape (area, line or pin), click the vertices on the real world, double-click
* or press Enter to finish, type a label. Each finished shape goes through the
* or press Enter to finish, then name and colour it in its row. Each finished
* shape goes through the
* SAME `annotationEngine.annotate()` the voice agent uses, with its geometry
* supplied and `manual: true`, so it renders with the whiteboard look,
* persists, de-dups, shows up in `.list()`, and clears with the board.
@@ -50,19 +51,18 @@ import {
removeLastVertex,
} from './drawMode.js';
import {
DRAW_COLORS,
DRAW_COLOR_CSS,
createDrawMarkList,
} from './drawMarkList.js';
import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js';
/** The id this tool claims the pointer under. */
const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
const PREVIEW = {
primary: '#8be9ff',
amber: '#ffb547',
cyan: '#39d0ff',
green: '#5dff9f',
red: '#ff6b6b',
};
const PREVIEW = DRAW_COLOR_CSS;
/**
* Wire the Draw control. Returns a handle with a `destroy()` the application
@@ -74,8 +74,8 @@ export function initDrawTool({ viewer, annotations }) {
const toggle = document.getElementById('draw-toggle');
const modeRow = document.getElementById('draw-mode-row');
const labelRow = document.getElementById('draw-label-row');
const labelInput = document.getElementById('draw-label-input');
const colorSelect = document.getElementById('draw-color-select');
const colorRow = document.getElementById('draw-colors');
const markListEl = document.getElementById('draw-mark-list');
const clearButton = document.getElementById('draw-clear');
const hint = document.getElementById('draw-hint');
if (!viewer || !annotations || !toggle) return null;
@@ -238,14 +238,14 @@ export function initDrawTool({ viewer, annotations }) {
if (reason !== 'too-few') setHint(drawHint(session));
return null;
}
const spec = finishSpec(session, { label: labelInput?.value || '', color });
// Named afterwards, in the shape's row.
const spec = finishSpec(session, { label: '', color });
if (!spec) return null;
// Start a fresh session BEFORE awaiting, so a second Enter or the second
// half of a double-click cannot submit the same shape twice.
session = createDrawSession(shape);
cursor = null;
syncPreview();
if (labelInput) labelInput.value = '';
const attempt = generation;
try {
const result = await annotations.annotate([spec], {
@@ -285,7 +285,7 @@ export function initDrawTool({ viewer, annotations }) {
// ---- keys: only while drawing, never while typing in another field ----
const typingElsewhere = (event) => {
const t = event.target;
if (!t || t === labelInput) return false;
if (!t) return false;
return (
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable
);
@@ -293,12 +293,12 @@ export function initDrawTool({ viewer, annotations }) {
/**
* Enter is the finish key, but on a focused control Enter means "press this
* control". Keyboard-focusing Clear and pressing Enter must clear the board,
* not finish the shape — so the finish key is only taken from the canvas,
* the page body, or the label field this tool owns.
* not finish the shape — so the finish key is only taken from the canvas or
* the page body.
*/
const enterBelongsToDrawing = (event) => {
const t = event.target;
if (!t || t === labelInput) return true;
if (!t) return true;
if (
typeof t.closest === 'function' &&
t.closest('button, a, select, [role="button"], [role="radio"]')
@@ -328,7 +328,7 @@ export function initDrawTool({ viewer, annotations }) {
else setActive(false);
return;
}
if (event.key === 'Backspace' && event.target !== labelInput) {
if (event.key === 'Backspace') {
if (removeLastVertex(session)) {
event.preventDefault();
syncPreview();
@@ -448,18 +448,21 @@ export function initDrawTool({ viewer, annotations }) {
}
},
});
listen(colorSelect, 'change', () => {
color = COLORS.includes(colorSelect.value) ? colorSelect.value : 'primary';
syncPreview();
const colorControl = createSegmentedControl({
buttons: [...(colorRow?.querySelectorAll('.draw-color[data-color]') || [])],
valueOf: (button) => button.dataset.color,
listen,
onChange: (value) => {
color = DRAW_COLORS.includes(value) ? value : 'primary';
colorControl.update(color);
syncPreview();
},
});
listen(clearButton, 'click', clearAll);
listen(labelInput, 'keydown', (event) => {
if (event.key === 'Enter' && session?.vertices?.length) {
event.preventDefault();
void finish();
}
});
setHint(drawHint(null));
const markList = markListEl
? createDrawMarkList({ listEl: markListEl, annotations, listen })
: null;
const api = {
get active() {
@@ -488,6 +491,11 @@ export function initDrawTool({ viewer, annotations }) {
finish,
cancel,
clearAll,
/** The hand-drawn marks the list shows. */
marks: () => markList?.marks() || [],
setColor(next) {
colorRow?.querySelector(`.draw-color[data-color="${next}"]`)?.click();
},
shapes: DRAW_SHAPES,
/** What this tool currently holds — for teardown and leak assertions. */
diagnostics() {
@@ -527,6 +535,7 @@ export function initDrawTool({ viewer, annotations }) {
if (active) setActive(false);
destroyed = true;
unregisterUnsavedWork();
markList?.destroy();
releaseSceneHandler();
releasePointer(lease);
lease = null;
+19 -6
View File
@@ -69,8 +69,8 @@ test('the Draw control markup carries the ids the tool binds to', () => {
'draw-toggle',
'draw-mode-row',
'draw-label-row',
'draw-label-input',
'draw-color-select',
'draw-colors',
'draw-mark-list',
'draw-clear',
'draw-hint',
];
@@ -81,13 +81,25 @@ test('the Draw control markup carries the ids the tool binds to', () => {
`#${id} missing from index.html`,
);
}
for (const shape of ['area', 'line', 'pin']) {
// Shapes are icons, named for screen readers and tooltips.
for (const [shape, name] of [
['area', 'Area'],
['line', 'Line'],
['pin', 'Pin'],
]) {
assert.match(
html,
new RegExp(`data-shape="${shape}"`),
new RegExp(
`data-shape="${shape}"[^>]*aria-label="${name}"[^>]*title="${name}"`,
),
`shape button ${shape} missing`,
);
}
for (const color of ['primary', 'amber', 'cyan', 'green', 'red'])
assert.match(
html,
new RegExp(`class="draw-color[^"]*" type="button" data-color="${color}"`),
);
for (const id of ids) {
assert.match(
tool(),
@@ -108,14 +120,15 @@ test('the DISPLAY rail styles live in their current owner, not the shim', () =>
);
assert.doesNotMatch(
shim,
/draw-hint|pp-text-input/,
/draw-hint|draw-color/,
'draw styles belong to a component stylesheet',
);
const controls = read('src/ui/styles/controls.css');
for (const rule of [
'#draw-mode-row',
'#draw-label-row',
'.pp-text-input',
'.draw-color',
'.draw-mark-name',
'.draw-clear-btn',
'.draw-hint',
'body.gev-drawing',
+1 -2
View File
@@ -116,8 +116,7 @@ function fakeDom() {
modeRow.children.push(button);
}
make('draw-label-row');
make('draw-label-input', 'INPUT');
make('draw-color-select', 'SELECT');
make('draw-colors');
make('draw-clear', 'BUTTON');
make('draw-hint', 'SPAN');