feat(pointer): an application's own tool can take the pointer

createApplicationTools returns pointer: claim(owner) takes the pointer as
Draw does and returns its lease, or null while another tool holds it;
release(lease) gives it back; isFree() lets the application's own click
handlers yield, as the layers' do. This replaces drawShape, which this
branch added: Draw is as it was.
This commit is contained in:
Sameh Khamis committed 2026-11-09 16:56:59 -08:00
1 parent 0600d86584
commit a7f96313e9
5 files changed
+18 -231

No files matched your search

+1 -65
View File
@@ -54,7 +54,6 @@ import {
DRAW_COLORS,
DRAW_COLOR_CSS,
drawMarkActions,
drawMarkGeometry,
drawMarkActionsFor,
drawMarkFallbackName,
presentDrawMarks,
@@ -121,9 +120,6 @@ export function initDrawTool({
let savedDoubleClick = null;
let savedSingleClick = null;
let cursor = null; // last mouse position on the canvas, for the rubber band
// One shape asked for by `drawShape`: `{ resolve, restoreShape, off }`. Its
// finish hands the geometry back instead of placing a mark.
let request = null;
// Bumped by anything that supersedes an in-flight finish: cancelling, clearing
// the board, leaving draw mode, teardown. An `annotate()` that resolves after
// one of those must not write its outcome over the newer state.
@@ -284,20 +280,6 @@ export function initDrawTool({
// Named afterwards, in the shape's row.
const spec = finishSpec(session, { label: '', color });
if (!spec) return null;
if (request) {
// As a row's actions get it: a shape across the antimeridian has none,
// and is drawn again rather than handed back wrapped round the world.
const geometry = drawMarkGeometry(spec);
if (!geometry) {
session = createDrawSession(shape);
cursor = null;
syncPreview();
setHint(() => t('That shape could not be placed.'));
return null;
}
endRequest(geometry);
return geometry;
}
// 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);
@@ -425,7 +407,6 @@ export function initDrawTool({
generation += 1;
session = null;
cursor = null;
endRequest(null);
releaseSceneHandler();
releasePointer(lease);
lease = null;
@@ -551,46 +532,6 @@ export function initDrawTool({
return true;
}
/**
* Close the asked-for shape with `geometry` (null when there is none): the
* shape chosen before it comes back, and draw mode ends.
*/
function endRequest(geometry) {
const open = request;
if (!open) return;
request = null;
open.off();
chooseShape(open.restoreShape);
open.resolve(geometry);
if (active) setActive(false);
}
/**
* Draw one area or line and hand back its geometry (a Polygon or a
* LineString) without placing a mark. Escape works as in Draw: the first
* clears a shape in progress, and one on an empty shape leaves. Resolves null
* when draw mode is left, `signal` aborts, or the pointer is taken.
* A second request ends the first with null.
* @param {{shape?: 'area'|'line', signal?: AbortSignal}} [options]
* @returns {Promise<{type: 'Polygon', coordinates: number[][][]}|{type: 'LineString', coordinates: number[][]}|null>}
*/
function drawShape({ shape: wanted = 'area', signal } = {}) {
if (destroyed || signal?.aborted) return Promise.resolve(null);
endRequest(null);
const restoreShape = shape;
return new Promise((resolve) => {
const abort = () => endRequest(null);
signal?.addEventListener('abort', abort, { once: true });
request = {
resolve,
restoreShape,
off: () => signal?.removeEventListener('abort', abort),
};
chooseShape(normalizeShape(wanted) === 'line' ? 'line' : 'area');
if (!active && !setActive(true)) endRequest(null);
});
}
/** Draw `next` from now on; an open shape starts over in it. */
function chooseShape(next) {
shape = normalizeShape(next);
@@ -604,10 +545,7 @@ export function initDrawTool({
}
const disconnect = connect({
toggleDraw: () => setActive(!active),
// An asked-for shape is the one asked for.
setDrawShape: (next) => {
if (!request) chooseShape(next);
},
setDrawShape: chooseShape,
setDrawColor: (value) => {
color = DRAW_COLORS.includes(value) ? value : 'primary';
present();
@@ -664,7 +602,6 @@ export function initDrawTool({
finish,
cancel,
clearAll,
drawShape,
shapes: DRAW_SHAPES,
/** What this tool currently holds — for teardown and leak assertions. */
diagnostics() {
@@ -701,7 +638,6 @@ export function initDrawTool({
if (destroyed) return attaching;
// Supersede any finish still in flight before anything is torn down.
generation += 1;
endRequest(null);
if (active) setActive(false);
destroyed = true;
offLocale();
-152
View File
@@ -704,158 +704,6 @@ test('what a row action answers is its row\'s note; the row is busy until then,
}
});
test('drawShape hands back the drawn area as a polygon and places no mark', async () => {
const h = harness();
try {
h.act('setDrawShape', 'line');
const drawn = h.tool.drawShape({ shape: 'area' });
assert.equal(h.tool.active, true, 'it opens draw mode');
assert.equal(h.tool.shape, 'area');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.tool.addVertex(-97.74, 30.27);
h.documentDouble.emit('keydown', {
key: 'Enter',
target: h.documentDouble.body,
});
const geometry = await drawn;
assert.deepEqual(geometry, {
type: 'Polygon',
coordinates: [
[
[-97.75, 30.26],
[-97.74, 30.26],
[-97.74, 30.27],
[-97.75, 30.26],
],
],
});
assert.equal(h.annotations.calls.annotate.length, 0, 'no mark is placed');
assert.equal(h.tool.active, false, 'draw mode ends');
assert.equal(isPointerFree(), true);
assert.equal(h.tool.shape, 'line', 'the shape chosen before comes back');
} finally {
h.restore();
}
});
test('drawShape hands back a line as a LineString', async () => {
const h = harness();
try {
const drawn = h.tool.drawShape({ shape: 'line' });
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', {
key: 'Enter',
target: h.documentDouble.body,
});
assert.deepEqual(await drawn, {
type: 'LineString',
coordinates: [
[-97.75, 30.26],
[-97.74, 30.26],
],
});
} finally {
h.restore();
}
});
test('drawShape resolves null on Escape, on abort, on a newer request and when the pointer is taken', async () => {
const h = harness();
try {
// Escape with a shape started clears it; a second Escape leaves.
const escaped = h.tool.drawShape({ shape: 'area' });
h.tool.addVertex(-97.75, 30.26);
h.documentDouble.emit('keydown', {
key: 'Escape',
target: h.documentDouble.body,
});
assert.equal(h.tool.active, true, 'the first Escape only clears the shape');
h.documentDouble.emit('keydown', {
key: 'Escape',
target: h.documentDouble.body,
});
assert.equal(await escaped, null);
assert.equal(h.tool.active, false);
const controller = new AbortController();
const aborted = h.tool.drawShape({
shape: 'area',
signal: controller.signal,
});
controller.abort();
assert.equal(await aborted, null);
assert.equal(isPointerFree(), true, 'an abort gives the pointer back');
const first = h.tool.drawShape({ shape: 'area' });
const second = h.tool.drawShape({ shape: 'line' });
assert.equal(await first, null, 'a newer request ends the older one');
h.act('toggleDraw');
assert.equal(await second, null, 'leaving draw mode ends it');
const done = new AbortController();
done.abort();
assert.equal(await h.tool.drawShape({ signal: done.signal }), null);
const other = claimPointer('directions');
assert.equal(
await h.tool.drawShape({ shape: 'area' }),
null,
'a held pointer refuses it',
);
releasePointer(other);
// Drawing by hand afterwards places marks as before.
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.tool.addVertex(-97.74, 30.27);
h.documentDouble.emit('keydown', {
key: 'Enter',
target: h.documentDouble.body,
});
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 1);
} finally {
h.restore();
}
});
test('drawShape keeps its shape, and a shape across the antimeridian is drawn again', async () => {
const h = harness();
try {
const drawn = h.tool.drawShape({ shape: 'line' });
h.act('setDrawShape', 'pin');
assert.equal(h.tool.shape, 'line', 'the controls cannot change the asked-for shape');
h.tool.addVertex(179.5, 10);
h.tool.addVertex(-179.5, 10);
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
assert.equal(h.tool.active, true, 'still drawing');
assert.equal(h.tool.session.vertices.length, 0, 'drawn again from the start');
assert.equal(h.shown.view.hint, 'That shape could not be placed.');
h.tool.addVertex(29, 41);
h.tool.addVertex(29.1, 41);
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
assert.equal((await drawn).type, 'LineString');
h.act('setDrawShape', 'pin');
assert.equal(h.tool.shape, 'pin', 'after it, the controls choose again');
} finally {
h.restore();
}
});
test('destroy ends a drawShape request with null', async () => {
const h = harness();
try {
const drawn = h.tool.drawShape({ shape: 'area' });
h.tool.destroy();
assert.equal(await drawn, null);
} finally {
h.restore();
}
});
test('Backspace on the page removes the last vertex', () => {
const h = harness();
try {
+13 -9
View File
@@ -1,7 +1,11 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import { isPointerFree } from '../data/inputOwnership.js';
import {
claimPointer,
isPointerFree,
releasePointer,
} from '../data/inputOwnership.js';
import { initGeoFileImport } from '../ui/geoFileImport.js';
import { useDisplayStore } from '../ui/stores/display.js';
import { applicationPinia } from '../ui/vueRuntime.js';
@@ -246,17 +250,17 @@ export function createApplicationTools({
signal,
}),
);
// Draw one area or line and get its geometry back, without placing a mark
// (annotations/drawTool.js `drawShape`); null without the Draw controls.
const drawShape = (options) =>
drawTool ? drawTool.drawShape(options) : Promise.resolve(null);
// An application's own click handlers yield while a tool holds the pointer,
// as the layers' do (data/inputOwnership.js).
// An application's own tool takes the pointer as Draw does, and its own
// click handlers yield while a tool holds it, as the layers' do
// (data/inputOwnership.js).
return {
sceneDirector,
annotations,
voiceCommands,
drawShape,
pointerFree: isPointerFree,
pointer: {
claim: claimPointer,
release: releasePointer,
isFree: isPointerFree,
},
};
}