mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
8543b7be43
commit
95ed686da5
23 files changed
+1061
-214
No files matched your search
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -902,14 +902,15 @@
|
||||
"Board cleared.": "Tablero borrado.",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Dibuja sobre el mundo: haz clic en los vértices, doble clic o Enter para terminar, Esc para cancelar",
|
||||
"Draw": "Dibujo",
|
||||
"Shape": "Forma",
|
||||
"Shape to draw": "Forma a dibujar",
|
||||
"Area": "Área",
|
||||
"Line": "Línea",
|
||||
"Pin": "Marcador",
|
||||
"Label (optional)": "Etiqueta (opcional)",
|
||||
"Label for the drawn shape": "Etiqueta de la forma dibujada",
|
||||
"Colour of the drawn shape": "Color de la forma dibujada",
|
||||
"Colour of {name}": "Color de {name}",
|
||||
"Change the colour of {name}": "Cambiar el color de {name}",
|
||||
"Name of {name}": "Nombre de {name}",
|
||||
"Colour of the next shape": "Color de la próxima forma",
|
||||
"Drawn shapes": "Formas dibujadas",
|
||||
"Primary": "Principal",
|
||||
"Amber": "Ámbar",
|
||||
"Cyan": "Cian",
|
||||
|
||||
@@ -910,14 +910,15 @@
|
||||
"Board cleared.": "Доска очищена.",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Рисовать на карте мира — щёлкайте по вершинам, двойной щелчок или Enter — завершить, Esc — отменить",
|
||||
"Draw": "Рисование",
|
||||
"Shape": "Фигура",
|
||||
"Shape to draw": "Фигура для рисования",
|
||||
"Area": "Зона",
|
||||
"Line": "Линия",
|
||||
"Pin": "Метка",
|
||||
"Label (optional)": "Подпись (необязательно)",
|
||||
"Label for the drawn shape": "Подпись нарисованной фигуры",
|
||||
"Colour of the drawn shape": "Цвет нарисованной фигуры",
|
||||
"Colour of {name}": "Цвет: {name}",
|
||||
"Change the colour of {name}": "Изменить цвет: {name}",
|
||||
"Name of {name}": "Название: {name}",
|
||||
"Colour of the next shape": "Цвет следующей фигуры",
|
||||
"Drawn shapes": "Нарисованные фигуры",
|
||||
"Primary": "Основной",
|
||||
"Amber": "Янтарный",
|
||||
"Cyan": "Голубой",
|
||||
|
||||
@@ -2657,14 +2657,15 @@
|
||||
"Scope edge feather": "视域边缘羽化",
|
||||
"Scope edge feather as a percentage of the keyhole radius": "视域边缘羽化,以视域半径的百分比表示",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上绘制——单击放置顶点,双击或按 Enter 完成,按 Esc 取消",
|
||||
"Shape": "形状",
|
||||
"Shape to draw": "要绘制的形状",
|
||||
"Area": "面",
|
||||
"Line": "线",
|
||||
"Pin": "图钉",
|
||||
"Label (optional)": "标签(可选)",
|
||||
"Label for the drawn shape": "绘制形状的标签",
|
||||
"Colour of the drawn shape": "绘制形状的颜色",
|
||||
"Colour of {name}": "{name} 的颜色",
|
||||
"Change the colour of {name}": "更改 {name} 的颜色",
|
||||
"Name of {name}": "{name} 的名称",
|
||||
"Colour of the next shape": "下一个形状的颜色",
|
||||
"Drawn shapes": "已绘制的形状",
|
||||
"Primary": "主题色",
|
||||
"Amber": "琥珀色",
|
||||
"Cyan": "青色",
|
||||
|
||||
@@ -2645,14 +2645,15 @@
|
||||
"Scope edge feather": "視域邊緣羽化",
|
||||
"Scope edge feather as a percentage of the keyhole radius": "視域邊緣羽化,以視域半徑的百分比表示",
|
||||
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上繪製——單擊放置頂點,雙擊或按 Enter 完成,按 Esc 取消",
|
||||
"Shape": "形狀",
|
||||
"Shape to draw": "要繪製的形狀",
|
||||
"Area": "面",
|
||||
"Line": "線",
|
||||
"Pin": "圖釘",
|
||||
"Label (optional)": "標籤(可選)",
|
||||
"Label for the drawn shape": "繪製形狀的標籤",
|
||||
"Colour of the drawn shape": "繪製形狀的顏色",
|
||||
"Colour of {name}": "{name} 的顏色",
|
||||
"Change the colour of {name}": "更改 {name} 的顏色",
|
||||
"Name of {name}": "{name} 的名稱",
|
||||
"Colour of the next shape": "下一個形狀的顏色",
|
||||
"Drawn shapes": "已繪製的形狀",
|
||||
"Primary": "主題色",
|
||||
"Amber": "琥珀色",
|
||||
"Cyan": "青色",
|
||||
|
||||
+102
-23
@@ -750,38 +750,117 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* UTILITIES ▸ Draw: shape segment, label field, colour, and the one-line hint */
|
||||
/* UTILITIES ▸ Draw: shape icons and colour swatches for the next shape, the
|
||||
hint with Clear, then one row per drawn shape (named and recoloured there) */
|
||||
#draw-mode-row {
|
||||
min-width: 184px;
|
||||
}
|
||||
#draw-label-row {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
column-gap: 10px;
|
||||
}
|
||||
.pp-text-input {
|
||||
flex: 1 1 110px;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.6px;
|
||||
padding: 2px 6px;
|
||||
#draw-mode-row .draw-shape-seg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#draw-mode-row .draw-shape-seg .gev-segment {
|
||||
min-width: 30px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
.draw-shape-seg .material-symbols-outlined {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.draw-colors {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
#utilities-panel .draw-color {
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(0, 0, 0, 0.45);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
#utilities-panel .draw-color[data-color='primary'] {
|
||||
background: #8be9ff;
|
||||
}
|
||||
#utilities-panel .draw-color[data-color='amber'] {
|
||||
background: #ffb547;
|
||||
}
|
||||
#utilities-panel .draw-color[data-color='cyan'] {
|
||||
background: #39d0ff;
|
||||
}
|
||||
#utilities-panel .draw-color[data-color='green'] {
|
||||
background: #5dff9f;
|
||||
}
|
||||
#utilities-panel .draw-color[data-color='red'] {
|
||||
background: #ff6b6b;
|
||||
}
|
||||
/* The swatch keeps its own colour in every theme; only a ring marks it. */
|
||||
#utilities-panel .draw-color:is(.active, :hover, :focus-visible) {
|
||||
border-color: rgba(0, 0, 0, 0.45);
|
||||
box-shadow: none;
|
||||
}
|
||||
#utilities-panel .draw-color.active,
|
||||
#utilities-panel .draw-color:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
#draw-label-row {
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
#draw-label-row .draw-hint {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.draw-mark-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.draw-mark-swatch {
|
||||
flex: 0 0 10px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.draw-mark-swatch:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* The name reads as text until it is clicked into, and gives way so the
|
||||
row's buttons stay on its line. */
|
||||
.draw-mark-row .draw-mark-name {
|
||||
flex: 1 1 0;
|
||||
width: 0;
|
||||
height: 22px;
|
||||
padding: 0 4px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
}
|
||||
.pp-text-input:focus {
|
||||
.draw-mark-name::placeholder {
|
||||
color: var(--text-primary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.draw-mark-name:hover {
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.draw-mark-name:focus {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.draw-color-select {
|
||||
flex: 0 0 74px;
|
||||
.draw-mark-row.hidden-mark .draw-mark-name,
|
||||
.draw-mark-row.hidden-mark .draw-mark-swatch {
|
||||
opacity: 0.45;
|
||||
}
|
||||
/* Sized to its longest colour name so a translated one is never clipped. */
|
||||
html:not([lang='en']) .draw-color-select {
|
||||
flex: 0 0 auto;
|
||||
min-width: 74px;
|
||||
.draw-mark-colors {
|
||||
flex-basis: 100%;
|
||||
padding: 2px 0 4px 16px;
|
||||
}
|
||||
.draw-clear-btn {
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -105,8 +105,6 @@ test('Display and parameter rows keep their English layout', () => {
|
||||
assert.equal(unguardedRules(source, '#param-sliders').length, 0);
|
||||
const [row] = unguardedRules(source, '.param-slider-row');
|
||||
assert.equal(declaration(row, 'display'), 'flex');
|
||||
const [color] = unguardedRules(source, '.draw-color-select');
|
||||
assert.equal(declaration(color, 'flex'), '0 0 74px');
|
||||
});
|
||||
|
||||
test('weather setting rows keep the 56px English label column', () => {
|
||||
|
||||
@@ -1495,8 +1495,7 @@ body.recording-mode #right-context-rail > [data-panel-id] {
|
||||
}
|
||||
|
||||
/* Draw's options sit flat under its name, like a layer row's controls, not
|
||||
as the card that hangs under a DISPLAY button. DISPLAY's shared label
|
||||
column means nothing here, so the Shape label takes its own width. */
|
||||
as the card that hangs under a DISPLAY button. */
|
||||
#utilities-panel .utilities-tools .pp-slider-row {
|
||||
padding: 4px 0;
|
||||
background: none;
|
||||
@@ -1505,7 +1504,3 @@ body.recording-mode #right-context-rail > [data-panel-id] {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
#utilities-panel .utilities-tools .pp-slider-mini-label {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -159,25 +159,24 @@
|
||||
<span class="pp-label" data-i18n>Draw</span>
|
||||
</button>
|
||||
<div class="pp-slider-row" id="draw-mode-row">
|
||||
<span class="pp-slider-mini-label" data-i18n>Shape</span>
|
||||
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
|
||||
<button class="gev-segment pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button>
|
||||
<button class="gev-segment pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button>
|
||||
<button class="gev-segment pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
|
||||
<div class="gev-segmented pp-mode-seg draw-shape-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
|
||||
<button class="gev-segment pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" aria-label="Area" title="Area" data-i18n-aria-label data-i18n-title><span class="material-symbols-outlined" aria-hidden="true">pentagon</span></button>
|
||||
<button class="gev-segment pp-mode-btn" data-shape="line" role="radio" aria-checked="false" aria-label="Line" title="Line" data-i18n-aria-label data-i18n-title><span class="material-symbols-outlined" aria-hidden="true">timeline</span></button>
|
||||
<button class="gev-segment pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" aria-label="Pin" title="Pin" data-i18n-aria-label data-i18n-title><span class="material-symbols-outlined" aria-hidden="true">location_on</span></button>
|
||||
</div>
|
||||
<div class="draw-colors" id="draw-colors" role="radiogroup" aria-label="Colour of the next shape" data-i18n-aria-label>
|
||||
<button class="draw-color active" type="button" data-color="primary" role="radio" aria-checked="true" aria-label="Primary" title="Primary" data-i18n-aria-label data-i18n-title></button>
|
||||
<button class="draw-color" type="button" data-color="amber" role="radio" aria-checked="false" aria-label="Amber" title="Amber" data-i18n-aria-label data-i18n-title></button>
|
||||
<button class="draw-color" type="button" data-color="cyan" role="radio" aria-checked="false" aria-label="Cyan" title="Cyan" data-i18n-aria-label data-i18n-title></button>
|
||||
<button class="draw-color" type="button" data-color="green" role="radio" aria-checked="false" aria-label="Green" title="Green" data-i18n-aria-label data-i18n-title></button>
|
||||
<button class="draw-color" type="button" data-color="red" role="radio" aria-checked="false" aria-label="Red" title="Red" data-i18n-aria-label data-i18n-title></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-slider-row" id="draw-label-row">
|
||||
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false">
|
||||
<select class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label>
|
||||
<option value="primary" data-i18n>Primary</option>
|
||||
<option value="amber" data-i18n>Amber</option>
|
||||
<option value="cyan" data-i18n>Cyan</option>
|
||||
<option value="green" data-i18n>Green</option>
|
||||
<option value="red" data-i18n>Red</option>
|
||||
</select>
|
||||
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" title="Remove every mark from the board" data-i18n-title data-i18n>Clear</button>
|
||||
<span class="draw-hint" id="draw-hint" aria-live="polite"></span>
|
||||
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" title="Remove every mark from the board" data-i18n-title data-i18n>Clear</button>
|
||||
</div>
|
||||
<ul class="geo-import-list draw-mark-list" id="draw-mark-list" aria-label="Drawn shapes" data-i18n-aria-label></ul>
|
||||
</div>
|
||||
<div class="pp-toggle-group" id="geo-import-group">
|
||||
<button class="pp-toggle-btn" id="geo-import-button" type="button" title="Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice." data-i18n-title>
|
||||
|
||||
Reference in new issue
Block a user