Draw rows take an application's own actions

createApplicationTools({ drawMarkActions }) adds an application's buttons to
the rows of drawn areas and lines. An action gets the shape's id, kind, name,
row title and [lon, lat] geometry, and what it returns or resolves to is a
note under the row. The standalone shell passes none, so the open-source
build is unchanged.
This commit is contained in:
Sameh Khamis committed 2026-11-08 18:12:32 -08:00
1 parent 922712121c
commit 27847bd063
9 files changed
+643 -16

No files matched your search

+144 -1
View File
@@ -43,24 +43,167 @@ export function drawMarkFallbackName(mark) {
: shape;
}
/** The kind of shape a row action is offered for: `area` or `line`. */
const ACTION_SHAPES = Object.freeze({ area: 'area', route: 'line' });
const lonLat = (point) =>
Array.isArray(point)
? [Number(point[0]), Number(point[1])]
: [Number(point?.lon), Number(point?.lat)];
const finitePairs = (points) =>
(Array.isArray(points) ? points : [])
.map(lonLat)
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
/** A longitude in [-180, 180]; an unwrapped outline can carry ±540. */
const wrapLon = (lon) =>
lon >= -180 && lon <= 180 ? lon : ((((lon + 180) % 360) + 360) % 360) - 180;
/** Whether consecutive positions step across the antimeridian once wrapped. */
const crossesAntimeridian = (points) =>
points.some(
(point, index) =>
index > 0 && Math.abs(point[0] - points[index - 1][0]) > 180,
);
/** Positions wrapped into range, or null when the sequence crosses ±180°. */
const inRange = (points) => {
if (points.some(([, lat]) => Math.abs(lat) > 90)) return null;
const wrapped = points.map(([lon, lat]) => [wrapLon(lon), lat]);
return crossesAntimeridian(wrapped) ? null : wrapped;
};
const closed = (ring) => {
if (ring.length < 3) return ring;
const [first, last] = [ring[0], ring[ring.length - 1]];
return first[0] === last[0] && first[1] === last[1] ? ring : [...ring, first];
};
/**
* A drawn shape's geometry as GeoJSON in `[lon, lat]` degrees, longitudes in
* [-180, 180]: an area is a closed `Polygon` (a `MultiPolygon` when an area
* handle has several parts), a line a `LineString`. A pin, a shape too small
* to be one, or one that crosses the antimeridian has none, so it is offered
* no action rather than a shape that wraps the wrong way round the world.
*/
export function drawMarkGeometry(mark) {
if (mark?.type === 'route') {
const path = inRange(finitePairs(mark.path));
return path?.length >= 2 ? { type: 'LineString', coordinates: path } : null;
}
if (mark?.type !== 'area') return null;
const ringOf = (points) => {
const ring = inRange(closed(finitePairs(points)));
return ring?.length >= 4 ? ring : null;
};
if (Array.isArray(mark.polygons) && mark.polygons.length) {
const polygons = [];
for (const rings of mark.polygons) {
const parts = (Array.isArray(rings) ? rings : []).map(ringOf);
// A part that cannot be given whole is no part: never a hole dropped.
if (!parts.length || parts.some((ring) => !ring)) return null;
polygons.push(parts);
}
return polygons.length > 1
? { type: 'MultiPolygon', coordinates: polygons }
: { type: 'Polygon', coordinates: polygons[0] };
}
const ring = ringOf(mark.ring);
return ring ? { type: 'Polygon', coordinates: [ring] } : null;
}
/**
* The actions an application adds to Draw's rows, checked once: each has an
* `id`, a Material Symbols `icon`, a `label(name)` worded by the application,
* the `shapes` it is for (`area`, `line`), an optional `accepts(geometry)` and
* `run({ id, shape, name, title, geometry })` (`name` the person's own, `title`
* as the row shows it). Malformed or repeated ids are dropped.
*/
export function drawMarkActions(actions = []) {
const kept = new Map();
for (const action of Array.isArray(actions) ? actions : []) {
const wellFormed =
typeof action?.id === 'string' &&
/^[a-z][a-z0-9-]{0,39}$/.test(action.id) &&
typeof action.icon === 'string' &&
typeof action.label === 'function' &&
typeof action.run === 'function' &&
Array.isArray(action.shapes);
if (!wellFormed || kept.has(action.id)) continue;
kept.set(
action.id,
Object.freeze({
id: action.id,
icon: action.icon,
label: action.label,
shapes: Object.freeze(
action.shapes.filter((shape) =>
Object.values(ACTION_SHAPES).includes(shape),
),
),
accepts:
typeof action.accepts === 'function' ? action.accepts : () => true,
run: action.run,
}),
);
}
return Object.freeze([...kept.values()]);
}
/** The actions from `drawMarkActions` that `mark` is offered, with its geometry. */
export function drawMarkActionsFor(mark, actions = []) {
const shape = ACTION_SHAPES[mark?.type];
if (!shape || !actions.length) return { shape, geometry: null, actions: [] };
const geometry = drawMarkGeometry(mark);
if (!geometry) return { shape, geometry, actions: [] };
return {
shape,
geometry,
actions: actions.filter((action) => {
if (!action.shapes.includes(shape)) return false;
try {
return action.accepts(geometry, shape) === true;
} catch {
return false;
}
}),
};
}
/** An action's words for a row; an application's label that throws shows its id. */
function actionLabel(action, title) {
try {
const text = action.label(title);
return typeof text === 'string' && text.trim() ? text.trim() : action.id;
} catch {
return action.id;
}
}
/**
* The rows for the hand-drawn marks on the board, in board order. Text is
* worded here, in the current language; the view only lays it out.
* @param {object[]} marks The engine's `list()`.
* @param {ReturnType<typeof drawMarkActions>} [actions] The application's row actions.
* @param {Map<string, {busy: boolean, text: string}>} [notes] What an action said on a shape.
*/
export function presentDrawMarks(marks = []) {
export function presentDrawMarks(marks = [], actions = [], notes = new Map()) {
return marks
.filter((mark) => mark?.manual === true)
.map((mark) => {
const fallback = drawMarkFallbackName(mark);
const title = mark.name || fallback;
const visible = !mark.hidden;
const offered = drawMarkActionsFor(mark, actions).actions;
return {
id: mark.id,
name: mark.name || '',
placeholder: fallback,
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
hidden: !visible,
busy: notes.get(mark.id)?.busy === true,
note: offered.length ? notes.get(mark.id)?.text || '' : '',
actions: offered.map((action) => ({
id: action.id,
icon: action.icon,
label: actionLabel(action, title),
})),
labels: {
name: t('Name of {name}', { name: title }),
swatch: t('Change the colour of {name}', { name: title }),
+219
View File
@@ -3,7 +3,10 @@ import assert from 'node:assert/strict';
import {
DRAW_COLORS,
DRAW_COLOR_CSS,
drawMarkActions,
drawMarkActionsFor,
drawMarkFallbackName,
drawMarkGeometry,
presentDrawMarks,
} from './drawMarks.js';
@@ -78,3 +81,219 @@ test('rows list only hand-drawn marks, in board order, with their words', () =>
test('every colour has its swatch', () => {
assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]);
});
test("a drawn shape's geometry is GeoJSON in [lon, lat]: a closed area, a line, no pin", () => {
assert.deepEqual(
drawMarkGeometry({
type: 'area',
ring: [
[0, 0],
[1, 0],
[1, 1],
[0, 0],
],
}),
{
type: 'Polygon',
coordinates: [
[
[0, 0],
[1, 0],
[1, 1],
[0, 0],
],
],
},
);
assert.deepEqual(
drawMarkGeometry({
type: 'area',
ring: [
[0, 0],
[1, 0],
[1, 1],
],
}).coordinates[0].at(-1),
[0, 0],
'an open ring is closed',
);
assert.equal(
drawMarkGeometry({
type: 'area',
ring: [
[0, 0],
[1, 0],
],
}),
null,
);
assert.deepEqual(
drawMarkGeometry({
type: 'route',
path: [
{ lon: 1, lat: 2, height: 9 },
{ lon: 3, lat: 4, height: 0 },
],
}),
{
type: 'LineString',
coordinates: [
[1, 2],
[3, 4],
],
},
);
assert.equal(
drawMarkGeometry({ type: 'pin', longitude: 1, latitude: 2 }),
null,
);
const square = (x) => [
[x, 0],
[x + 1, 0],
[x + 1, 1],
[x, 0],
];
assert.deepEqual(
drawMarkGeometry({
type: 'area',
ring: square(0),
polygons: [[square(0)], [square(5)]],
}),
{ type: 'MultiPolygon', coordinates: [[square(0)], [square(5)]] },
'an area handle with several parts keeps them all',
);
assert.equal(
drawMarkGeometry({
type: 'area',
polygons: [
[
square(0),
[
[0.2, 0.2],
[0.3, 0.2],
],
],
],
}),
null,
'a hole that cannot be given whole is never dropped silently',
);
});
test('longitudes are wrapped into range, and a shape across the antimeridian has no geometry', () => {
assert.deepEqual(
drawMarkGeometry({
type: 'route',
path: [
[370, 1],
[371, 1],
],
}).coordinates,
[
[10, 1],
[11, 1],
],
);
assert.deepEqual(
drawMarkGeometry({
type: 'area',
ring: [
[180, 0],
[179, 0],
[179, 1],
],
}).coordinates[0][0],
[180, 0],
'the antimeridian itself stays put',
);
assert.equal(
drawMarkGeometry({
type: 'area',
ring: [
[179.9, 0],
[-179.9, 0],
[-179.9, 1],
],
}),
null,
);
assert.equal(
drawMarkGeometry({
type: 'route',
path: [
[179.5, 0],
[180.5, 0],
],
}),
null,
);
assert.equal(
drawMarkGeometry({
type: 'route',
path: [
[0, 91],
[1, 0],
],
}),
null,
);
});
test('row actions are checked once: malformed ones and repeated ids are dropped', () => {
const run = () => {};
const label = () => 'x';
const actions = drawMarkActions([
{ id: 'Bad Id', icon: 'a', label, run, shapes: ['area'] },
{ id: 'tag', icon: 'a', label: 'not a function', run, shapes: ['area'] },
{
id: 'tag',
icon: 'sell',
label,
run,
shapes: ['area', 'line', 'pin'],
},
{ id: 'tag', icon: 'other', label, run, shapes: ['area'] },
null,
]);
assert.deepEqual(
actions.map(({ id, icon, shapes }) => ({ id, icon, shapes: [...shapes] })),
[{ id: 'tag', icon: 'sell', shapes: ['area', 'line'] }],
);
assert.deepEqual(drawMarkActions('nope'), []);
const area = {
type: 'area',
ring: [
[0, 0],
[1, 0],
[1, 1],
],
};
const offer = drawMarkActionsFor(area, actions);
assert.equal(offer.shape, 'area');
assert.deepEqual(
offer.actions.map((a) => a.id),
['tag'],
);
const refusing = drawMarkActions([
{
id: 'measure',
icon: 'a',
label,
run,
shapes: ['area'],
accepts: () => {
throw new Error('no');
},
},
]);
assert.deepEqual(
drawMarkActionsFor(area, refusing).actions,
[],
'an accepts that throws offers nothing',
);
assert.deepEqual(
presentDrawMarks([{ id: 'p', type: 'pin', manual: true }], actions)[0]
.actions,
[],
);
});
+90 -5
View File
@@ -50,9 +50,28 @@ import {
removeLastVertex,
} from './drawMode.js';
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
import {
DRAW_COLORS,
DRAW_COLOR_CSS,
drawMarkActions,
drawMarkActionsFor,
drawMarkFallbackName,
presentDrawMarks,
} from './drawMarks.js';
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
/** The longest note a row action leaves under its row, in characters. */
const NOTE_MAX = 200;
/** An action's answer as a row note: trimmed, and cut at a word with an ellipsis. */
function noteText(text) {
if (typeof text !== 'string') return '';
const trimmed = text.trim();
if (trimmed.length <= NOTE_MAX) return trimmed;
const cut = trimmed.slice(0, NOTE_MAX - 1);
const space = cut.lastIndexOf(' ');
return `${(space > NOTE_MAX / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
}
/** The id this tool claims the pointer under. */
const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
@@ -67,7 +86,10 @@ const PREVIEW = DRAW_COLOR_CSS;
* through the actions it gives `connect`, which returns their disconnect.
* A row's fly-to is an immediate navigation through `navigation`, like
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
* `markActions` are the application's own buttons on an area's or a line's
* row (drawMarks.js `drawMarkActions`); a press runs the action with the
* shape's id, kind, name and geometry (drawMarks.js `drawMarkGeometry`).
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function, markActions?: object[]}} deps
* @returns {{destroy: Function}|null}
*/
export function initDrawTool({
@@ -76,7 +98,12 @@ export function initDrawTool({
navigation = null,
publish = () => {},
connect = () => () => {},
markActions = [],
}) {
const rowActions = drawMarkActions(markActions);
// What a row action said last on each drawn shape: `{ run, busy, text }`.
const notes = new Map();
let noteRun = 0;
// A page without the Draw controls (the Display controls view omitted) has
// no draw tool.
if (!viewer || !annotations || !document.getElementById('draw-toggle'))
@@ -115,14 +142,19 @@ export function initDrawTool({
() => null,
);
const present = () =>
const present = () => {
const marks = annotations.list?.() || [];
// A note goes with its shape.
for (const id of notes.keys())
if (!marks.some((mark) => mark.id === id)) notes.delete(id);
publish({
active,
shape,
color,
hint,
marks: presentDrawMarks(annotations.list?.() || []),
marks: presentDrawMarks(marks, rowActions, notes),
});
};
// The hint is kept as the call that words it, so a language switch can
// word it again.
let hintText = () => '';
@@ -450,6 +482,56 @@ export function initDrawTool({
return annotations.frame?.(id) ?? false;
}
/**
* Run the application's row action `actionId` on drawn shape `id`, if the
* shape is still on the board and still offered it. Returns whether it ran.
* What the action returns, or resolves to, is the row's note (a measurement,
* "saved"): the row is busy until it settles, a newer press or the shape's
* removal makes a late answer moot, and a failure clears the note, so an
* action words its own refusals and resolves with them.
*/
function runMarkAction(id, actionId) {
const mark = (annotations.list?.() || []).find(
(entry) => entry.id === id && entry.manual === true,
);
const offer = drawMarkActionsFor(mark, rowActions);
const action = offer.actions.find((entry) => entry.id === actionId);
if (!action || notes.get(mark.id)?.busy) return false;
const run = ++noteRun;
const settle = (text) => {
if (destroyed || notes.get(mark.id)?.run !== run) return;
notes.set(mark.id, {
run,
busy: false,
text: noteText(text),
});
present();
};
const fail = (error) => {
console.warn(`[draw] ${actionId} failed`, error);
settle('');
};
notes.set(mark.id, { run, busy: true, text: '' });
let answer;
try {
answer = action.run({
id: mark.id,
shape: offer.shape,
name: mark.name || '',
title: mark.name || drawMarkFallbackName(mark),
geometry: offer.geometry,
});
} catch (error) {
fail(error);
return false;
}
if (typeof answer?.then === 'function') {
present();
Promise.resolve(answer).then(settle, fail);
} else settle(answer);
return true;
}
/** Draw `next` from now on; an open shape starts over in it. */
function chooseShape(next) {
shape = normalizeShape(next);
@@ -478,6 +560,7 @@ export function initDrawTool({
},
flyToDrawMark: flyToMark,
removeDrawMark: (id) => annotations.remove?.(id),
runDrawMarkAction: runMarkAction,
});
// The rows follow the board: a shape drawn, renamed, hidden or removed
// here, by voice or by a clear.
@@ -500,13 +583,15 @@ export function initDrawTool({
setActive,
setShape: chooseShape,
/** The hand-drawn marks the list shows, as its rows. */
marks: () => presentDrawMarks(annotations.list?.() || []),
marks: () =>
presentDrawMarks(annotations.list?.() || [], rowActions, notes),
setColor(next) {
color = DRAW_COLORS.includes(next) ? next : 'primary';
present();
syncPreview();
},
flyToMark,
runMarkAction,
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
addVertex(lon, lat, height = 0) {
if (!session) return false;
+129 -1
View File
@@ -223,7 +223,7 @@ function fakeAnnotations() {
}
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
function harness({ navigation = null } = {}) {
function harness({ navigation = null, markActions = [] } = {}) {
resetPointerOwnership();
const { byId, documentDouble } = fakeDom();
const viewer = fakeViewer();
@@ -239,6 +239,7 @@ function harness({ navigation = null } = {}) {
viewer,
annotations,
navigation,
markActions,
publish: (view) => (shown.view = view),
connect: (actions) => {
controls.actions = actions;
@@ -576,6 +577,133 @@ test('each drawn shape is a row whose actions reach the board, and fly-to is a n
}
});
test('an application\'s row actions show on the shapes they are for and run with the shape', async () => {
const ran = [];
const h = harness({
markActions: [
{
id: 'tag',
icon: 'sell',
label: (name) => `Tag ${name}`,
shapes: ['area', 'line'],
run: (shape) => ran.push(['tag', shape]),
},
{
id: 'measure',
icon: 'straighten',
label: (name) => `Measure ${name}`,
shapes: ['area'],
accepts: (geometry) => geometry.type === 'Polygon',
run: (shape) => ran.push(['measure', shape]),
},
],
});
try {
const board = h.annotations;
board.marks.push(
{ id: 'r1', type: 'area', manual: true, name: 'Strait', color: 'primary', hidden: false, ring: [[29, 41], [29.1, 41], [29.1, 41.1]] },
{ id: 'l1', type: 'route', manual: true, name: null, label: '9 km', color: 'amber', hidden: false, path: [{ lon: 29, lat: 41.05, height: 0 }, { lon: 29.1, lat: 41.05, height: 0 }] },
{ id: 'p1', type: 'pin', manual: true, name: 'Camp', color: 'red', hidden: false },
);
board.changed();
assert.deepEqual(
h.shown.view.marks.map(({ id, actions }) => [id, actions]),
[
['r1', [
{ id: 'tag', icon: 'sell', label: 'Tag Strait' },
{ id: 'measure', icon: 'straighten', label: 'Measure Strait' },
]],
['l1', [{ id: 'tag', icon: 'sell', label: 'Tag Line · 9 km' }]],
['p1', []],
],
);
assert.equal(h.act('runDrawMarkAction', 'l1', 'tag'), true);
assert.equal(h.act('runDrawMarkAction', 'l1', 'measure'), false, 'a line is not offered an area action');
assert.equal(h.act('runDrawMarkAction', 'p1', 'tag'), false);
assert.equal(h.act('runDrawMarkAction', 'gone', 'tag'), false);
assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
assert.deepEqual(ran, [
['tag', { id: 'l1', shape: 'line', name: '', title: 'Line · 9 km', geometry: { type: 'LineString', coordinates: [[29, 41.05], [29.1, 41.05]] } }],
['measure', { id: 'r1', shape: 'area', name: 'Strait', title: 'Strait', geometry: { type: 'Polygon', coordinates: [[[29, 41], [29.1, 41], [29.1, 41.1], [29, 41]]] } }],
]);
} finally {
h.restore();
}
});
test('an action that throws is reported and leaves the row alone', async () => {
const h = harness({
markActions: [
{ id: 'boom', icon: 'bolt', label: () => { throw new Error('label'); }, shapes: ['area'], run: () => { throw new Error('run'); } },
],
});
const warn = console.warn;
const warned = [];
console.warn = (...args) => warned.push(args[0]);
try {
h.annotations.marks.push({ id: 'r1', type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] });
h.annotations.changed();
assert.deepEqual(h.shown.view.marks[0].actions, [{ id: 'boom', icon: 'bolt', label: 'boom' }]);
assert.equal(h.act('runDrawMarkAction', 'r1', 'boom'), false);
assert.deepEqual(warned, ['[draw] boom failed']);
} finally {
console.warn = warn;
h.restore();
}
});
test('what a row action answers is its row\'s note; the row is busy until then, and a removed shape drops it', async () => {
const pending = [];
const h = harness({
markActions: [
{
id: 'measure',
icon: 'straighten',
label: (name) => `Measure ${name}`,
shapes: ['area'],
run: () => new Promise((resolve, reject) => pending.push({ resolve, reject })),
},
{ id: 'now', icon: 'bolt', label: () => 'Now', shapes: ['area'], run: () => ' 12 km² ' },
{ id: 'long', icon: 'notes', label: () => 'Long', shapes: ['area'], run: () => `${'word '.repeat(60)}end` },
],
});
const warn = console.warn;
console.warn = () => {};
try {
const board = h.annotations;
const area = (id) => ({ id, type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] });
board.marks.push(area('r1'), area('r2'));
board.changed();
const row = (id) => h.shown.view.marks.find((entry) => entry.id === id);
assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), true);
assert.deepEqual([row('r1').busy, row('r1').note], [false, '12 km²']);
h.act('runDrawMarkAction', 'r1', 'long');
assert.ok(row('r1').note.length <= 200 && row('r1').note.endsWith('word…'), 'a long note is cut at a word');
assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
assert.deepEqual([row('r1').busy, row('r1').note], [true, '']);
assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), false, 'a busy row takes no second press');
pending[0].resolve('412 km², 38 km of edge');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual([row('r1').busy, row('r1').note], [false, '412 km², 38 km of edge']);
h.act('runDrawMarkAction', 'r1', 'measure');
pending[1].reject(new Error('refused'));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual([row('r1').busy, row('r1').note], [false, ''], 'a failure clears the note');
h.act('runDrawMarkAction', 'r2', 'measure');
board.marks.splice(1, 1);
board.changed();
pending[2].resolve('late');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(row('r2'), undefined);
board.marks.push(area('r2'));
board.changed();
assert.deepEqual([row('r2').busy, row('r2').note], [false, ''], 'a late answer never reaches a shape that went');
} finally {
console.warn = warn;
h.restore();
}
});
test('Backspace on the page removes the last vertex', () => {
const h = harness();
try {
+4
View File
@@ -35,6 +35,7 @@ export function createApplicationTools({
startChrome,
onSceneDirector,
sceneDataPacks,
drawMarkActions = [],
signal,
defer,
}) {
@@ -80,12 +81,15 @@ export function createApplicationTools({
// while a session is open, so its teardown belongs to the application
// lifetime rather than to whoever last pressed the button. A row's fly-to
// goes through the shell's navigation policy, as Import's does.
// `drawMarkActions` are an application's own buttons on a drawn area's or
// line's row (annotations/drawMarks.js); the standalone shell adds none.
const drawTool = initDrawTool({
viewer,
annotations,
navigation: styleManager,
publish: (view) => displayStore.publishDraw(view),
connect: connectDisplay,
markActions: drawMarkActions,
});
defer(() => drawTool?.destroy());
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
+20
View File
@@ -987,6 +987,26 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
.draw-mark-row.hidden-mark .draw-mark-swatch {
opacity: 0.45;
}
/* What an application's row action said (a measurement, a confirmation), under
the row; empty, it takes no room. */
.draw-mark-note {
order: 2;
flex-basis: 100%;
padding: 0 0 2px 16px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.4px;
color: var(--text-dim);
overflow-wrap: anywhere;
}
.draw-mark-note:empty {
display: none;
}
/* Busy, but focusable: disabling the pressed button would drop focus. */
.draw-mark-action[aria-disabled='true'] {
opacity: 0.5;
cursor: progress;
}
.draw-mark-colors {
/* Right after its swatch for keyboard and screen readers, shown last. */
order: 1;
+29 -3
View File
@@ -7,7 +7,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { presentDrawMarks } from '../../../annotations/drawMarks.js';
import { drawMarkActions, presentDrawMarks } from '../../../annotations/drawMarks.js';
import { useDisplayStore } from '../../stores/display.js';
import DrawMarkList from './DrawMarkList.vue';
@@ -34,8 +34,8 @@ function mount(t) {
return { store, calls };
}
async function show(store, marks) {
store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks(marks) });
async function show(store, marks, actions = []) {
store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks(marks, drawMarkActions(actions)) });
await nextTick();
}
const row = (id) => document.querySelector(`#draw-mark-list [data-mark-id="${id}"]`);
@@ -63,6 +63,32 @@ test('lists only hand-drawn marks, named or by kind, with Import-style actions',
assert.equal(control('a2', 'remove').getAttribute('aria-label'), 'Remove Camp');
});
test('an application\'s row actions sit before fly to, labelled, and act on their mark', async (t) => {
const { store, calls } = mount(t);
const tag = { id: 'tag', icon: 'sell', label: (name) => `Tag ${name}`, shapes: ['line'], run: () => {} };
await show(store, [{ ...LINE, path: [[0, 0], [1, 0]] }, PIN], [tag]);
const button = control('a1', 'action-tag');
assert.equal(button.getAttribute('aria-label'), 'Tag Line · 950 m');
assert.equal(button.textContent, 'sell');
assert.equal(button.nextElementSibling, control('a1', 'fly'));
assert.equal(control('a2', 'action-tag'), null, 'a pin is not offered a line action');
button.click();
assert.deepEqual(calls, [['runDrawMarkAction', 'a1', 'tag']]);
const note = row('a1').querySelector('.draw-mark-note');
assert.equal(note.getAttribute('role'), 'status');
assert.equal(note.textContent, '');
assert.equal(row('a2').querySelector('.draw-mark-note'), null, 'a row without actions has no note');
const line = { ...LINE, path: [[0, 0], [1, 0]] };
store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks([line], drawMarkActions([tag]), new Map([['a1', { busy: true, text: '' }]])) });
await nextTick();
assert.equal(control('a1', 'action-tag').getAttribute('aria-disabled'), 'true');
assert.equal(row('a1').querySelector('.draw-mark-note').textContent, '…');
store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks([line], drawMarkActions([tag]), new Map([['a1', { busy: false, text: 'Tagged' }]])) });
await nextTick();
assert.equal(control('a1', 'action-tag').getAttribute('aria-disabled'), 'false');
assert.equal(row('a1').querySelector('.draw-mark-note').textContent, 'Tagged');
});
test('show/hide, fly to and remove act on that one mark', async (t) => {
const { store, calls } = mount(t);
await show(store, [LINE, PIN]);
+6 -5
View File
@@ -10,10 +10,11 @@ import { useDisplayStore } from '../../stores/display.js';
// UTILITIES ▸ Draw's shapes, one row each like Import's files: the colour
// swatch (it opens the five colours in the row), the name typed in place,
// and show/hide, fly to and remove. The draw tool publishes the rows and
// answers the controls. Rows are keyed, so a board change elsewhere updates a
// row in place and never takes the button under a click or the field being
// typed in. The list's template is one line: templates keep whitespace.
// and show/hide, the application's own row actions, fly to and remove. The
// draw tool publishes the rows and answers the controls. Rows are keyed, so a
// board change elsewhere updates a row in place and never takes the button
// under a click or the field being typed in. The list's template is one
// line: templates keep whitespace.
const display = useDisplayStore();
const { draw } = storeToRefs(display);
const rows = computed(() => draw.value?.marks ?? []);
@@ -98,5 +99,5 @@ function leave() {
</script>
<template>
<ul class="geo-import-list draw-mark-list" :class="{ visible: listed }" id="draw-mark-list" v-focus-safe-children="leave" :aria-label="t('Drawn shapes')"><li v-for="row in rows" :key="row.id" class="geo-import-row draw-mark-row" :class="{ 'hidden-mark': row.hidden }" :data-mark-id="row.id" @keydown="onRowKey(row, $event)"><button type="button" class="geo-import-swatch draw-mark-swatch" data-control="swatch" :style="{ background: DRAW_COLOR_CSS[row.color] }" :title="row.labels.swatch" :aria-label="row.labels.swatch" :aria-expanded="String(picking === row.id)" :aria-controls="`draw-mark-colors-${row.id}`" @click="picking = picking === row.id ? null : row.id"></button><DrawSwatches v-if="picking === row.id" :id="`draw-mark-colors-${row.id}`" class="draw-mark-colors" data-control="picker" autofocus :label="row.labels.colors" :model-value="row.color" @update:model-value="recolor(row, $event)" @choose="closePicker(row.id)" /><input type="text" class="geo-import-name draw-mark-name" data-control="name" maxlength="120" autocomplete="off" spellcheck="false" v-field-value="row.name" :placeholder="row.placeholder" :aria-label="row.labels.name" @keydown="onNameKey(row, $event)" @focusout="commit(row, $event.currentTarget)"><button type="button" class="geo-import-action" data-control="toggle" :title="row.labels.toggle" :aria-label="row.labels.toggle" @click="display.act('toggleDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">{{ row.hidden ? 'visibility_off' : 'visibility' }}</span></button><button type="button" class="geo-import-action" data-control="fly" :title="row.labels.fly" :aria-label="row.labels.fly" @click="display.act('flyToDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">my_location</span></button><button type="button" class="geo-import-action" data-control="remove" :title="row.labels.remove" :aria-label="row.labels.remove" @click="display.act('removeDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">close</span></button></li></ul>
<ul class="geo-import-list draw-mark-list" :class="{ visible: listed }" id="draw-mark-list" v-focus-safe-children="leave" :aria-label="t('Drawn shapes')"><li v-for="row in rows" :key="row.id" class="geo-import-row draw-mark-row" :class="{ 'hidden-mark': row.hidden }" :data-mark-id="row.id" @keydown="onRowKey(row, $event)"><button type="button" class="geo-import-swatch draw-mark-swatch" data-control="swatch" :style="{ background: DRAW_COLOR_CSS[row.color] }" :title="row.labels.swatch" :aria-label="row.labels.swatch" :aria-expanded="String(picking === row.id)" :aria-controls="`draw-mark-colors-${row.id}`" @click="picking = picking === row.id ? null : row.id"></button><DrawSwatches v-if="picking === row.id" :id="`draw-mark-colors-${row.id}`" class="draw-mark-colors" data-control="picker" autofocus :label="row.labels.colors" :model-value="row.color" @update:model-value="recolor(row, $event)" @choose="closePicker(row.id)" /><input type="text" class="geo-import-name draw-mark-name" data-control="name" maxlength="120" autocomplete="off" spellcheck="false" v-field-value="row.name" :placeholder="row.placeholder" :aria-label="row.labels.name" @keydown="onNameKey(row, $event)" @focusout="commit(row, $event.currentTarget)"><button type="button" class="geo-import-action" data-control="toggle" :title="row.labels.toggle" :aria-label="row.labels.toggle" @click="display.act('toggleDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">{{ row.hidden ? 'visibility_off' : 'visibility' }}</span></button><button v-for="action in row.actions" :key="action.id" type="button" class="geo-import-action draw-mark-action" :data-control="`action-${action.id}`" :data-action="action.id" :title="action.label" :aria-label="action.label" :aria-disabled="String(row.busy)" @click="display.act('runDrawMarkAction', row.id, action.id)"><span class="material-symbols-outlined" aria-hidden="true">{{ action.icon }}</span></button><button type="button" class="geo-import-action" data-control="fly" :title="row.labels.fly" :aria-label="row.labels.fly" @click="display.act('flyToDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">my_location</span></button><button type="button" class="geo-import-action" data-control="remove" :title="row.labels.remove" :aria-label="row.labels.remove" @click="display.act('removeDrawMark', row.id)"><span class="material-symbols-outlined" aria-hidden="true">close</span></button><span v-if="row.actions.length" class="draw-mark-note" role="status" :aria-busy="String(row.busy)">{{ row.busy ? '…' : row.note }}</span></li></ul>
</template>