@defra/interactive-map 0.0.43-alpha → 0.0.44-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api.md +16 -8
  7. package/docs/plugins/draw.md +1 -1
  8. package/package.json +1 -1
  9. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  10. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  11. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  12. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  13. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  14. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  15. package/plugins/datasets/dist/css/index.css +1 -1
  16. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  17. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  18. package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +1 -0
  19. package/plugins/datasets/src/initialise/DatasetsInit.jsx +2 -2
  20. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +6 -5
  21. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  22. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  23. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  24. package/plugins/draw/dist/esm/index.js +1 -1
  25. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
  26. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  27. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  28. package/plugins/draw/dist/umd/index.js +1 -1
  29. package/plugins/draw/src/DrawInit.jsx +31 -26
  30. package/plugins/draw/src/DrawInit.test.jsx +55 -12
  31. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +113 -114
  32. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +82 -9
  33. package/plugins/draw/src/adapters/maplibre/drawEvents.js +3 -1
  34. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +1 -1
  35. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +18 -9
  36. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +9 -1
  37. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +11 -1
  38. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +8 -2
  39. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +35 -0
  40. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +29 -19
  41. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +17 -2
  42. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +1 -1
  43. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +35 -29
  44. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +24 -6
  45. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +22 -20
  46. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +1 -1
  47. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +2 -2
  48. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +3 -3
  49. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +31 -0
  50. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +8 -1
  51. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +38 -30
  52. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +20 -3
  53. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +5 -5
  54. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +2 -2
  55. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +2 -2
  56. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +3 -3
  57. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -7
  58. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -4
  59. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +14 -14
  60. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +12 -32
  61. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +10 -10
  62. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +48 -22
  63. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +74 -6
  64. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +2 -2
  65. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +6 -0
  66. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +9 -0
  67. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +15 -2
  68. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +27 -1
  69. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +1 -1
  70. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +31 -17
  71. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +55 -1
  72. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +13 -7
  73. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +7 -15
  74. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +36 -4
  75. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +41 -1
  76. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +35 -25
  77. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +40 -2
  78. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
  79. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
  80. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +32 -57
  81. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +14 -13
  82. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +1 -1
  83. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +17 -1
  84. package/plugins/draw/src/events.js +4 -4
  85. package/plugins/draw/src/events.test.js +1 -1
  86. package/plugins/draw/src/hooks/useSpatialList.js +180 -0
  87. package/plugins/draw/src/hooks/useSpatialList.test.js +339 -0
  88. package/plugins/draw/src/utils/spatial.js +1 -31
  89. package/plugins/draw/src/utils/spatial.test.js +1 -24
  90. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  91. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  92. package/plugins/interact/dist/umd/index.js +1 -1
  93. package/plugins/interact/src/InteractInit.jsx +2 -2
  94. package/plugins/interact/src/InteractInit.test.js +3 -3
  95. package/plugins/interact/src/events.js +5 -0
  96. package/plugins/interact/src/hooks/useAttachEvents.js +20 -0
  97. package/plugins/interact/src/hooks/useAttachEvents.test.js +44 -1
  98. package/plugins/interact/src/hooks/useCrossHairVisibility.js +4 -4
  99. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +7 -7
  100. package/plugins/interact/src/hooks/{useMapItemList.js → useSpatialList.js} +87 -52
  101. package/plugins/interact/src/hooks/{useMapItemList.test.js → useSpatialList.test.js} +152 -71
  102. package/plugins/interact/src/manifest.js +12 -1
  103. package/plugins/interact/src/manifest.test.js +20 -0
  104. package/plugins/interact/src/utils/spatial.js +1 -1
  105. package/providers/beta/openlayers/src/utils/queryFeatures.js +1 -1
  106. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  107. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  108. package/providers/maplibre/dist/umd/index.js +1 -1
  109. package/providers/maplibre/src/utils/labels.js +2 -2
  110. package/providers/maplibre/src/utils/labels.test.js +5 -5
  111. package/providers/maplibre/src/utils/spatial.js +0 -57
  112. package/providers/maplibre/src/utils/spatial.test.js +0 -27
  113. package/src/App/components/CrossHair/CrossHair.jsx +10 -4
  114. package/src/App/components/CrossHair/CrossHair.module.scss +16 -12
  115. package/src/App/components/CrossHair/CrossHair.test.jsx +9 -4
  116. package/src/App/components/Hints/Hints.jsx +3 -3
  117. package/src/App/components/Hints/Hints.test.jsx +3 -3
  118. package/src/App/components/MapButton/MapButton.module.scss +1 -2
  119. package/src/App/components/{MoveControls/MoveControls.jsx → MapControls/MapControls.jsx} +10 -10
  120. package/src/App/components/{MoveControls/MoveControls.module.scss → MapControls/MapControls.module.scss} +25 -25
  121. package/src/App/components/{MoveControls/MoveControls.test.jsx → MapControls/MapControls.test.jsx} +48 -48
  122. package/src/App/components/Markers/Markers.jsx +2 -2
  123. package/src/App/components/Markers/Markers.test.jsx +3 -3
  124. package/src/App/components/Markers/SymbolMarker.test.jsx +2 -2
  125. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  126. package/src/App/components/Viewport/SpatialList.jsx +47 -0
  127. package/src/App/components/Viewport/{Features.module.scss → SpatialList.module.scss} +3 -3
  128. package/src/App/components/Viewport/{Features.test.jsx → SpatialList.test.jsx} +60 -43
  129. package/src/App/components/Viewport/Viewport.jsx +20 -10
  130. package/src/App/components/Viewport/Viewport.test.jsx +18 -2
  131. package/src/App/hooks/useCrossHairAPI.js +5 -5
  132. package/src/App/hooks/useCrossHairAPI.test.js +5 -5
  133. package/src/App/hooks/useHintsAPI.js +5 -5
  134. package/src/App/hooks/useSpatialListFocus.js +296 -0
  135. package/src/App/hooks/{useFeatureFocus.test.js → useSpatialListFocus.test.js} +318 -163
  136. package/src/App/hooks/useSpatialListItems.js +51 -0
  137. package/src/App/hooks/useSpatialListItems.test.js +217 -0
  138. package/src/App/initialiseApp.js +6 -3
  139. package/src/App/initialiseApp.test.js +11 -0
  140. package/src/App/registry/spatialListRegistry.js +104 -0
  141. package/src/App/registry/spatialListRegistry.test.js +218 -0
  142. package/src/App/store/AppProvider.jsx +3 -2
  143. package/src/config/appConfig.js +14 -14
  144. package/src/config/appConfig.test.js +19 -19
  145. package/src/config/defaults.js +2 -2
  146. package/src/config/events.js +8 -8
  147. package/src/scss/main.scss +2 -2
  148. package/src/test-utils.js +8 -0
  149. package/src/types.js +11 -9
  150. package/src/utils/findNearestItemInDirection.js +29 -0
  151. package/src/utils/findNearestItemInDirection.test.js +67 -0
  152. package/src/utils/globalAltShortcuts.js +12 -0
  153. package/src/utils/globalAltShortcuts.test.js +27 -0
  154. package/src/utils/spatialNavigate.js +49 -0
  155. package/src/utils/spatialNavigate.test.js +42 -0
  156. package/src/App/components/Viewport/Features.jsx +0 -49
  157. package/src/App/hooks/useFeatureFocus.js +0 -257
  158. package/src/App/hooks/useFeatureItems.js +0 -39
  159. package/src/App/hooks/useFeatureItems.test.js +0 -155
@@ -84,7 +84,14 @@ const tryClose = ({ drawInteraction, canFinish, geom, sketchCoords, coord, lastP
84
84
  *
85
85
  * @returns {{ placeVertex, updateRubberbanding, clearLastCoord }}
86
86
  */
87
- export const createVertexPlacement = ({ drawInteraction, mapProvider, snap, canFinish, canPlace, getInterfaceType }) => {
87
+ // Both snappedCenter and updateRubberbanding are only ever reached via the crosshair path
88
+ // (Enter, the add-vertex button, or the crosshair's own click) — never a real mouse click,
89
+ // which goes through OL's own Draw interaction and its separate snap interaction instead.
90
+ // So there's no interfaceType to gate on here: whenever these run, the target is by
91
+ // definition the crosshair, and snap (when available) should always apply. Callers decide
92
+ // *when* to call updateRubberbanding at all (see draw/drawInput.js's onCenterChange etc.),
93
+ // not this file.
94
+ export const createVertexPlacement = ({ drawInteraction, mapProvider, snap, canFinish, canPlace }) => {
88
95
  let sketchFeature = null
89
96
  let lastPlacedCoord = null
90
97
 
@@ -92,21 +99,20 @@ export const createVertexPlacement = ({ drawInteraction, mapProvider, snap, canF
92
99
  sketchFeature = feature
93
100
  lastPlacedCoord = null
94
101
  }
95
- drawInteraction.on('drawstart', (e) => resetSketch(e.feature))
102
+ drawInteraction.on('drawstart', (event) => resetSketch(event.feature))
96
103
  drawInteraction.on('drawend', () => resetSketch())
97
104
  drawInteraction.on('drawabort', () => resetSketch())
98
105
 
99
106
  const snappedCenter = () => {
100
107
  const raw = mapProvider.getCenter()
101
- return (getInterfaceType() !== 'mouse' && snap) ? snap.apply(raw) : raw
108
+ return snap ? snap.apply(raw) : raw
102
109
  }
103
110
 
104
111
  const updateRubberbanding = () => {
105
112
  if (!sketchFeature) {
106
- // No sketch yet — update snap indicator at crosshair position so targets are
107
- // visible before the first vertex is placed (touch/keyboard only; mouse uses
108
- // the OL snap interaction's pointermove handler instead).
109
- if (getInterfaceType() !== 'mouse' && snap) {
113
+ // No sketch yet — update the snap indicator at the crosshair so targets are visible
114
+ // before the first vertex is placed.
115
+ if (snap) {
110
116
  snap.apply(mapProvider.getCenter())
111
117
  }
112
118
  return
@@ -6,7 +6,7 @@ import { createFakeMap, createEmitter, polygonFeature, lineFeature } from '../__
6
6
 
7
7
  const CENTER = [5, 5]
8
8
 
9
- const setup = ({ interfaceType = 'touch', snap = null, canFinish = () => true, canPlace } = {}) => {
9
+ const setup = ({ snap = null, canFinish = () => true, canPlace } = {}) => {
10
10
  const map = createFakeMap()
11
11
  const bus = createEmitter()
12
12
  const drawInteraction = {
@@ -21,8 +21,7 @@ const setup = ({ interfaceType = 'touch', snap = null, canFinish = () => true, c
21
21
  mapProvider: { getCenter: () => CENTER },
22
22
  snap,
23
23
  canFinish,
24
- canPlace,
25
- getInterfaceType: () => interfaceType
24
+ canPlace
26
25
  })
27
26
  const startSketch = (feature) => { bus.emit('drawstart', { feature }); return feature }
28
27
  return { drawInteraction, placement, startSketch, bus }
@@ -59,13 +58,6 @@ describe('placing vertices', () => {
59
58
  expect(drawInteraction.finishDrawing).toHaveBeenCalledTimes(1)
60
59
  })
61
60
 
62
- test('the mouse interface ignores snapping (the OL snap interaction covers it)', () => {
63
- const snap = { apply: jest.fn(() => [9, 9]), hideIndicator: jest.fn() }
64
- const { placement, drawInteraction } = setup({ interfaceType: 'mouse', snap })
65
- placement.placeVertex()
66
- expect(drawInteraction.appendCoordinates).toHaveBeenCalledWith([CENTER])
67
- })
68
-
69
61
  test('a second tap at the same spot finishes the shape instead of duplicating — when finishable', () => {
70
62
  const { placement, drawInteraction, startSketch } = setup()
71
63
  startSketch(lineFeature([[0, 0], [50, 50]]))
@@ -152,16 +144,16 @@ describe('rubber-banding', () => {
152
144
  expect(feature.getGeometry().getCoordinates()[1]).toEqual(hole) // inner rings untouched
153
145
  })
154
146
 
155
- test('without a sketch it only refreshes the snap indicator at the crosshair (non-mouse)', () => {
147
+ test('without a sketch it refreshes the snap indicator at the crosshair — the caller decides when it\'s worth calling this at all', () => {
156
148
  const snap = { apply: jest.fn((c) => c), hideIndicator: jest.fn() }
157
149
  const { placement } = setup({ snap })
158
150
  placement.updateRubberbanding()
159
151
  expect(snap.apply).toHaveBeenCalledWith(CENTER)
152
+ })
160
153
 
161
- const mouse = setup({ interfaceType: 'mouse', snap })
162
- snap.apply.mockClear()
163
- mouse.placement.updateRubberbanding()
164
- expect(snap.apply).not.toHaveBeenCalled()
154
+ test('without a sketch and no snap manager, updateRubberbanding is a harmless no-op', () => {
155
+ const { placement } = setup()
156
+ expect(() => placement.updateRubberbanding()).not.toThrow()
165
157
  })
166
158
 
167
159
  test('an empty sketch geometry is left alone', () => {
@@ -258,8 +258,8 @@ const wireMapSync = ({ map, manager, layers, selection, touchHandler, live }) =>
258
258
  }
259
259
 
260
260
  // The mode interface consumed by OLDrawManager
261
- const buildModeApi = ({ manager, store, olFeature, originalFeatureStyle, selection, actions, parts }) => {
262
- const { state } = selection
261
+ const buildModeApi = ({ manager, store, olFeature, originalFeatureStyle, selection, actions, parts, undoStack }) => {
262
+ const { state, setState, syncGeom, emitGeometryValidation } = selection
263
263
  const { touchHandler } = parts
264
264
 
265
265
  return {
@@ -291,10 +291,41 @@ const buildModeApi = ({ manager, store, olFeature, originalFeatureStyle, selecti
291
291
 
292
292
  undo: actions.doUndo,
293
293
  deleteVertex: actions.doDeleteVertex,
294
- // MoveControls' D-pad, routed here via mapProvider.activeMoveTarget (see
294
+ // MapControls' D-pad, routed here via mapProvider.activeMoveTarget (see
295
295
  // events.js) once a vertex is selected.
296
296
  nudgeSelectedVertex: parts.keyboardHandler.nudgeByDelta,
297
297
 
298
+ // Read-only vertex/midpoint coordinates for the shared spatial listbox
299
+ // (plugins/draw/src/hooks/useSpatialList.js) — already kept live and correctly indexed
300
+ // by syncGeom()/updateLayersFromGeom() (selectionState.js), so nothing to recompute here.
301
+ getVertexItems: () => ({ vertices: state.vertices, midpoints: state.midpoints }),
302
+
303
+ // Moves the live cursor to a vertex or midpoint by flat index — preview only, no geometry
304
+ // change — for the spatial listbox's roving-tabindex move. Same shape as the local
305
+ // selectVertex closure in wireTouchHandler above, promoted here since buildModeApi needs
306
+ // its own entry point too.
307
+ selectVertex (index) {
308
+ setState({ selectedVertexIndex: index, selectedVertexType: index < state.vertices.length ? VERTEX_TYPE : 'midpoint' })
309
+ touchHandler.updateTargetPosition()
310
+ },
311
+
312
+ // Commits a new vertex exactly at midpoint `index` (no directional offset) — the
313
+ // destructive counterpart to selectVertex's preview-only move, for the spatial listbox's
314
+ // Enter/Space confirm on a midpoint item. Mirrors onTap's own midpoint-insert handling
315
+ // above exactly (insertAtMidpoint already inserts with no offset — that's how tap-to-insert
316
+ // already works, no keyboard-style directional nudge to strip).
317
+ insertVertexAtMidpoint (index) {
318
+ const result = insertAtMidpoint(olFeature, state.midpoints, index, state.vertices.length)
319
+ if (!result) {
320
+ return
321
+ }
322
+ undoStack.push({ type: 'insert_vertex', vertexIndex: result.insertedIndex })
323
+ syncGeom()
324
+ emitGeometryValidation(INSERT_VERTEX, result.insertedIndex)
325
+ setState({ selectedVertexIndex: result.insertedIndex, selectedVertexType: VERTEX_TYPE })
326
+ touchHandler.updateTargetPosition()
327
+ },
328
+
298
329
  destroy () {
299
330
  parts.live.destroy()
300
331
  olFeature.setStyle(originalFeatureStyle)
@@ -312,7 +343,7 @@ const buildModeApi = ({ manager, store, olFeature, originalFeatureStyle, selecti
312
343
  }
313
344
 
314
345
  /**
315
- * @returns {{ setInterfaceType, done, cancel, undo, deleteVertex, nudgeSelectedVertex, destroy } | null}
346
+ * @returns {{ setInterfaceType, done, cancel, undo, deleteVertex, nudgeSelectedVertex, getVertexItems, selectVertex, insertVertexAtMidpoint, destroy } | null}
316
347
  */
317
348
  export const createEditMode = ({ map, manager, options }) => {
318
349
  const { featureId, container, interfaceType, deleteVertexButtonId, snap } = options
@@ -380,6 +411,7 @@ export const createEditMode = ({ map, manager, options }) => {
380
411
  originalFeatureStyle,
381
412
  selection,
382
413
  actions,
414
+ undoStack,
383
415
  parts: { touchHandler, keyboardHandler, pointerHandlers, modify, mapSync, layers, live }
384
416
  })
385
417
  }
@@ -154,7 +154,7 @@ test('select via pointer, delete the vertex, then undo restores it', () => {
154
154
  mode.undo() // empty stack — no-op
155
155
  })
156
156
 
157
- test('nudgeSelectedVertex (MoveControls D-pad) moves the selected vertex and is undoable', () => {
157
+ test('nudgeSelectedVertex (MapControls D-pad) moves the selected vertex and is undoable', () => {
158
158
  const { container, manager, mode, ring } = setup()
159
159
  container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 }))
160
160
  mode.nudgeSelectedVertex(1, 0, true)
@@ -165,6 +165,46 @@ test('nudgeSelectedVertex (MoveControls D-pad) moves the selected vertex and is
165
165
  expect(ring()[1]).toEqual([100, 0])
166
166
  })
167
167
 
168
+ // getVertexItems/selectVertex/insertVertexAtMidpoint feed the shared spatial listbox
169
+ // (plugins/draw/src/hooks/useSpatialList.js).
170
+ describe('getVertexItems / selectVertex / insertVertexAtMidpoint (spatial listbox bridge)', () => {
171
+ test('getVertexItems reads the live vertices/midpoints kept in sync by selectionState', () => {
172
+ const { mode } = setup()
173
+ const { vertices, midpoints } = mode.getVertexItems()
174
+ expect(vertices).toEqual([[0, 0], [100, 0], [100, 100], [0, 100]])
175
+ expect(midpoints).toHaveLength(4)
176
+ })
177
+
178
+ test('selectVertex moves the cursor to a real vertex, without changing the geometry', () => {
179
+ const { manager, mode, ring } = setup()
180
+ mode.selectVertex(1)
181
+ expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, expect.objectContaining({ index: 1 }))
182
+ expect(ring()).toHaveLength(5) // unchanged
183
+ })
184
+
185
+ test('selectVertex moves the cursor to a midpoint (flat index past the real vertices) — reports index: -1', () => {
186
+ const { manager, mode } = setup()
187
+ mode.selectVertex(4) // first midpoint, between vertex 0 and 1
188
+ expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, expect.objectContaining({ index: -1 }))
189
+ })
190
+
191
+ test('insertVertexAtMidpoint commits a new vertex exactly at the midpoint, selects it, and is undoable', () => {
192
+ const { manager, mode, ring } = setup()
193
+ mode.insertVertexAtMidpoint(4) // first midpoint, between vertex 0 and 1
194
+ expect(ring()).toHaveLength(6)
195
+ expect(ring()[1]).toEqual([50, 0]) // exactly the midpoint — no directional offset
196
+ expect(manager.undoStack.pop()).toEqual({ type: 'insert_vertex', vertexIndex: 1 })
197
+ expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, expect.objectContaining({ index: 1 }))
198
+ })
199
+
200
+ test('insertVertexAtMidpoint is a no-op for an out-of-range index', () => {
201
+ const { manager, mode, ring } = setup()
202
+ mode.insertVertexAtMidpoint(999)
203
+ expect(ring()).toHaveLength(5)
204
+ expect(manager.undoStack.length).toBe(0)
205
+ })
206
+ })
207
+
168
208
  test('undo re-validates with the inverse change phase (undo of a delete re-inserts)', () => {
169
209
  jest.useFakeTimers()
170
210
  const { container, manager, mode } = setup()
@@ -1,5 +1,6 @@
1
1
  import { coordToPixel } from '../utils/olCoords.js'
2
- import { spatialNavigate } from '../../../utils/spatial.js'
2
+ import { spatialNavigate } from '../../../../../../src/utils/spatialNavigate.js'
3
+ import { stopIfGlobalAltKey } from '../../../../../../src/utils/globalAltShortcuts.js'
3
4
  import { wireNudge } from './nudge.js'
4
5
 
5
6
  const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'])
@@ -54,34 +55,34 @@ const isInteractiveElementFocused = (appViewport) => {
54
55
  }
55
56
 
56
57
  const buildKeydownHandler = ({ map, getState, setState, nudge, keyMove, onUndo, onKeyboardActive, isFocused }) => {
57
- const handleArrowKey = (e) => {
58
- if (e.altKey) {
59
- e.preventDefault()
60
- e.stopPropagation()
61
- navigateTo(e.key, map, getState, setState)
58
+ const handleArrowKey = (event) => {
59
+ if (event.altKey) {
60
+ event.preventDefault()
61
+ event.stopPropagation()
62
+ navigateTo(event.key, map, getState, setState)
62
63
  } else if (getState().selectedVertexIndex >= 0) {
63
- e.preventDefault()
64
- e.stopPropagation()
65
- nudge(e)
64
+ event.preventDefault()
65
+ event.stopPropagation()
66
+ nudge(event)
66
67
  } else {
67
68
  // No action: arrow with no selection and no alt modifier
68
69
  }
69
70
  }
70
71
 
71
- const handleKey = (e) => {
72
+ const handleKey = (event) => {
72
73
  onKeyboardActive?.()
73
- if (e.key === ' ') {
74
- e.preventDefault()
74
+ if (event.key === ' ') {
75
+ event.preventDefault()
75
76
  if (getState().selectedVertexIndex < 0) {
76
77
  selectNearest(map, getState, setState)
77
78
  }
78
- } else if (ARROW_KEYS.has(e.key)) {
79
- handleArrowKey(e)
80
- } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
79
+ } else if (ARROW_KEYS.has(event.key)) {
80
+ handleArrowKey(event)
81
+ } else if (event.key === 'z' && (event.metaKey || event.ctrlKey)) {
81
82
  const tag = document.activeElement?.tagName
82
83
  if (!INTERACTIVE_TAGS.has(tag)) {
83
- e.preventDefault()
84
- e.stopPropagation()
84
+ event.preventDefault()
85
+ event.stopPropagation()
85
86
  onUndo()
86
87
  }
87
88
  } else {
@@ -89,33 +90,42 @@ const buildKeydownHandler = ({ map, getState, setState, nudge, keyMove, onUndo,
89
90
  }
90
91
  }
91
92
 
92
- return (e) => {
93
+ return (event) => {
93
94
  if (isFocused()) {
94
95
  return
95
96
  }
96
- if (e.key === 'Escape' && getState().selectedVertexIndex >= 0) {
97
- e.preventDefault()
97
+ if (event.key === 'Escape' && getState().selectedVertexIndex >= 0) {
98
+ event.preventDefault()
98
99
  keyMove.start = null
99
100
  keyMove.index = null
100
101
  setState({ selectedVertexIndex: -1, selectedVertexType: null })
101
102
  } else {
102
- handleKey(e)
103
+ handleKey(event)
103
104
  }
104
105
  }
105
106
  }
106
107
 
107
- const buildKeyupHandler = ({ keyMove, onVertexMoved, onDeleted, isFocused }) => (e) => {
108
+ const buildKeyupHandler = ({ getState, keyMove, onVertexMoved, onDeleted, isFocused }) => (event) => {
108
109
  if (isFocused()) {
109
110
  return
110
111
  }
111
- if (ARROW_KEYS.has(e.key) && keyMove.start && keyMove.index != null) {
112
+ // Registered with capture:true below, so this runs first — shadows global Alt+<key>
113
+ // shortcuts (src/utils/globalAltShortcuts.js) only while something is actually selected —
114
+ // the same condition navigateTo/nudge already require locally. edit_point's state always
115
+ // reports selectedVertexIndex: 0 (a point is "always selected" — see pointSelectionState.js),
116
+ // so this naturally stays unconditional for that mode while genuinely gating on selection
117
+ // for edit_vertex, without needing to special-case either.
118
+ if (getState().selectedVertexIndex >= 0) {
119
+ stopIfGlobalAltKey(event)
120
+ }
121
+ if (ARROW_KEYS.has(event.key) && keyMove.start && keyMove.index != null) {
112
122
  // Not hiding the snap indicator here — nudge.js's own snap.apply() already left it showing
113
123
  // correctly, and it should stay that way after the key is released.
114
124
  onVertexMoved({ vertexIndex: keyMove.index, previousCoord: keyMove.start })
115
125
  keyMove.start = null
116
126
  keyMove.index = null
117
127
  }
118
- if (e.key === 'Delete') {
128
+ if (event.key === 'Delete') {
119
129
  onDeleted()
120
130
  }
121
131
  }
@@ -146,7 +156,7 @@ export const createKeyboardHandler = (options) => {
146
156
  const isFocused = () => isInteractiveElementFocused(appViewport)
147
157
 
148
158
  const onKeydown = buildKeydownHandler({ map, getState, setState, nudge, keyMove, onUndo, onKeyboardActive, isFocused })
149
- const onKeyup = buildKeyupHandler({ keyMove, onVertexMoved, onDeleted, isFocused })
159
+ const onKeyup = buildKeyupHandler({ getState, keyMove, onVertexMoved, onDeleted, isFocused })
150
160
 
151
161
  globalThis.addEventListener('keydown', onKeydown, { capture: true })
152
162
  globalThis.addEventListener('keyup', onKeyup, { capture: true })
@@ -27,13 +27,13 @@ const setup = () => {
27
27
 
28
28
  const liveHandlers = []
29
29
  afterEach(() => {
30
- liveHandlers.splice(0).forEach((h) => h.destroy())
30
+ liveHandlers.splice(0).forEach((handler) => handler.destroy())
31
31
  document.body.innerHTML = ''
32
32
  })
33
33
 
34
34
  const key = (type, props) => window.dispatchEvent(new KeyboardEvent(type, { cancelable: true, ...props }))
35
35
 
36
- test('nudgeByDelta (exposed for MoveControls) moves the selected vertex and reports it via onVertexMoved', () => {
36
+ test('nudgeByDelta (exposed for MapControls) moves the selected vertex and reports it via onVertexMoved', () => {
37
37
  const { state, handler, onVertexMoved } = setup()
38
38
  Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' })
39
39
  handler.nudgeByDelta(1, 0, true)
@@ -178,6 +178,44 @@ test('Delete deletes, ctrl/cmd+z undoes — but not while typing in an input ins
178
178
  expect(onUndo).toHaveBeenCalledTimes(1)
179
179
  })
180
180
 
181
+ test('Alt+Enter keyup stops propagation while a vertex is selected — never reaches the app-wide "highlight label at center" shortcut', () => {
182
+ const { state } = setup()
183
+ state.selectedVertexIndex = 1
184
+ const event = new KeyboardEvent('keyup', { key: 'Enter', altKey: true, cancelable: true, bubbles: true })
185
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
186
+ window.dispatchEvent(event)
187
+ expect(stopSpy).toHaveBeenCalled()
188
+ })
189
+
190
+ test('Alt+Arrow keyup stops propagation too, while a vertex is selected — never reaches the app-wide "highlight next label" shortcut', () => {
191
+ const { state } = setup()
192
+ state.selectedVertexIndex = 1
193
+ const event = new KeyboardEvent('keyup', { key: 'ArrowRight', altKey: true, cancelable: true, bubbles: true })
194
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
195
+ window.dispatchEvent(event)
196
+ expect(stopSpy).toHaveBeenCalled()
197
+ })
198
+
199
+ test('leaves Alt+Enter/Alt+Arrow alone with nothing selected — no local meaning to protect, so map-label selection still works', () => {
200
+ setup() // default state.selectedVertexIndex is -1 — nothing selected
201
+ const enterEvent = new KeyboardEvent('keyup', { key: 'Enter', altKey: true, cancelable: true, bubbles: true })
202
+ const arrowEvent = new KeyboardEvent('keyup', { key: 'ArrowRight', altKey: true, cancelable: true, bubbles: true })
203
+ const enterSpy = jest.spyOn(enterEvent, 'stopPropagation')
204
+ const arrowSpy = jest.spyOn(arrowEvent, 'stopPropagation')
205
+ window.dispatchEvent(enterEvent)
206
+ window.dispatchEvent(arrowEvent)
207
+ expect(enterSpy).not.toHaveBeenCalled()
208
+ expect(arrowSpy).not.toHaveBeenCalled()
209
+ })
210
+
211
+ test('a plain Enter keyup (no Alt) is left alone', () => {
212
+ setup()
213
+ const event = new KeyboardEvent('keyup', { key: 'Enter', altKey: false, cancelable: true, bubbles: true })
214
+ const stopSpy = jest.spyOn(event, 'stopPropagation')
215
+ window.dispatchEvent(event)
216
+ expect(stopSpy).not.toHaveBeenCalled()
217
+ })
218
+
181
219
  test('keys are ignored while an interactive element outside the viewport has focus', () => {
182
220
  const { onDeleted, onKeyboardActive } = setup()
183
221
  const button = document.createElement('button')
@@ -106,12 +106,12 @@ export const wireNudge = ({ map, snap, getState, setState, onInserted, onVertexM
106
106
  moveSelectedVertexBy(dx, dy)
107
107
  }
108
108
 
109
- // Explicit-delta counterpart to nudge(e) — the entry point for MoveControls'
109
+ // Explicit-delta counterpart to nudge(e) — the entry point for MapControls'
110
110
  // D-pad (see mapProvider.activeMoveTarget in events.js). Unlike nudge(e)'s
111
111
  // held-key sequencing (undo pushed on keyup via keyMove, so repeated keydowns
112
112
  // while held batch into one undo step), each call here is one complete,
113
113
  // undoable action — a button click has no "held" state to batch. Midpoints
114
- // aren't handled here: MoveControls only claims the D-pad once a real vertex is
114
+ // aren't handled here: MapControls only claims the D-pad once a real vertex is
115
115
  // selected (see buildVertexMoveTarget in events.js).
116
116
  const nudgeByDelta = (dx, dy, isLargeStep) => {
117
117
  const step = isLargeStep ? KEYBOARD.stepAmount : KEYBOARD.nudgeAmount
@@ -53,7 +53,7 @@ describe('nudging a vertex', () => {
53
53
  })
54
54
  })
55
55
 
56
- describe('nudgeByDelta (MoveControls D-pad)', () => {
56
+ describe('nudgeByDelta (MapControls D-pad)', () => {
57
57
  test('moves the selected vertex by an explicit direction, scaled by isLargeStep, and reports the move for undo', () => {
58
58
  const { state, nudgeByDelta, olFeature, onVertexMoved } = setup()
59
59
  Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' })
@@ -79,7 +79,7 @@ describe('nudgeByDelta (MoveControls D-pad)', () => {
79
79
  const { state, nudgeByDelta, onVertexMoved } = setup()
80
80
  nudgeByDelta(1, 0, true) // nothing selected
81
81
  Object.assign(state, { selectedVertexIndex: 4, selectedVertexType: 'midpoint' })
82
- nudgeByDelta(1, 0, true) // midpoint selected — MoveControls only claims a real vertex
82
+ nudgeByDelta(1, 0, true) // midpoint selected — MapControls only claims a real vertex
83
83
  state.olFeature = null
84
84
  Object.assign(state, { selectedVertexIndex: 1, selectedVertexType: 'vertex' })
85
85
  nudgeByDelta(1, 0, true)
@@ -7,10 +7,8 @@ import { TOLERANCES } from '../defaults.js'
7
7
 
8
8
  const MODE = 'draw_point'
9
9
 
10
- // A point has no ring/rubber-band to build a candidate from just the coordinate about to
11
- // be placed. Mirrors validateGeometry.js's attemptPlacement, but that helper hardcodes
12
- // Polygon/LineString via MODE_BY_GEOMETRY, so a Point candidate is built and validated
13
- // directly (same approach as the MapLibre adapter's drawPointMode.js).
10
+ // A point has no ring/rubber-band to build a candidate from, so it's built and validated
11
+ // directly rather than via validateGeometry.js's Polygon/LineString-only attemptPlacement.
14
12
  const canPlaceVertex = (manager) => (coordinate) => {
15
13
  const candidate = { type: 'Feature', geometry: { type: 'Point', coordinates: coordinate }, properties: {} }
16
14
  const { valid, reason } = validatePlacement(
@@ -24,14 +22,9 @@ const canPlaceVertex = (manager) => (coordinate) => {
24
22
  return valid
25
23
  }
26
24
 
27
- // Commit a finished point to the store under the requested id and emit CREATE — mirrors
28
- // draw/DrawMode.js's finalizeDrawnFeature (identical shape, kept separate rather than
29
- // imported since draw/DrawMode.js isn't otherwise a shared dependency of this mode).
30
- // Symbol resolution runs after the feature is in the store (not before), matching the
31
- // MapLibre adapter's own ordering. The resolver itself is injected by OLDrawManager via
32
- // changeMode's options (mirrors the ML adapter's state.resolvePointSymbol convention — see
33
- // MaplibreDrawAdapter.js's changeMode) so this mode has no direct dependency on
34
- // symbolRegistry/mapProvider/pointSymbolImages.js.
25
+ // Commit a finished point to the store under the requested id and emit CREATE. Symbol
26
+ // resolution runs after the feature is in the store, and the resolver itself is injected by
27
+ // OLDrawManager (mirrors the MapLibre adapter's own ordering and resolvePointSymbol convention).
35
28
  const finalizeDrawnFeature = (manager, resolvePointSymbol, olFeature, featureId, properties) => {
36
29
  olFeature.setId(String(featureId))
37
30
  olFeature.setProperties(properties)
@@ -41,45 +34,41 @@ const finalizeDrawnFeature = (manager, resolvePointSymbol, olFeature, featureId,
41
34
  }
42
35
 
43
36
  /**
44
- * Touch/keyboard/"Add point" input for draw_point — the same concerns as
45
- * draw/drawInput.js (crosshair, interface-type tracking, add-vertex-button/Enter), reused
46
- * directly, but with a much smaller placement step: a point has no rubber band to update
47
- * while positioning (the crosshair itself already shows where it'll land) and nothing to
48
- * undo before a single-click commit, so those callbacks are no-ops here.
37
+ * Touch/keyboard/"Add point" input for draw_point — reuses draw/drawInput.js's crosshair and
38
+ * interface-type wiring, but with no rubber band or undo since a point commits on one click.
49
39
  */
50
40
  const buildPointInput = ({ drawInteraction, options, canPlace }) => {
51
41
  const { container, addVertexButtonId, mapProvider, snap, crossHair } = options
52
42
  let interfaceType = options.interfaceType ?? 'mouse'
53
43
  const getInterfaceType = () => interfaceType
54
44
 
55
- // appendCoordinates() only builds up LineString/Polygon rings — for a Point-type Draw
56
- // interaction it does nothing beyond starting the sketch (confirmed by reading OL's own
57
- // source), so finishDrawing() has to be called explicitly right after to actually commit.
45
+ // appendCoordinates() only starts the sketch for a Point-type Draw interaction, so
46
+ // finishDrawing() must be called explicitly to commit. Only ever reached via the crosshair
47
+ // path (Enter, the add-vertex button, or the crosshair's own click), never a real mouse
48
+ // click, so snap always applies here when available.
58
49
  const placePoint = () => {
59
50
  const raw = mapProvider.getCenter()
60
- const coord = (getInterfaceType() !== 'mouse' && snap) ? snap.apply(raw) : raw
51
+ const coord = snap ? snap.apply(raw) : raw
61
52
  snap?.hideIndicator()
62
53
  if (!canPlace(coord)) { return }
63
54
  drawInteraction.appendCoordinates([coord])
64
55
  drawInteraction.finishDrawing()
65
56
  }
66
57
 
67
- // Keeps the snap indicator visible at the crosshair while positioning via touch/keyboard
68
- // (mouse hover is handled by the separate OL snap interaction already attached by
69
- // OLDrawManager) the closest point-shaped equivalent of updateRubberbanding.
58
+ // Keeps the snap indicator visible at the crosshair the point-shaped equivalent of
59
+ // updateRubberbanding. Callers decide when this is worth calling (see applyInterfaceType
60
+ // below and draw/drawInput.js's onCenterChange).
70
61
  const updateSnapIndicator = () => {
71
- if (getInterfaceType() !== 'mouse' && snap) { snap.apply(mapProvider.getCenter()) }
62
+ if (snap) { snap.apply(mapProvider.getCenter()) }
72
63
  }
73
64
 
74
- // Mirrors draw/drawInput.js's createDrawInput: an interface-type switch to touch/keyboard
75
- // must refresh the snap indicator immediately at the crosshair, not wait for the next
76
- // pointermove/change:center otherwise it stays hidden until the user first pans or drags
77
- // after entering the mode (the same gap the MapLibre adapter's onInterfaceTypeChange already
78
- // guards against by calling onMove(state) right after _setInterface).
65
+ // Mirrors draw/drawInput.js's createDrawInput: a switch to touch/keyboard refreshes the
66
+ // snap indicator immediately rather than waiting for the next pan. Switching back to mouse
67
+ // has no crosshair target to refresh.
79
68
  const applyInterfaceType = (type) => {
80
69
  interfaceType = type
81
70
  applyCrossHairVisibility(crossHair, type)
82
- updateSnapIndicator()
71
+ if (type !== 'mouse') { updateSnapIndicator() }
83
72
  }
84
73
 
85
74
  const events = wireInputEvents({
@@ -96,10 +85,8 @@ const buildPointInput = ({ drawInteraction, options, canPlace }) => {
96
85
 
97
86
  applyCrossHairVisibility(crossHair, interfaceType)
98
87
 
99
- // Single shared "commit here" entry point for the crosshair button's own onClick
100
- // (CrossHair.jsx) — the same action Enter/the touch add-vertex button already trigger via
101
- // placeVertex, so a click (real, touch, or Voice Control's "Click Target") does exactly
102
- // what those already do.
88
+ // Shared "commit here" entry point for the crosshair button's own onClick — same action as
89
+ // Enter/the touch add-vertex button.
103
90
  if (crossHair) {
104
91
  crossHair.activate = placePoint
105
92
  }
@@ -115,21 +102,12 @@ const buildPointInput = ({ drawInteraction, options, canPlace }) => {
115
102
  }
116
103
 
117
104
  /**
118
- * Draw mode for placing a single point: mouse click, or crosshair + Enter/"Add point"
119
- * button for touch/keyboard. Commits immediately on the first placement unlike
120
- * draw_line/draw_polygon, there is no rubber band, no multi-vertex undo, and no "Done"
121
- * step (mirrors the MapLibre adapter's drawPointMode.js decision).
122
- *
123
- * OL's own Draw({type:'Point'}) interaction already finishes a real mouse click
124
- * immediately (confirmed by reading its handleUpEvent — mode 'Point' always attempts
125
- * finishDrawing() on pointer-up, no second click needed), so the mouse path needs no
126
- * extra wiring beyond the placement-veto condition every mode already has. Deliberately
127
- * NOT built via draw/DrawMode.js's createDrawMode()/createVertexPlacement() — those are
128
- * parametrised around a ring of placed vertices + a trailing rubber-band coordinate,
129
- * which a single-click commit doesn't have (mirrors the same decision already made for
130
- * the MapLibre adapter, and the OL EditMode/DrawMode split noted in
131
- * feedback_adapter_parity.md: don't force point through machinery shaped for lines/polygons
132
- * just because they happen to share a file).
105
+ * Draw mode for placing a single point: mouse click, or crosshair + Enter/"Add point" button
106
+ * for touch/keyboard. Commits immediately, with no rubber band, undo, or "Done" step. OL's own
107
+ * Draw({type:'Point'}) interaction already finishes a real mouse click on pointer-up, so the
108
+ * mouse path needs no extra wiring. Deliberately not built via draw/DrawMode.js's
109
+ * createDrawMode()/createVertexPlacement() — those assume a ring of vertices plus a trailing
110
+ * rubber-band coordinate, which a single-click commit doesn't have.
133
111
  */
134
112
  export const createDrawPointMode = ({ map, manager, options }) => {
135
113
  const { featureId, properties = {} } = options
@@ -137,18 +115,15 @@ export const createDrawPointMode = ({ map, manager, options }) => {
137
115
 
138
116
  const drawInteraction = new Draw({
139
117
  type: 'Point',
140
- // Without an explicit style, OL renders its own default sketch style (a blue circle)
141
- // following the cursor before commit core/styles.js's createSketchStyle() already
142
- // suppresses this same default for the "ghost" Point sketch OL renders alongside a
143
- // Polygon/LineString sketch (see its own comment); a real Point sketch needs the same
144
- // treatment, since it commits on the very first click with nothing meaningful to preview.
118
+ // Suppresses OL's default sketch style (a blue circle following the cursor) — nothing
119
+ // meaningful to preview since this commits on the first click.
145
120
  style: () => [],
146
121
  snapTolerance: TOLERANCES.snapRadius,
147
- condition: (e) => noModifierKeys(e) && canPlace(e.coordinate)
122
+ condition: (event) => noModifierKeys(event) && canPlace(event.coordinate)
148
123
  })
149
124
  map.addInteraction(drawInteraction)
150
125
 
151
- drawInteraction.on('drawend', (e) => finalizeDrawnFeature(manager, options.resolvePointSymbol, e.feature, featureId, properties))
126
+ drawInteraction.on('drawend', (event) => finalizeDrawnFeature(manager, options.resolvePointSymbol, event.feature, featureId, properties))
152
127
  drawInteraction.on('drawabort', () => { manager.emit(ADAPTER_EVENTS.CANCEL) })
153
128
 
154
129
  const input = buildPointInput({ drawInteraction, options, canPlace })