@defra/interactive-map 0.0.36-alpha → 0.0.38-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 (248) hide show
  1. package/assets/templates/draw-tools.njk +4 -3
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api.md +32 -0
  8. package/docs/examples/draw-tools.mdx +0 -4
  9. package/docs/plugins/draw.md +482 -0
  10. package/docs/plugins.md +2 -6
  11. package/govuk-prototype-kit.config.json +3 -2
  12. package/package.json +2 -4
  13. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  14. package/plugins/beta/draw-es/src/events.js +1 -1
  15. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  16. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  17. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  18. package/plugins/beta/map-styles/dist/css/index.css +1 -97
  19. package/plugins/datasets/dist/esm/esriLayerAdapter.js +1 -1
  20. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  21. package/plugins/datasets/dist/umd/im-datasets-esri-adapter.js +1 -1
  22. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  23. package/plugins/datasets/src/adapters/esri/esriLayerAdapter.js +13 -0
  24. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -0
  25. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -0
  26. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -0
  27. package/plugins/draw/dist/esm/index.js +1 -0
  28. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -0
  29. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +2 -0
  30. package/plugins/draw/dist/umd/im-draw-ol-adapter.js.LICENSE.txt +1 -0
  31. package/plugins/draw/dist/umd/im-draw-plugin.js +2 -0
  32. package/plugins/draw/dist/umd/im-draw-plugin.js.LICENSE.txt +1 -0
  33. package/plugins/draw/dist/umd/index.js +2 -0
  34. package/plugins/draw/dist/umd/index.js.LICENSE.txt +1 -0
  35. package/plugins/draw/src/DrawInit.jsx +89 -0
  36. package/plugins/draw/src/DrawInit.test.jsx +184 -0
  37. package/plugins/draw/src/adapterEvents.js +127 -0
  38. package/plugins/draw/src/adapterEvents.test.js +20 -0
  39. package/plugins/draw/src/adapters/adapterContract.test.js +48 -0
  40. package/plugins/draw/src/adapters/loadDrawAdapter.js +14 -0
  41. package/plugins/draw/src/adapters/loadDrawAdapter.test.js +53 -0
  42. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +367 -0
  43. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +799 -0
  44. package/plugins/draw/src/adapters/maplibre/defaults.js +1 -0
  45. package/plugins/draw/src/adapters/maplibre/drawEvents.js +35 -0
  46. package/plugins/draw/src/adapters/maplibre/mapboxDraw.js +126 -0
  47. package/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +268 -0
  48. package/plugins/draw/src/adapters/maplibre/mapboxSnap.js +39 -0
  49. package/plugins/draw/src/adapters/maplibre/mapboxSnap.test.js +91 -0
  50. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.js +58 -0
  51. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +53 -0
  52. package/plugins/draw/src/adapters/maplibre/modes/disabledMode.js +23 -0
  53. package/plugins/draw/src/adapters/maplibre/modes/disabledMode.test.js +21 -0
  54. package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.js +15 -0
  55. package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.test.js +37 -0
  56. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +115 -0
  57. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +193 -0
  58. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +241 -0
  59. package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js +78 -0
  60. package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js +75 -0
  61. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +78 -0
  62. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +48 -0
  63. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +103 -0
  64. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +127 -0
  65. package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js +59 -0
  66. package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js +34 -0
  67. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js +193 -0
  68. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +195 -0
  69. package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.js +16 -0
  70. package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js +27 -0
  71. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js +107 -0
  72. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +139 -0
  73. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +46 -0
  74. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js +2 -0
  75. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js +17 -0
  76. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +151 -0
  77. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +123 -0
  78. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +161 -0
  79. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js +98 -0
  80. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +133 -0
  81. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +105 -0
  82. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +166 -0
  83. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +133 -0
  84. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +210 -0
  85. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +167 -0
  86. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +121 -0
  87. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +73 -0
  88. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +249 -0
  89. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +153 -0
  90. package/plugins/draw/src/adapters/maplibre/snap/constants.js +2 -0
  91. package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.js +39 -0
  92. package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.test.js +106 -0
  93. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js +148 -0
  94. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js +238 -0
  95. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.js +115 -0
  96. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js +151 -0
  97. package/plugins/draw/src/adapters/maplibre/snap/sourceData.js +34 -0
  98. package/plugins/draw/src/adapters/maplibre/snap/sourceData.test.js +62 -0
  99. package/plugins/draw/src/adapters/maplibre/styles.js +207 -0
  100. package/plugins/draw/src/adapters/maplibre/styles.test.js +155 -0
  101. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js +202 -0
  102. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js +253 -0
  103. package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.js +57 -0
  104. package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js +106 -0
  105. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +117 -0
  106. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +159 -0
  107. package/plugins/draw/src/adapters/openlayers/__helpers__/harness.js +90 -0
  108. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +178 -0
  109. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +153 -0
  110. package/plugins/draw/src/adapters/openlayers/core/featureStore.js +69 -0
  111. package/plugins/draw/src/adapters/openlayers/core/featureStore.test.js +58 -0
  112. package/plugins/draw/src/adapters/openlayers/core/internalEvents.js +8 -0
  113. package/plugins/draw/src/adapters/openlayers/core/styles.js +155 -0
  114. package/plugins/draw/src/adapters/openlayers/core/styles.test.js +143 -0
  115. package/plugins/draw/src/adapters/openlayers/defaults.js +1 -0
  116. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +272 -0
  117. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +431 -0
  118. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +139 -0
  119. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +140 -0
  120. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +144 -0
  121. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +196 -0
  122. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +385 -0
  123. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +303 -0
  124. package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.js +55 -0
  125. package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js +53 -0
  126. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +157 -0
  127. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +195 -0
  128. package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.js +57 -0
  129. package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.test.js +42 -0
  130. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js +83 -0
  131. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js +80 -0
  132. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +118 -0
  133. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +165 -0
  134. package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.js +79 -0
  135. package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.test.js +78 -0
  136. package/plugins/draw/src/adapters/openlayers/edit/selectionState.js +116 -0
  137. package/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js +109 -0
  138. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.js +184 -0
  139. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js +133 -0
  140. package/plugins/draw/src/adapters/openlayers/edit/undoOps.js +94 -0
  141. package/plugins/draw/src/adapters/openlayers/edit/undoOps.test.js +64 -0
  142. package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.js +74 -0
  143. package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.test.js +28 -0
  144. package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.js +49 -0
  145. package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.test.js +42 -0
  146. package/plugins/draw/src/adapters/openlayers/edit/vertexOps.js +100 -0
  147. package/plugins/draw/src/adapters/openlayers/edit/vertexOps.test.js +80 -0
  148. package/plugins/draw/src/adapters/openlayers/olDraw.js +40 -0
  149. package/plugins/draw/src/adapters/openlayers/olDraw.test.js +70 -0
  150. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.js +220 -0
  151. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js +232 -0
  152. package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.js +198 -0
  153. package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.test.js +136 -0
  154. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js +81 -0
  155. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.test.js +72 -0
  156. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js +57 -0
  157. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js +68 -0
  158. package/plugins/draw/src/adapters/openlayers/snap/snapManager.js +99 -0
  159. package/plugins/draw/src/adapters/openlayers/snap/snapManager.test.js +101 -0
  160. package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.js +103 -0
  161. package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.test.js +76 -0
  162. package/plugins/draw/src/adapters/openlayers/utils/olCoords.js +35 -0
  163. package/plugins/draw/src/adapters/openlayers/utils/olCoords.test.js +22 -0
  164. package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.js +22 -0
  165. package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.test.js +18 -0
  166. package/plugins/draw/src/adapters/openlayers/utils/touchTarget.js +8 -0
  167. package/plugins/draw/src/api/addFeature.js +22 -0
  168. package/plugins/draw/src/api/addFeature.test.js +41 -0
  169. package/plugins/draw/src/api/deleteFeature.js +11 -0
  170. package/plugins/draw/src/api/deleteFeature.test.js +24 -0
  171. package/plugins/draw/src/api/editFeature.js +54 -0
  172. package/plugins/draw/src/api/editFeature.test.js +134 -0
  173. package/plugins/draw/src/api/merge.js +30 -0
  174. package/plugins/draw/src/api/merge.test.js +57 -0
  175. package/plugins/draw/src/api/newLine.js +41 -0
  176. package/plugins/draw/src/api/newLine.test.js +93 -0
  177. package/plugins/draw/src/api/newPolygon.js +41 -0
  178. package/plugins/draw/src/api/newPolygon.test.js +93 -0
  179. package/plugins/draw/src/api/split.js +117 -0
  180. package/plugins/draw/src/api/split.test.js +204 -0
  181. package/plugins/draw/src/defaults.js +52 -0
  182. package/plugins/draw/src/defaults.test.js +18 -0
  183. package/plugins/draw/src/draw.scss +43 -0
  184. package/plugins/draw/src/events.js +237 -0
  185. package/plugins/draw/src/events.test.js +369 -0
  186. package/plugins/draw/src/index.js +10 -0
  187. package/plugins/draw/src/index.test.js +24 -0
  188. package/plugins/draw/src/manifest.js +170 -0
  189. package/plugins/draw/src/manifest.test.js +147 -0
  190. package/plugins/draw/src/reducer.js +69 -0
  191. package/plugins/draw/src/reducer.test.js +85 -0
  192. package/plugins/draw/src/utils/debounce.js +16 -0
  193. package/plugins/draw/src/utils/debounce.test.js +40 -0
  194. package/plugins/draw/src/utils/eventBus.js +36 -0
  195. package/plugins/draw/src/utils/eventBus.test.js +47 -0
  196. package/plugins/draw/src/utils/flattenStyleProperties.js +25 -0
  197. package/plugins/draw/src/utils/flattenStyleProperties.test.js +33 -0
  198. package/plugins/draw/src/utils/getValueForStyle.js +33 -0
  199. package/plugins/draw/src/utils/getValueForStyle.test.js +32 -0
  200. package/plugins/draw/src/utils/resolveColors.js +39 -0
  201. package/plugins/draw/src/utils/resolveColors.test.js +34 -0
  202. package/plugins/draw/src/utils/spatial.js +243 -0
  203. package/plugins/draw/src/utils/spatial.test.js +243 -0
  204. package/plugins/draw/src/utils/touchTarget.js +95 -0
  205. package/plugins/draw/src/utils/touchTarget.test.js +107 -0
  206. package/plugins/draw/src/utils/undoStack.js +31 -0
  207. package/plugins/draw/src/utils/undoStack.test.js +49 -0
  208. package/plugins/draw/src/validation/liveDrawChecks.js +120 -0
  209. package/plugins/draw/src/validation/liveDrawChecks.test.js +159 -0
  210. package/plugins/draw/src/validation/liveStroke.js +85 -0
  211. package/plugins/draw/src/validation/liveStroke.test.js +150 -0
  212. package/plugins/draw/src/validation/rules.areaError.test.js +12 -0
  213. package/plugins/draw/src/validation/rules.js +186 -0
  214. package/plugins/draw/src/validation/rules.test.js +120 -0
  215. package/plugins/draw/src/validation/validateGeometry.js +119 -0
  216. package/plugins/draw/src/validation/validateGeometry.test.js +200 -0
  217. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  218. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  219. package/plugins/interact/src/hooks/useInteractionHandlers.js +1 -1
  220. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +1 -1
  221. package/plugins/interact/src/utils/buildStylesMap.js +1 -0
  222. package/plugins/search/dist/css/index.css +1 -317
  223. package/rollup.esm.mjs +35 -28
  224. package/sonar-project.properties +2 -2
  225. package/src/App/components/Attributions/Attributions.module.scss +1 -0
  226. package/src/App/components/Hints/Hints.module.scss +5 -4
  227. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +1 -1
  228. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +11 -0
  229. package/src/App/components/MoveControl/MoveControl.jsx +73 -9
  230. package/src/App/components/MoveControl/MoveControl.module.scss +12 -2
  231. package/src/App/components/MoveControl/MoveControl.test.jsx +136 -0
  232. package/src/App/hooks/useHintsAPI.js +67 -0
  233. package/src/App/hooks/useHintsAPI.test.js +145 -0
  234. package/src/App/renderer/PluginInits.jsx +4 -0
  235. package/src/App/renderer/PluginInits.test.jsx +7 -1
  236. package/src/InteractiveMap/InteractiveMap.js +20 -0
  237. package/src/InteractiveMap/InteractiveMap.test.js +7 -0
  238. package/src/config/events.js +4 -0
  239. package/src/scss/settings/_dimensions.scss +3 -0
  240. package/src/services/announcer.js +38 -3
  241. package/src/services/announcer.test.js +133 -1
  242. package/src/services/hints.js +3 -0
  243. package/src/services/hints.test.js +19 -0
  244. package/src/types.js +12 -0
  245. package/src/utils/detectInterfaceType.js +5 -1
  246. package/src/utils/isMac.js +15 -0
  247. package/src/utils/isMac.test.js +37 -0
  248. package/webpack.umd.mjs +1 -0
@@ -0,0 +1,46 @@
1
+ import {
2
+ getCoords, getRingSegments, getSegmentForIndex, getModifiableCoords, coordPathToFlatIndex
3
+ } from './geometryHelpers.js'
4
+
5
+ const POLYGON = () => ({ type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]] })
6
+ const LINE = () => ({ type: 'LineString', coordinates: [[0, 0], [10, 0], [10, 10]] })
7
+
8
+ describe('geometryHelpers', () => {
9
+ test('getCoords flattens every geometry type and guards missing input', () => {
10
+ expect(getCoords(LINE())).toHaveLength(3)
11
+ expect(getCoords(POLYGON())).toHaveLength(5)
12
+ expect(getCoords({ type: 'MultiLineString', coordinates: [[[0, 0], [1, 1]], [[2, 2]]] })).toHaveLength(3)
13
+ expect(getCoords({ type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 1]]], [[[2, 2]]]] })).toHaveLength(3)
14
+ expect(getCoords({ type: 'Point', coordinates: [0, 0] })).toEqual([])
15
+ expect(getCoords(null)).toEqual([])
16
+ })
17
+
18
+ test('getRingSegments describes each geometry type and guards missing input', () => {
19
+ expect(getRingSegments(LINE())).toEqual([{ start: 0, length: 3, path: [], closed: false }])
20
+ expect(getRingSegments(POLYGON())).toEqual([{ start: 0, length: 5, path: [0], closed: true }])
21
+ expect(getRingSegments({ type: 'MultiLineString', coordinates: [[[0, 0], [1, 1]], [[2, 2]]] }))
22
+ .toEqual([{ start: 0, length: 2, path: [0], closed: false }, { start: 2, length: 1, path: [1], closed: false }])
23
+ expect(getRingSegments({ type: 'MultiPolygon', coordinates: [[[[0, 0], [1, 1]]], [[[2, 2]]]] }))
24
+ .toEqual([{ start: 0, length: 2, path: [0, 0], closed: true }, { start: 2, length: 1, path: [1, 0], closed: true }])
25
+ expect(getRingSegments({ type: 'Point', coordinates: [0, 0] })).toEqual([])
26
+ expect(getRingSegments(null)).toEqual([])
27
+ })
28
+
29
+ test('getSegmentForIndex finds the owning segment or returns null', () => {
30
+ const segments = getRingSegments(POLYGON())
31
+ expect(getSegmentForIndex(segments, 2)).toEqual({ segment: segments[0], localIdx: 2 })
32
+ expect(getSegmentForIndex(segments, 99)).toBeNull()
33
+ })
34
+
35
+ test('getModifiableCoords walks the hierarchical path', () => {
36
+ const geojson = { geometry: { type: 'MultiPolygon', coordinates: [[[[0, 0]]], [[[9, 9]]]] } }
37
+ expect(getModifiableCoords(geojson, [1, 0])).toEqual([[9, 9]])
38
+ })
39
+
40
+ test('coordPathToFlatIndex resolves matched paths and falls back to the last index', () => {
41
+ const poly = POLYGON()
42
+ expect(coordPathToFlatIndex(poly, '0.2')).toBe(2)
43
+ // Unmatched path falls back to the trailing number
44
+ expect(coordPathToFlatIndex(poly, '5.7')).toBe(7)
45
+ })
46
+ })
@@ -0,0 +1,2 @@
1
+ export const scalePoint = (point, scale) => ({ x: point.x * scale, y: point.y * scale })
2
+ export const isOnSVG = (el) => el instanceof window.SVGElement || el.ownerSVGElement
@@ -0,0 +1,17 @@
1
+ import { scalePoint, isOnSVG } from './helpers.js'
2
+
3
+ describe('helpers', () => {
4
+ test('scalePoint multiplies both axes by the scale', () => {
5
+ expect(scalePoint({ x: 2, y: 3 }, 4)).toEqual({ x: 8, y: 12 })
6
+ expect(scalePoint({ x: 5, y: 5 }, 1)).toEqual({ x: 5, y: 5 })
7
+ })
8
+
9
+ test('isOnSVG detects SVG elements and their descendants, not plain DOM nodes', () => {
10
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
11
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
12
+ svg.appendChild(circle)
13
+ expect(isOnSVG(svg)).toBeTruthy() // instanceof SVGElement
14
+ expect(isOnSVG(circle)).toBeTruthy() // nested SVG element
15
+ expect(isOnSVG(document.createElement('div'))).toBeFalsy()
16
+ })
17
+ })
@@ -0,0 +1,151 @@
1
+ import { getSnapInstance, clearSnapIndicator } from '../../utils/snapHelpers.js'
2
+ import { getCoords } from './geometryHelpers.js'
3
+
4
+ const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'])
5
+ const ARROW_OFFSETS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] }
6
+ const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT', 'A'])
7
+
8
+ // Keyboard shortcuts are ignored while a form control outside the map viewport has
9
+ // focus, but still work for elements inside the viewport (e.g. draw toolbar buttons).
10
+ const isInteractiveElementFocused = (state) => {
11
+ const el = document.activeElement
12
+ if (!el || el === document.body) { return false }
13
+ if (state.container?.contains(el)) { return false }
14
+ return INTERACTIVE_TAGS.has(el.tagName) || el.isContentEditable || el.hasAttribute('tabindex')
15
+ }
16
+
17
+ const isUndoShortcut = (e) => e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey
18
+
19
+ /**
20
+ * Keyboard interaction for the vertex-edit mode: arrow-key vertex movement/insertion,
21
+ * space-to-select, Escape, and Cmd/Ctrl+Z undo. Mixed into EditVertexMode.
22
+ */
23
+ export const keyboardHandlers = {
24
+ onKeydown (state, e) {
25
+ if (isInteractiveElementFocused(state)) {
26
+ return
27
+ }
28
+
29
+ state.interfaceType = 'keyboard'
30
+ this.hideTouchVertexIndicator(state)
31
+
32
+ if (e.key === ' ') {
33
+ this.handleSpace(state, e)
34
+ return
35
+ }
36
+ if (ARROW_KEYS.has(e.key) && state.selectedVertexIndex >= 0) {
37
+ this.handleArrowKey(state, e)
38
+ return
39
+ }
40
+ if (e.key === 'Escape') {
41
+ this.changeMode(state, { isPanEnabled: true, selectedVertexIndex: -1, selectedVertexType: null })
42
+ return
43
+ }
44
+ if (isUndoShortcut(e)) {
45
+ this.handleUndoShortcut(state, e)
46
+ }
47
+ },
48
+
49
+ // Space always cancels the default; with no active selection it starts keyboard editing.
50
+ handleSpace (state, e) {
51
+ e.preventDefault()
52
+ if (state.selectedVertexIndex < 0) {
53
+ this.startKeyboardSelection(state)
54
+ }
55
+ },
56
+
57
+ // Alt+arrow steps to the next vertex/midpoint; a plain arrow nudges the selected vertex.
58
+ handleArrowKey (state, e) {
59
+ e.preventDefault()
60
+ e.stopPropagation()
61
+ if (e.altKey) {
62
+ this.updateVertex(state, e.key)
63
+ return
64
+ }
65
+ this.moveVertexByKey(state, e)
66
+ },
67
+
68
+ // Space with no active selection: select the first vertex for keyboard editing.
69
+ startKeyboardSelection (state) {
70
+ const snap = getSnapInstance(this.map)
71
+ if (snap) {
72
+ clearSnapIndicator(snap, this.map)
73
+ }
74
+ if (!state.vertecies?.length) {
75
+ state.vertecies = this.getVerticies(state.featureId)
76
+ state.midpoints = this.getMidpoints(state.featureId)
77
+ }
78
+ if (!state.vertecies?.length) {
79
+ return
80
+ }
81
+ state.isPanEnabled = false
82
+ this.updateVertex(state)
83
+ },
84
+
85
+ // Arrow key with a selected vertex: insert (midpoint) or nudge the vertex, honouring snap.
86
+ moveVertexByKey (state, e) {
87
+ if (state.selectedVertexType === 'midpoint') {
88
+ this.insertVertex(state, e)
89
+ return
90
+ }
91
+
92
+ const feature = this.getFeature(state.featureId)
93
+ const currentCoord = feature && getCoords(feature)?.[state.selectedVertexIndex]
94
+ if (!currentCoord) {
95
+ return
96
+ }
97
+
98
+ // Save starting position for undo (only on first move of sequence)
99
+ if (!state._keyboardMoveStartPosition) {
100
+ state._keyboardMoveStartPosition = [...currentCoord]
101
+ state._keyboardMoveStartIndex = state.selectedVertexIndex
102
+ }
103
+
104
+ this.moveVertex(state, this._keyboardMoveTarget(state, e, currentCoord))
105
+ },
106
+
107
+ // Resolve the destination coordinate for a keyboard nudge, applying or breaking
108
+ // snap — delegates to the shared resolver (vertexOperations.js) also used by
109
+ // MoveControl's nudgeVertexByDelta, so both snap identically.
110
+ _keyboardMoveTarget (state, e, currentCoord) {
111
+ const [dx, dy] = ARROW_OFFSETS[e.key]
112
+ return this.resolveSnapTarget(state, dx, dy, currentCoord, () => this.getNewCoord(state, e))
113
+ },
114
+
115
+ // Cmd/Ctrl+Z: undo the last edit, unless the user is typing in a text field.
116
+ handleUndoShortcut (state, e) {
117
+ const tag = document.activeElement?.tagName
118
+ if (tag === 'INPUT' || tag === 'TEXTAREA') {
119
+ return
120
+ }
121
+ e.preventDefault()
122
+ e.stopPropagation()
123
+ this.handleUndo(state)
124
+ },
125
+
126
+ onKeyup (state, e) {
127
+ if (isInteractiveElementFocused(state)) {
128
+ return
129
+ }
130
+
131
+ state.interfaceType = 'keyboard'
132
+ if (ARROW_KEYS.has(e.key) && state.selectedVertexIndex >= 0) {
133
+ e.stopPropagation()
134
+
135
+ // Push undo for keyboard move sequence
136
+ if (state._keyboardMoveStartPosition && state._keyboardMoveStartIndex != null) {
137
+ this.pushUndo({
138
+ type: 'move_vertex',
139
+ featureId: state.featureId,
140
+ vertexIndex: state._keyboardMoveStartIndex,
141
+ previousPosition: state._keyboardMoveStartPosition
142
+ })
143
+ state._keyboardMoveStartPosition = null
144
+ state._keyboardMoveStartIndex = null
145
+ }
146
+ }
147
+ if (e.key === 'Delete') {
148
+ this.deleteVertex(state)
149
+ }
150
+ }
151
+ }
@@ -0,0 +1,123 @@
1
+ import { createHarness } from './__helpers__/harness.js'
2
+
3
+ describe('keyboardHandlers', () => {
4
+ const keydown = (ctx, state, key, extra = {}) => ctx.onKeydown(state, { key, preventDefault: jest.fn(), stopPropagation: jest.fn(), ...extra })
5
+ const keyup = (ctx, state, key) => ctx.onKeyup(state, { key, stopPropagation: jest.fn() })
6
+
7
+ test('shortcuts are ignored outside the viewport (input, focusable non-input) and when the container is absent', () => {
8
+ const { ctx, state } = createHarness()
9
+ const input = document.createElement('input')
10
+ document.body.appendChild(input)
11
+ input.focus()
12
+ keydown(ctx, state, 'ArrowRight')
13
+ keyup(ctx, state, 'ArrowRight')
14
+ expect(state.interfaceType).not.toBe('keyboard')
15
+
16
+ const focusable = document.createElement('div')
17
+ focusable.tabIndex = 0
18
+ document.body.appendChild(focusable)
19
+ focusable.focus()
20
+ keydown(ctx, state, 'ArrowRight')
21
+ expect(state.interfaceType).not.toBe('keyboard')
22
+
23
+ input.focus()
24
+ const s = { ...state, container: undefined, interfaceType: 'mouse' }
25
+ keydown(ctx, s, 'ArrowRight')
26
+ expect(s.interfaceType).toBe('mouse')
27
+ })
28
+
29
+ test('space selects the first vertex, or does nothing when a vertex is selected or there are none', () => {
30
+ const { ctx, state, map } = createHarness()
31
+ map._snapInstance = { status: true, snapStatus: true, snapCoords: [1, 1] }
32
+ const updateSpy = jest.spyOn(ctx, 'updateVertex').mockImplementation(() => {})
33
+ keydown(ctx, state, ' ')
34
+ expect(state.isPanEnabled).toBe(false)
35
+ expect(updateSpy).toHaveBeenCalledTimes(1)
36
+
37
+ keydown(ctx, { ...state, selectedVertexIndex: 0 }, ' ') // already selected → just cancels default
38
+ expect(updateSpy).toHaveBeenCalledTimes(1)
39
+
40
+ map._snapInstance = null // no snap indicator to clear
41
+ const s = { ...state, featureId: 'missing', vertecies: [], selectedVertexIndex: -1, isPanEnabled: true }
42
+ keydown(ctx, s, ' ')
43
+ expect(s.isPanEnabled).toBe(true)
44
+ })
45
+
46
+ test('an arrow key moves the selected vertex, snapping when active and using the raw coord otherwise', () => {
47
+ const { ctx, state, map } = createHarness()
48
+ state.selectedVertexIndex = 1
49
+ keydown(ctx, state, 'ArrowRight')
50
+ expect(state._keyboardMoveStartIndex).toBe(1)
51
+
52
+ state.getSnapEnabled = () => true
53
+ map._snapInstance = { status: true, snapStatus: true, snapCoords: [7, 8], snapToClosestPoint: jest.fn() }
54
+ keydown(ctx, state, 'ArrowUp')
55
+ expect(state._isSnapped).toBe(true)
56
+ expect(state.vertecies[1]).toEqual([7, 8])
57
+
58
+ keydown(ctx, state, 'ArrowLeft') // snapped already → break out of the snap radius
59
+ expect(state._isSnapped).toBe(false)
60
+
61
+ map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() }
62
+ keydown(ctx, state, 'ArrowRight') // snap enabled but inactive → raw new coord
63
+ expect(state._isSnapped).toBe(false)
64
+ })
65
+
66
+ test('an arrow key on a midpoint inserts a vertex; guards a missing feature or out-of-range vertex', () => {
67
+ const { ctx, state } = createHarness()
68
+ const insertSpy = jest.spyOn(ctx, 'insertVertex').mockImplementation(() => {})
69
+ keydown(ctx, { ...state, selectedVertexIndex: state.vertecies.length, selectedVertexType: 'midpoint' }, 'ArrowRight')
70
+ expect(insertSpy).toHaveBeenCalled()
71
+
72
+ keydown(ctx, { ...state, featureId: 'missing', selectedVertexIndex: 0 }, 'ArrowRight')
73
+ keydown(ctx, { ...state, selectedVertexIndex: 99 }, 'ArrowRight')
74
+ expect(ctx.map._undoStack.length).toBe(0)
75
+ })
76
+
77
+ test('alt+arrow steps the selection, Escape clears it, and Cmd/Ctrl+Z undoes (ignoring shift and text fields)', () => {
78
+ const { ctx, state, api } = createHarness()
79
+ state.selectedVertexIndex = 0
80
+ const updateSpy = jest.spyOn(ctx, 'updateVertex').mockImplementation(() => {})
81
+ keydown(ctx, state, 'ArrowRight', { altKey: true })
82
+ expect(updateSpy).toHaveBeenCalledWith(state, 'ArrowRight')
83
+
84
+ keydown(ctx, state, 'Escape')
85
+ expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ isPanEnabled: true }))
86
+
87
+ const undoSpy = jest.spyOn(ctx, 'handleUndo').mockImplementation(() => {})
88
+ keydown(ctx, state, 'z', { metaKey: true })
89
+ keydown(ctx, state, 'z', { ctrlKey: true })
90
+ expect(undoSpy).toHaveBeenCalledTimes(2)
91
+ keydown(ctx, state, 'z', { metaKey: true, shiftKey: true }) // shift → not undo
92
+ expect(undoSpy).toHaveBeenCalledTimes(2)
93
+
94
+ // Ignored while typing in a text field inside the viewport
95
+ const input = document.createElement('input')
96
+ state.container.appendChild(input)
97
+ input.focus()
98
+ keydown(ctx, state, 'z', { metaKey: true })
99
+ expect(undoSpy).toHaveBeenCalledTimes(2)
100
+ })
101
+
102
+ test('onKeyup pushes a move undo after a sequence, deletes on Delete, no-ops otherwise, and allows viewport focus', () => {
103
+ const { ctx, state, map } = createHarness()
104
+ state.selectedVertexIndex = 1
105
+ state._keyboardMoveStartPosition = [10, 0]
106
+ state._keyboardMoveStartIndex = 1
107
+ keyup(ctx, state, 'ArrowRight')
108
+ expect(map._undoStack.pop()).toMatchObject({ type: 'move_vertex', vertexIndex: 1 })
109
+
110
+ keyup(ctx, { ...state, selectedVertexIndex: 1 }, 'ArrowRight') // no active sequence → no undo
111
+ expect(map._undoStack.length).toBe(0)
112
+
113
+ const deleteSpy = jest.spyOn(ctx, 'deleteVertex').mockImplementation(() => {})
114
+ keyup(ctx, state, 'Delete')
115
+ expect(deleteSpy).toHaveBeenCalled()
116
+
117
+ const child = document.createElement('button') // focus inside the viewport is non-blocking
118
+ state.container.appendChild(child)
119
+ child.focus()
120
+ keyup(ctx, state, 'ArrowRight')
121
+ expect(state.interfaceType).toBe('keyboard')
122
+ })
123
+ })
@@ -0,0 +1,161 @@
1
+ import DirectSelect from '../../../../../../../node_modules/@mapbox/mapbox-gl-draw/src/modes/direct_select.js' // NOSONAR
2
+ import {
3
+ getSnapInstance, isSnapEnabled, getSnapLngLat, triggerSnapAtPoint, clearSnapState
4
+ } from '../../utils/snapHelpers.js'
5
+ import { getCoords, coordPathToFlatIndex } from './geometryHelpers.js'
6
+
7
+ const EVENT_VERTEX_SELECTION = 'draw.vertexselection'
8
+
9
+ /**
10
+ * Mouse/pointer interaction for the vertex-edit mode: vertex/midpoint mouse-down,
11
+ * click-to-commit insertions, drag (with snapping) and mouse-up undo bookkeeping.
12
+ * Wraps the parent DirectSelect handlers. Mixed into EditVertexMode.
13
+ */
14
+ export const pointerHandlers = {
15
+ onMouseDown (state, e) {
16
+ clearSnapState(getSnapInstance(this.map))
17
+ const meta = e.featureTarget?.properties.meta
18
+ const coordPath = e.featureTarget?.properties.coord_path
19
+
20
+ if (['vertex', 'midpoint'].includes(meta)) {
21
+ state.dragMoveLocation = e.lngLat
22
+ state.dragMoving = false
23
+ DirectSelect.onMouseDown.call(this, state, e)
24
+
25
+ // Update selection state for vertex clicks (so onSelectionChange has correct context)
26
+ if (meta === 'vertex' && coordPath) {
27
+ const feature = this.getFeature(state.featureId)
28
+ const vertexIndex = coordPathToFlatIndex(feature, coordPath)
29
+ state.selectedVertexIndex = vertexIndex
30
+ state.selectedVertexType = 'vertex'
31
+ state.coordPath = coordPath
32
+ const vertex = state.vertecies?.[vertexIndex]
33
+ if (vertex) {
34
+ state._moveStartPosition = [...vertex]
35
+ state._moveStartIndex = vertexIndex
36
+ }
37
+ }
38
+ }
39
+ if (meta === 'midpoint') {
40
+ // DirectSelect converts midpoint to vertex - track this as an insert
41
+ const feature = this.getFeature(state.featureId)
42
+ const insertedIndex = coordPathToFlatIndex(feature, coordPath)
43
+
44
+ // Track this insertion for undo (will be pushed on mouseUp if drag occurred)
45
+ state._insertedVertexIndex = insertedIndex
46
+ state._isInsertingVertex = true
47
+
48
+ state.selectedVertexIndex = this.getVertexIndexFromMidpoint(state, coordPath)
49
+ state.selectedVertexType = 'vertex'
50
+ state.coordPath = null // Clear coordPath for midpoints
51
+ this.map.fire(EVENT_VERTEX_SELECTION, { index: state.selectedVertexIndex, numVertecies: state.vertecies.length })
52
+ }
53
+ },
54
+
55
+ onClick (state, e) { // NOSONAR — complexity accumulated from object-level context; single guard clause, see feedback_mgl_click_vs_mouseup.md
56
+ if (state._isInsertingVertex && state._insertedVertexIndex != null) {
57
+ const insertedIndex = state._insertedVertexIndex
58
+ this.syncVertices(state)
59
+ this.pushUndo({ type: 'insert_vertex', featureId: state.featureId, vertexIndex: insertedIndex })
60
+ state.selectedVertexIndex = insertedIndex
61
+ state.selectedVertexType = 'vertex'
62
+ state._isInsertingVertex = false
63
+ state._insertedVertexIndex = null
64
+ this.map.fire(EVENT_VERTEX_SELECTION, { index: insertedIndex, numVertecies: state.vertecies.length })
65
+ return
66
+ }
67
+ DirectSelect.onClick.call(this, state, e)
68
+ },
69
+
70
+ onMouseUp (state, e) {
71
+ clearSnapState(getSnapInstance(this.map))
72
+
73
+ const wasInsertion = state._isInsertingVertex && state._insertedVertexIndex != null
74
+ const vertexMoved = this._didVertexMove(state)
75
+
76
+ if (state.dragMoving || vertexMoved || wasInsertion) {
77
+ this.syncVertices(state)
78
+ if (wasInsertion) {
79
+ this._recordInsertionUndo(state)
80
+ } else if (vertexMoved) {
81
+ this._recordMoveUndo(state)
82
+ } else {
83
+ // dragMoving without an actual move or insertion: nothing to record
84
+ }
85
+ }
86
+
87
+ // Clean up move state
88
+ state._moveStartPosition = null
89
+ state._moveStartIndex = null
90
+
91
+ DirectSelect.onMouseUp.call(this, state, e)
92
+ },
93
+
94
+ // Did the selected vertex actually change position during this interaction?
95
+ // Reads the live feature (not the cached state.vertecies) for reliability.
96
+ _didVertexMove (state) {
97
+ if (!state._moveStartPosition || state._moveStartIndex == null) {
98
+ return false
99
+ }
100
+ const feature = this.getFeature(state.featureId)
101
+ const currentVertex = feature && getCoords(feature)?.[state._moveStartIndex]
102
+ if (!currentVertex) {
103
+ return false
104
+ }
105
+ return currentVertex[0] !== state._moveStartPosition[0] ||
106
+ currentVertex[1] !== state._moveStartPosition[1]
107
+ },
108
+
109
+ // Commit a midpoint-drag insertion: record undo, reselect the new vertex, broadcast the count.
110
+ _recordInsertionUndo (state) {
111
+ const insertedIndex = state._insertedVertexIndex
112
+ this.pushUndo({ type: 'insert_vertex', featureId: state.featureId, vertexIndex: insertedIndex })
113
+ // selectedVertexIndex pointed at the old midpoint-range index; use the real flat index
114
+ state.selectedVertexIndex = insertedIndex
115
+ state.selectedVertexType = 'vertex'
116
+ state._isInsertingVertex = false
117
+ state._insertedVertexIndex = null
118
+ // DirectSelect.onMouseUp fires draw.update but not draw.selectionchange, so broadcast the count here
119
+ this.map.fire(EVENT_VERTEX_SELECTION, { index: insertedIndex, numVertecies: state.vertecies.length })
120
+ },
121
+
122
+ _recordMoveUndo (state) {
123
+ this.pushUndo({
124
+ type: 'move_vertex',
125
+ featureId: state.featureId,
126
+ vertexIndex: state._moveStartIndex,
127
+ previousPosition: state._moveStartPosition
128
+ })
129
+ },
130
+
131
+ onDrag (state, e) {
132
+ if (state.interfaceType === 'touch') {
133
+ return
134
+ }
135
+
136
+ this.map.fire('draw.geometrychange', state.feature)
137
+
138
+ const snap = getSnapInstance(this.map)
139
+ if (snap) {
140
+ snap.snapStatus = false
141
+ snap.snapCoords = null
142
+ }
143
+
144
+ if (!isSnapEnabled(state) || !snap?.status) {
145
+ DirectSelect.onDrag.call(this, state, e)
146
+ return
147
+ }
148
+
149
+ if (!state.selectedCoordPaths?.length || !state.canDragMove) {
150
+ return
151
+ }
152
+
153
+ state.dragMoving = true
154
+ e.originalEvent.stopPropagation()
155
+ triggerSnapAtPoint(snap, this.map, e.point)
156
+
157
+ const finalLngLat = getSnapLngLat(snap) || e.lngLat
158
+ state.feature.updateCoordinate(state.selectedCoordPaths[0], finalLngLat.lng, finalLngLat.lat)
159
+ state.dragMoveLocation = e.lngLat
160
+ }
161
+ }
@@ -0,0 +1,98 @@
1
+ import { createHarness, dragEvt } from './__helpers__/harness.js'
2
+
3
+ describe('pointerHandlers', () => {
4
+ test('onMouseDown selects a vertex and records the move start, ignoring empty/no-coord targets', () => {
5
+ const { ctx, state } = createHarness()
6
+ ctx.onMouseDown(state, { lngLat: { lng: 10, lat: 0 }, featureTarget: { properties: { meta: 'vertex', coord_path: '0.1' } } })
7
+ expect(state.selectedVertexIndex).toBe(1)
8
+ expect(state._moveStartIndex).toBe(1)
9
+
10
+ const h = createHarness()
11
+ h.ctx.onMouseDown(h.state, { lngLat: { lng: 0, lat: 0 }, featureTarget: undefined })
12
+ h.ctx.onMouseDown(h.state, { lngLat: { lng: 0, lat: 0 }, featureTarget: { properties: { meta: 'vertex', coord_path: '0.9' } } })
13
+ expect(h.state._moveStartIndex).toBeUndefined()
14
+ })
15
+
16
+ test('onMouseDown on a midpoint marks an insertion in progress', () => {
17
+ const { ctx, state, map } = createHarness()
18
+ ctx.onMouseDown(state, { lngLat: { lng: 5, lat: 0 }, featureTarget: { properties: { meta: 'midpoint', coord_path: '0.1', lng: 5, lat: 0 } } })
19
+ expect(state._isInsertingVertex).toBe(true)
20
+ expect(map.fire).toHaveBeenCalledWith('draw.vertexselection', expect.any(Object))
21
+ })
22
+
23
+ test('onClick commits an in-progress insertion, otherwise delegates to DirectSelect', () => {
24
+ const { ctx, state, api } = createHarness()
25
+ state._isInsertingVertex = true
26
+ state._insertedVertexIndex = 2
27
+ ctx.onClick(state, {})
28
+ expect(ctx.map._undoStack.pop()).toMatchObject({ type: 'insert_vertex', vertexIndex: 2 })
29
+ expect(state._isInsertingVertex).toBe(false)
30
+
31
+ ctx.onClick(state, { featureTarget: undefined }) // noTarget → clickNoTarget → changeMode
32
+ expect(api.changeMode).toHaveBeenCalledWith('edit_vertex', expect.objectContaining({ selectedVertexIndex: -1 }))
33
+ })
34
+
35
+ test('onMouseUp records a move undo, an insertion undo, or nothing', () => {
36
+ const { ctx, state, map } = createHarness()
37
+ // nothing
38
+ ctx.onMouseUp(state, {})
39
+ expect(map._undoStack.length).toBe(0)
40
+
41
+ // move
42
+ state._moveStartIndex = 1
43
+ state._moveStartPosition = [10, 0]
44
+ ctx.moveVertex({ ...state, selectedVertexIndex: 1 }, { lng: 3, lat: 3 })
45
+ ctx.onMouseUp(state, {})
46
+ expect(map._undoStack.pop()).toMatchObject({ type: 'move_vertex', vertexIndex: 1 })
47
+
48
+ // insertion
49
+ state.dragMoving = true
50
+ state._isInsertingVertex = true
51
+ state._insertedVertexIndex = 2
52
+ ctx.onMouseUp(state, {})
53
+ expect(map._undoStack.pop()).toMatchObject({ type: 'insert_vertex', vertexIndex: 2 })
54
+
55
+ // In-progress drag with no move/insertion records nothing; guards missing feature/stale index; detects a vertical-only move
56
+ const h = createHarness()
57
+ h.ctx.onMouseUp({ ...h.state, dragMoving: true }, {})
58
+ h.ctx.onMouseUp({ ...h.state, _moveStartPosition: [0, 0], _moveStartIndex: 0, featureId: 'missing' }, {})
59
+ h.ctx.onMouseUp({ ...h.state, _moveStartPosition: [0, 0], _moveStartIndex: 99 }, {})
60
+ expect(h.map._undoStack.length).toBe(0)
61
+ h.ctx.moveVertex({ ...h.state, selectedVertexIndex: 1 }, { lng: 10, lat: 5 }) // same lng, new lat
62
+ h.ctx.onMouseUp({ ...h.state, _moveStartPosition: [10, 0], _moveStartIndex: 1 }, {})
63
+ expect(h.map._undoStack.pop()).toMatchObject({ type: 'move_vertex', vertexIndex: 1 })
64
+ })
65
+
66
+ test('onDrag skips touch, delegates without snap, and snaps when enabled', () => {
67
+ const { ctx, state, map } = createHarness()
68
+ ctx.onDrag({ ...state, interfaceType: 'touch' }, { originalEvent: { stopPropagation: jest.fn() }, lngLat: { lng: 1, lat: 1 }, point: { x: 10, y: 10 } })
69
+
70
+ // no snap → DirectSelect.onDrag moves the vertex
71
+ Object.assign(state, { canDragMove: true, selectedCoordPaths: ['0.1'], dragMoveLocation: { lng: 0, lat: 0 } })
72
+ ctx.onDrag(state, { originalEvent: { stopPropagation: jest.fn() }, lngLat: { lng: 2, lat: 2 }, point: { x: 20, y: 20 } })
73
+ expect(map.fire).toHaveBeenCalledWith('draw.geometrychange', expect.anything())
74
+
75
+ // snap enabled
76
+ state.getSnapEnabled = () => true
77
+ map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() }
78
+ ctx.onDrag(state, { originalEvent: { stopPropagation: jest.fn() }, lngLat: { lng: 3, lat: 3 }, point: { x: 30, y: 30 } })
79
+ expect(state.dragMoving).toBe(true)
80
+
81
+ // Snapping enabled but nothing selected → bail out
82
+ const nosel = createHarness()
83
+ nosel.map._snapInstance = { status: true, snapStatus: true, snapCoords: [1, 1] }
84
+ const ns = { ...nosel.state, getSnapEnabled: () => true, selectedCoordPaths: [], canDragMove: false }
85
+ nosel.ctx.onDrag(ns, dragEvt(1, 1))
86
+ expect(ns.dragMoving).not.toBe(true)
87
+
88
+ // Snapping active → snapped point; inactive → raw point
89
+ const snapped = createHarness()
90
+ snapped.map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn(() => { snapped.map._snapInstance.snapStatus = true; snapped.map._snapInstance.snapCoords = [7, 8] }) }
91
+ snapped.ctx.onDrag({ ...snapped.state, getSnapEnabled: () => true, selectedCoordPaths: ['0.1'], canDragMove: true }, dragEvt(2, 2))
92
+ expect(snapped.ctx.getFeature('feat-1').getCoordinate('0.1')).toEqual([7, 8])
93
+ const raw = createHarness()
94
+ raw.map._snapInstance = { status: true, snapStatus: false, snapCoords: null, snapToClosestPoint: jest.fn() }
95
+ raw.ctx.onDrag({ ...raw.state, getSnapEnabled: () => true, selectedCoordPaths: ['0.1'], canDragMove: true }, dragEvt(3, 3))
96
+ expect(raw.ctx.getFeature('feat-1').getCoordinate('0.1')).toEqual([3, 3])
97
+ })
98
+ })