@defra/interactive-map 0.0.37-alpha → 0.0.39-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 (247) 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-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  15. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  16. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  17. package/plugins/beta/map-styles/dist/css/index.css +1 -97
  18. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  19. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  20. package/plugins/datasets/src/registry/dataset.js +3 -1
  21. package/plugins/datasets/src/registry/datasetRegistry.js +30 -24
  22. package/plugins/datasets/src/registry/datasetRegistry.test.js +25 -18
  23. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -0
  24. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -0
  25. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -0
  26. package/plugins/draw/dist/esm/index.js +1 -0
  27. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -0
  28. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +2 -0
  29. package/plugins/draw/dist/umd/im-draw-ol-adapter.js.LICENSE.txt +1 -0
  30. package/plugins/draw/dist/umd/im-draw-plugin.js +2 -0
  31. package/plugins/draw/dist/umd/im-draw-plugin.js.LICENSE.txt +1 -0
  32. package/plugins/draw/dist/umd/index.js +2 -0
  33. package/plugins/draw/dist/umd/index.js.LICENSE.txt +1 -0
  34. package/plugins/draw/src/DrawInit.jsx +89 -0
  35. package/plugins/draw/src/DrawInit.test.jsx +184 -0
  36. package/plugins/draw/src/adapterEvents.js +127 -0
  37. package/plugins/draw/src/adapterEvents.test.js +20 -0
  38. package/plugins/draw/src/adapters/adapterContract.test.js +48 -0
  39. package/plugins/draw/src/adapters/loadDrawAdapter.js +14 -0
  40. package/plugins/draw/src/adapters/loadDrawAdapter.test.js +53 -0
  41. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +367 -0
  42. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +799 -0
  43. package/plugins/draw/src/adapters/maplibre/defaults.js +1 -0
  44. package/plugins/draw/src/adapters/maplibre/drawEvents.js +35 -0
  45. package/plugins/draw/src/adapters/maplibre/mapboxDraw.js +126 -0
  46. package/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +268 -0
  47. package/plugins/draw/src/adapters/maplibre/mapboxSnap.js +39 -0
  48. package/plugins/draw/src/adapters/maplibre/mapboxSnap.test.js +91 -0
  49. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.js +58 -0
  50. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +53 -0
  51. package/plugins/draw/src/adapters/maplibre/modes/disabledMode.js +23 -0
  52. package/plugins/draw/src/adapters/maplibre/modes/disabledMode.test.js +21 -0
  53. package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.js +15 -0
  54. package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.test.js +37 -0
  55. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +115 -0
  56. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +193 -0
  57. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +241 -0
  58. package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js +78 -0
  59. package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js +75 -0
  60. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +78 -0
  61. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +48 -0
  62. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +103 -0
  63. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +127 -0
  64. package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js +59 -0
  65. package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js +34 -0
  66. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js +193 -0
  67. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +195 -0
  68. package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.js +16 -0
  69. package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js +27 -0
  70. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js +107 -0
  71. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +139 -0
  72. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +46 -0
  73. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js +2 -0
  74. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js +17 -0
  75. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +151 -0
  76. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +123 -0
  77. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +161 -0
  78. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js +98 -0
  79. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +133 -0
  80. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +105 -0
  81. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +166 -0
  82. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +133 -0
  83. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +210 -0
  84. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +167 -0
  85. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +121 -0
  86. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +73 -0
  87. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +249 -0
  88. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +153 -0
  89. package/plugins/draw/src/adapters/maplibre/snap/constants.js +2 -0
  90. package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.js +39 -0
  91. package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.test.js +106 -0
  92. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js +148 -0
  93. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js +238 -0
  94. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.js +115 -0
  95. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js +151 -0
  96. package/plugins/draw/src/adapters/maplibre/snap/sourceData.js +34 -0
  97. package/plugins/draw/src/adapters/maplibre/snap/sourceData.test.js +62 -0
  98. package/plugins/draw/src/adapters/maplibre/styles.js +207 -0
  99. package/plugins/draw/src/adapters/maplibre/styles.test.js +155 -0
  100. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js +202 -0
  101. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js +253 -0
  102. package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.js +57 -0
  103. package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js +106 -0
  104. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +117 -0
  105. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +159 -0
  106. package/plugins/draw/src/adapters/openlayers/__helpers__/harness.js +90 -0
  107. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +178 -0
  108. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +153 -0
  109. package/plugins/draw/src/adapters/openlayers/core/featureStore.js +69 -0
  110. package/plugins/draw/src/adapters/openlayers/core/featureStore.test.js +58 -0
  111. package/plugins/draw/src/adapters/openlayers/core/internalEvents.js +8 -0
  112. package/plugins/draw/src/adapters/openlayers/core/styles.js +155 -0
  113. package/plugins/draw/src/adapters/openlayers/core/styles.test.js +143 -0
  114. package/plugins/draw/src/adapters/openlayers/defaults.js +1 -0
  115. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +272 -0
  116. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +431 -0
  117. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +139 -0
  118. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +140 -0
  119. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +144 -0
  120. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +196 -0
  121. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +385 -0
  122. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +303 -0
  123. package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.js +55 -0
  124. package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js +53 -0
  125. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +157 -0
  126. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +195 -0
  127. package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.js +57 -0
  128. package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.test.js +42 -0
  129. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js +83 -0
  130. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js +80 -0
  131. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +118 -0
  132. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +165 -0
  133. package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.js +79 -0
  134. package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.test.js +78 -0
  135. package/plugins/draw/src/adapters/openlayers/edit/selectionState.js +116 -0
  136. package/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js +109 -0
  137. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.js +184 -0
  138. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js +133 -0
  139. package/plugins/draw/src/adapters/openlayers/edit/undoOps.js +94 -0
  140. package/plugins/draw/src/adapters/openlayers/edit/undoOps.test.js +64 -0
  141. package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.js +74 -0
  142. package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.test.js +28 -0
  143. package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.js +49 -0
  144. package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.test.js +42 -0
  145. package/plugins/draw/src/adapters/openlayers/edit/vertexOps.js +100 -0
  146. package/plugins/draw/src/adapters/openlayers/edit/vertexOps.test.js +80 -0
  147. package/plugins/draw/src/adapters/openlayers/olDraw.js +40 -0
  148. package/plugins/draw/src/adapters/openlayers/olDraw.test.js +70 -0
  149. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.js +220 -0
  150. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js +232 -0
  151. package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.js +198 -0
  152. package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.test.js +136 -0
  153. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js +81 -0
  154. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.test.js +72 -0
  155. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js +57 -0
  156. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js +68 -0
  157. package/plugins/draw/src/adapters/openlayers/snap/snapManager.js +99 -0
  158. package/plugins/draw/src/adapters/openlayers/snap/snapManager.test.js +101 -0
  159. package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.js +103 -0
  160. package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.test.js +76 -0
  161. package/plugins/draw/src/adapters/openlayers/utils/olCoords.js +35 -0
  162. package/plugins/draw/src/adapters/openlayers/utils/olCoords.test.js +22 -0
  163. package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.js +22 -0
  164. package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.test.js +18 -0
  165. package/plugins/draw/src/adapters/openlayers/utils/touchTarget.js +8 -0
  166. package/plugins/draw/src/api/addFeature.js +22 -0
  167. package/plugins/draw/src/api/addFeature.test.js +41 -0
  168. package/plugins/draw/src/api/deleteFeature.js +11 -0
  169. package/plugins/draw/src/api/deleteFeature.test.js +24 -0
  170. package/plugins/draw/src/api/editFeature.js +54 -0
  171. package/plugins/draw/src/api/editFeature.test.js +134 -0
  172. package/plugins/draw/src/api/merge.js +30 -0
  173. package/plugins/draw/src/api/merge.test.js +57 -0
  174. package/plugins/draw/src/api/newLine.js +41 -0
  175. package/plugins/draw/src/api/newLine.test.js +93 -0
  176. package/plugins/draw/src/api/newPolygon.js +41 -0
  177. package/plugins/draw/src/api/newPolygon.test.js +93 -0
  178. package/plugins/draw/src/api/split.js +117 -0
  179. package/plugins/draw/src/api/split.test.js +204 -0
  180. package/plugins/draw/src/defaults.js +52 -0
  181. package/plugins/draw/src/defaults.test.js +18 -0
  182. package/plugins/draw/src/draw.scss +43 -0
  183. package/plugins/draw/src/events.js +237 -0
  184. package/plugins/draw/src/events.test.js +369 -0
  185. package/plugins/draw/src/index.js +10 -0
  186. package/plugins/draw/src/index.test.js +24 -0
  187. package/plugins/draw/src/manifest.js +170 -0
  188. package/plugins/draw/src/manifest.test.js +147 -0
  189. package/plugins/draw/src/reducer.js +69 -0
  190. package/plugins/draw/src/reducer.test.js +85 -0
  191. package/plugins/draw/src/utils/debounce.js +16 -0
  192. package/plugins/draw/src/utils/debounce.test.js +40 -0
  193. package/plugins/draw/src/utils/eventBus.js +36 -0
  194. package/plugins/draw/src/utils/eventBus.test.js +47 -0
  195. package/plugins/draw/src/utils/flattenStyleProperties.js +25 -0
  196. package/plugins/draw/src/utils/flattenStyleProperties.test.js +33 -0
  197. package/plugins/draw/src/utils/getValueForStyle.js +33 -0
  198. package/plugins/draw/src/utils/getValueForStyle.test.js +32 -0
  199. package/plugins/draw/src/utils/resolveColors.js +39 -0
  200. package/plugins/draw/src/utils/resolveColors.test.js +34 -0
  201. package/plugins/draw/src/utils/spatial.js +243 -0
  202. package/plugins/draw/src/utils/spatial.test.js +243 -0
  203. package/plugins/draw/src/utils/touchTarget.js +95 -0
  204. package/plugins/draw/src/utils/touchTarget.test.js +107 -0
  205. package/plugins/draw/src/utils/undoStack.js +31 -0
  206. package/plugins/draw/src/utils/undoStack.test.js +49 -0
  207. package/plugins/draw/src/validation/liveDrawChecks.js +120 -0
  208. package/plugins/draw/src/validation/liveDrawChecks.test.js +159 -0
  209. package/plugins/draw/src/validation/liveStroke.js +85 -0
  210. package/plugins/draw/src/validation/liveStroke.test.js +150 -0
  211. package/plugins/draw/src/validation/rules.areaError.test.js +12 -0
  212. package/plugins/draw/src/validation/rules.js +186 -0
  213. package/plugins/draw/src/validation/rules.test.js +120 -0
  214. package/plugins/draw/src/validation/validateGeometry.js +119 -0
  215. package/plugins/draw/src/validation/validateGeometry.test.js +200 -0
  216. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  217. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  218. package/plugins/interact/src/hooks/useInteractionHandlers.js +1 -1
  219. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +1 -1
  220. package/plugins/interact/src/utils/buildStylesMap.js +1 -0
  221. package/plugins/search/dist/css/index.css +1 -317
  222. package/rollup.esm.mjs +35 -28
  223. package/sonar-project.properties +2 -2
  224. package/src/App/components/Attributions/Attributions.module.scss +1 -0
  225. package/src/App/components/Hints/Hints.module.scss +5 -4
  226. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +1 -1
  227. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +11 -0
  228. package/src/App/components/MoveControl/MoveControl.jsx +73 -9
  229. package/src/App/components/MoveControl/MoveControl.module.scss +12 -2
  230. package/src/App/components/MoveControl/MoveControl.test.jsx +136 -0
  231. package/src/App/hooks/useHintsAPI.js +67 -0
  232. package/src/App/hooks/useHintsAPI.test.js +145 -0
  233. package/src/App/renderer/PluginInits.jsx +4 -0
  234. package/src/App/renderer/PluginInits.test.jsx +7 -1
  235. package/src/InteractiveMap/InteractiveMap.js +20 -0
  236. package/src/InteractiveMap/InteractiveMap.test.js +7 -0
  237. package/src/config/events.js +4 -0
  238. package/src/scss/settings/_dimensions.scss +3 -0
  239. package/src/services/announcer.js +38 -3
  240. package/src/services/announcer.test.js +133 -1
  241. package/src/services/hints.js +3 -0
  242. package/src/services/hints.test.js +19 -0
  243. package/src/types.js +12 -0
  244. package/src/utils/detectInterfaceType.js +5 -1
  245. package/src/utils/isMac.js +15 -0
  246. package/src/utils/isMac.test.js +37 -0
  247. package/webpack.umd.mjs +1 -0
@@ -0,0 +1,385 @@
1
+ import { createMidpointLayer } from './midpointLayer.js'
2
+ import { createVertexLayer } from './vertexLayer.js'
3
+ import { createActiveVertexLayer } from './activeVertexLayer.js'
4
+ import { createSelectionState } from './selectionState.js'
5
+ import { createModifyInteraction, deriveModifyOp } from './modifyInteraction.js'
6
+ import { createPointerHandlers } from './pointerHandlers.js'
7
+ import { createTouchHandler } from './touchHandler.js'
8
+ import { createKeyboardHandler } from './keyboardHandler.js'
9
+ import { deleteVertex, insertAtMidpoint } from './vertexOps.js'
10
+ import { applyUndo } from './undoOps.js'
11
+ import { ADAPTER_EVENTS } from '../../../adapterEvents.js'
12
+ import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js'
13
+ import { createLiveStroke } from '../../../validation/liveStroke.js'
14
+
15
+ const TOUCH_INTERFACE = 'touch'
16
+ const VERTEX_TYPE = 'vertex'
17
+ const MOVE_VERTEX = 'commit-move'
18
+ const INSERT_VERTEX = 'commit-insert'
19
+ const DELETE_VERTEX = 'commit-delete'
20
+
21
+ // Map an undo-op type onto the geometry-change `phase` consumed by validation.
22
+ const OP_PHASE = {
23
+ move_vertex: MOVE_VERTEX,
24
+ insert_vertex: INSERT_VERTEX,
25
+ delete_vertex: DELETE_VERTEX
26
+ }
27
+
28
+ // Undoing an op commits the inverse change (undo of a delete re-inserts, etc.),
29
+ // so its re-validation reports the inverse phase.
30
+ const UNDO_INVERSE_PHASE = {
31
+ move_vertex: MOVE_VERTEX,
32
+ insert_vertex: DELETE_VERTEX,
33
+ delete_vertex: INSERT_VERTEX
34
+ }
35
+
36
+ // Live invalid-stroke wiring: re-validate the displayed geometry on every geometry
37
+ // change (Modify drag, touch drag, keyboard nudge) via the shared controller —
38
+ // default rules synchronously, user callback throttled. All committed vertices are
39
+ // "placed", so the polygon count drops only OL's closing duplicate.
40
+ //
41
+ // The style is set ONCE to a function backed by `invalid`, rather than calling
42
+ // Feature#setStyle() on every flip: setStyle() fires a feature-level 'change'
43
+ // event, which OL's Modify interaction also listens for (to detect out-of-band
44
+ // edits and rebuild its internal drag/segment index) — guarded only while Modify
45
+ // itself is synchronously mutating the geometry. The built-in rules' flip stays
46
+ // inside that guarded window, but the user-callback flip is deliberately
47
+ // throttled to the next animation frame (liveStroke.js), landing outside it —
48
+ // so calling setStyle() there makes Modify treat its own flip as an external
49
+ // edit and rebuild mid-drag, killing the in-progress drag. A style function +
50
+ // map.render() repaints without ever touching the feature, so Modify's listener
51
+ // never fires from our own bookkeeping.
52
+ const wireLiveStroke = ({ map, manager, olFeature }) => {
53
+ let invalid = false
54
+ olFeature.setStyle(() => (invalid ? manager.styles.editFeatureStyleInvalid : manager.styles.editFeatureStyle))
55
+ const liveStroke = createLiveStroke({
56
+ onChange: (next, reason) => {
57
+ invalid = next
58
+ map.render()
59
+ // In edit mode the displayed shape is exactly what Done finishes, so live
60
+ // validity flips also gate the Done button (events.js dispatches them).
61
+ manager.emit(ADAPTER_EVENTS.VALIDITY_CHANGE, { valid: !next, reason })
62
+ }
63
+ })
64
+ const updateLiveValidity = () => {
65
+ const geom = olFeature.getGeometry()
66
+ const type = geom.getType()
67
+ const coordinates = geom.getCoordinates()
68
+ const numVertices = type === 'Polygon'
69
+ ? Math.max(0, (coordinates[0]?.length ?? 1) - 1)
70
+ : coordinates.length
71
+ liveStroke.update({
72
+ feature: { type: 'Feature', geometry: { type, coordinates } },
73
+ context: { mode: 'edit_vertex' },
74
+ numVertices,
75
+ onGeometryChange: manager._geometryValidator
76
+ })
77
+ }
78
+ olFeature.getGeometry().on('change', updateLiveValidity)
79
+ return {
80
+ liveStroke,
81
+ destroy () {
82
+ olFeature.getGeometry().un('change', updateLiveValidity)
83
+ liveStroke.destroy()
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Edit vertex mode — handles edit_vertex.
90
+ *
91
+ * createEditMode composes the OL pieces along their natural seams:
92
+ * - selectionState: shared mutable state + layer/event sync
93
+ * - modifyInteraction: OL Modify (pointer drag / midpoint insert) + undo-op derivation
94
+ * - pointerHandlers: mouse selection + delete-vertex button
95
+ * - touchHandler / keyboardHandler: touch drag and keyboard nudge input
96
+ */
97
+
98
+ // Delete-selected-vertex and undo operations, shared by pointer, touch and keyboard input
99
+ const createVertexActions = ({ olFeature, undoStack, selection, getTouchHandler }) => {
100
+ const { state, setState, syncGeom, emitGeometryValidation } = selection
101
+
102
+ const doDeleteVertex = () => {
103
+ if (state.selectedVertexType !== VERTEX_TYPE || state.selectedVertexIndex < 0) {
104
+ return
105
+ }
106
+ const result = deleteVertex(olFeature, state.selectedVertexIndex)
107
+ if (!result) {
108
+ return
109
+ }
110
+ undoStack.push({ type: 'delete_vertex', vertexIndex: result.deletedIndex, deletedCoord: result.deletedCoord })
111
+ syncGeom()
112
+ emitGeometryValidation(DELETE_VERTEX, result.deletedIndex)
113
+ setState({ selectedVertexIndex: -1, selectedVertexType: null })
114
+ }
115
+
116
+ const doUndo = () => {
117
+ const op = undoStack.pop()
118
+ if (!op) {
119
+ return
120
+ }
121
+ const previousIndex = state.selectedVertexIndex
122
+ const restoredIndex = applyUndo(olFeature, op)
123
+ syncGeom()
124
+ // An undo commits the inverse change, so it must re-validate like any other
125
+ // commit — otherwise the invalid stroke and the Done gate go stale.
126
+ emitGeometryValidation(UNDO_INVERSE_PHASE[op.type], restoredIndex)
127
+ // Only re-select if a vertex was already active — undo must not create a new selection
128
+ const newIndex = previousIndex >= 0 ? restoredIndex : -1
129
+ setState({
130
+ selectedVertexIndex: newIndex,
131
+ selectedVertexType: newIndex >= 0 ? VERTEX_TYPE : null
132
+ })
133
+ if (previousIndex >= 0 && newIndex >= 0 && state.interfaceType === TOUCH_INTERFACE) {
134
+ getTouchHandler().updateTargetPosition()
135
+ }
136
+ }
137
+
138
+ return { doDeleteVertex, doUndo }
139
+ }
140
+
141
+ const wireTouchHandler = ({ map, container, manager, snap, olFeature, undoStack, selection }) => {
142
+ const { state, getState, setState, syncGeom, emitGeometryValidation } = selection
143
+ const selectVertex = (index) => setState({ selectedVertexIndex: index, selectedVertexType: VERTEX_TYPE })
144
+
145
+ const touchHandler = createTouchHandler({
146
+ map,
147
+ container,
148
+ getState,
149
+ setState,
150
+ colors: manager.colors,
151
+ snap,
152
+ onVertexMoved ({ vertexIndex, previousCoord }) {
153
+ undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord })
154
+ syncGeom()
155
+ emitGeometryValidation(MOVE_VERTEX, vertexIndex)
156
+ selectVertex(vertexIndex)
157
+ touchHandler.updateTargetPosition()
158
+ },
159
+ onTap (hit) {
160
+ if (!hit) {
161
+ setState({ selectedVertexIndex: -1, selectedVertexType: null })
162
+ return
163
+ }
164
+ if (hit.type === VERTEX_TYPE) {
165
+ selectVertex(hit.index)
166
+ touchHandler.updateTargetPosition()
167
+ return
168
+ }
169
+ // Only vertex and midpoint hits reach here, and the vertex case returned above
170
+ const result = insertAtMidpoint(olFeature, state.midpoints, hit.index, state.vertices.length)
171
+ if (!result) {
172
+ return
173
+ }
174
+ undoStack.push({ type: 'insert_vertex', vertexIndex: result.insertedIndex })
175
+ syncGeom()
176
+ emitGeometryValidation(INSERT_VERTEX, result.insertedIndex)
177
+ selectVertex(result.insertedIndex)
178
+ touchHandler.updateTargetPosition()
179
+ }
180
+ })
181
+
182
+ selection.setHooks({
183
+ onDeselect: () => touchHandler.hide(),
184
+ onUpdate () {
185
+ if (state.interfaceType === TOUCH_INTERFACE) {
186
+ touchHandler.updateTargetPosition()
187
+ }
188
+ }
189
+ })
190
+
191
+ return touchHandler
192
+ }
193
+
194
+ const wireKeyboardHandler = ({ map, container, snap, undoStack, selection, touchHandler, actions }) => {
195
+ const { state, getState, setState, syncGeom, emitGeometryValidation } = selection
196
+
197
+ return createKeyboardHandler({
198
+ map,
199
+ getState,
200
+ setState,
201
+ snap,
202
+ onVertexMoved ({ vertexIndex, previousCoord }) {
203
+ undoStack.push({ type: 'move_vertex', vertexIndex, previousCoord })
204
+ syncGeom()
205
+ emitGeometryValidation(MOVE_VERTEX, vertexIndex)
206
+ setState({ selectedVertexIndex: vertexIndex, selectedVertexType: VERTEX_TYPE })
207
+ },
208
+ onInserted ({ insertedIndex }) {
209
+ undoStack.push({ type: 'insert_vertex', vertexIndex: insertedIndex })
210
+ syncGeom()
211
+ emitGeometryValidation(INSERT_VERTEX, insertedIndex)
212
+ },
213
+ onDeleted: actions.doDeleteVertex,
214
+ onUndo: actions.doUndo,
215
+ onKeyboardActive () {
216
+ if (state.interfaceType === 'keyboard') {
217
+ return
218
+ }
219
+ state.interfaceType = 'keyboard'
220
+ touchHandler.hide()
221
+ container.focus({ preventScroll: true })
222
+ }
223
+ })
224
+ }
225
+
226
+ // Style hot-swap on map style change + touch-target reposition on map resize
227
+ const wireMapSync = ({ map, manager, layers, selection, touchHandler, live }) => {
228
+ const { state } = selection
229
+
230
+ const onStylesChanged = (styles) => {
231
+ // Re-assert through the live-stroke controller so an invalid (dashed) shape
232
+ // stays dashed across a map style change.
233
+ live.liveStroke.refresh()
234
+ layers.vertexLayer.updateStyle(styles.vertexStyle)
235
+ layers.midpointLayer.updateStyle(styles.midpointStyle)
236
+ layers.activeLayer.update(state)
237
+ touchHandler.updateColors(manager.colors)
238
+ }
239
+ manager.on(STYLES_CHANGED_EVENT, onStylesChanged)
240
+
241
+ // Reposition the touch target after OL re-renders with the new size.
242
+ // change:size fires before the render, so we wait for postrender to get
243
+ // correct pixel coords from getPixelFromCoordinate.
244
+ const onMapSizeChange = () => {
245
+ if (state.interfaceType !== TOUCH_INTERFACE || state.selectedVertexIndex < 0) {
246
+ return
247
+ }
248
+ map.once('postrender', () => touchHandler.updateTargetPosition())
249
+ }
250
+ map.on('change:size', onMapSizeChange)
251
+
252
+ return {
253
+ destroy () {
254
+ manager.off(STYLES_CHANGED_EVENT, onStylesChanged)
255
+ map.un('change:size', onMapSizeChange)
256
+ }
257
+ }
258
+ }
259
+
260
+ // The mode interface consumed by OLDrawManager
261
+ const buildModeApi = ({ manager, store, olFeature, originalFeatureStyle, selection, actions, parts }) => {
262
+ const { state } = selection
263
+ const { touchHandler } = parts
264
+
265
+ return {
266
+ setInterfaceType (type) {
267
+ if (type === state.interfaceType) {
268
+ return
269
+ }
270
+ state.interfaceType = type
271
+ if (type === TOUCH_INTERFACE) {
272
+ touchHandler.updateTargetPosition()
273
+ } else {
274
+ touchHandler.hide()
275
+ }
276
+ },
277
+
278
+ done () {
279
+ manager.emit(ADAPTER_EVENTS.EDIT_FINISH, store.toGeoJSON(olFeature))
280
+ },
281
+
282
+ // Committed-verdict write (events.js): routed through the live-stroke
283
+ // controller so its cached state stays in sync with the rendered style.
284
+ setInvalid (invalid) {
285
+ parts.live.liveStroke.set(invalid)
286
+ },
287
+
288
+ // Nothing to restore here: the pre-edit feature is kept as tempFeature in the
289
+ // reducer and events.js re-adds it on cancel
290
+ cancel () {},
291
+
292
+ undo: actions.doUndo,
293
+ deleteVertex: actions.doDeleteVertex,
294
+ // MoveControl's D-pad, routed here via mapProvider.activeMoveTarget (see
295
+ // events.js) once a vertex is selected.
296
+ nudgeSelectedVertex: parts.keyboardHandler.nudgeByDelta,
297
+
298
+ destroy () {
299
+ parts.live.destroy()
300
+ olFeature.setStyle(originalFeatureStyle)
301
+ selection.destroy()
302
+ parts.mapSync.destroy()
303
+ parts.pointerHandlers.destroy()
304
+ parts.modify.destroy()
305
+ parts.layers.activeLayer.remove()
306
+ parts.layers.midpointLayer.remove()
307
+ parts.layers.vertexLayer.remove()
308
+ touchHandler.destroy()
309
+ parts.keyboardHandler.destroy()
310
+ }
311
+ }
312
+ }
313
+
314
+ /**
315
+ * @returns {{ setInterfaceType, done, cancel, undo, deleteVertex, nudgeSelectedVertex, destroy } | null}
316
+ */
317
+ export const createEditMode = ({ map, manager, options }) => {
318
+ const { featureId, container, interfaceType, deleteVertexButtonId, snap } = options
319
+ const { store, undoStack } = manager
320
+
321
+ const olFeature = store.getOL(featureId)
322
+ if (!olFeature) {
323
+ return null
324
+ }
325
+
326
+ const originalFeatureStyle = olFeature.getStyle()
327
+
328
+ const midpointLayer = createMidpointLayer(map, manager.styles.midpointStyle)
329
+ const vertexLayer = createVertexLayer(map, manager.styles.vertexStyle)
330
+ const activeLayer = createActiveVertexLayer(map, () => manager.styles)
331
+
332
+ const selection = createSelectionState({
333
+ map,
334
+ manager,
335
+ store,
336
+ olFeature,
337
+ interfaceType,
338
+ layers: { vertexLayer, midpointLayer, activeLayer }
339
+ })
340
+ const { state, getState, setState, syncGeom, emitGeometryValidation } = selection
341
+
342
+ const modify = createModifyInteraction({
343
+ map,
344
+ olFeature,
345
+ getState,
346
+ onModifyEnd (prevCoords) {
347
+ syncGeom()
348
+ const op = prevCoords && deriveModifyOp(prevCoords, state.vertices)
349
+ if (!op) {
350
+ return
351
+ }
352
+ undoStack.push(op)
353
+ emitGeometryValidation(OP_PHASE[op.type], op.vertexIndex)
354
+ setState({ selectedVertexIndex: op.vertexIndex, selectedVertexType: VERTEX_TYPE })
355
+ }
356
+ })
357
+
358
+ syncGeom() // initial populate
359
+
360
+ const layers = { vertexLayer, midpointLayer, activeLayer }
361
+ const live = wireLiveStroke({ map, manager, olFeature })
362
+ const touchHandler = wireTouchHandler({ map, container, manager, snap, olFeature, undoStack, selection })
363
+ const actions = createVertexActions({ olFeature, undoStack, selection, getTouchHandler: () => touchHandler })
364
+ const keyboardHandler = wireKeyboardHandler({ map, container, snap, undoStack, selection, touchHandler, actions })
365
+ const pointerHandlers = createPointerHandlers({
366
+ map,
367
+ container,
368
+ getState,
369
+ setState,
370
+ touchHandler,
371
+ deleteVertexButtonId,
372
+ onDeleteVertex: actions.doDeleteVertex
373
+ })
374
+ const mapSync = wireMapSync({ map, manager, layers, selection, touchHandler, live })
375
+
376
+ return buildModeApi({
377
+ manager,
378
+ store,
379
+ olFeature,
380
+ originalFeatureStyle,
381
+ selection,
382
+ actions,
383
+ parts: { touchHandler, keyboardHandler, pointerHandlers, modify, mapSync, layers, live }
384
+ })
385
+ }
@@ -0,0 +1,303 @@
1
+ import { createEditMode } from './EditMode.js'
2
+ import { createFeatureStore } from '../core/featureStore.js'
3
+ import { STYLES_CHANGED_EVENT } from '../core/internalEvents.js'
4
+ import { ADAPTER_EVENTS } from '../../../adapterEvents.js'
5
+ import { createFakeMap, createFakeManager, createContainer, domEvent } from '../__helpers__/harness.js'
6
+ import Style from 'ol/style/Style.js'
7
+ import Polygon from 'ol/geom/Polygon.js'
8
+
9
+ const RING = [[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]] // square: 4 vertices, deletable
10
+
11
+ // The feature style is a live function (see wireLiveStroke in EditMode.js — avoids
12
+ // Feature#setStyle() on every flip, which would break an in-progress Modify drag),
13
+ // not a static Style instance, so tests must invoke it to read the current style.
14
+ const currentStyle = (olFeature) => olFeature.getStyle()()
15
+
16
+ const setup = (ring = RING) => {
17
+ const map = createFakeMap()
18
+ const manager = createFakeManager()
19
+ manager.store = createFeatureStore()
20
+ manager.store.add({ type: 'Feature', id: 'f1', properties: {}, geometry: { type: 'Polygon', coordinates: [ring] } })
21
+ const container = createContainer()
22
+ map.getViewport().appendChild(container) // keyboard handler treats focus inside the viewport as map focus
23
+ const mode = createEditMode({
24
+ map,
25
+ manager,
26
+ options: { featureId: 'f1', container, interfaceType: 'mouse', deleteVertexButtonId: 'del-v', snap: null }
27
+ })
28
+ liveModes.push(mode)
29
+ const olFeature = manager.store.getOL('f1')
30
+ const tapAt = (x, y) => {
31
+ container.dispatchEvent(domEvent('touchstart', { touches: [{ clientX: x, clientY: y }] }))
32
+ container.dispatchEvent(domEvent('touchend', { changedTouches: [{ clientX: x, clientY: y }] }))
33
+ }
34
+ return { map, manager, container, mode, olFeature, ring: () => olFeature.getGeometry().getCoordinates()[0], tapAt }
35
+ }
36
+
37
+ const liveModes = []
38
+ afterEach(() => {
39
+ liveModes.splice(0).forEach((m) => m?.destroy())
40
+ document.body.innerHTML = ''
41
+ })
42
+
43
+ const key = (type, props) => window.dispatchEvent(new KeyboardEvent(type, { cancelable: true, ...props }))
44
+
45
+ test('returns null for an unknown feature id', () => {
46
+ const { map, manager } = setup()
47
+ expect(createEditMode({ map, manager, options: { featureId: 'nope', container: createContainer() } })).toBeNull()
48
+ })
49
+
50
+ test('entering edit mode swaps the feature style and reports the initial vertex state', () => {
51
+ const { manager, olFeature } = setup()
52
+ expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyle)
53
+ expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_CHANGE, { numVertices: 4 })
54
+ expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.UPDATE, expect.objectContaining({ id: 'f1' }))
55
+ })
56
+
57
+ test('setInvalid swaps between the solid and dashed edit styles', () => {
58
+ const { manager, mode, olFeature } = setup()
59
+ mode.setInvalid(true)
60
+ expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyleInvalid)
61
+ mode.setInvalid(false)
62
+ expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyle)
63
+ })
64
+
65
+ test('a mid-drag crossing turns the stroke dashed live, and back when it clears', () => {
66
+ const { manager, olFeature } = setup()
67
+ // Geometry mutation (as during a Modify/touch drag) — no commit yet.
68
+ olFeature.getGeometry().setCoordinates([[[0, 0], [100, 100], [100, 0], [0, 100], [0, 0]]])
69
+ expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyleInvalid)
70
+ olFeature.getGeometry().setCoordinates([[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]])
71
+ expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyle)
72
+ })
73
+
74
+ test('a validity flip repaints the map without touching the feature — Modify tracks its own drag via feature #change, which setStyle() would trip', () => {
75
+ const { map, olFeature } = setup()
76
+ const renderCallsBefore = map.render.mock.calls.length
77
+ olFeature.getGeometry().setCoordinates([[[0, 0], [100, 100], [100, 0], [0, 100], [0, 0]]])
78
+ expect(map.render.mock.calls.length).toBeGreaterThan(renderCallsBefore)
79
+ })
80
+
81
+ test('validity flips while editing also gate the Done button', () => {
82
+ const { manager, olFeature } = setup()
83
+ olFeature.getGeometry().setCoordinates([[[0, 0], [100, 100], [100, 0], [0, 100], [0, 0]]])
84
+ expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VALIDITY_CHANGE,
85
+ expect.objectContaining({ valid: false, reason: expect.any(String) }))
86
+ olFeature.getGeometry().setCoordinates([[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]])
87
+ expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VALIDITY_CHANGE,
88
+ expect.objectContaining({ valid: true }))
89
+ })
90
+
91
+ test('the user callback runs throttled during an edit drag', () => {
92
+ jest.useFakeTimers()
93
+ const { manager, olFeature } = setup()
94
+ manager._geometryValidator = jest.fn(() => ({ valid: false, reason: 'outside region' }))
95
+ olFeature.getGeometry().setCoordinates([[[0, 0], [110, 0], [100, 100], [0, 100], [0, 0]]])
96
+ olFeature.getGeometry().setCoordinates([[[0, 0], [120, 0], [100, 100], [0, 100], [0, 0]]])
97
+ expect(manager._geometryValidator).not.toHaveBeenCalled() // deferred to the frame
98
+ jest.runAllTimers()
99
+ expect(manager._geometryValidator).toHaveBeenCalledTimes(1) // trailing edge only
100
+ expect(currentStyle(olFeature)).toBe(manager.styles.editFeatureStyleInvalid)
101
+ jest.useRealTimers()
102
+ })
103
+
104
+ test('done() emits the edited feature; cancel() is a no-op', () => {
105
+ const { manager, mode } = setup()
106
+ mode.done()
107
+ expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.EDIT_FINISH, expect.objectContaining({ id: 'f1' }))
108
+ expect(mode.cancel()).toBeUndefined()
109
+ })
110
+
111
+ test('select via pointer, delete the vertex, then undo restores it', () => {
112
+ const { container, manager, mode, ring } = setup()
113
+ container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 }))
114
+ expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, expect.objectContaining({ index: 1 }))
115
+
116
+ mode.deleteVertex()
117
+ expect(ring()).toHaveLength(4) // one vertex fewer (closed ring keeps closing coord)
118
+ expect(manager.undoStack.length).toBe(1)
119
+
120
+ mode.undo()
121
+ expect(ring()).toHaveLength(5)
122
+ expect(ring()[1]).toEqual([100, 0])
123
+ mode.undo() // empty stack — no-op
124
+ })
125
+
126
+ test('nudgeSelectedVertex (MoveControl D-pad) moves the selected vertex and is undoable', () => {
127
+ const { container, manager, mode, ring } = setup()
128
+ container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 }))
129
+ mode.nudgeSelectedVertex(1, 0, true)
130
+ expect(ring()[1]).not.toEqual([100, 0])
131
+ expect(manager.undoStack.length).toBe(1)
132
+
133
+ mode.undo()
134
+ expect(ring()[1]).toEqual([100, 0])
135
+ })
136
+
137
+ test('undo re-validates with the inverse change phase (undo of a delete re-inserts)', () => {
138
+ jest.useFakeTimers()
139
+ const { container, manager, mode } = setup()
140
+ container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 }))
141
+ mode.deleteVertex()
142
+ jest.runAllTimers()
143
+ manager.emit.mockClear()
144
+ mode.undo()
145
+ jest.runAllTimers()
146
+ expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.GEOMETRY_CHANGE, expect.objectContaining({
147
+ phase: 'commit-insert',
148
+ vertexIndex: 1,
149
+ feature: expect.any(Object)
150
+ }))
151
+ jest.useRealTimers()
152
+ })
153
+
154
+ test('a Modify drag pushes a move op derived from the before/after vertices', () => {
155
+ const { map, manager, olFeature } = setup()
156
+ const interaction = map.interactions[0]
157
+ interaction.dispatchEvent({ type: 'modifystart' })
158
+ olFeature.getGeometry().setCoordinates([[[0, 0], [120, 5], [100, 100], [0, 100], [0, 0]]])
159
+ interaction.dispatchEvent({ type: 'modifyend' })
160
+ expect(manager.undoStack.pop()).toEqual({ type: 'move_vertex', vertexIndex: 1, previousCoord: [100, 0] })
161
+ expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, expect.objectContaining({ index: 1 }))
162
+
163
+ interaction.dispatchEvent({ type: 'modifystart' })
164
+ interaction.dispatchEvent({ type: 'modifyend' }) // no coordinate change — nothing pushed
165
+ expect(manager.undoStack.length).toBe(0)
166
+ })
167
+
168
+ test('deleting needs a selected vertex and respects the minimum ring size', () => {
169
+ const { mode, manager } = setup()
170
+ mode.deleteVertex() // nothing selected
171
+ expect(manager.undoStack.length).toBe(0)
172
+
173
+ const triangle = setup([[0, 0], [100, 0], [100, 100], [0, 0]])
174
+ triangle.container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 }))
175
+ triangle.mode.deleteVertex() // 3 vertices is the minimum for a ring
176
+ expect(triangle.ring()).toHaveLength(4)
177
+ expect(triangle.manager.undoStack.length).toBe(0)
178
+ })
179
+
180
+ test('keyboard: arrows nudge the selected vertex and commit one undo op on keyup', () => {
181
+ const { container, manager, ring } = setup()
182
+ container.dispatchEvent(domEvent('pointerdown', { pointerType: 'mouse', clientX: 100, clientY: 0 }))
183
+ key('keydown', { key: 'ArrowRight' })
184
+ key('keyup', { key: 'ArrowRight' })
185
+ expect(ring()[1]).toEqual([105, 0])
186
+ expect(manager.undoStack.pop()).toEqual({ type: 'move_vertex', vertexIndex: 1, previousCoord: [100, 0] })
187
+ })
188
+
189
+ test('keyboard: Space selects the midpoint nearest the crosshair; nudging inserts it as a vertex', () => {
190
+ const { manager, ring } = setup()
191
+ key('keydown', { key: ' ' }) // nearest handle to center [50,50] is the midpoint [50,0]... all midpoints are at 50
192
+ key('keydown', { key: 'ArrowRight' })
193
+ key('keyup', { key: 'ArrowRight' })
194
+ expect(ring()).toHaveLength(6)
195
+ expect(ring()[1]).toEqual([55, 0]) // inserted at the midpoint, nudged one step right
196
+ expect(manager.undoStack.pop()).toEqual({ type: 'move_vertex', vertexIndex: 1, previousCoord: [50, 0] })
197
+ expect(manager.undoStack.pop()).toEqual({ type: 'insert_vertex', vertexIndex: 1 })
198
+ })
199
+
200
+ test('touch: dragging the offset target moves the vertex and records one move op', () => {
201
+ const { container, manager, mode, tapAt, ring } = setup()
202
+ tapAt(100, 0)
203
+ mode.setInterfaceType('touch')
204
+ // The finger lands on the SVG's inner circle, not the SVG element itself
205
+ const grip = container.querySelector('[data-im-draw-touch-target] circle')
206
+ grip.dispatchEvent(domEvent('touchstart', { touches: [{ clientX: 100, clientY: 0 }] }))
207
+ grip.dispatchEvent(domEvent('touchmove', { touches: [{ clientX: 120, clientY: 10 }] }))
208
+ grip.dispatchEvent(domEvent('touchend', { changedTouches: [{ clientX: 120, clientY: 10 }] }))
209
+ expect(ring()[1]).toEqual([120, 10])
210
+ expect(manager.undoStack.pop()).toEqual({ type: 'move_vertex', vertexIndex: 1, previousCoord: [100, 0] })
211
+ })
212
+
213
+ test('touch: tapping a midpoint inserts a vertex there and selects it', () => {
214
+ const { manager, tapAt, ring } = setup()
215
+ tapAt(50, 0) // midpoint between [0,0] and [100,0]
216
+ expect(ring()).toHaveLength(6)
217
+ expect(ring()[1]).toEqual([50, 0])
218
+ expect(manager.undoStack.pop()).toEqual({ type: 'insert_vertex', vertexIndex: 1 })
219
+ })
220
+
221
+ test('touch: tapping a stale midpoint the geometry can no longer place is a no-op', () => {
222
+ const { manager, olFeature, ring, tapAt } = setup()
223
+ // Desync state from geometry: swap in a smaller geometry object so the
224
+ // selection-state change listener (bound to the previous geometry) never
225
+ // fires. state.midpoints still describes the 4-vertex square (4 midpoints),
226
+ // while the live geometry is a 3-vertex triangle that has only 3 midpoints.
227
+ olFeature.setGeometry(new Polygon([[[0, 0], [100, 0], [100, 100], [0, 0]]]))
228
+ tapAt(0, 50) // the stale square's left-edge midpoint (index 3) — the triangle can't place it
229
+ expect(manager.undoStack.length).toBe(0)
230
+ expect(ring()).toHaveLength(4) // triangle geometry left untouched
231
+ })
232
+
233
+ test('undo in touch mode repositions the offset target on the restored vertex', () => {
234
+ const { container, manager, mode, tapAt } = setup()
235
+ tapAt(100, 0)
236
+ key('keydown', { key: 'ArrowRight' }) // keyboard nudge (switches interface to keyboard)
237
+ key('keyup', { key: 'ArrowRight' })
238
+ mode.setInterfaceType('touch')
239
+ mode.undo()
240
+ const target = container.querySelector('[data-im-draw-touch-target]')
241
+ expect(target.style.display).toBe('block')
242
+ expect(manager.undoStack.length).toBe(0)
243
+ })
244
+
245
+ test('touch tap selects a vertex and interface switching shows/hides the touch target', () => {
246
+ const { container, manager, mode, tapAt } = setup()
247
+ tapAt(100, 0)
248
+ expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, expect.objectContaining({ index: 1 }))
249
+
250
+ const target = container.querySelector('[data-im-draw-touch-target]')
251
+ mode.setInterfaceType('touch')
252
+ expect(target.style.display).toBe('block')
253
+ mode.setInterfaceType('touch') // same type — no change
254
+ mode.setInterfaceType('mouse')
255
+ expect(target.style.display).toBe('none')
256
+ })
257
+
258
+ test('a tap on empty map deselects', () => {
259
+ const { manager, tapAt } = setup()
260
+ tapAt(100, 0)
261
+ tapAt(500, 500)
262
+ expect(manager.emit).toHaveBeenCalledWith(ADAPTER_EVENTS.VERTEX_SELECTION, expect.objectContaining({ index: -1 }))
263
+ })
264
+
265
+ test('style changes re-style the feature and handles', () => {
266
+ const { manager, olFeature } = setup()
267
+ // The real manager swaps manager.styles before emitting (see setMapStyle).
268
+ const newStyles = { ...manager.styles, editFeatureStyle: new Style({}) }
269
+ manager.styles = newStyles
270
+ manager.emit(STYLES_CHANGED_EVENT, newStyles)
271
+ expect(currentStyle(olFeature)).toBe(newStyles.editFeatureStyle)
272
+ })
273
+
274
+ test('a style change while the shape is invalid keeps the dashed stroke', () => {
275
+ const { manager, olFeature } = setup()
276
+ olFeature.getGeometry().setCoordinates([[[0, 0], [100, 100], [100, 0], [0, 100], [0, 0]]]) // dashed
277
+ const newStyles = {
278
+ ...manager.styles,
279
+ editFeatureStyle: new Style({}),
280
+ editFeatureStyleInvalid: new Style({})
281
+ }
282
+ manager.styles = newStyles
283
+ manager.emit(STYLES_CHANGED_EVENT, newStyles)
284
+ expect(currentStyle(olFeature)).toBe(newStyles.editFeatureStyleInvalid)
285
+ })
286
+
287
+ test('map resize repositions the touch target after the next render — touch with a selection only', () => {
288
+ const { map, mode, tapAt } = setup()
289
+ map.emit('change:size') // mouse interface — EditMode's own resize hook ignores it
290
+ tapAt(100, 0)
291
+ mode.setInterfaceType('touch')
292
+ map.emit('change:size')
293
+ map.emit('postrender') // once-listener fires; must not throw
294
+ expect(map.once).toHaveBeenCalledWith('postrender', expect.any(Function))
295
+ })
296
+
297
+ test('destroy removes interactions, layers and listeners, and restores the feature style', () => {
298
+ const { map, mode, olFeature } = setup()
299
+ mode.destroy()
300
+ expect(map.interactions).toHaveLength(0)
301
+ expect(map.layers).toHaveLength(0)
302
+ expect(olFeature.getStyle()).toBeNull()
303
+ })