@pascal-app/editor 1.0.4 → 1.0.5

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 (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +39 -37
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/bake-thumbnail.tsx +6 -15
  5. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  6. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  7. package/src/components/editor/custom-camera-controls.tsx +81 -8
  8. package/src/components/editor/editor-layout-mobile.tsx +1 -1
  9. package/src/components/editor/editor-layout-v2.tsx +1 -1
  10. package/src/components/editor/export-manager.tsx +14 -19
  11. package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +22 -5
  13. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  14. package/src/components/editor/first-person/ground-holes.ts +130 -0
  15. package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
  16. package/src/components/editor/first-person/mechanism-targets.ts +52 -0
  17. package/src/components/editor/first-person-controls.tsx +19 -11
  18. package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  20. package/src/components/editor/floorplan-panel.tsx +72 -23
  21. package/src/components/editor/follow-pascal.test.tsx +912 -0
  22. package/src/components/editor/follow-pascal.tsx +354 -0
  23. package/src/components/editor/group-actions.ts +2 -0
  24. package/src/components/editor/group-move-3d.ts +2 -0
  25. package/src/components/editor/group-rotate-handle.tsx +2 -0
  26. package/src/components/editor/group-transform-shared.test.ts +460 -0
  27. package/src/components/editor/index.tsx +166 -79
  28. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  29. package/src/components/editor/room-highlight.tsx +13 -5
  30. package/src/components/editor/room-labels.test.ts +621 -128
  31. package/src/components/editor/room-labels.tsx +1090 -190
  32. package/src/components/editor/selection-manager.tsx +38 -117
  33. package/src/components/editor/slab-hole-highlights.tsx +2 -2
  34. package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
  35. package/src/components/editor/snapshot-capture.test.ts +69 -58
  36. package/src/components/editor/snapshot-capture.ts +7 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +350 -4
  38. package/src/components/editor/thumbnail-generator.tsx +128 -54
  39. package/src/components/editor/use-floorplan-background-placement.ts +3 -1
  40. package/src/components/editor/views/plugins-view.tsx +1 -8
  41. package/src/components/editor/views/view-bar.test.tsx +54 -0
  42. package/src/components/editor/views/view-bar.tsx +140 -17
  43. package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
  44. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  45. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  46. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
  47. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
  49. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  50. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  51. package/src/components/point-ask/bubble.tsx +538 -0
  52. package/src/components/point-ask/hover-chip.tsx +91 -0
  53. package/src/components/point-ask/mode-pill.tsx +92 -0
  54. package/src/components/point-ask/optional-motion.ts +22 -0
  55. package/src/components/point-ask/outline-corners.tsx +93 -0
  56. package/src/components/point-ask/pins.tsx +711 -0
  57. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  58. package/src/components/point-ask/point-ask-styles.ts +105 -0
  59. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  60. package/src/components/point-ask/region-marquee.tsx +64 -0
  61. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  62. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  63. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  64. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  65. package/src/components/tools/distribution/distribution-public.ts +9 -0
  66. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  67. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  68. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  69. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  70. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  71. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  72. package/src/components/tools/distribution/ports.test.ts +56 -0
  73. package/src/components/tools/distribution/ports.ts +378 -0
  74. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  75. package/src/components/tools/distribution/run-cursor.ts +36 -0
  76. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  77. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  78. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  79. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  80. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  81. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  82. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  83. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  84. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  85. package/src/components/tools/item/placement-history.test.tsx +38 -0
  86. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  87. package/src/components/tools/item/placement-strategies.ts +3 -6
  88. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  89. package/src/components/tools/item/use-draft-node.test.tsx +94 -94
  90. package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
  91. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  92. package/src/components/tools/registry/item-surface-move.ts +2 -0
  93. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  94. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
  96. package/src/components/tools/tool-manager.tsx +14 -5
  97. package/src/components/tools/wall/wall-drafting.test.ts +170 -57
  98. package/src/components/ui/action-menu/action-button.tsx +6 -1
  99. package/src/components/ui/action-menu/camera-actions.tsx +7 -3
  100. package/src/components/ui/action-menu/control-modes.tsx +13 -7
  101. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  102. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  103. package/src/components/ui/action-menu/index.tsx +56 -33
  104. package/src/components/ui/action-menu/measurement-control.tsx +11 -9
  105. package/src/components/ui/action-menu/placement.ts +20 -0
  106. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  107. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  108. package/src/components/ui/build-panel/build-panel.tsx +10 -3
  109. package/src/components/ui/command-palette/editor-commands.tsx +3 -12
  110. package/src/components/ui/command-palette/index.tsx +45 -15
  111. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  112. package/src/components/ui/controls/action-button.tsx +5 -2
  113. package/src/components/ui/controls/metric-control.tsx +1 -1
  114. package/src/components/ui/controls/segmented-control.tsx +21 -8
  115. package/src/components/ui/controls/select-control.tsx +1 -1
  116. package/src/components/ui/controls/slider-control.tsx +1 -1
  117. package/src/components/ui/controls/toggle-control.tsx +3 -3
  118. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  119. package/src/components/ui/floating-level-selector.tsx +150 -45
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
  121. package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
  122. package/src/components/ui/helpers/helper-manager.tsx +7 -1
  123. package/src/components/ui/helpers/item-helper.tsx +9 -1
  124. package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
  125. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  126. package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
  127. package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
  128. package/src/components/ui/panels/panel-wrapper.tsx +53 -27
  129. package/src/components/ui/panels/parametric-field-control.tsx +2 -2
  130. package/src/components/ui/panels/parametric-inspector.tsx +3 -3
  131. package/src/components/ui/panels/room-panel.tsx +65 -0
  132. package/src/components/ui/primitives/badge.tsx +8 -0
  133. package/src/components/ui/primitives/sidebar.tsx +1 -1
  134. package/src/components/ui/right-stack.tsx +21 -2
  135. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  136. package/src/components/ui/sidebar/icon-rail.tsx +3 -3
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  139. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  140. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  141. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  142. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  143. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
  144. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  145. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  146. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  147. package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
  148. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
  149. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
  150. package/src/components/ui/sidebar/tab-bar.tsx +4 -4
  151. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  152. package/src/components/viewer/viewer-rooms.tsx +16 -0
  153. package/src/hooks/use-auto-save.integration.test.tsx +96 -38
  154. package/src/hooks/use-auto-save.ts +26 -1
  155. package/src/hooks/use-ceiling-events.test.ts +123 -118
  156. package/src/hooks/use-grid-events.test.ts +105 -105
  157. package/src/hooks/use-keyboard.test.ts +65 -16
  158. package/src/hooks/use-keyboard.ts +29 -12
  159. package/src/hooks/use-placement-notice.test.ts +24 -1
  160. package/src/hooks/use-placement-notice.ts +37 -6
  161. package/src/index.tsx +71 -24
  162. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  163. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  164. package/src/lib/add-storey.test.ts +177 -0
  165. package/src/lib/add-storey.ts +59 -0
  166. package/src/lib/below-grade.test.tsx +171 -0
  167. package/src/lib/below-grade.ts +133 -0
  168. package/src/lib/camera-facing.test.ts +24 -0
  169. package/src/lib/camera-facing.ts +24 -0
  170. package/src/lib/direct-manipulation.ts +2 -0
  171. package/src/lib/download-project-file.ts +17 -0
  172. package/src/lib/editor-level-display.ts +9 -2
  173. package/src/lib/editor-views.ts +35 -1
  174. package/src/lib/export-texture-utils.ts +2 -4
  175. package/src/lib/floor-foundation-support.test.ts +1 -0
  176. package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
  177. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
  178. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
  179. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  180. package/src/lib/floorplan/sheet-export.test.ts +24 -0
  181. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
  182. package/src/lib/floorplan/site-plan/coverage.ts +2 -4
  183. package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
  184. package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
  185. package/src/lib/floorplan/site-plan/geometry.ts +1 -416
  186. package/src/lib/floorplan/site-plan/index.ts +0 -16
  187. package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
  188. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
  189. package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
  190. package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
  191. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  192. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  193. package/src/lib/follow-pascal-exterior.ts +39 -0
  194. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  195. package/src/lib/follow-pascal-presentation.ts +129 -0
  196. package/src/lib/follow-pascal-sight.ts +84 -0
  197. package/src/lib/follow-pascal-targets.test.ts +406 -0
  198. package/src/lib/follow-pascal-targets.ts +198 -0
  199. package/src/lib/follow-pascal.test.ts +350 -0
  200. package/src/lib/follow-pascal.ts +284 -0
  201. package/src/lib/fresh-planar-placement.test.ts +112 -0
  202. package/src/lib/fresh-planar-placement.ts +57 -3
  203. package/src/lib/geometry-script/author.ts +6 -1
  204. package/src/lib/geometry-script/client.ts +8 -1
  205. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  206. package/src/lib/geometry-script/protocol.ts +1 -0
  207. package/src/lib/glb-export.test.ts +187 -162
  208. package/src/lib/glb-export.ts +4 -1
  209. package/src/lib/history.test.ts +12 -7
  210. package/src/lib/interaction/scope.ts +4 -0
  211. package/src/lib/level-selection.test.ts +57 -0
  212. package/src/lib/level-selection.ts +16 -1
  213. package/src/lib/lot/clean-ring.test.ts +2 -1
  214. package/src/lib/lot/drop-in.ts +9 -24
  215. package/src/lib/lot/lot-patch.test.ts +1 -1
  216. package/src/lib/lot/lot-patch.ts +2 -2
  217. package/src/lib/measurement-kind.test.ts +6 -8
  218. package/src/lib/measurements.test.ts +23 -0
  219. package/src/lib/orbit-floor-clearance.test.ts +36 -0
  220. package/src/lib/orbit-floor-clearance.ts +48 -0
  221. package/src/lib/paint-pass-through.test.ts +11 -0
  222. package/src/lib/paint-pass-through.ts +4 -2
  223. package/src/lib/paint-scope.ts +1 -0
  224. package/src/lib/parametric-node-update.ts +16 -7
  225. package/src/lib/plugin-chat.ts +15 -0
  226. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  227. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  228. package/src/lib/point-ask/ask-created.test.ts +195 -0
  229. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  230. package/src/lib/point-ask/build-context.test.ts +173 -0
  231. package/src/lib/point-ask/build-context.ts +94 -0
  232. package/src/lib/point-ask/capture-crop.ts +129 -0
  233. package/src/lib/point-ask/choreography.ts +69 -0
  234. package/src/lib/point-ask/compare.test.ts +145 -0
  235. package/src/lib/point-ask/compare.ts +73 -0
  236. package/src/lib/point-ask/describe-target.test.ts +165 -0
  237. package/src/lib/point-ask/describe-target.ts +234 -0
  238. package/src/lib/point-ask/dictation.ts +79 -0
  239. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  240. package/src/lib/point-ask/edge-marker.ts +27 -0
  241. package/src/lib/point-ask/frame-loop.ts +25 -0
  242. package/src/lib/point-ask/history-marks.test.ts +94 -0
  243. package/src/lib/point-ask/history-marks.ts +39 -0
  244. package/src/lib/point-ask/host-api.ts +32 -0
  245. package/src/lib/point-ask/outline-box.test.ts +30 -0
  246. package/src/lib/point-ask/outline-box.ts +11 -0
  247. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  248. package/src/lib/point-ask/pick.test.ts +220 -0
  249. package/src/lib/point-ask/pick.ts +121 -0
  250. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  251. package/src/lib/point-ask/pin-machine.ts +217 -0
  252. package/src/lib/point-ask/placement.test.ts +203 -0
  253. package/src/lib/point-ask/placement.ts +141 -0
  254. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  255. package/src/lib/point-ask/projector.ts +83 -0
  256. package/src/lib/point-ask/region.test.ts +157 -0
  257. package/src/lib/point-ask/region.ts +136 -0
  258. package/src/lib/point-ask/retry.test.ts +217 -0
  259. package/src/lib/point-ask/retry.ts +37 -0
  260. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  261. package/src/lib/point-ask/scene-commit.ts +32 -0
  262. package/src/lib/point-ask/session.ts +1391 -0
  263. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  264. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  265. package/src/lib/point-ask/spring.test.ts +63 -0
  266. package/src/lib/point-ask/spring.ts +107 -0
  267. package/src/lib/point-ask/suggestions.test.ts +80 -0
  268. package/src/lib/point-ask/suggestions.ts +125 -0
  269. package/src/lib/point-ask/target-scope.test.ts +622 -0
  270. package/src/lib/point-ask/target-scope.ts +338 -0
  271. package/src/lib/point-ask/types.ts +48 -0
  272. package/src/lib/portable-export.test.ts +34 -1
  273. package/src/lib/portable-export.ts +119 -53
  274. package/src/lib/print-feature-thickness.ts +10 -1
  275. package/src/lib/print-roof-solids.test.ts +39 -1
  276. package/src/lib/print-roof-solids.ts +43 -10
  277. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  278. package/src/lib/room-assembly-overlay.ts +2 -3
  279. package/src/lib/room-selection.test.ts +45 -1
  280. package/src/lib/scene-clipboard.test.ts +246 -1
  281. package/src/lib/scene-clipboard.ts +44 -14
  282. package/src/lib/selection-routing.ts +1 -1
  283. package/src/lib/sfx-bus.ts +13 -0
  284. package/src/lib/sfx-player.test.ts +70 -9
  285. package/src/lib/sfx-player.ts +6 -0
  286. package/src/lib/stair-levels.test.ts +25 -0
  287. package/src/lib/stair-levels.ts +22 -3
  288. package/src/lib/terrace-draft.ts +2 -1
  289. package/src/lib/viewer-selection.ts +22 -11
  290. package/src/lib/wall-placement-pointer.test.ts +42 -0
  291. package/src/lib/wall-placement-pointer.ts +57 -0
  292. package/src/lib/window-changes.ts +35 -0
  293. package/src/store/use-audio.test.ts +146 -0
  294. package/src/store/use-audio.tsx +127 -3
  295. package/src/store/use-construction-reveal.test.ts +173 -0
  296. package/src/store/use-construction-reveal.ts +145 -0
  297. package/src/store/use-corner-snap-hint.test.ts +24 -0
  298. package/src/store/use-corner-snap-hint.ts +47 -0
  299. package/src/store/use-follow-pascal.test.ts +62 -0
  300. package/src/store/use-follow-pascal.ts +53 -0
  301. package/src/store/use-point-ask.ts +247 -0
  302. package/src/store/use-stair-build-preview.ts +25 -5
  303. package/src/store/view-layout.test.ts +27 -1
  304. package/src/store/view-layout.ts +4 -1
  305. package/src/test-utils/render-current-markup.tsx +16 -0
  306. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  307. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  308. package/src/components/tools/stair/stair-defaults.ts +0 -15
  309. package/src/components/tools/stair/stair-tool.tsx +0 -682
@@ -0,0 +1,47 @@
1
+ import type { CornerBlockReason } from '@pascal-app/core'
2
+ import { create } from 'zustand'
3
+
4
+ /**
5
+ * What the window being placed or dragged near a wall end says: `join` when a window already waits
6
+ * on the other wall, `new` when one would be created, the reason when the end is not a corner a
7
+ * window can wrap, null away from a wall end. The tools write it; the HUD reads it, so the hint is
8
+ * there only inside the zone.
9
+ */
10
+ export type CornerSnapHint = 'join' | 'new' | CornerBlockReason | null
11
+
12
+ export const useCornerSnapHint = create<{
13
+ hint: CornerSnapHint
14
+ setHint: (hint: CornerSnapHint) => void
15
+ }>((set) => ({
16
+ hint: null,
17
+ setHint: (hint) => set((state) => (state.hint === hint ? state : { hint })),
18
+ }))
19
+
20
+ /** One line each, in the HUD's voice: what is in the way, not what to do about it. */
21
+ export const CORNER_BLOCK_NOTES: Record<CornerBlockReason, string> = {
22
+ free_end: 'No wall meets here',
23
+ several_walls: 'Three or more walls meet here',
24
+ angle: 'The walls meet at too sharp or too flat an angle',
25
+ curved: 'A curved wall takes no corner window',
26
+ curtain: 'A curtain wall takes no corner window',
27
+ no_room: 'No room for the matching window on the other wall',
28
+ in_the_way: 'Another opening is in the way at the corner',
29
+ too_tall: 'The window waiting there is taller than this wall',
30
+ }
31
+
32
+ export type CornerHintRow = { keys: string[]; label: string }
33
+
34
+ /** The HUD rows for a hint, shared by the placement tool and the move HUD. */
35
+ export function cornerHintRows(hint: CornerSnapHint): CornerHintRow[] {
36
+ if (!hint) return []
37
+ if (hint === 'join' || hint === 'new')
38
+ return [
39
+ {
40
+ keys: ['Left click'],
41
+ label: hint === 'join' ? 'Join as a corner window' : 'Corner window',
42
+ },
43
+ { keys: ['Alt'], label: 'Place without the corner snap' },
44
+ ]
45
+ // The HUD's notice row, as for a placement that does not fit: a note, not a key to press.
46
+ return [{ keys: ['!'], label: CORNER_BLOCK_NOTES[hint] }]
47
+ }
@@ -0,0 +1,62 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import { FOLLOW_PASCAL_STORAGE_KEY, useFollowPascal } from './use-follow-pascal'
3
+
4
+ // Follow Pascal's switch and its pause: touching the camera pauses it and offers "Resume following";
5
+ // a new build starts it again; the switch itself is the person's, kept across sessions.
6
+ beforeEach(() => {
7
+ useFollowPascal.setState({ on: true, paused: false, building: false })
8
+ })
9
+
10
+ describe('Follow Pascal', () => {
11
+ test('is on until the person turns it off, and turning it off clears a pause', () => {
12
+ expect(useFollowPascal.getState().on).toBe(true)
13
+ useFollowPascal.getState().pause()
14
+ expect(useFollowPascal.getState().paused).toBe(true)
15
+ useFollowPascal.getState().setOn(false)
16
+ expect(useFollowPascal.getState()).toMatchObject({ on: false, paused: false })
17
+ })
18
+
19
+ test('a pause waits for the person, and only matters while it is on', () => {
20
+ useFollowPascal.setState({ on: false })
21
+ useFollowPascal.getState().pause()
22
+ expect(useFollowPascal.getState().paused).toBe(false)
23
+ useFollowPascal.setState({ on: true })
24
+ useFollowPascal.getState().pause()
25
+ expect(useFollowPascal.getState().paused).toBe(true)
26
+ useFollowPascal.getState().resume()
27
+ expect(useFollowPascal.getState().paused).toBe(false)
28
+ })
29
+
30
+ test('a build that begins takes the camera again, even after a pause', () => {
31
+ useFollowPascal.getState().pause()
32
+ useFollowPascal.getState().beginBuild()
33
+ expect(useFollowPascal.getState()).toMatchObject({ paused: false, building: true })
34
+ useFollowPascal.getState().endBuild()
35
+ expect(useFollowPascal.getState().building).toBe(false)
36
+ })
37
+
38
+ test('only the switch is kept across sessions', () => {
39
+ const persisted = (
40
+ useFollowPascal as unknown as {
41
+ persist: { getOptions(): { partialize?: (state: unknown) => unknown; name?: string } }
42
+ }
43
+ ).persist.getOptions()
44
+ expect(persisted.name).toBe(FOLLOW_PASCAL_STORAGE_KEY)
45
+ expect(persisted.partialize?.({ on: false, paused: true, building: true })).toEqual({
46
+ on: false,
47
+ })
48
+ })
49
+
50
+ test('the pill offers itself only to a paused follower during a build', () => {
51
+ const shows = () => {
52
+ const { on, paused, building } = useFollowPascal.getState()
53
+ return on && paused && building
54
+ }
55
+ useFollowPascal.getState().beginBuild()
56
+ expect(shows()).toBe(false)
57
+ useFollowPascal.getState().pause()
58
+ expect(shows()).toBe(true)
59
+ useFollowPascal.getState().endBuild()
60
+ expect(shows()).toBe(false)
61
+ })
62
+ })
@@ -0,0 +1,53 @@
1
+ 'use client'
2
+
3
+ import { create } from 'zustand'
4
+ import { createJSONStorage, persist } from 'zustand/middleware'
5
+
6
+ export const FOLLOW_PASCAL_STORAGE_KEY = 'pascal-follow-pascal'
7
+
8
+ /** Reads `localStorage` on each call, so a page without it (a test, a sandbox) still has the store's persist API. */
9
+ const browserStorage = createJSONStorage(() => ({
10
+ getItem: (key: string) => globalThis.localStorage?.getItem(key) ?? null,
11
+ setItem: (key: string, value: string) => globalThis.localStorage?.setItem(key, value),
12
+ removeItem: (key: string) => globalThis.localStorage?.removeItem(key),
13
+ }))
14
+
15
+ type FollowPascalState = {
16
+ /** The person's switch: the camera follows what a build works on. Kept across sessions. */
17
+ on: boolean
18
+ /** The person took the camera: it stays theirs until they resume or the next build begins. */
19
+ paused: boolean
20
+ /** A build is playing (set by the follower from the reveal's own words). */
21
+ building: boolean
22
+ setOn: (on: boolean) => void
23
+ pause: () => void
24
+ resume: () => void
25
+ beginBuild: () => void
26
+ endBuild: () => void
27
+ }
28
+
29
+ /** Follow Pascal's switch, its pause and whether a build is being followed. */
30
+ const useFollowPascal = create<FollowPascalState>()(
31
+ persist(
32
+ (set, get) => ({
33
+ on: true,
34
+ paused: false,
35
+ building: false,
36
+ setOn: (on) => set({ on, paused: false }),
37
+ pause: () => {
38
+ if (get().on) set({ paused: true })
39
+ },
40
+ resume: () => set({ paused: false }),
41
+ beginBuild: () => set({ building: true, paused: false }),
42
+ endBuild: () => set({ building: false }),
43
+ }),
44
+ {
45
+ name: FOLLOW_PASCAL_STORAGE_KEY,
46
+ storage: browserStorage,
47
+ partialize: (state) => ({ on: state.on }),
48
+ },
49
+ ),
50
+ )
51
+
52
+ export default useFollowPascal
53
+ export { useFollowPascal }
@@ -0,0 +1,247 @@
1
+ 'use client'
2
+
3
+ import type { PointAskSubmit, PointCamera } from '@pascal-app/core'
4
+ import { create } from 'zustand'
5
+ import type { CompareRefusal } from '../lib/point-ask/compare'
6
+ import { createPin, type PinEvent, type PinModel, reducePin } from '../lib/point-ask/pin-machine'
7
+ import type { PointTarget, Rect, ScreenPoint } from '../lib/point-ask/types'
8
+
9
+ // Point and ask's state (the owner, 8 October). Everything the overlay draws comes from here, and
10
+ // everything that changes per frame (positions) is derived from it, not stored in it.
11
+
12
+ export type WorldBox = { min: [number, number, number]; max: [number, number, number] }
13
+
14
+ /** A pointed element with its world box, so the overlay can outline and anchor to it. */
15
+ export type BubbleTarget = PointTarget & { box: WorldBox | null }
16
+
17
+ export type PointHit = {
18
+ point: [number, number, number]
19
+ normal: [number, number, number]
20
+ face?: 'interior' | 'exterior' | 'top' | 'bottom'
21
+ }
22
+
23
+ export type PointHover = {
24
+ target: BubbleTarget
25
+ /** The surface the pick came through when it picked its room (the "⌥ the wall" line). */
26
+ ambiguous: boolean
27
+ hit: PointHit
28
+ }
29
+
30
+ export type PointCrop = {
31
+ state: 'none' | 'pending' | 'ready' | 'failed'
32
+ dataUrl?: string
33
+ width?: number
34
+ height?: number
35
+ marked?: boolean
36
+ }
37
+
38
+ /** A dragged rectangle (spec 2.3): drawn while the person drags, then settled until the ask goes or is dismissed. */
39
+ export type RegionState = {
40
+ rect: Rect
41
+ sizeLabel: string | null
42
+ settled: boolean
43
+ /** Changes when the shutter fires, so the marquee flashes once. */
44
+ flashKey: number
45
+ }
46
+
47
+ /** What a region bubble is about: the rectangle, the point its tail points at, and what is in it. */
48
+ export type BubbleArea = {
49
+ rect: Rect
50
+ click: ScreenPoint
51
+ ids: string[]
52
+ more: number
53
+ }
54
+
55
+ export type PointBubble = {
56
+ targets: BubbleTarget[]
57
+ /** Where the finger landed, world metres: the bubble's tail and the pin's spot. */
58
+ anchor: [number, number, number]
59
+ hit: PointHit
60
+ gesture: 'click' | 'multi' | 'region'
61
+ /** The moment of pointing (pointer-down), ISO. */
62
+ capturedAt: string
63
+ /** The view the person pointed from, recorded at pointer-down. */
64
+ camera: PointCamera | null
65
+ crop: PointCrop
66
+ draft: string
67
+ /** Why the last send did not go (out of credits, no chat): shown in the bubble, the draft kept. */
68
+ error: string | null
69
+ /** A region's rectangle, 0-1 of the canvas, when the gesture was a drag. */
70
+ region?: [number, number, number, number]
71
+ /** Set when the bubble is about a region: it is a view, not a selection, so it has no targets. */
72
+ area?: BubbleArea
73
+ /**
74
+ * Set when the bubble asks for more on what a done pin changed ("Change more"): that pin gives
75
+ * way while the bubble is open and goes when this ask is sent. The chips offer something else.
76
+ */
77
+ changesAsk?: { askId: string; after: string }
78
+ }
79
+
80
+ export type PinRecord = {
81
+ model: PinModel
82
+ /** The spot in the world at send, so the pin shows where it was planted when its target is gone. */
83
+ anchor: [number, number, number]
84
+ /** The same spot in the first target's own frame, so it rides along when Pascal moves the element. */
85
+ local: { nodeId: string; offset: [number, number, number] } | null
86
+ /** "Window · Kitchen, north wall": the chat chip's words. */
87
+ label: string
88
+ levelId: string | null
89
+ before?: PointCrop
90
+ after?: PointCrop
91
+ /** When the pin may retire (ms epoch), set once its turn ends and it is out of the person's way. */
92
+ retireAt: number | null
93
+ /** What was handed to the chat, kept for "Try again". */
94
+ submit?: PointAskSubmit
95
+ /** "Change more" was sent from this pin's card: the new ask's pin stands in for it. */
96
+ supersededBy?: string
97
+ createdAt: number
98
+ }
99
+
100
+ type PointAskState = {
101
+ active: boolean
102
+ /** A tap latched the mode; a hold keeps it only while the key is down. */
103
+ latched: boolean
104
+ hover: PointHover | null
105
+ alt: boolean
106
+ shift: boolean
107
+ bubble: PointBubble | null
108
+ /** Drafts kept per first target until the mode leaves ("no, that one" keeps the words). */
109
+ drafts: Record<string, string>
110
+ pins: Record<string, PinRecord>
111
+ pinOrder: string[]
112
+ /** The host's "Playful touches" setting: false turns the flash, ripples and sounds off. */
113
+ playful: boolean
114
+ /** Pascal is on a turn, so a new ask will wait its turn (the Send button says Queue). */
115
+ busy: boolean
116
+ /**
117
+ * Messages already wait in the chat's queue, or are held there after an error: a new ask goes
118
+ * behind them (the Send button says Queue) though no turn runs. Apart from `busy`, which says a
119
+ * turn runs and ends Follow Pascal and settles the pins.
120
+ */
121
+ behindQueue: boolean
122
+ /** The pin whose card is open (hover or focus of the pin, or its own 4 s opening). */
123
+ cardFor: string | null
124
+ /** The rectangle being dragged or just released. */
125
+ region: RegionState | null
126
+ /** The ask whose "before" is on screen while a compare control is held, or null. */
127
+ comparing: string | null
128
+ /** A hold that did not start, for its card to say why. */
129
+ compareRefusal: { askId: string; reason: CompareRefusal } | null
130
+ /** How the last bubble left, so the exit can fold into a pin (send) or just shrink (dismiss). */
131
+ closeKind: 'send' | 'dismiss'
132
+
133
+ setActive: (active: boolean, latched?: boolean) => void
134
+ setLatched: (latched: boolean) => void
135
+ setRegion: (region: RegionState | null) => void
136
+ setComparing: (askId: string | null) => void
137
+ setCompareRefusal: (refusal: { askId: string; reason: CompareRefusal } | null) => void
138
+ setModifiers: (alt: boolean, shift: boolean) => void
139
+ setHover: (hover: PointHover | null) => void
140
+ openBubble: (bubble: PointBubble) => void
141
+ patchBubble: (patch: Partial<PointBubble>) => void
142
+ closeBubble: (kind?: 'send' | 'dismiss') => void
143
+ rememberDraft: (key: string, text: string) => void
144
+ addPin: (pin: PinRecord) => void
145
+ patchPin: (askId: string, patch: Partial<Omit<PinRecord, 'model'>>) => void
146
+ dispatchPin: (askId: string, event: PinEvent) => void
147
+ removePin: (askId: string) => void
148
+ setPlayful: (playful: boolean) => void
149
+ setBusy: (busy: boolean) => void
150
+ setBehindQueue: (behindQueue: boolean) => void
151
+ openCard: (askId: string | null) => void
152
+ }
153
+
154
+ export const usePointAsk = create<PointAskState>((set) => ({
155
+ active: false,
156
+ latched: false,
157
+ hover: null,
158
+ alt: false,
159
+ shift: false,
160
+ bubble: null,
161
+ drafts: {},
162
+ pins: {},
163
+ pinOrder: [],
164
+ playful: true,
165
+ busy: false,
166
+ behindQueue: false,
167
+ cardFor: null,
168
+ closeKind: 'dismiss',
169
+ region: null,
170
+ comparing: null,
171
+ compareRefusal: null,
172
+
173
+ setActive: (active, latched = false) =>
174
+ set((state) =>
175
+ active === state.active && latched === state.latched
176
+ ? state
177
+ : active
178
+ ? { active, latched }
179
+ : { active: false, latched: false, hover: null, bubble: null, region: null, drafts: {} },
180
+ ),
181
+ setLatched: (latched) => set((state) => (state.latched === latched ? state : { latched })),
182
+ setRegion: (region) => set((state) => (state.region === region ? state : { region })),
183
+ setComparing: (comparing) =>
184
+ set((state) => (state.comparing === comparing ? state : { comparing })),
185
+ setCompareRefusal: (compareRefusal) => set({ compareRefusal }),
186
+ setModifiers: (alt, shift) =>
187
+ set((state) => (state.alt === alt && state.shift === shift ? state : { alt, shift })),
188
+ setHover: (hover) =>
189
+ set((state) => {
190
+ if (state.hover === hover) return state
191
+ if (state.hover && hover && state.hover.target.id === hover.target.id) {
192
+ const same =
193
+ state.hover.ambiguous === hover.ambiguous &&
194
+ state.hover.hit.point.every((v, i) => v === hover.hit.point[i])
195
+ if (same) return state
196
+ }
197
+ return { hover }
198
+ }),
199
+ openBubble: (bubble) => set({ bubble }),
200
+ patchBubble: (patch) =>
201
+ set((state) => (state.bubble ? { bubble: { ...state.bubble, ...patch } } : state)),
202
+ closeBubble: (kind = 'dismiss') =>
203
+ set((state) => (state.bubble ? { bubble: null, closeKind: kind } : state)),
204
+ rememberDraft: (key, text) =>
205
+ set((state) => {
206
+ if ((state.drafts[key] ?? '') === text) return state
207
+ const drafts = { ...state.drafts }
208
+ if (text) drafts[key] = text
209
+ else delete drafts[key]
210
+ return { drafts }
211
+ }),
212
+ addPin: (pin) =>
213
+ set((state) => ({
214
+ pins: { ...state.pins, [pin.model.askId]: pin },
215
+ pinOrder: [...state.pinOrder.filter((id) => id !== pin.model.askId), pin.model.askId],
216
+ })),
217
+ patchPin: (askId, patch) =>
218
+ set((state) => {
219
+ const pin = state.pins[askId]
220
+ return pin ? { pins: { ...state.pins, [askId]: { ...pin, ...patch } } } : state
221
+ }),
222
+ dispatchPin: (askId, event) =>
223
+ set((state) => {
224
+ const pin = state.pins[askId]
225
+ if (!pin) return state
226
+ const model = reducePin(pin.model, event)
227
+ return model === pin.model ? state : { pins: { ...state.pins, [askId]: { ...pin, model } } }
228
+ }),
229
+ removePin: (askId) =>
230
+ set((state) => {
231
+ if (!state.pins[askId]) return state
232
+ const pins = { ...state.pins }
233
+ delete pins[askId]
234
+ return { pins, pinOrder: state.pinOrder.filter((id) => id !== askId) }
235
+ }),
236
+ setPlayful: (playful) => set((state) => (state.playful === playful ? state : { playful })),
237
+ setBusy: (busy) => set((state) => (state.busy === busy ? state : { busy })),
238
+ setBehindQueue: (behindQueue) =>
239
+ set((state) => (state.behindQueue === behindQueue ? state : { behindQueue })),
240
+ openCard: (askId) => set((state) => (state.cardFor === askId ? state : { cardFor: askId })),
241
+ }))
242
+
243
+ /** Whether a new ask waits its turn: a turn runs, or messages already wait in the queue. */
244
+ export const asksWait = (state: Pick<PointAskState, 'busy' | 'behindQueue'>) =>
245
+ state.busy || state.behindQueue
246
+
247
+ export { createPin }
@@ -17,12 +17,20 @@ type StairBuildPreviewState = {
17
17
  point: StairPreviewPoint | null
18
18
  /** Yaw (radians), cycled by R / T. */
19
19
  rotation: number
20
+ length: number | null
21
+ stepCount: number | null
20
22
  rise: number | null
21
23
  /** Set the snapped point. No-ops (skips the store update, so subscribers
22
24
  * don't re-render) when the point is unchanged — `grid:move` fires far more
23
25
  * often than the snapped cell actually changes. */
24
26
  setPoint(point: StairPreviewPoint | null): void
25
- setPreview(point: StairPreviewPoint | null, rotation: number, rise?: number | null): void
27
+ setPreview(
28
+ point: StairPreviewPoint | null,
29
+ rotation: number,
30
+ rise?: number | null,
31
+ length?: number | null,
32
+ stepCount?: number | null,
33
+ ): void
26
34
  rotateBy(deltaRadians: number): void
27
35
  reset(): void
28
36
  }
@@ -31,6 +39,8 @@ export const useStairBuildPreview = create<StairBuildPreviewState>((set) => ({
31
39
  point: null,
32
40
  rotation: 0,
33
41
  rise: null,
42
+ length: null,
43
+ stepCount: null,
34
44
  setPoint: (point) =>
35
45
  set((state) => {
36
46
  const prev = state.point
@@ -38,24 +48,34 @@ export const useStairBuildPreview = create<StairBuildPreviewState>((set) => ({
38
48
  if (point && prev && prev[0] === point[0] && prev[1] === point[1]) return state
39
49
  return { point: point ? [point[0], point[1]] : null }
40
50
  }),
41
- setPreview: (point, rotation, rise) =>
51
+ setPreview: (point, rotation, rise, length = null, stepCount = null) =>
42
52
  set((state) => {
43
53
  const samePoint =
44
54
  (!point && !state.point) ||
45
55
  Boolean(point && state.point && state.point[0] === point[0] && state.point[1] === point[1])
46
- return samePoint && state.rotation === rotation && (rise === undefined || rise === state.rise)
56
+ return samePoint &&
57
+ state.rotation === rotation &&
58
+ (rise === undefined || rise === state.rise) &&
59
+ length === state.length &&
60
+ stepCount === state.stepCount
47
61
  ? state
48
62
  : {
49
63
  point: point ? [point[0], point[1]] : null,
50
64
  rotation,
65
+ length,
66
+ stepCount,
51
67
  rise: rise === undefined ? state.rise : rise,
52
68
  }
53
69
  }),
54
70
  rotateBy: (deltaRadians) => set((state) => ({ rotation: state.rotation + deltaRadians })),
55
71
  reset: () =>
56
72
  set((state) =>
57
- state.point === null && state.rotation === 0 && state.rise === null
73
+ state.point === null &&
74
+ state.rotation === 0 &&
75
+ state.rise === null &&
76
+ state.length === null &&
77
+ state.stepCount === null
58
78
  ? state
59
- : { point: null, rotation: 0, rise: null },
79
+ : { point: null, rotation: 0, rise: null, length: null, stepCount: null },
60
80
  ),
61
81
  }))
@@ -1,5 +1,10 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test'
2
- import { availableViews, editorHostViewRegistry, registerEditorHostView } from '../lib/editor-views'
2
+ import {
3
+ availableViews,
4
+ editorHostViewRegistry,
5
+ registerEditorHostView,
6
+ unregisterEditorHostView,
7
+ } from '../lib/editor-views'
3
8
  import useEditor, { normalizePersistedEditorUiState } from './use-editor'
4
9
  import {
5
10
  activeViewLayout,
@@ -129,6 +134,27 @@ describe('layouts saved before views', () => {
129
134
  })
130
135
 
131
136
  describe('registered views', () => {
137
+ test('dismissing a document removes it from both workspace layouts, including a hidden split pane', () => {
138
+ registerEditorHostView({
139
+ id: 'document:test',
140
+ label: 'Plan.pdf',
141
+ icon: { kind: 'iconify', name: 'lucide:file' },
142
+ component: async () => ({ default: () => null }),
143
+ })
144
+ useEditor.getState().showView('document:test', { beside: true })
145
+ useEditor.getState().setWorkspaceMode('studio')
146
+ useEditor
147
+ .getState()
148
+ .setViewLayout({ ...DEFAULT_VIEW_LAYOUT, panes: [VIEW_3D, 'document:test'] })
149
+ useEditor.getState().setViewPinned('document:test', true)
150
+ unregisterEditorHostView('document:test')
151
+ expect(editorHostViewRegistry.getSnapshot()).toEqual([])
152
+ expect(useEditor.getState().pinnedViews['document:test']).toBeUndefined()
153
+ expect(layout().panes).toEqual([VIEW_3D, VIEW_2D])
154
+ useEditor.getState().setWorkspaceMode('edit')
155
+ expect(layout()).toMatchObject({ split: false, panes: [VIEW_3D, VIEW_2D] })
156
+ })
157
+
132
158
  test('a plugin view is offered only where its plugin is installed and its workspace matches', () => {
133
159
  registerEditorHostView({
134
160
  id: 'acme:pets',
@@ -113,7 +113,10 @@ export function withSplitToggled(layout: ViewLayout): ViewLayout {
113
113
  */
114
114
  export function withoutView(layout: ViewLayout, viewId: string): ViewLayout {
115
115
  const pane = paneOfView(layout, viewId)
116
- if (pane === null) return layout
116
+ if (pane === null) {
117
+ if (layout.panes[1] !== viewId) return layout
118
+ return { ...layout, panes: [layout.panes[0], layout.panes[0] === VIEW_3D ? VIEW_2D : VIEW_3D] }
119
+ }
117
120
  const kept = layout.split ? layout.panes[pane === 0 ? 1 : 0] : VIEW_3D
118
121
  return { ...layout, split: false, panes: [kept, kept === VIEW_3D ? VIEW_2D : VIEW_3D], focus: 0 }
119
122
  }
@@ -0,0 +1,16 @@
1
+ import { spyOn } from 'bun:test'
2
+ import React, { type ReactElement } from 'react'
3
+ import { renderToStaticMarkup } from 'react-dom/server'
4
+
5
+ /** Server-renders `element` from the stores' current state, not the startup state a server render reads. */
6
+ export function renderCurrentMarkup(element: ReactElement): string {
7
+ const useSyncExternalStore = React.useSyncExternalStore
8
+ const serverSnapshot = spyOn(React, 'useSyncExternalStore').mockImplementation(
9
+ (subscribe, getSnapshot) => useSyncExternalStore(subscribe, getSnapshot, getSnapshot),
10
+ )
11
+ try {
12
+ return renderToStaticMarkup(element)
13
+ } finally {
14
+ serverSnapshot.mockRestore()
15
+ }
16
+ }
@@ -1,58 +0,0 @@
1
- import { describe, expect, test } from 'bun:test'
2
- import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard'
3
-
4
- describe('createStairCommitGate', () => {
5
- test('allows commits until the session exits', () => {
6
- const gate = createStairCommitGate()
7
- expect(gate.shouldCommit()).toBe(true)
8
- // Repeat continuation: consecutive commits stay allowed.
9
- expect(gate.shouldCommit()).toBe(true)
10
- })
11
-
12
- test('refuses every trigger after a single-continuation exit', () => {
13
- const gate = createStairCommitGate()
14
- expect(gate.shouldCommit()).toBe(true)
15
- gate.markExited()
16
- // The native follow-up click / stray node click of the same gesture.
17
- expect(gate.shouldCommit()).toBe(false)
18
- expect(gate.shouldCommit()).toBe(false)
19
- })
20
- })
21
-
22
- describe('swallowFollowUpBrowserClick', () => {
23
- test('stops exactly one follow-up click', () => {
24
- const target = new EventTarget()
25
- swallowFollowUpBrowserClick(target)
26
-
27
- let firstStopped = 0
28
- const first = new Event('click', { cancelable: true })
29
- Object.defineProperty(first, 'stopPropagation', { value: () => firstStopped++ })
30
- target.dispatchEvent(first)
31
- expect(firstStopped).toBe(1)
32
- expect(first.defaultPrevented).toBe(true)
33
-
34
- // `once: true` — the next click of the next gesture passes through.
35
- let secondStopped = 0
36
- const second = new Event('click', { cancelable: true })
37
- Object.defineProperty(second, 'stopPropagation', { value: () => secondStopped++ })
38
- target.dispatchEvent(second)
39
- expect(secondStopped).toBe(0)
40
- expect(second.defaultPrevented).toBe(false)
41
- })
42
-
43
- test('disarms after the timeout when no click follows', async () => {
44
- const target = new EventTarget()
45
- swallowFollowUpBrowserClick(target, 5)
46
- await new Promise((resolve) => setTimeout(resolve, 15))
47
-
48
- let stopped = 0
49
- const late = new Event('click', { cancelable: true })
50
- Object.defineProperty(late, 'stopPropagation', { value: () => stopped++ })
51
- target.dispatchEvent(late)
52
- expect(stopped).toBe(0)
53
- })
54
-
55
- test('no-ops without a window-like target', () => {
56
- expect(() => swallowFollowUpBrowserClick(undefined)).not.toThrow()
57
- })
58
- })
@@ -1,72 +0,0 @@
1
- /**
2
- * Guards for the stair tool's commit triggers.
3
- *
4
- * One physical validation click can reach the stair tool's commit handler
5
- * twice: node-surface clicks (`slab:click`, `wall:click`, …) are synthesized
6
- * on *pointerup* by the viewer (`use-node-events`), while `grid:click` rides
7
- * the browser's native *click* event from a canvas-level DOM listener
8
- * (`use-grid-events`) that deliberately ignores R3F stopPropagation — and
9
- * after a single-continuation commit the tool's emitter subscriptions survive
10
- * until React unmounts it, which lands only after that native click. Without
11
- * these guards a validation click over any node surface (a deck or floor
12
- * slab, a wall, another stair) created TWO stairs from one click.
13
- *
14
- * Same hazard and same countermeasures as
15
- * `packages/nodes/src/shared/floor-placement.ts` (`stopPlacementCommitPropagation`)
16
- * and the `committed` flag in `move-registry-node-tool.tsx` — reimplemented
17
- * here because `@pascal-app/editor` cannot depend on `@pascal-app/nodes`.
18
- */
19
-
20
- export type StairCommitGate = {
21
- /** True while the armed session may still commit. */
22
- shouldCommit: () => boolean
23
- /**
24
- * Mark the session exited (single continuation): every further click
25
- * trigger reaching the still-subscribed handler — the native follow-up
26
- * click, a stray second node click — is refused.
27
- */
28
- markExited: () => void
29
- }
30
-
31
- export function createStairCommitGate(): StairCommitGate {
32
- let exited = false
33
- return {
34
- shouldCommit: () => !exited,
35
- markExited: () => {
36
- exited = true
37
- },
38
- }
39
- }
40
-
41
- type ClickSwallowTarget = {
42
- addEventListener: (
43
- type: string,
44
- listener: (event: Event) => void,
45
- options?: AddEventListenerOptions,
46
- ) => void
47
- removeEventListener: (
48
- type: string,
49
- listener: (event: Event) => void,
50
- options?: EventListenerOptions,
51
- ) => void
52
- }
53
-
54
- /**
55
- * Eat the one native browser `click` that follows a pointerup-synthesized
56
- * node click, before it reaches the canvas `grid:click` listener (capture
57
- * phase on window runs first). Needed in repeat continuation too, where the
58
- * tool stays armed and the gate above must keep allowing one commit per
59
- * gesture. Self-disarms after the click or `timeoutMs`, whichever first.
60
- */
61
- export function swallowFollowUpBrowserClick(
62
- target: ClickSwallowTarget | undefined = typeof window === 'undefined' ? undefined : window,
63
- timeoutMs = 300,
64
- ): void {
65
- if (!target) return
66
- const swallow = (event: Event) => {
67
- event.stopPropagation()
68
- event.preventDefault()
69
- }
70
- target.addEventListener('click', swallow, { capture: true, once: true })
71
- setTimeout(() => target.removeEventListener('click', swallow, { capture: true }), timeoutMs)
72
- }