mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Hand focus back to the globe canvas after a render failure
When the control that had focus is gone or cannot take it, focus went to the #cesiumContainer div, where Enter no longer finished a drawing. It now goes to the globe's canvas, which the map's keys read as the map; the canvas is focusable only until focus moves on or the globe is pressed. A control disabled while the notice was up no longer leaves focus on the page, and neither focus call scrolls.
This commit is contained in:
1 parent
771ed98e3b
commit
6976cbc941
4 files changed
+392
-65
No files matched your search
@@ -2225,9 +2225,12 @@ black globe behind controls that still respond. `installRenderFailureNotice()`
|
|||||||
Cesium stops its render loop, and for the canvas' `webglcontextlost`. It logs
|
Cesium stops its render loop, and for the canvas' `webglcontextlost`. It logs
|
||||||
the error and publishes a notice to the status store, which
|
the error and publishes a notice to the status store, which
|
||||||
`RenderFailure.vue` renders as an alert dialog that takes focus. When the
|
`RenderFailure.vue` renders as an alert dialog that takes focus. When the
|
||||||
globe draws again, focus goes back to the element that had it, or to the map
|
globe draws again, focus goes back to the element that had it, or, if that
|
||||||
(`#cesiumContainer`, made focusable with `tabindex="-1"`) if that element is
|
element is gone or cannot take focus, to the globe's canvas, which the map's
|
||||||
gone, unless the user has moved focus since. The
|
keys (Enter to finish a drawing, Space to talk) read as the map. The canvas
|
||||||
|
gets `tabindex="-1"` only until focus moves on or the globe is pressed, and a
|
||||||
|
press leaves focus on the page, as Cesium has it. Focus the user moved
|
||||||
|
meanwhile stays put. The
|
||||||
application scene creates its viewer with `showRenderLoopErrors: false`, so
|
application scene creates its viewer with `showRenderLoopErrors: false`, so
|
||||||
Cesium's own "Rendering has stopped" panel, which offers no way back, no longer
|
Cesium's own "Rendering has stopped" panel, which offers no way back, no longer
|
||||||
appears; `applicationViewerOptions()` and `createApplicationViewer()` keep
|
appears; `applicationViewerOptions()` and `createApplicationViewer()` keep
|
||||||
|
|||||||
@@ -68,9 +68,17 @@ class FakeElement {
|
|||||||
}
|
}
|
||||||
closest(selector) {
|
closest(selector) {
|
||||||
// Only what the tool asks: "am I inside an activatable control?"
|
// Only what the tool asks: "am I inside an activatable control?"
|
||||||
const activatable = ['button', 'a', 'select', '[role="button"]', '[role="radio"]'];
|
const activatable = [
|
||||||
|
'button',
|
||||||
|
'a',
|
||||||
|
'select',
|
||||||
|
'[role="button"]',
|
||||||
|
'[role="radio"]',
|
||||||
|
];
|
||||||
if (!activatable.some((part) => selector.includes(part))) return null;
|
if (!activatable.some((part) => selector.includes(part))) return null;
|
||||||
return this.tagName === 'BUTTON' || this.tagName === 'A' || this.tagName === 'SELECT'
|
return this.tagName === 'BUTTON' ||
|
||||||
|
this.tagName === 'A' ||
|
||||||
|
this.tagName === 'SELECT'
|
||||||
? this
|
? this
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
@@ -175,7 +183,14 @@ function fakeViewer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fakeAnnotations() {
|
function fakeAnnotations() {
|
||||||
const calls = { annotate: [], clear: 0, restyle: [], setHidden: [], frame: [], remove: [] };
|
const calls = {
|
||||||
|
annotate: [],
|
||||||
|
clear: 0,
|
||||||
|
restyle: [],
|
||||||
|
setHidden: [],
|
||||||
|
frame: [],
|
||||||
|
remove: [],
|
||||||
|
};
|
||||||
const marks = [];
|
const marks = [];
|
||||||
const listeners = new Set();
|
const listeners = new Set();
|
||||||
const changed = () => listeners.forEach((listener) => listener());
|
const changed = () => listeners.forEach((listener) => listener());
|
||||||
@@ -300,7 +315,11 @@ test('Draw refuses to start when something else holds the pointer', async () =>
|
|||||||
h.shown.view.hint,
|
h.shown.view.hint,
|
||||||
'Directions is using the pointer — close it first.',
|
'Directions is using the pointer — close it first.',
|
||||||
);
|
);
|
||||||
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
|
assert.equal(
|
||||||
|
h.tool.diagnostics().sceneHandler,
|
||||||
|
false,
|
||||||
|
'no handler was bound',
|
||||||
|
);
|
||||||
|
|
||||||
releasePointer(other);
|
releasePointer(other);
|
||||||
h.act('toggleDraw');
|
h.act('toggleDraw');
|
||||||
@@ -342,7 +361,10 @@ test('both stock viewer click actions are borrowed for the session and given bac
|
|||||||
const stockClick = () => 'viewer picks and selects';
|
const stockClick = () => 'viewer picks and selects';
|
||||||
const stockDouble = () => 'viewer tracks';
|
const stockDouble = () => 'viewer tracks';
|
||||||
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
|
h.viewer.screenSpaceEventHandler.setInputAction(stockClick, LEFT_CLICK);
|
||||||
h.viewer.screenSpaceEventHandler.setInputAction(stockDouble, LEFT_DOUBLE_CLICK);
|
h.viewer.screenSpaceEventHandler.setInputAction(
|
||||||
|
stockDouble,
|
||||||
|
LEFT_DOUBLE_CLICK,
|
||||||
|
);
|
||||||
|
|
||||||
h.act('toggleDraw');
|
h.act('toggleDraw');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -380,20 +402,61 @@ test('Enter finishes from the canvas and the label field, never from a focused b
|
|||||||
h.tool.addVertex(-97.74, 30.27);
|
h.tool.addVertex(-97.74, 30.27);
|
||||||
|
|
||||||
// Enter while the Clear button has keyboard focus means "press Clear".
|
// Enter while the Clear button has keyboard focus means "press Clear".
|
||||||
h.documentDouble.emit('keydown', { key: 'Enter', target: h.element('draw-clear') });
|
h.documentDouble.emit('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
target: h.element('draw-clear'),
|
||||||
|
});
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
h.annotations.calls.annotate.length,
|
h.annotations.calls.annotate.length,
|
||||||
0,
|
0,
|
||||||
'a focused button keeps its own Enter',
|
'a focused button keeps its own Enter',
|
||||||
);
|
);
|
||||||
assert.equal(h.tool.session.vertices.length, 3, 'and the shape is untouched');
|
assert.equal(
|
||||||
|
h.tool.session.vertices.length,
|
||||||
|
3,
|
||||||
|
'and the shape is untouched',
|
||||||
|
);
|
||||||
|
|
||||||
// Enter from the page body (the canvas has focus in practice) finishes it.
|
// Enter from the page body (the canvas has focus in practice) finishes it.
|
||||||
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
|
h.documentDouble.emit('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
target: h.documentDouble.body,
|
||||||
|
});
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
assert.equal(h.annotations.calls.annotate.length, 1, 'Enter on the map finishes');
|
assert.equal(
|
||||||
|
h.annotations.calls.annotate.length,
|
||||||
|
1,
|
||||||
|
'Enter on the map finishes',
|
||||||
|
);
|
||||||
|
assert.equal(h.annotations.calls.annotate[0].specs[0].type, 'area');
|
||||||
|
} finally {
|
||||||
|
h.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Enter on the globe canvas finishes, as after the render failure notice hands focus back', async () => {
|
||||||
|
const h = harness();
|
||||||
|
try {
|
||||||
|
h.act('toggleDraw');
|
||||||
|
h.tool.setShape('area');
|
||||||
|
h.tool.addVertex(-97.75, 30.26);
|
||||||
|
h.tool.addVertex(-97.74, 30.26);
|
||||||
|
h.tool.addVertex(-97.74, 30.27);
|
||||||
|
// src/ui/views/panels/RenderFailure.vue focuses the canvas when the
|
||||||
|
// globe draws again and the control that had focus is gone.
|
||||||
|
h.documentDouble.emit('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
target: h.viewer.scene.canvas,
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(
|
||||||
|
h.annotations.calls.annotate.length,
|
||||||
|
1,
|
||||||
|
'Enter on the canvas finishes',
|
||||||
|
);
|
||||||
assert.equal(h.annotations.calls.annotate[0].specs[0].type, 'area');
|
assert.equal(h.annotations.calls.annotate[0].specs[0].type, 'area');
|
||||||
} finally {
|
} finally {
|
||||||
h.restore();
|
h.restore();
|
||||||
@@ -422,7 +485,10 @@ test('the live preview hangs at the height the click landed on', async () => {
|
|||||||
|
|
||||||
// …and the finished shape is still flattened, deliberately: the renderer
|
// …and the finished shape is still flattened, deliberately: the renderer
|
||||||
// drapes, so a height carried further would be discarded out of sight.
|
// drapes, so a height carried further would be discarded out of sight.
|
||||||
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
|
h.documentDouble.emit('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
target: h.documentDouble.body,
|
||||||
|
});
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
const spec = h.annotations.calls.annotate[0].specs[0];
|
const spec = h.annotations.calls.annotate[0].specs[0];
|
||||||
@@ -478,8 +544,16 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
|
|||||||
stockClick,
|
stockClick,
|
||||||
'the viewer got its selecting click back',
|
'the viewer got its selecting click back',
|
||||||
);
|
);
|
||||||
assert.equal(h.viewer._attached.length, 0, 'the preview data source is detached');
|
assert.equal(
|
||||||
assert.equal(h.annotations.listeners.size, 0, 'and it no longer follows the board');
|
h.viewer._attached.length,
|
||||||
|
0,
|
||||||
|
'the preview data source is detached',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
h.annotations.listeners.size,
|
||||||
|
0,
|
||||||
|
'and it no longer follows the board',
|
||||||
|
);
|
||||||
|
|
||||||
// Idempotent: a second destroy is harmless.
|
// Idempotent: a second destroy is harmless.
|
||||||
await h.tool.destroy();
|
await h.tool.destroy();
|
||||||
@@ -498,7 +572,11 @@ test('Clear wipes the board and the shape in progress', async () => {
|
|||||||
|
|
||||||
h.act('clearDraw');
|
h.act('clearDraw');
|
||||||
assert.equal(h.annotations.calls.clear, 1, 'placed marks are removed');
|
assert.equal(h.annotations.calls.clear, 1, 'placed marks are removed');
|
||||||
assert.equal(h.tool.session.vertices.length, 0, 'and so is the shape in progress');
|
assert.equal(
|
||||||
|
h.tool.session.vertices.length,
|
||||||
|
0,
|
||||||
|
'and so is the shape in progress',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
h.restore();
|
h.restore();
|
||||||
}
|
}
|
||||||
@@ -512,7 +590,13 @@ test('the controls choose the shape and colour the next shape is drawn with; it
|
|||||||
h.act('setDrawColor', 'amber');
|
h.act('setDrawColor', 'amber');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
{ ...h.shown.view, hint: undefined },
|
{ ...h.shown.view, hint: undefined },
|
||||||
{ active: true, shape: 'line', color: 'amber', hint: undefined, marks: [] },
|
{
|
||||||
|
active: true,
|
||||||
|
shape: 'line',
|
||||||
|
color: 'amber',
|
||||||
|
hint: undefined,
|
||||||
|
marks: [],
|
||||||
|
},
|
||||||
'the view shows what the controls chose',
|
'the view shows what the controls chose',
|
||||||
);
|
);
|
||||||
h.act('setDrawColor', 'purple');
|
h.act('setDrawColor', 'purple');
|
||||||
@@ -524,7 +608,10 @@ test('the controls choose the shape and colour the next shape is drawn with; it
|
|||||||
assert.equal(h.tool.session.vertices.length, 0);
|
assert.equal(h.tool.session.vertices.length, 0);
|
||||||
h.tool.addVertex(-97.75, 30.26);
|
h.tool.addVertex(-97.75, 30.26);
|
||||||
h.tool.addVertex(-97.74, 30.26);
|
h.tool.addVertex(-97.74, 30.26);
|
||||||
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
|
h.documentDouble.emit('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
target: h.documentDouble.body,
|
||||||
|
});
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
const spec = h.annotations.calls.annotate[0].specs[0];
|
const spec = h.annotations.calls.annotate[0].specs[0];
|
||||||
@@ -549,12 +636,31 @@ test('each drawn shape is a row whose actions reach the board, and fly-to is a n
|
|||||||
try {
|
try {
|
||||||
const board = h.annotations;
|
const board = h.annotations;
|
||||||
board.marks.push(
|
board.marks.push(
|
||||||
{ id: 'a1', type: 'route', manual: true, name: null, label: '950 m', color: 'amber', hidden: false },
|
{
|
||||||
{ id: 'a2', type: 'pin', manual: false, label: 'Voice pin', color: 'primary', hidden: false },
|
id: 'a1',
|
||||||
|
type: 'route',
|
||||||
|
manual: true,
|
||||||
|
name: null,
|
||||||
|
label: '950 m',
|
||||||
|
color: 'amber',
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'a2',
|
||||||
|
type: 'pin',
|
||||||
|
manual: false,
|
||||||
|
label: 'Voice pin',
|
||||||
|
color: 'primary',
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
board.changed();
|
board.changed();
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
h.shown.view.marks.map(({ id, placeholder, hidden }) => ({ id, placeholder, hidden })),
|
h.shown.view.marks.map(({ id, placeholder, hidden }) => ({
|
||||||
|
id,
|
||||||
|
placeholder,
|
||||||
|
hidden,
|
||||||
|
})),
|
||||||
[{ id: 'a1', placeholder: 'Line · 950 m', hidden: false }],
|
[{ id: 'a1', placeholder: 'Line · 950 m', hidden: false }],
|
||||||
'only hand-drawn marks are listed, an unnamed line by its length',
|
'only hand-drawn marks are listed, an unnamed line by its length',
|
||||||
);
|
);
|
||||||
@@ -569,7 +675,11 @@ test('each drawn shape is a row whose actions reach the board, and fly-to is a n
|
|||||||
]);
|
]);
|
||||||
assert.deepEqual(board.calls.setHidden, [['a1', true]]);
|
assert.deepEqual(board.calls.setHidden, [['a1', true]]);
|
||||||
assert.deepEqual(board.calls.frame, ['a1']);
|
assert.deepEqual(board.calls.frame, ['a1']);
|
||||||
assert.deepEqual(navigations, ['drawing'], 'fly-to goes through the navigation policy');
|
assert.deepEqual(
|
||||||
|
navigations,
|
||||||
|
['drawing'],
|
||||||
|
'fly-to goes through the navigation policy',
|
||||||
|
);
|
||||||
assert.deepEqual(board.calls.remove, ['a1']);
|
assert.deepEqual(board.calls.remove, ['a1']);
|
||||||
assert.deepEqual(h.shown.view.marks, [], 'the row goes with its mark');
|
assert.deepEqual(h.shown.view.marks, [], 'the row goes with its mark');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -577,7 +687,7 @@ 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 () => {
|
test("an application's row actions show on the shapes they are for and run with the shape", async () => {
|
||||||
const ran = [];
|
const ran = [];
|
||||||
const h = harness({
|
const h = harness({
|
||||||
markActions: [
|
markActions: [
|
||||||
@@ -601,30 +711,102 @@ test('an application\'s row actions show on the shapes they are for and run with
|
|||||||
try {
|
try {
|
||||||
const board = h.annotations;
|
const board = h.annotations;
|
||||||
board.marks.push(
|
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: 'r1',
|
||||||
{ id: 'p1', type: 'pin', manual: true, name: 'Camp', color: 'red', hidden: false },
|
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();
|
board.changed();
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
h.shown.view.marks.map(({ id, actions }) => [id, actions]),
|
h.shown.view.marks.map(({ id, actions }) => [id, actions]),
|
||||||
[
|
[
|
||||||
['r1', [
|
[
|
||||||
{ id: 'tag', icon: 'sell', label: 'Tag Strait' },
|
'r1',
|
||||||
{ id: 'measure', icon: 'straighten', label: 'Measure Strait' },
|
[
|
||||||
]],
|
{ id: 'tag', icon: 'sell', label: 'Tag Strait' },
|
||||||
|
{ id: 'measure', icon: 'straighten', label: 'Measure Strait' },
|
||||||
|
],
|
||||||
|
],
|
||||||
['l1', [{ id: 'tag', icon: 'sell', label: 'Tag Line · 9 km' }]],
|
['l1', [{ id: 'tag', icon: 'sell', label: 'Tag Line · 9 km' }]],
|
||||||
['p1', []],
|
['p1', []],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.equal(h.act('runDrawMarkAction', 'l1', 'tag'), true);
|
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', 'l1', 'measure'),
|
||||||
|
false,
|
||||||
|
'a line is not offered an area action',
|
||||||
|
);
|
||||||
assert.equal(h.act('runDrawMarkAction', 'p1', 'tag'), false);
|
assert.equal(h.act('runDrawMarkAction', 'p1', 'tag'), false);
|
||||||
assert.equal(h.act('runDrawMarkAction', 'gone', 'tag'), false);
|
assert.equal(h.act('runDrawMarkAction', 'gone', 'tag'), false);
|
||||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
|
assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
|
||||||
assert.deepEqual(ran, [
|
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]]] } }],
|
'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 {
|
} finally {
|
||||||
h.restore();
|
h.restore();
|
||||||
@@ -634,16 +816,40 @@ test('an application\'s row actions show on the shapes they are for and run with
|
|||||||
test('an action that throws is reported and leaves the row alone', async () => {
|
test('an action that throws is reported and leaves the row alone', async () => {
|
||||||
const h = harness({
|
const h = harness({
|
||||||
markActions: [
|
markActions: [
|
||||||
{ id: 'boom', icon: 'bolt', label: () => { throw new Error('label'); }, shapes: ['area'], run: () => { throw new Error('run'); } },
|
{
|
||||||
|
id: 'boom',
|
||||||
|
icon: 'bolt',
|
||||||
|
label: () => {
|
||||||
|
throw new Error('label');
|
||||||
|
},
|
||||||
|
shapes: ['area'],
|
||||||
|
run: () => {
|
||||||
|
throw new Error('run');
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const warn = console.warn;
|
const warn = console.warn;
|
||||||
const warned = [];
|
const warned = [];
|
||||||
console.warn = (...args) => warned.push(args[0]);
|
console.warn = (...args) => warned.push(args[0]);
|
||||||
try {
|
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.marks.push({
|
||||||
|
id: 'r1',
|
||||||
|
type: 'area',
|
||||||
|
manual: true,
|
||||||
|
name: null,
|
||||||
|
color: 'primary',
|
||||||
|
hidden: false,
|
||||||
|
ring: [
|
||||||
|
[0, 0],
|
||||||
|
[1, 0],
|
||||||
|
[1, 1],
|
||||||
|
],
|
||||||
|
});
|
||||||
h.annotations.changed();
|
h.annotations.changed();
|
||||||
assert.deepEqual(h.shown.view.marks[0].actions, [{ id: 'boom', icon: 'bolt', label: 'boom' }]);
|
assert.deepEqual(h.shown.view.marks[0].actions, [
|
||||||
|
{ id: 'boom', icon: 'bolt', label: 'boom' },
|
||||||
|
]);
|
||||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'boom'), false);
|
assert.equal(h.act('runDrawMarkAction', 'r1', 'boom'), false);
|
||||||
assert.deepEqual(warned, ['[draw] boom failed']);
|
assert.deepEqual(warned, ['[draw] boom failed']);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -652,7 +858,7 @@ test('an action that throws is reported and leaves the row alone', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('what a row action answers is its row\'s note; the row is busy until then, and a removed shape drops it', async () => {
|
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 pending = [];
|
||||||
const h = harness({
|
const h = harness({
|
||||||
markActions: [
|
markActions: [
|
||||||
@@ -661,34 +867,73 @@ test('what a row action answers is its row\'s note; the row is busy until then,
|
|||||||
icon: 'straighten',
|
icon: 'straighten',
|
||||||
label: (name) => `Measure ${name}`,
|
label: (name) => `Measure ${name}`,
|
||||||
shapes: ['area'],
|
shapes: ['area'],
|
||||||
run: () => new Promise((resolve, reject) => pending.push({ resolve, reject })),
|
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`,
|
||||||
},
|
},
|
||||||
{ 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;
|
const warn = console.warn;
|
||||||
console.warn = () => {};
|
console.warn = () => {};
|
||||||
try {
|
try {
|
||||||
const board = h.annotations;
|
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]] });
|
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.marks.push(area('r1'), area('r2'));
|
||||||
board.changed();
|
board.changed();
|
||||||
const row = (id) => h.shown.view.marks.find((entry) => entry.id === id);
|
const row = (id) => h.shown.view.marks.find((entry) => entry.id === id);
|
||||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), true);
|
assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), true);
|
||||||
assert.deepEqual([row('r1').busy, row('r1').note], [false, '12 km²']);
|
assert.deepEqual([row('r1').busy, row('r1').note], [false, '12 km²']);
|
||||||
h.act('runDrawMarkAction', 'r1', 'long');
|
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.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.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
|
||||||
assert.deepEqual([row('r1').busy, row('r1').note], [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');
|
assert.equal(
|
||||||
|
h.act('runDrawMarkAction', 'r1', 'now'),
|
||||||
|
false,
|
||||||
|
'a busy row takes no second press',
|
||||||
|
);
|
||||||
pending[0].resolve('412 km², 38 km of edge');
|
pending[0].resolve('412 km², 38 km of edge');
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
assert.deepEqual([row('r1').busy, row('r1').note], [false, '412 km², 38 km of edge']);
|
assert.deepEqual(
|
||||||
|
[row('r1').busy, row('r1').note],
|
||||||
|
[false, '412 km², 38 km of edge'],
|
||||||
|
);
|
||||||
h.act('runDrawMarkAction', 'r1', 'measure');
|
h.act('runDrawMarkAction', 'r1', 'measure');
|
||||||
pending[1].reject(new Error('refused'));
|
pending[1].reject(new Error('refused'));
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
assert.deepEqual([row('r1').busy, row('r1').note], [false, ''], 'a failure clears the note');
|
assert.deepEqual(
|
||||||
|
[row('r1').busy, row('r1').note],
|
||||||
|
[false, ''],
|
||||||
|
'a failure clears the note',
|
||||||
|
);
|
||||||
h.act('runDrawMarkAction', 'r2', 'measure');
|
h.act('runDrawMarkAction', 'r2', 'measure');
|
||||||
board.marks.splice(1, 1);
|
board.marks.splice(1, 1);
|
||||||
board.changed();
|
board.changed();
|
||||||
@@ -697,7 +942,11 @@ test('what a row action answers is its row\'s note; the row is busy until then,
|
|||||||
assert.equal(row('r2'), undefined);
|
assert.equal(row('r2'), undefined);
|
||||||
board.marks.push(area('r2'));
|
board.marks.push(area('r2'));
|
||||||
board.changed();
|
board.changed();
|
||||||
assert.deepEqual([row('r2').busy, row('r2').note], [false, ''], 'a late answer never reaches a shape that went');
|
assert.deepEqual(
|
||||||
|
[row('r2').busy, row('r2').note],
|
||||||
|
[false, ''],
|
||||||
|
'a late answer never reaches a shape that went',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
console.warn = warn;
|
console.warn = warn;
|
||||||
h.restore();
|
h.restore();
|
||||||
@@ -710,30 +959,49 @@ test('Backspace on the page removes the last vertex', () => {
|
|||||||
h.act('toggleDraw');
|
h.act('toggleDraw');
|
||||||
h.tool.addVertex(-97.75, 30.26);
|
h.tool.addVertex(-97.75, 30.26);
|
||||||
h.tool.addVertex(-97.74, 30.26);
|
h.tool.addVertex(-97.74, 30.26);
|
||||||
h.documentDouble.emit('keydown', { key: 'Backspace', target: h.documentDouble.body });
|
h.documentDouble.emit('keydown', {
|
||||||
|
key: 'Backspace',
|
||||||
|
target: h.documentDouble.body,
|
||||||
|
});
|
||||||
assert.equal(h.tool.session.vertices.length, 1);
|
assert.equal(h.tool.session.vertices.length, 1);
|
||||||
} finally {
|
} finally {
|
||||||
h.restore();
|
h.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keys on the shape list are the list\'s, not the drawing\'s', async () => {
|
test("keys on the shape list are the list's, not the drawing's", async () => {
|
||||||
const h = harness();
|
const h = harness();
|
||||||
try {
|
try {
|
||||||
const listButton = new FakeElement('BUTTON');
|
const listButton = new FakeElement('BUTTON');
|
||||||
listButton.closest = (selector) => (selector === '#draw-mark-list' ? {} : null);
|
listButton.closest = (selector) =>
|
||||||
|
selector === '#draw-mark-list' ? {} : null;
|
||||||
const elsewhere = new FakeElement('INPUT', 'location-search');
|
const elsewhere = new FakeElement('INPUT', 'location-search');
|
||||||
h.act('toggleDraw');
|
h.act('toggleDraw');
|
||||||
h.tool.addVertex(-97.75, 30.26);
|
h.tool.addVertex(-97.75, 30.26);
|
||||||
h.tool.addVertex(-97.74, 30.26);
|
h.tool.addVertex(-97.74, 30.26);
|
||||||
h.documentDouble.emit('keydown', { key: 'Escape', target: listButton });
|
h.documentDouble.emit('keydown', { key: 'Escape', target: listButton });
|
||||||
assert.equal(h.tool.session.vertices.length, 2, 'Escape in the list keeps the shape');
|
assert.equal(
|
||||||
|
h.tool.session.vertices.length,
|
||||||
|
2,
|
||||||
|
'Escape in the list keeps the shape',
|
||||||
|
);
|
||||||
assert.equal(h.tool.active, true, 'and keeps Draw on');
|
assert.equal(h.tool.active, true, 'and keeps Draw on');
|
||||||
h.documentDouble.emit('keydown', { key: 'Enter', target: elsewhere });
|
h.documentDouble.emit('keydown', { key: 'Enter', target: elsewhere });
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
assert.equal(h.annotations.calls.annotate.length, 0, 'another field keeps its Enter');
|
assert.equal(
|
||||||
h.documentDouble.emit('keydown', { key: 'Escape', target: h.documentDouble.body });
|
h.annotations.calls.annotate.length,
|
||||||
assert.equal(h.tool.session.vertices.length, 0, 'Escape on the page drops the open shape');
|
0,
|
||||||
|
'another field keeps its Enter',
|
||||||
|
);
|
||||||
|
h.documentDouble.emit('keydown', {
|
||||||
|
key: 'Escape',
|
||||||
|
target: h.documentDouble.body,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
h.tool.session.vertices.length,
|
||||||
|
0,
|
||||||
|
'Escape on the page drops the open shape',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
h.restore();
|
h.restore();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,8 +109,17 @@ test('focus goes back where it was when the globe draws again', async (t) => {
|
|||||||
assert.equal(document.activeElement, search);
|
assert.equal(document.activeElement, search);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('focus goes to the map when the element it came from is gone', async (t) => {
|
// The globe's canvas, as CesiumWidget builds it inside the container.
|
||||||
|
const addGlobeCanvas = () => {
|
||||||
|
const widget = document.createElement('div');
|
||||||
|
widget.className = 'cesium-widget';
|
||||||
|
document.getElementById('cesiumContainer').appendChild(widget);
|
||||||
|
return widget.appendChild(document.createElement('canvas'));
|
||||||
|
};
|
||||||
|
|
||||||
|
test('focus goes to the globe canvas when the element it came from is gone', async (t) => {
|
||||||
const store = mount(t);
|
const store = mount(t);
|
||||||
|
const canvas = addGlobeCanvas();
|
||||||
const search = document.body.appendChild(document.createElement('input'));
|
const search = document.body.appendChild(document.createElement('input'));
|
||||||
search.focus();
|
search.focus();
|
||||||
store.publishRenderFailure(failure());
|
store.publishRenderFailure(failure());
|
||||||
@@ -118,9 +127,29 @@ test('focus goes to the map when the element it came from is gone', async (t) =>
|
|||||||
search.remove();
|
search.remove();
|
||||||
store.publishRenderFailure(null);
|
store.publishRenderFailure(null);
|
||||||
await settle();
|
await settle();
|
||||||
const map = document.getElementById('cesiumContainer');
|
// The map's keys, Enter to finish a drawing among them, read the canvas
|
||||||
assert.equal(document.activeElement, map);
|
// as the map (src/annotations/drawTool.js).
|
||||||
assert.equal(map.getAttribute('tabindex'), '-1');
|
assert.equal(document.activeElement, canvas);
|
||||||
|
assert.equal(canvas.getAttribute('tabindex'), '-1');
|
||||||
|
canvas.blur();
|
||||||
|
assert.equal(canvas.hasAttribute('tabindex'), false, 'only this once');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('focus goes to the globe canvas when the element it came from cannot take it', async (t) => {
|
||||||
|
const store = mount(t);
|
||||||
|
const canvas = addGlobeCanvas();
|
||||||
|
const button = document.body.appendChild(document.createElement('button'));
|
||||||
|
button.focus();
|
||||||
|
store.publishRenderFailure(failure());
|
||||||
|
await settle();
|
||||||
|
button.disabled = true;
|
||||||
|
store.publishRenderFailure(null);
|
||||||
|
await settle();
|
||||||
|
assert.equal(document.activeElement, canvas);
|
||||||
|
// A press on the globe leaves focus on the page again, as Cesium has it.
|
||||||
|
canvas.dispatchEvent(new Event('pointerdown'));
|
||||||
|
assert.equal(document.activeElement, document.body);
|
||||||
|
assert.equal(canvas.hasAttribute('tabindex'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('focus the user moved while the notice was up stays put', async (t) => {
|
test('focus the user moved while the notice was up stays put', async (t) => {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ const MESSAGES = {
|
|||||||
};
|
};
|
||||||
// Focus moves to the first action each time the notice appears, so a
|
// Focus moves to the first action each time the notice appears, so a
|
||||||
// keyboard user can act on it at once. When the globe draws again, it goes
|
// keyboard user can act on it at once. When the globe draws again, it goes
|
||||||
// back where it was, or to the map if that is gone, unless the user has moved
|
// back where it was, or to the map if that cannot take it, unless the user
|
||||||
// it since.
|
// has moved it since.
|
||||||
let focusBefore;
|
let focusBefore;
|
||||||
watch(
|
watch(
|
||||||
() => Boolean(failure.value),
|
() => Boolean(failure.value),
|
||||||
@@ -38,16 +38,43 @@ watch(
|
|||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
if (active && active !== document.body) return;
|
if (active && active !== document.body) return;
|
||||||
if (previous?.isConnected && previous !== document.body) {
|
if (previous?.isConnected && previous !== document.body) {
|
||||||
previous.focus();
|
previous.focus({ preventScroll: true });
|
||||||
return;
|
// A control disabled, hidden or made inert since does not take it.
|
||||||
|
if (document.activeElement === previous) return;
|
||||||
}
|
}
|
||||||
const map = document.getElementById('cesiumContainer');
|
focusMap();
|
||||||
if (!map) return;
|
|
||||||
if (!map.hasAttribute('tabindex')) map.tabIndex = -1;
|
|
||||||
map.focus();
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The globe's canvas, which the map's keys (Enter to finish a drawing, Space
|
||||||
|
// to talk) read as the map. It takes focus only this once: when focus moves
|
||||||
|
// on, or the globe is pressed, it goes back to a canvas that cannot take
|
||||||
|
// focus, and a press leaves focus on the page, as Cesium has it.
|
||||||
|
function focusMap() {
|
||||||
|
const canvas = document.querySelector(
|
||||||
|
'#cesiumContainer .cesium-widget canvas',
|
||||||
|
);
|
||||||
|
if (!canvas) return;
|
||||||
|
if (!canvas.hasAttribute('tabindex')) {
|
||||||
|
canvas.tabIndex = -1;
|
||||||
|
const done = new AbortController();
|
||||||
|
const release = () => {
|
||||||
|
done.abort();
|
||||||
|
canvas.removeAttribute('tabindex');
|
||||||
|
};
|
||||||
|
canvas.addEventListener('blur', release, { signal: done.signal });
|
||||||
|
canvas.addEventListener(
|
||||||
|
'pointerdown',
|
||||||
|
() => {
|
||||||
|
release();
|
||||||
|
canvas.blur();
|
||||||
|
},
|
||||||
|
{ capture: true, signal: done.signal },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
canvas.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
Reference in new issue
Block a user