@defra/interactive-map 0.0.43-fmp-experimental → 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 (275) hide show
  1. package/assets/templates/add-polygons.njk +5 -1
  2. package/assets/templates/add-symbols.njk +5 -1
  3. package/dist/css/index.css +1 -1
  4. package/dist/esm/im-core.js +1 -1
  5. package/dist/esm/im-shell.js +1 -1
  6. package/dist/umd/im-core.js +1 -1
  7. package/dist/umd/index.js +1 -1
  8. package/docs/api/context.md +24 -0
  9. package/docs/api/keyboard-shortcut-definition.md +102 -0
  10. package/docs/api.md +18 -8
  11. package/docs/building-a-plugin.md +1 -0
  12. package/docs/plugins/draw.md +2 -2
  13. package/docs/plugins/plugin-manifest.md +9 -0
  14. package/package.json +4 -2
  15. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  16. package/plugins/beta/draw-es/src/api/newPolygon.js +1 -0
  17. package/plugins/beta/draw-es/src/events.js +17 -8
  18. package/plugins/beta/draw-es/src/reducer.js +8 -1
  19. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  20. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  21. package/plugins/beta/draw-ol/dist/css/index.css +1 -13
  22. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  23. package/plugins/beta/frame/dist/css/index.css +1 -11
  24. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  25. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  26. package/plugins/beta/frame/src/Frame.jsx +28 -2
  27. package/plugins/beta/map-styles/dist/css/index.css +1 -1
  28. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  29. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  30. package/plugins/beta/scale-bar/dist/css/index.css +1 -8
  31. package/plugins/datasets/dist/css/5648.index.css +1 -5
  32. package/plugins/datasets/dist/css/index.css +1 -1
  33. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  34. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  35. package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +1 -0
  36. package/plugins/datasets/src/initialise/DatasetsInit.jsx +2 -2
  37. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +6 -5
  38. package/plugins/datasets/src/registry/dataset.js +8 -12
  39. package/plugins/datasets/src/registry/dataset.test.js +17 -0
  40. package/plugins/datasets/src/registry/datasetRegistry.js +4 -5
  41. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  42. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  43. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  44. package/plugins/draw/dist/esm/index.js +1 -1
  45. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
  46. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  47. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  48. package/plugins/draw/dist/umd/index.js +1 -1
  49. package/plugins/draw/src/DrawInit.jsx +41 -26
  50. package/plugins/draw/src/DrawInit.test.jsx +55 -12
  51. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +113 -114
  52. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +82 -9
  53. package/plugins/draw/src/adapters/maplibre/drawEvents.js +3 -1
  54. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +1 -1
  55. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +18 -9
  56. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +9 -1
  57. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +11 -1
  58. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +23 -2
  59. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +53 -0
  60. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +29 -19
  61. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +17 -2
  62. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +1 -1
  63. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +35 -29
  64. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +24 -6
  65. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +22 -20
  66. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +1 -1
  67. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +2 -2
  68. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +3 -3
  69. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +31 -0
  70. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +8 -1
  71. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +38 -30
  72. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +20 -3
  73. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +5 -5
  74. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +2 -2
  75. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +2 -2
  76. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +3 -3
  77. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -7
  78. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -4
  79. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +14 -14
  80. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +12 -32
  81. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +10 -10
  82. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +48 -22
  83. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +74 -6
  84. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +2 -2
  85. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +6 -0
  86. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +9 -0
  87. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +15 -2
  88. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +27 -1
  89. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +1 -1
  90. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +40 -17
  91. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +55 -1
  92. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +13 -7
  93. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +7 -15
  94. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +36 -4
  95. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +41 -1
  96. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +35 -25
  97. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +40 -2
  98. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
  99. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
  100. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +40 -54
  101. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +32 -13
  102. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +1 -1
  103. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +17 -1
  104. package/plugins/draw/src/events.js +4 -4
  105. package/plugins/draw/src/events.test.js +1 -1
  106. package/plugins/draw/src/hooks/useSpatialList.js +180 -0
  107. package/plugins/draw/src/hooks/useSpatialList.test.js +339 -0
  108. package/plugins/draw/src/manifest.js +4 -1
  109. package/plugins/draw/src/manifest.test.js +19 -7
  110. package/plugins/draw/src/utils/spatial.js +1 -31
  111. package/plugins/draw/src/utils/spatial.test.js +1 -24
  112. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  113. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  114. package/plugins/interact/dist/umd/index.js +1 -1
  115. package/plugins/interact/src/InteractInit.jsx +2 -2
  116. package/plugins/interact/src/InteractInit.test.js +3 -3
  117. package/plugins/interact/src/events.js +11 -2
  118. package/plugins/interact/src/events.test.js +32 -0
  119. package/plugins/interact/src/hooks/useAttachEvents.js +20 -0
  120. package/plugins/interact/src/hooks/useAttachEvents.test.js +44 -1
  121. package/plugins/interact/src/hooks/useCrossHairVisibility.js +9 -4
  122. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +7 -7
  123. package/plugins/interact/src/hooks/useSpatialList.js +251 -0
  124. package/plugins/interact/src/hooks/{useMapItemList.test.js → useSpatialList.test.js} +216 -70
  125. package/plugins/interact/src/manifest.js +12 -1
  126. package/plugins/interact/src/manifest.test.js +20 -0
  127. package/plugins/interact/src/utils/spatial.js +24 -1
  128. package/plugins/interact/src/utils/spatial.test.js +26 -1
  129. package/plugins/map-key/dist/css/index.css +1 -106
  130. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  131. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  132. package/plugins/map-key/dist/umd/index.js +1 -1
  133. package/plugins/map-key/src/components/Key/Key.jsx +3 -4
  134. package/plugins/map-key/src/components/Key/Key.module.scss +10 -8
  135. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +2 -2
  136. package/plugins/map-key/src/components/Key/KeyItem.jsx +2 -2
  137. package/plugins/map-key/src/components/Key/KeySvg.jsx +9 -7
  138. package/plugins/map-key/src/components/Key/KeySvg.test.jsx +4 -4
  139. package/plugins/map-key/src/components/Key/MapKey.jsx +5 -3
  140. package/plugins/map-key/src/manifest.js +1 -1
  141. package/plugins/map-key/src/utils/groupStyles.js +7 -0
  142. package/plugins/map-key/src/utils/mergeKeyGroupItems.js +12 -0
  143. package/plugins/map-key/src/utils/mergeKeyGroupItems.test.js +53 -0
  144. package/plugins/menu/dist/css/index.css +1 -80
  145. package/plugins/menu/dist/esm/im-menu-plugin.js +1 -1
  146. package/plugins/menu/dist/umd/im-menu-plugin.js +1 -1
  147. package/plugins/menu/dist/umd/index.js +1 -1
  148. package/plugins/menu/src/initialise/MenuInit.jsx +15 -3
  149. package/plugins/menu/src/initialise/MenuInit.test.jsx +25 -0
  150. package/plugins/menu/src/initialise/createMenu.js +3 -1
  151. package/plugins/menu/src/initialise/createMenu.test.js +20 -5
  152. package/plugins/menu/src/initialise/updateUrl.js +44 -0
  153. package/plugins/menu/src/initialise/updateUrl.test.js +91 -0
  154. package/plugins/menu/src/reducers/menuStateReducer.js +19 -1
  155. package/plugins/menu/src/reducers/menuStateReducer.test.js +53 -20
  156. package/plugins/menu/src/reducers/pluginState.js +1 -6
  157. package/plugins/menu/src/reducers/pluginState.test.js +0 -25
  158. package/plugins/search/dist/css/index.css +1 -1
  159. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  160. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  161. package/plugins/search/src/manifest.js +5 -9
  162. package/plugins/search/src/manifest.test.js +5 -0
  163. package/providers/beta/esri/dist/css/index.css +1 -34
  164. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  165. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  166. package/providers/beta/openlayers/src/openlayersProvider.js +3 -3
  167. package/providers/beta/openlayers/src/openlayersProvider.test.js +6 -3
  168. package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -0
  169. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +122 -1
  170. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  171. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  172. package/providers/maplibre/dist/umd/index.js +1 -1
  173. package/providers/maplibre/src/defaults.test.js +26 -0
  174. package/providers/maplibre/src/utils/labels.js +2 -2
  175. package/providers/maplibre/src/utils/labels.test.js +5 -5
  176. package/providers/maplibre/src/utils/spatial.js +0 -57
  177. package/providers/maplibre/src/utils/spatial.test.js +0 -27
  178. package/rollup.esm.mjs +2 -1
  179. package/scripts/publish-package.sh +36 -0
  180. package/src/App/App.jsx +1 -1
  181. package/src/App/App.test.jsx +6 -1
  182. package/src/App/components/CrossHair/CrossHair.jsx +25 -11
  183. package/src/App/components/CrossHair/CrossHair.module.scss +37 -12
  184. package/src/App/components/CrossHair/CrossHair.test.jsx +40 -11
  185. package/src/App/components/Hints/Hints.jsx +3 -3
  186. package/src/App/components/Hints/Hints.test.jsx +3 -3
  187. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +4 -1
  188. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +17 -0
  189. package/src/App/components/MapButton/MapButton.jsx +19 -7
  190. package/src/App/components/MapButton/MapButton.module.scss +1 -2
  191. package/src/App/components/MapButton/MapButton.test.jsx +42 -4
  192. package/src/App/components/{MoveControls/MoveControls.jsx → MapControls/MapControls.jsx} +10 -10
  193. package/src/App/components/{MoveControls/MoveControls.module.scss → MapControls/MapControls.module.scss} +25 -25
  194. package/src/App/components/{MoveControls/MoveControls.test.jsx → MapControls/MapControls.test.jsx} +48 -48
  195. package/src/App/components/Markers/Markers.jsx +2 -2
  196. package/src/App/components/Markers/Markers.test.jsx +3 -3
  197. package/src/App/components/Markers/SymbolMarker.jsx +2 -3
  198. package/src/App/components/Markers/SymbolMarker.test.jsx +8 -9
  199. package/src/App/components/Panel/Panel.jsx +19 -20
  200. package/src/App/components/Panel/Panel.module.scss +5 -0
  201. package/src/App/components/Panel/Panel.test.jsx +69 -4
  202. package/src/App/components/PopupMenu/PopupMenu.jsx +7 -3
  203. package/src/App/components/PopupMenu/PopupMenu.test.jsx +71 -0
  204. package/src/App/components/PopupMenu/usePopupMenu.js +10 -9
  205. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  206. package/src/App/components/Viewport/SpatialList.jsx +47 -0
  207. package/src/App/components/Viewport/{Features.module.scss → SpatialList.module.scss} +6 -6
  208. package/src/App/components/Viewport/SpatialList.test.jsx +285 -0
  209. package/src/App/components/Viewport/Viewport.jsx +21 -11
  210. package/src/App/components/Viewport/Viewport.test.jsx +18 -2
  211. package/src/App/controls/keyboardMappings.js +7 -2
  212. package/src/App/controls/keyboardMappings.test.js +21 -0
  213. package/src/App/controls/keyboardShortcuts.js +16 -2
  214. package/src/App/controls/keyboardShortcuts.test.js +21 -0
  215. package/src/App/hooks/useCrossHairAPI.js +29 -1
  216. package/src/App/hooks/useCrossHairAPI.test.js +17 -1
  217. package/src/App/hooks/useHintsAPI.js +5 -5
  218. package/src/App/hooks/useInterfaceAPI.js +2 -1
  219. package/src/App/hooks/useInterfaceAPI.test.js +9 -0
  220. package/src/App/hooks/useKeyboardShortcuts.js +66 -35
  221. package/src/App/hooks/useKeyboardShortcuts.test.js +52 -0
  222. package/src/App/hooks/useModalPanelBehaviour.js +48 -34
  223. package/src/App/hooks/useModalPanelBehaviour.test.js +59 -0
  224. package/src/App/hooks/useSpatialListFocus.js +296 -0
  225. package/src/App/hooks/useSpatialListFocus.test.js +957 -0
  226. package/src/App/hooks/useSpatialListItems.js +51 -0
  227. package/src/App/hooks/useSpatialListItems.test.js +217 -0
  228. package/src/App/initialiseApp.js +6 -3
  229. package/src/App/initialiseApp.test.js +11 -0
  230. package/src/App/layout/Layout.jsx +4 -2
  231. package/src/App/layout/Layout.test.jsx +21 -0
  232. package/src/App/layout/layout.module.scss +7 -1
  233. package/src/App/registry/keyboardShortcutRegistry.js +2 -2
  234. package/src/App/registry/keyboardShortcutRegistry.test.js +16 -0
  235. package/src/App/registry/pluginRegistry.js +13 -0
  236. package/src/App/registry/pluginRegistry.test.js +15 -0
  237. package/src/App/registry/spatialListRegistry.js +104 -0
  238. package/src/App/registry/spatialListRegistry.test.js +218 -0
  239. package/src/App/renderer/HtmlElementHost.jsx +12 -32
  240. package/src/App/renderer/mapButtons.js +4 -0
  241. package/src/App/renderer/mapButtons.test.js +20 -0
  242. package/src/App/renderer/mapPanels.js +33 -53
  243. package/src/App/renderer/mapPanels.test.js +29 -4
  244. package/src/App/renderer/slotHelpers.js +33 -0
  245. package/src/App/renderer/slotHelpers.test.js +79 -2
  246. package/src/App/store/AppProvider.jsx +3 -2
  247. package/src/App/store/ServiceProvider.jsx +5 -3
  248. package/src/App/store/ServiceProvider.test.jsx +20 -1
  249. package/src/InteractiveMap/InteractiveMap.js +13 -2
  250. package/src/InteractiveMap/InteractiveMap.test.js +33 -0
  251. package/src/config/appConfig.js +16 -16
  252. package/src/config/appConfig.test.js +19 -19
  253. package/src/config/defaults.js +2 -2
  254. package/src/config/events.js +8 -8
  255. package/src/scss/main.scss +2 -2
  256. package/src/test-utils.js +8 -0
  257. package/src/types.js +51 -9
  258. package/src/utils/findNearestItemInDirection.js +29 -0
  259. package/src/utils/findNearestItemInDirection.test.js +67 -0
  260. package/src/utils/getPanelElementId.js +4 -0
  261. package/src/utils/getPanelElementId.test.js +11 -0
  262. package/src/utils/getPanelRole.js +22 -0
  263. package/src/utils/getPanelRole.test.js +56 -0
  264. package/src/utils/globalAltShortcuts.js +12 -0
  265. package/src/utils/globalAltShortcuts.test.js +27 -0
  266. package/src/utils/spatialNavigate.js +49 -0
  267. package/src/utils/spatialNavigate.test.js +42 -0
  268. package/webpack.umd.mjs +6 -1
  269. package/plugins/interact/src/hooks/useMapItemList.js +0 -187
  270. package/src/App/components/Viewport/Features.jsx +0 -35
  271. package/src/App/components/Viewport/Features.test.jsx +0 -124
  272. package/src/App/hooks/useFeatureFocus.js +0 -199
  273. package/src/App/hooks/useFeatureFocus.test.js +0 -635
  274. package/src/App/hooks/useFeatureItems.js +0 -39
  275. package/src/App/hooks/useFeatureItems.test.js +0 -155
@@ -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,29 +85,29 @@ const buildPointInput = ({ drawInteraction, options, canPlace }) => {
96
85
 
97
86
  applyCrossHairVisibility(crossHair, interfaceType)
98
87
 
88
+ // Shared "commit here" entry point for the crosshair button's own onClick — same action as
89
+ // Enter/the touch add-vertex button.
90
+ if (crossHair) {
91
+ crossHair.activate = placePoint
92
+ }
93
+
99
94
  return {
100
95
  getInterfaceType,
101
96
  setInterfaceType: applyInterfaceType,
102
- destroy () { events.destroy() }
97
+ destroy () {
98
+ if (crossHair?.activate === placePoint) { crossHair.activate = null }
99
+ events.destroy()
100
+ }
103
101
  }
104
102
  }
105
103
 
106
104
  /**
107
- * Draw mode for placing a single point: mouse click, or crosshair + Enter/"Add point"
108
- * button for touch/keyboard. Commits immediately on the first placement unlike
109
- * draw_line/draw_polygon, there is no rubber band, no multi-vertex undo, and no "Done"
110
- * step (mirrors the MapLibre adapter's drawPointMode.js decision).
111
- *
112
- * OL's own Draw({type:'Point'}) interaction already finishes a real mouse click
113
- * immediately (confirmed by reading its handleUpEvent — mode 'Point' always attempts
114
- * finishDrawing() on pointer-up, no second click needed), so the mouse path needs no
115
- * extra wiring beyond the placement-veto condition every mode already has. Deliberately
116
- * NOT built via draw/DrawMode.js's createDrawMode()/createVertexPlacement() — those are
117
- * parametrised around a ring of placed vertices + a trailing rubber-band coordinate,
118
- * which a single-click commit doesn't have (mirrors the same decision already made for
119
- * the MapLibre adapter, and the OL EditMode/DrawMode split noted in
120
- * feedback_adapter_parity.md: don't force point through machinery shaped for lines/polygons
121
- * 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.
122
111
  */
123
112
  export const createDrawPointMode = ({ map, manager, options }) => {
124
113
  const { featureId, properties = {} } = options
@@ -126,18 +115,15 @@ export const createDrawPointMode = ({ map, manager, options }) => {
126
115
 
127
116
  const drawInteraction = new Draw({
128
117
  type: 'Point',
129
- // Without an explicit style, OL renders its own default sketch style (a blue circle)
130
- // following the cursor before commit core/styles.js's createSketchStyle() already
131
- // suppresses this same default for the "ghost" Point sketch OL renders alongside a
132
- // Polygon/LineString sketch (see its own comment); a real Point sketch needs the same
133
- // 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.
134
120
  style: () => [],
135
121
  snapTolerance: TOLERANCES.snapRadius,
136
- condition: (e) => noModifierKeys(e) && canPlace(e.coordinate)
122
+ condition: (event) => noModifierKeys(event) && canPlace(event.coordinate)
137
123
  })
138
124
  map.addInteraction(drawInteraction)
139
125
 
140
- 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))
141
127
  drawInteraction.on('drawabort', () => { manager.emit(ADAPTER_EVENTS.CANCEL) })
142
128
 
143
129
  const input = buildPointInput({ drawInteraction, options, canPlace })
@@ -56,7 +56,7 @@ describe('createDrawPointMode', () => {
56
56
  const { interaction, manager, emitted } = setup()
57
57
  interaction.dispatchEvent({ type: 'drawend', feature: pointFeature([1, 2], 'ol-generated-id') })
58
58
 
59
- const created = emitted().filter((e) => e.type === ADAPTER_EVENTS.CREATE)
59
+ const created = emitted().filter((event) => event.type === ADAPTER_EVENTS.CREATE)
60
60
  expect(created).toHaveLength(1)
61
61
  expect(created[0].payload.id).toBe('point-1') // re-id'd, not the OL-generated one
62
62
  expect(created[0].payload.geometry.coordinates).toEqual([1, 2])
@@ -82,7 +82,7 @@ describe('createDrawPointMode', () => {
82
82
  test('defaults properties to {} when none are supplied', () => {
83
83
  const { interaction, manager, emitted } = setup({ properties: undefined })
84
84
  interaction.dispatchEvent({ type: 'drawend', feature: pointFeature([1, 2], 'ol-generated-id') })
85
- const created = emitted().filter((e) => e.type === ADAPTER_EVENTS.CREATE)
85
+ const created = emitted().filter((event) => event.type === ADAPTER_EVENTS.CREATE)
86
86
  // OL's GeoJSON writer reports no properties as null (not {}) — the meaningful
87
87
  // assertion is what setProperties() was actually called with, on the stored feature.
88
88
  expect(created[0].payload.properties).toBeNull()
@@ -96,7 +96,7 @@ describe('createDrawPointMode', () => {
96
96
  const condition = interaction.condition_
97
97
  const allowed = condition({ coordinate: [1, 2], originalEvent: {} })
98
98
  expect(allowed).toBe(false)
99
- const blocked = emitted().filter((e) => e.type === ADAPTER_EVENTS.PLACEMENT_BLOCKED)
99
+ const blocked = emitted().filter((event) => event.type === ADAPTER_EVENTS.PLACEMENT_BLOCKED)
100
100
  expect(blocked).toHaveLength(1)
101
101
  expect(blocked[0].payload).toMatchObject({ reason: 'not allowed here', phase: 'place', mode: 'draw_point' })
102
102
  })
@@ -116,7 +116,7 @@ describe('createDrawPointMode', () => {
116
116
  test('drawabort emits CANCEL', () => {
117
117
  const { interaction, emitted } = setup()
118
118
  interaction.dispatchEvent({ type: 'drawabort' })
119
- expect(emitted().some((e) => e.type === ADAPTER_EVENTS.CANCEL)).toBe(true)
119
+ expect(emitted().some((event) => event.type === ADAPTER_EVENTS.CANCEL)).toBe(true)
120
120
  })
121
121
 
122
122
  describe('touch/keyboard placement (via the wired placeVertex callback)', () => {
@@ -142,13 +142,13 @@ describe('createDrawPointMode', () => {
142
142
  expect(finish).toHaveBeenCalled()
143
143
  })
144
144
 
145
- test('does not snap on mouse interface', () => {
146
- const snap = { apply: jest.fn(), hideIndicator: jest.fn() }
145
+ test('still snaps with interfaceType mouse — placeVertex is only ever reached via the crosshair path (Enter, the add-vertex button, or the crosshair\'s own click), never a real mouse click', () => {
146
+ const snap = { apply: jest.fn(() => [9, 9]), hideIndicator: jest.fn() }
147
147
  const { wireArgs, interaction } = setup({ snap, interfaceType: 'mouse' })
148
148
  const { append } = spyOnCommit(interaction)
149
149
  wireArgs.placeVertex()
150
- expect(snap.apply).not.toHaveBeenCalled()
151
- expect(append).toHaveBeenCalledWith([[5, 5]])
150
+ expect(snap.apply).toHaveBeenCalledWith([5, 5])
151
+ expect(append).toHaveBeenCalledWith([[9, 9]])
152
152
  })
153
153
 
154
154
  test('a vetoed crosshair placement never commits', () => {
@@ -158,7 +158,7 @@ describe('createDrawPointMode', () => {
158
158
  wireArgs.placeVertex()
159
159
  expect(append).not.toHaveBeenCalled()
160
160
  expect(finish).not.toHaveBeenCalled()
161
- expect(emitted().filter((e) => e.type === ADAPTER_EVENTS.PLACEMENT_BLOCKED)).toHaveLength(1)
161
+ expect(emitted().filter((event) => event.type === ADAPTER_EVENTS.PLACEMENT_BLOCKED)).toHaveLength(1)
162
162
  })
163
163
 
164
164
  test('updateRubberbanding shows the snap indicator at the crosshair for touch/keyboard, not mouse', () => {
@@ -169,11 +169,12 @@ describe('createDrawPointMode', () => {
169
169
  expect(snap.apply).toHaveBeenCalledWith([7, 7])
170
170
  })
171
171
 
172
- test('updateRubberbanding does nothing on mouse interface', () => {
172
+ test('updateRubberbanding still refreshes the indicator regardless of interfaceType — the caller decides when it\'s worth calling this at all', () => {
173
173
  const snap = { apply: jest.fn(), hideIndicator: jest.fn() }
174
- const { wireArgs } = setup({ snap, interfaceType: 'mouse' })
174
+ const mapProvider = { getCenter: () => [7, 7] }
175
+ const { wireArgs } = setup({ snap, mapProvider, interfaceType: 'mouse' })
175
176
  wireArgs.updateRubberbanding()
176
- expect(snap.apply).not.toHaveBeenCalled()
177
+ expect(snap.apply).toHaveBeenCalledWith([7, 7])
177
178
  })
178
179
 
179
180
  // Regression: without this, switching to touch/keyboard left the indicator hidden until
@@ -239,8 +240,26 @@ describe('createDrawPointMode', () => {
239
240
  const { mode, map, interaction, emitted } = setup()
240
241
  const removeSpy = jest.spyOn(map, 'removeInteraction')
241
242
  mode.destroy()
242
- expect(emitted().some((e) => e.type === ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE)).toBe(true)
243
+ expect(emitted().some((event) => event.type === ADAPTER_EVENTS.INTERFACE_TYPE_CHANGE)).toBe(true)
243
244
  expect(removeSpy).toHaveBeenCalledWith(interaction)
244
245
  })
246
+
247
+ test('wires crossHair.activate to the same placeVertex callback given to wireInputEvents — the crosshair button\'s own onClick (CrossHair.jsx) does exactly what Enter/the touch add-vertex button already do', () => {
248
+ const crossHair = {}
249
+ const { wireArgs } = setup({ crossHair })
250
+ expect(crossHair.activate).toBe(wireArgs.placeVertex)
251
+ })
252
+
253
+ test('does not wire crossHair.activate when no crossHair is provided', () => {
254
+ expect(() => setup({ crossHair: null })).not.toThrow()
255
+ })
256
+
257
+ test('destroy clears crossHair.activate so a stale closure cannot fire after the mode has gone', () => {
258
+ const crossHair = {}
259
+ const { mode } = setup({ crossHair })
260
+ expect(typeof crossHair.activate).toBe('function')
261
+ mode.destroy()
262
+ expect(crossHair.activate).toBeNull()
263
+ })
245
264
  })
246
265
  })
@@ -250,7 +250,7 @@ export const createEditPointMode = ({ map, manager, options }) => {
250
250
  // Matches MaplibreDrawAdapter.deleteVertex's existing no-op precedent.
251
251
  deleteVertex () {},
252
252
 
253
- // MoveControls' D-pad, routed here via mapProvider.activeMoveTarget (events.js) — claimed
253
+ // MapControls' D-pad, routed here via mapProvider.activeMoveTarget (events.js) — claimed
254
254
  // once at construction (pointSelectionState's VERTEX_SELECTION) and held for the session.
255
255
  nudgeSelectedVertex: keyboardHandler.nudgeByDelta,
256
256
 
@@ -208,7 +208,7 @@ describe('mouse hit-testing respects the icon\'s own rendered pixels, not a fixe
208
208
  })
209
209
  })
210
210
 
211
- test('nudgeSelectedVertex (MoveControls D-pad) moves the point and is undoable', () => {
211
+ test('nudgeSelectedVertex (MapControls D-pad) moves the point and is undoable', () => {
212
212
  const { manager, mode, coord } = setup()
213
213
  mode.nudgeSelectedVertex(1, 0, true)
214
214
  expect(coord()).not.toEqual([5, 5])
@@ -249,6 +249,22 @@ test('keyboard: Delete is a no-op — there is no delete-vertex action for a poi
249
249
  expect(coord()).toEqual([5, 5])
250
250
  })
251
251
 
252
+ // A point is "always selected" (pointSelectionState.js's selectedVertexIndex never leaves 0),
253
+ // so the shared keyboardHandler.js's Alt+<key> shadow guard (gated on selectedVertexIndex >= 0)
254
+ // stays unconditionally true here — unlike edit_vertex, where it only applies once a vertex is
255
+ // actually selected.
256
+ test('keyboard: Alt+Enter/Alt+Arrow keyup are shadowed from the app-wide label shortcuts for the whole session', () => {
257
+ setup()
258
+ const enterEvent = new KeyboardEvent('keyup', { key: 'Enter', altKey: true, cancelable: true, bubbles: true })
259
+ const arrowEvent = new KeyboardEvent('keyup', { key: 'ArrowRight', altKey: true, cancelable: true, bubbles: true })
260
+ const enterSpy = jest.spyOn(enterEvent, 'stopPropagation')
261
+ const arrowSpy = jest.spyOn(arrowEvent, 'stopPropagation')
262
+ window.dispatchEvent(enterEvent)
263
+ window.dispatchEvent(arrowEvent)
264
+ expect(enterSpy).toHaveBeenCalled()
265
+ expect(arrowSpy).toHaveBeenCalled()
266
+ })
267
+
252
268
  test('keyboard: Cmd/Ctrl+Z undoes via the same op-popping logic as mode.undo()', () => {
253
269
  const { manager, coord } = setup()
254
270
  key('keydown', { key: 'ArrowRight' })
@@ -36,8 +36,8 @@ const createGeometryInvalidEmitter = (eventBus, hints) => (payload) => {
36
36
  if (payload.reason && !isIncompleteShape(payload.reason)) { hints.show(payload.reason) }
37
37
  }
38
38
 
39
- // Claims MoveControls' D-pad for the currently selected vertex — see the generic
40
- // mapProvider.activeMoveTarget contract (MoveControls.jsx). Any plugin could use
39
+ // Claims MapControls' D-pad for the currently selected vertex — see the generic
40
+ // mapProvider.activeMoveTarget contract (MapControls.jsx). Any plugin could use
41
41
  // this slot; the draw plugin is just its first consumer.
42
42
  const buildVertexMoveTarget = (draw) => ({
43
43
  move: (dx, dy, isLargeStep) => draw.nudgeSelectedVertex(dx, dy, isLargeStep),
@@ -45,7 +45,7 @@ const buildVertexMoveTarget = (draw) => ({
45
45
  })
46
46
 
47
47
  // Vertex-selection handlers: sync pluginState.selectedVertexIndex, and claim/release
48
- // MoveControls' D-pad via buildVertexMoveTarget above.
48
+ // MapControls' D-pad via buildVertexMoveTarget above.
49
49
  const createVertexSelectionHandlers = ({ draw, pluginState, mapProvider, eventBus }) => ({
50
50
  onVertexSelection: (e) => {
51
51
  pluginState.dispatch({ type: 'SET_SELECTED_VERTEX_INDEX', payload: e })
@@ -233,7 +233,7 @@ export function attachEvents ({ appState, appConfig, mapState, pluginState, mapP
233
233
  const resetState = () => {
234
234
  pluginState.dispatch({ type: 'SET_MODE', payload: null })
235
235
  pluginState.dispatch({ type: 'SET_FEATURE', payload: { feature: null, tempFeature: null } })
236
- // Release MoveControls' D-pad back to panning the map whenever a draw/edit
236
+ // Release MapControls' D-pad back to panning the map whenever a draw/edit
237
237
  // session ends — a stale claim here would silently hijack it for good.
238
238
  mapProvider.activeMoveTarget = null
239
239
  }
@@ -179,7 +179,7 @@ describe('draw event handlers', () => {
179
179
  expect(() => drawHandler(draw, 'cancel')()).not.toThrow()
180
180
  })
181
181
 
182
- test('vertexselection dispatches, emits, and claims mapProvider.activeMoveTarget for MoveControls', () => {
182
+ test('vertexselection dispatches, emits, and claims mapProvider.activeMoveTarget for MapControls', () => {
183
183
  const { draw, dispatch, eventBus, mapProvider } = setup()
184
184
  drawHandler(draw, 'vertexselection')({ index: 2 })
185
185
  expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED_VERTEX_INDEX', payload: { index: 2 } })
@@ -0,0 +1,180 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { EVENTS } from '../../../../src/config/events.js'
3
+ import { scaleFactor } from '../../../../src/config/appConfig.js'
4
+ import { ADAPTER_EVENTS } from '../adapterEvents.js'
5
+
6
+ const EDIT_VERTEX_MODE = 'edit_vertex'
7
+ const DRAW_PROVIDER_ID = 'draw'
8
+
9
+ // Mirrors interact's own projectToScreen (plugins/interact/src/hooks/useSpatialList.js) —
10
+ // mapProvider.mapToScreen is the provider-agnostic projection every mapProvider implements,
11
+ // not any adapter-internal pixel conversion.
12
+ const projectToScreen = (mapProvider, mapSize, coord) => {
13
+ const { x, y } = mapProvider.mapToScreen(coord)
14
+ return { x: x * scaleFactor[mapSize], y: y * scaleFactor[mapSize] }
15
+ }
16
+
17
+ // ids stay flat vertices-then-midpoints indices — matches the mode's own selectedVertexIndex
18
+ // convention on both adapters (see the adapters' getVertexItems() doc comments) and is what
19
+ // selectVertex/insertVertexAtMidpoint expect, so it's unrelated to display order. The array
20
+ // order below is interleaved for display only: Point 1, Insert 1-2, Point 2, ... The "between
21
+ // N and M" label (and this interleave) only reads correctly for a single ring/part (the
22
+ // common case); a shape with holes or multiple parts would need ring-aware numbering to stay
23
+ // accurate, not done here.
24
+ const buildVertexItems = (mapProvider, mapSize) => {
25
+ const { vertices, midpoints } = mapProvider.draw?.getVertexItems?.() ?? { vertices: [], midpoints: [] }
26
+ const toVertexItem = (coord, i) => ({
27
+ id: String(i),
28
+ label: `Point ${i + 1}`,
29
+ isMidpoint: false,
30
+ ...projectToScreen(mapProvider, mapSize, coord)
31
+ })
32
+ const toMidpointItem = (coord, i) => ({
33
+ id: String(vertices.length + i),
34
+ // Kept as short as possible — a voice user hears/reads this in full each time, and
35
+ // "Insert point between N and M" was long enough to lose.
36
+ label: `Insert ${i + 1}-${((i + 1) % vertices.length) + 1}`,
37
+ isMidpoint: true,
38
+ ...projectToScreen(mapProvider, mapSize, coord)
39
+ })
40
+ const items = []
41
+ vertices.forEach((coord, i) => {
42
+ items.push(toVertexItem(coord, i))
43
+ if (i < midpoints.length) {
44
+ items.push(toMidpointItem(midpoints[i], i))
45
+ }
46
+ })
47
+ return items
48
+ }
49
+
50
+ /**
51
+ * Rebuilds the vertex/midpoint item list whenever edit_vertex is entered/left, on every
52
+ * geometry change, and on every map move (pan/zoom moves each item's screen position even
53
+ * though the underlying geographic coordinate hasn't changed — same reason interact's own
54
+ * hook rebuilds on MAP_MOVE_END). Keeps the result in itemsRef for useConfirmHandler to
55
+ * resolve against later, and registers as an exclusive spatialListRegistry provider —
56
+ * claiming ownership while edit_vertex is active (interact's own items are excluded
57
+ * meanwhile, restored automatically once the claim is released) rather than contributing
58
+ * additively like interact does.
59
+ */
60
+ function useVertexItemSync ({ pluginState, mapProvider, mapSize, spatialListRegistry, itemsRef, eventBus }) {
61
+ useEffect(() => {
62
+ const rebuild = () => {
63
+ itemsRef.current = pluginState.mode === EDIT_VERTEX_MODE ? buildVertexItems(mapProvider, mapSize) : []
64
+ spatialListRegistry.notifyItemsChanged(DRAW_PROVIDER_ID)
65
+ }
66
+ rebuild()
67
+ eventBus.on(EVENTS.MAP_MOVE_END, rebuild)
68
+ const { draw } = mapProvider
69
+ if (!draw) {
70
+ return () => eventBus.off(EVENTS.MAP_MOVE_END, rebuild)
71
+ }
72
+ draw.on(ADAPTER_EVENTS.UPDATE, rebuild)
73
+ return () => {
74
+ eventBus.off(EVENTS.MAP_MOVE_END, rebuild)
75
+ draw.off(ADAPTER_EVENTS.UPDATE, rebuild)
76
+ }
77
+ }, [pluginState.mode, mapProvider, mapSize, spatialListRegistry, itemsRef, eventBus])
78
+
79
+ // Registers once, for the lifetime of the hook — safe outside edit_vertex too, since an
80
+ // exclusive-capable provider only ever contributes while it holds the claim (see
81
+ // spatialListRegistry.js). Declared after the effect above for the same initial-recompute
82
+ // ordering reason interact's own hook documents.
83
+ useEffect(() => {
84
+ spatialListRegistry.registerItemProvider(DRAW_PROVIDER_ID, {
85
+ // focusable: false — Tab-only keyboard users already have a full path via the map's own
86
+ // arrow-key nudge/Alt+Arrow navigate, so this list exists purely for Voice Control and a
87
+ // screen reader's own virtual-cursor discovery + activation. See SpatialList.jsx's own
88
+ // comment on why forcing every item's tabIndex to -1 is deliberate here.
89
+ getItems: () => ({ items: itemsRef.current, multiselectable: false, label: 'Shape points', focusable: false }),
90
+ exclusive: true
91
+ })
92
+ return () => { spatialListRegistry.unregisterItemProvider(DRAW_PROVIDER_ID) }
93
+ }, [spatialListRegistry, itemsRef])
94
+
95
+ useEffect(() => {
96
+ if (pluginState.mode !== EDIT_VERTEX_MODE) {
97
+ return undefined
98
+ }
99
+ spatialListRegistry.claimExclusive(DRAW_PROVIDER_ID)
100
+ return () => spatialListRegistry.releaseExclusive(DRAW_PROVIDER_ID)
101
+ }, [pluginState.mode, spatialListRegistry])
102
+ }
103
+
104
+ /**
105
+ * Listens for MAP_SET_ACTIVE_ITEM and previews (non-destructively) whichever vertex or
106
+ * midpoint the listbox cursor is now on — every arrow-key move through the list, mirroring
107
+ * how mouse/touch/keyboard selection already behaves live elsewhere in edit_vertex. Unlike
108
+ * interact's real map features (which need an explicit Enter/Space confirm before anything
109
+ * happens), there's no separate "preview" step for a real vertex anywhere else in this mode —
110
+ * arrowing onto one already is the live selection. A midpoint is also just previewed here;
111
+ * confirming the actual insert is useConfirmHandler's job below.
112
+ */
113
+ function useActiveVertexHandler ({ eventBus, mapProvider, pluginState, activeIdRef }) {
114
+ useEffect(() => {
115
+ const handle = ({ id }) => {
116
+ activeIdRef.current = id
117
+ if (id === null || pluginState.mode !== EDIT_VERTEX_MODE) {
118
+ return
119
+ }
120
+ mapProvider.draw?.selectVertex?.(Number(id))
121
+ }
122
+ eventBus.on(EVENTS.MAP_SET_ACTIVE_ITEM, handle)
123
+ return () => { eventBus.off(EVENTS.MAP_SET_ACTIVE_ITEM, handle) }
124
+ }, [eventBus, mapProvider, pluginState.mode, activeIdRef])
125
+ }
126
+
127
+ /**
128
+ * Handles MAP_SELECT_ITEM (Enter/Space, or a click/Voice-Control click — and, since none of
129
+ * these items are ever a real Tab stop, a screen reader's own virtual-cursor activation gesture
130
+ * too, which likewise fires as a click — all three arrive here identically). For a midpoint,
131
+ * this is the destructive step: commits a new vertex there (a real vertex is already the live
132
+ * selection the moment it's previewed, so confirming one doesn't change anything on the map).
133
+ * Either way, confirming announces what happened and hands keyboard focus back to the viewport
134
+ * — landing the user exactly where the pre-existing, untouched arrow-key nudge/Alt+Arrow flow
135
+ * already works, same as if they'd selected that point with a mouse click. This is deliberately
136
+ * unconditional (not just for midpoints) so every input path ends up in the same place.
137
+ */
138
+ function useConfirmHandler ({ eventBus, mapProvider, pluginState, activeIdRef, itemsRef, viewportRef, announce }) {
139
+ useEffect(() => {
140
+ const handleConfirm = () => {
141
+ if (pluginState.mode !== EDIT_VERTEX_MODE || activeIdRef.current === null) {
142
+ return
143
+ }
144
+ const item = itemsRef.current.find(i => i.id === activeIdRef.current)
145
+ if (!item) {
146
+ return
147
+ }
148
+ if (item.isMidpoint) {
149
+ mapProvider.draw?.insertVertexAtMidpoint?.(Number(activeIdRef.current))
150
+ announce('New point inserted', 'action')
151
+ } else {
152
+ announce(`${item.label} selected`, 'action')
153
+ }
154
+ viewportRef.current?.focus()
155
+ }
156
+ eventBus.on(EVENTS.MAP_SELECT_ITEM, handleConfirm)
157
+ return () => { eventBus.off(EVENTS.MAP_SELECT_ITEM, handleConfirm) }
158
+ }, [eventBus, mapProvider, pluginState.mode, activeIdRef, itemsRef, viewportRef, announce])
159
+ }
160
+
161
+ /**
162
+ * Orchestrates the accessible (Voice Control / screen-reader-discoverable) vertex/midpoint
163
+ * targets for draw's edit_vertex mode. Mirrors plugins/interact/src/hooks/useSpatialList.js's
164
+ * structure (item-sync + active-item concerns), but claims the registry's exclusive slot
165
+ * instead of contributing additively, declares focusable: false (no real Tab stop — see
166
+ * useVertexItemSync's own comment on why), and splits "preview" (any listbox move, non-
167
+ * destructive) from "confirm" (inserts at a midpoint, then always hands focus back to the
168
+ * viewport) rather than interact's own "preview, then explicit confirm for everything" split.
169
+ *
170
+ * @param {{ mapState: object, pluginState: object, services: object, mapProvider: object, spatialListRegistry: object, viewportRef: React.RefObject }} params
171
+ */
172
+ export function useSpatialList ({ mapState, pluginState, services, mapProvider, spatialListRegistry, viewportRef }) {
173
+ const { mapSize } = mapState
174
+ const { eventBus, announce } = services
175
+ const itemsRef = useRef([])
176
+ const activeIdRef = useRef(null)
177
+ useVertexItemSync({ pluginState, mapProvider, mapSize, spatialListRegistry, itemsRef, eventBus })
178
+ useActiveVertexHandler({ eventBus, mapProvider, pluginState, activeIdRef })
179
+ useConfirmHandler({ eventBus, mapProvider, pluginState, activeIdRef, itemsRef, viewportRef, announce })
180
+ }