@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,538 @@
1
+ 'use client'
2
+
3
+ import { useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
6
+ import {
7
+ BUBBLE_CLOSE_MS,
8
+ BUBBLE_OPEN_BLUR_PX,
9
+ BUBBLE_OPEN_SCALE,
10
+ EASE_OUT,
11
+ ENTER_MS,
12
+ SPRING,
13
+ SUGGESTION_DELAY_MS,
14
+ SUGGESTION_STAGGER_MS,
15
+ } from '../../lib/point-ask/choreography'
16
+ import { type Dictation, isDictationSupported, startDictation } from '../../lib/point-ask/dictation'
17
+ import { subscribeFrame } from '../../lib/point-ask/frame-loop'
18
+ import { type PlacementState, placeBubble } from '../../lib/point-ask/placement'
19
+ import { freeViewport, projectBox, projectWorld, unionRect } from '../../lib/point-ask/projector'
20
+ import { regionLabel } from '../../lib/point-ask/region'
21
+ import { closeBubble, sendBubble } from '../../lib/point-ask/session'
22
+ import { SpringPair } from '../../lib/point-ask/spring'
23
+ import { springKeyframes } from '../../lib/point-ask/spring-keyframes'
24
+ import {
25
+ followUpSuggestionsFor,
26
+ kindForTargets,
27
+ placeholderFor,
28
+ suggestionsFor,
29
+ } from '../../lib/point-ask/suggestions'
30
+ import { pointRoomHasContents } from '../../lib/point-ask/target-scope'
31
+ import { asksWait, type PointBubble, usePointAsk } from '../../store/use-point-ask'
32
+ import { finishOptionalAnimations, pointMotionCalm, usePointMotionCalm } from './optional-motion'
33
+
34
+ const ARROW = (
35
+ <svg
36
+ aria-hidden
37
+ fill="none"
38
+ stroke="currentColor"
39
+ strokeLinecap="round"
40
+ strokeLinejoin="round"
41
+ strokeWidth="1.8"
42
+ viewBox="0 0 16 16"
43
+ >
44
+ <path d="M8 13V3.5M3.8 7.5L8 3.3l4.2 4.2" />
45
+ </svg>
46
+ )
47
+ const CROSS = (
48
+ <svg
49
+ aria-hidden
50
+ fill="none"
51
+ stroke="currentColor"
52
+ strokeLinecap="round"
53
+ strokeWidth="1.8"
54
+ viewBox="0 0 10 10"
55
+ >
56
+ <path d="M2 2l6 6M8 2L2 8" />
57
+ </svg>
58
+ )
59
+ const MIC = (
60
+ <svg
61
+ aria-hidden
62
+ fill="none"
63
+ stroke="currentColor"
64
+ strokeLinecap="round"
65
+ strokeLinejoin="round"
66
+ strokeWidth="1.6"
67
+ viewBox="0 0 16 16"
68
+ >
69
+ <rect height="8" rx="2.5" width="5" x="5.5" y="1.5" />
70
+ <path d="M3 7.5a5 5 0 0010 0M8 12.5V15" />
71
+ </svg>
72
+ )
73
+
74
+ /** What the bubble's own outline covers: its targets, or the elements inside its region. */
75
+ const bubbleIds = (bubble: PointBubble) =>
76
+ (bubble.area ? bubble.area.ids : bubble.targets.map((t) => t.id)) as never
77
+
78
+ /** The last words sent, for ↑ in an empty field. */
79
+ let lastAsk = ''
80
+
81
+ /** Keeps the last value mounted for `ms` after it goes, so an exit can play. */
82
+ function useExitPresence<T>(value: T | null, ms: number): { shown: T | null; exiting: boolean } {
83
+ const [shown, setShown] = useState<T | null>(value)
84
+ const [exiting, setExiting] = useState(false)
85
+ useEffect(() => {
86
+ if (value) {
87
+ setShown(value)
88
+ setExiting(false)
89
+ return
90
+ }
91
+ if (!shown) return
92
+ setExiting(true)
93
+ const timer = setTimeout(() => {
94
+ setShown(null)
95
+ setExiting(false)
96
+ }, ms)
97
+ return () => clearTimeout(timer)
98
+ }, [value, ms, shown])
99
+ return { shown, exiting }
100
+ }
101
+
102
+ /**
103
+ * The anchored bubble: it grows from the exact click point, sits beside the target (never over it)
104
+ * and follows the camera 1:1; on send it folds into the pin at the same spot. See the spec's 2.4
105
+ * and the motion choreography.
106
+ */
107
+ export function PointAskBubble() {
108
+ const bubble = usePointAsk((s) => s.bubble)
109
+ const closeKind = usePointAsk((s) => s.closeKind)
110
+ const { shown, exiting } = useExitPresence<PointBubble>(bubble, 190)
111
+ if (!shown) return null
112
+ return (
113
+ <BubbleView
114
+ bubble={shown}
115
+ exiting={exiting}
116
+ fold={closeKind === 'send'}
117
+ key={shown.capturedAt}
118
+ />
119
+ )
120
+ }
121
+
122
+ function BubbleView({
123
+ bubble,
124
+ exiting,
125
+ fold,
126
+ }: {
127
+ bubble: PointBubble
128
+ exiting: boolean
129
+ fold: boolean
130
+ }) {
131
+ const wrap = useRef<HTMLDivElement>(null)
132
+ const bub = useRef<HTMLDivElement>(null)
133
+ const tail = useRef<HTMLSpanElement>(null)
134
+ const input = useRef<HTMLTextAreaElement>(null)
135
+ const place = useRef<{
136
+ state: PlacementState
137
+ pair: SpringPair
138
+ glide: boolean
139
+ first: boolean
140
+ offset: { x: number; y: number }
141
+ }>({
142
+ state: { side: null, badMs: 0 },
143
+ pair: new SpringPair(0, 0, SPRING),
144
+ glide: false,
145
+ first: true,
146
+ offset: { x: 0, y: 0 },
147
+ })
148
+ const bubbleRef = useRef(bubble)
149
+ bubbleRef.current = bubble
150
+ const busy = usePointAsk(asksWait)
151
+ const [listening, setListening] = useState(false)
152
+ const dictation = useRef<Dictation | null>(null)
153
+ const dictationBase = useRef('')
154
+ const optionalEntrance = useRef<Animation[]>([])
155
+ const optionalExit = useRef<Animation | null>(null)
156
+ const calm = usePointMotionCalm()
157
+
158
+ // Position: beside the target, tracked 1:1 each frame, a side switch gliding on the default spring.
159
+ useEffect(() => {
160
+ return subscribeFrame((dt) => {
161
+ const current = bubbleRef.current
162
+ const wrapEl = wrap.current
163
+ const bubEl = bub.current
164
+ const viewport = freeViewport()
165
+ if (!(wrapEl && bubEl && viewport)) return
166
+ const area = current.area
167
+ const click = area
168
+ ? { x: area.click.x, y: area.click.y, ok: true }
169
+ : projectWorld(current.anchor)
170
+ const boxes = current.targets
171
+ .flatMap((t) => (t.box ? [projectBox(t.box)] : []))
172
+ .flatMap((r) => (r ? [r] : []))
173
+ const box = area?.rect ??
174
+ unionRect(boxes) ?? { x0: click.x, y0: click.y, x1: click.x, y1: click.y }
175
+ const p = place.current
176
+ const result = placeBubble(
177
+ {
178
+ box,
179
+ click: click.ok ? { x: click.x, y: click.y } : null,
180
+ size: { w: bubEl.offsetWidth || 320, h: bubEl.offsetHeight || 150 },
181
+ viewport,
182
+ offset: p.offset,
183
+ first: p.first,
184
+ },
185
+ p.state,
186
+ dt * 1000,
187
+ )
188
+ p.state = result.state
189
+ if (result.glide) p.glide = true
190
+ if (p.first || !p.glide || pointMotionCalm()) {
191
+ p.pair.snap(result.pos.x, result.pos.y)
192
+ p.glide = false
193
+ } else {
194
+ p.pair.to(result.pos.x, result.pos.y).step(dt)
195
+ if (p.pair.rest) p.glide = false
196
+ }
197
+ const { x, y } = p.pair.value
198
+ wrapEl.style.transform = `translate3d(${x}px,${y}px,0)`
199
+ const tailEl = tail.current
200
+ if (tailEl) {
201
+ // The tail follows the glide: its anchor is the click, measured from the bubble as drawn.
202
+ const lx = (click.ok ? click.x : result.pos.x) - x
203
+ const ly = (click.ok ? click.y : result.pos.y) - y
204
+ const w = bubEl.offsetWidth || 320
205
+ const h = bubEl.offsetHeight || 150
206
+ let tx: number | null = null
207
+ let ty: number | null = null
208
+ if (click.ok) {
209
+ if (lx < 0) [tx, ty] = [0, Math.min(Math.max(ly, 16), h - 16)]
210
+ else if (lx > w) [tx, ty] = [w, Math.min(Math.max(ly, 16), h - 16)]
211
+ else if (ly < 0) [tx, ty] = [Math.min(Math.max(lx, 16), w - 16), 0]
212
+ else if (ly > h) [tx, ty] = [Math.min(Math.max(lx, 16), w - 16), h]
213
+ }
214
+ tailEl.style.visibility = tx === null ? 'hidden' : ''
215
+ if (tx !== null && ty !== null)
216
+ tailEl.style.transform = `translate(${tx - 6}px,${ty - 6}px) rotate(45deg)`
217
+ }
218
+ if (p.first) {
219
+ bubEl.style.transformOrigin = `${result.origin.x}px ${result.origin.y}px`
220
+ p.first = false
221
+ }
222
+ })
223
+ }, [])
224
+
225
+ // Opens on pointer-up: grows from the click, the field focused on the same frame so a key typed
226
+ // during the animation lands; the suggestions follow at a 30 ms stagger.
227
+ useLayoutEffect(() => {
228
+ input.current?.focus({ preventScroll: true })
229
+ }, [])
230
+ useEffect(() => {
231
+ const el = bub.current
232
+ if (!el) return
233
+ if (pointMotionCalm()) {
234
+ const fade = el.animate([{ opacity: 0 }, { opacity: 1 }], {
235
+ duration: ENTER_MS,
236
+ easing: EASE_OUT,
237
+ })
238
+ return () => fade.cancel()
239
+ }
240
+ optionalEntrance.current.push(
241
+ el.animate(
242
+ springKeyframes(
243
+ { scale: BUBBLE_OPEN_SCALE, opacity: 0, blur: BUBBLE_OPEN_BLUR_PX },
244
+ { scale: 1, opacity: 1, blur: 0 },
245
+ 420,
246
+ {
247
+ damping: SPRING.damping,
248
+ response: SPRING.response,
249
+ },
250
+ ),
251
+ { duration: 420, easing: 'linear', fill: 'backwards' },
252
+ ),
253
+ )
254
+ el.querySelectorAll<HTMLElement>('.pa-sugs .pa-btn').forEach((chip, index) => {
255
+ optionalEntrance.current.push(
256
+ chip.animate(
257
+ [
258
+ { opacity: 0, transform: 'translateY(4px)' },
259
+ { opacity: 1, transform: 'none' },
260
+ ],
261
+ {
262
+ duration: ENTER_MS,
263
+ delay: SUGGESTION_DELAY_MS + index * SUGGESTION_STAGGER_MS,
264
+ easing: EASE_OUT,
265
+ fill: 'backwards',
266
+ },
267
+ ),
268
+ )
269
+ })
270
+ return () => {
271
+ for (const animation of optionalEntrance.current) animation.cancel()
272
+ optionalEntrance.current = []
273
+ }
274
+ }, [])
275
+
276
+ // Closes back into the tail, faster than it opened; sending folds it toward the pin.
277
+ useEffect(() => {
278
+ if (!exiting) return
279
+ const el = bub.current
280
+ if (!el) return
281
+ // A bubble on its way out must not keep the keyboard: the next key belongs to the editor, not to its field.
282
+ if (el.contains(document.activeElement)) (document.activeElement as HTMLElement).blur()
283
+ for (const animation of el.getAnimations()) animation.cancel()
284
+ const now = getComputedStyle(el)
285
+ const from = {
286
+ transform: now.transform === 'none' ? 'none' : now.transform,
287
+ opacity: 1,
288
+ filter: 'blur(0px)',
289
+ }
290
+ const quiet = pointMotionCalm()
291
+ const keys: Keyframe[] = quiet
292
+ ? [{ opacity: 1 }, { opacity: 0 }]
293
+ : [
294
+ from,
295
+ {
296
+ transform: `scale(${fold ? 0.9 : BUBBLE_OPEN_SCALE})`,
297
+ opacity: 0,
298
+ filter: `blur(${BUBBLE_OPEN_BLUR_PX}px)`,
299
+ },
300
+ ]
301
+ const animation = el.animate(keys, {
302
+ duration: fold ? ENTER_MS : BUBBLE_CLOSE_MS,
303
+ easing: EASE_OUT,
304
+ fill: 'forwards',
305
+ })
306
+ optionalExit.current = quiet ? null : animation
307
+ if (wrap.current) wrap.current.style.pointerEvents = 'none'
308
+ return () => {
309
+ animation.cancel()
310
+ optionalExit.current = null
311
+ }
312
+ }, [exiting, fold])
313
+
314
+ useEffect(() => {
315
+ if (!calm) return
316
+ finishOptionalAnimations(optionalEntrance.current)
317
+ if (optionalExit.current && bub.current) {
318
+ optionalExit.current.cancel()
319
+ optionalExit.current = null
320
+ // Keep the existing presence deadline and closed state, not a second exit or a visible reset.
321
+ bub.current.style.opacity = '0'
322
+ bub.current.style.transform = 'none'
323
+ bub.current.style.filter = 'none'
324
+ }
325
+ }, [calm])
326
+
327
+ useEffect(() => {
328
+ return () => dictation.current?.stop()
329
+ }, [])
330
+
331
+ const patch = usePointAsk((s) => s.patchBubble)
332
+ const grow = useCallback(() => {
333
+ const el = input.current
334
+ if (!el) return
335
+ el.style.height = 'auto'
336
+ el.style.height = `${Math.min(el.scrollHeight, 6 * 20)}px`
337
+ }, [])
338
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the field grows whenever its text changes
339
+ useLayoutEffect(grow, [bubble.draft, grow])
340
+
341
+ const send = useCallback((words?: string) => {
342
+ const text = (words ?? input.current?.value ?? '').trim()
343
+ if (!text) return
344
+ lastAsk = text
345
+ dictation.current?.stop()
346
+ const rect = input.current?.getBoundingClientRect()
347
+ void sendBubble(text, {
348
+ x: rect?.left ?? 0,
349
+ y: rect?.top ?? 0,
350
+ width: rect?.width ?? 0,
351
+ height: rect?.height ?? 0,
352
+ })
353
+ }, [])
354
+
355
+ const toggleDictation = () => {
356
+ if (dictation.current) {
357
+ dictation.current.stop()
358
+ return
359
+ }
360
+ dictationBase.current = input.current?.value ?? ''
361
+ const base = dictationBase.current
362
+ dictation.current = startDictation({
363
+ onText: (heard) =>
364
+ patch({ draft: `${base}${base && !base.endsWith(' ') ? ' ' : ''}${heard}` }),
365
+ onEnd: () => {
366
+ dictation.current = null
367
+ setListening(false)
368
+ },
369
+ })
370
+ setListening(dictation.current !== null)
371
+ }
372
+
373
+ const targets = bubble.targets
374
+ const kind = kindForTargets(targets, bubble.gesture === 'region')
375
+ const roomId = kind === 'room' && !bubble.changesAsk ? targets[0]?.id : undefined
376
+ const roomEmpty = useScene((s) => (roomId ? !pointRoomHasContents(roomId, s.nodes) : false))
377
+ // Asking again on what a turn just changed offers the next things to try, not the first ones.
378
+ const suggestions = bubble.changesAsk
379
+ ? followUpSuggestionsFor(kind, bubble.changesAsk.after)
380
+ : suggestionsFor(kind, { empty: roomEmpty })
381
+ const empty = bubble.draft.trim() === ''
382
+
383
+ return (
384
+ <div className="pa-bwrap" ref={wrap}>
385
+ <div aria-label="Ask Pascal" className="pa-bub" ref={bub} role="dialog">
386
+ <span className="pa-tail" ref={tail} />
387
+ <div
388
+ className="pa-ctx"
389
+ onPointerDown={(event) => {
390
+ if ((event.target as HTMLElement).closest('button')) return
391
+ event.preventDefault()
392
+ const p = place.current
393
+ const sx = event.clientX - p.offset.x
394
+ const sy = event.clientY - p.offset.y
395
+ const move = (m: PointerEvent) => {
396
+ p.offset = { x: m.clientX - sx, y: m.clientY - sy }
397
+ }
398
+ const up = () => {
399
+ window.removeEventListener('pointermove', move)
400
+ window.removeEventListener('pointerup', up)
401
+ }
402
+ window.addEventListener('pointermove', move)
403
+ window.addEventListener('pointerup', up)
404
+ }}
405
+ title="Drag to move it aside"
406
+ >
407
+ {bubble.area ? (
408
+ <span
409
+ className="pa-tchip"
410
+ onPointerEnter={() => useViewer.getState().setPointedIds(bubble.area?.ids as never)}
411
+ onPointerLeave={() => useViewer.getState().setPointedIds(bubbleIds(bubble))}
412
+ >
413
+ <i />
414
+ <span>{regionLabel(bubble.area.ids.length + bubble.area.more)}</span>
415
+ <button
416
+ aria-label="Remove the area"
417
+ className="tx"
418
+ onClick={() => {
419
+ useViewer.getState().setPointedIds(null)
420
+ closeBubble('dismiss')
421
+ }}
422
+ type="button"
423
+ >
424
+ {CROSS}
425
+ </button>
426
+ </span>
427
+ ) : null}
428
+ {targets.map((t) => (
429
+ <span
430
+ className="pa-tchip"
431
+ key={t.id}
432
+ onPointerEnter={() => useViewer.getState().setPointedIds([t.id as never])}
433
+ onPointerLeave={() => useViewer.getState().setPointedIds(bubbleIds(bubble))}
434
+ >
435
+ <i />
436
+ <span>
437
+ {t.name}
438
+ {t.size ? ` · ${t.size}` : ''}
439
+ </span>
440
+ <button
441
+ aria-label={`Remove ${t.name}`}
442
+ className="tx"
443
+ onClick={() => {
444
+ const rest = targets.filter((x) => x.id !== t.id)
445
+ useViewer
446
+ .getState()
447
+ .setPointedIds(rest.length ? (rest.map((x) => x.id) as never) : null)
448
+ if (rest.length === 0) closeBubble('dismiss')
449
+ else patch({ targets: rest, gesture: rest.length > 1 ? 'multi' : 'click' })
450
+ }}
451
+ type="button"
452
+ >
453
+ {CROSS}
454
+ </button>
455
+ </span>
456
+ ))}
457
+ {bubble.crop.state === 'ready' && bubble.crop.dataUrl ? (
458
+ // biome-ignore lint/performance/noImgElement: a data URL the capture just made
459
+ <img alt="What Pascal will see" className="pa-thumb" src={bubble.crop.dataUrl} />
460
+ ) : bubble.crop.state === 'failed' ? (
461
+ <span className="pa-nopic">No picture</span>
462
+ ) : null}
463
+ </div>
464
+ <form
465
+ className="pa-field"
466
+ onSubmit={(event) => {
467
+ event.preventDefault()
468
+ send()
469
+ }}
470
+ >
471
+ <textarea
472
+ aria-label="Ask Pascal"
473
+ onChange={(event) => patch({ draft: event.target.value, error: null })}
474
+ onKeyDown={(event) => {
475
+ if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
476
+ event.preventDefault()
477
+ send()
478
+ } else if (
479
+ event.key === 'ArrowUp' &&
480
+ (input.current?.value ?? '') === '' &&
481
+ lastAsk
482
+ ) {
483
+ event.preventDefault()
484
+ patch({ draft: lastAsk })
485
+ }
486
+ }}
487
+ placeholder={placeholderFor(targets)}
488
+ ref={input}
489
+ rows={1}
490
+ style={{
491
+ flex: 1,
492
+ minWidth: 0,
493
+ minHeight: 30,
494
+ maxHeight: 120,
495
+ resize: 'none',
496
+ padding: '5px 0',
497
+ font: 'inherit',
498
+ fontSize: 14,
499
+ lineHeight: '20px',
500
+ color: 'inherit',
501
+ background: 'none',
502
+ border: 0,
503
+ outline: 0,
504
+ }}
505
+ value={bubble.draft}
506
+ />
507
+ {isDictationSupported() ? (
508
+ <button
509
+ aria-label={listening ? 'Stop dictating' : 'Dictate'}
510
+ aria-pressed={listening}
511
+ className={`pa-dict${listening ? ' on' : ''}`}
512
+ onClick={toggleDictation}
513
+ type="button"
514
+ >
515
+ {MIC}
516
+ </button>
517
+ ) : null}
518
+ <button
519
+ aria-label={busy ? 'Queue' : 'Send'}
520
+ className={`pa-send${busy ? ' queue' : ''}`}
521
+ disabled={empty}
522
+ type="submit"
523
+ >
524
+ {busy ? 'Queue' : ARROW}
525
+ </button>
526
+ </form>
527
+ {bubble.error ? <div className="pa-err">{bubble.error}</div> : null}
528
+ <div className="pa-sugs">
529
+ {suggestions.map((text) => (
530
+ <button className="pa-btn" key={text} onClick={() => send(text)} type="button">
531
+ {text}
532
+ </button>
533
+ ))}
534
+ </div>
535
+ </div>
536
+ </div>
537
+ )
538
+ }
@@ -0,0 +1,91 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+ import { CHIP_OFFSET, DWELL_MS } from '../../lib/point-ask/choreography'
5
+ import { subscribeFrame } from '../../lib/point-ask/frame-loop'
6
+ import { getPointer, subscribePointer } from '../../lib/point-ask/pointer-tracker'
7
+ import { usePointAsk } from '../../store/use-point-ask'
8
+
9
+ /**
10
+ * The label chip at the cursor: what the click will send, named before the click (the hover is
11
+ * feedforward). It follows the pointer on the frame of the pointer event; the first eligible
12
+ * appearance waits 120 ms then fades in, and target substitutions are instant (the tooltip rule).
13
+ */
14
+ export function HoverChip() {
15
+ const wrap = useRef<HTMLDivElement>(null)
16
+ const label = useRef<HTMLDivElement>(null)
17
+ const hover = usePointAsk((s) => s.hover)
18
+ const active = usePointAsk((s) => s.active)
19
+ const shift = usePointAsk((s) => s.shift)
20
+ const bubbleOpen = usePointAsk((s) => s.bubble !== null)
21
+ const draggingRegion = usePointAsk((s) => s.region !== null && !s.region.settled)
22
+ const busy = usePointAsk((s) =>
23
+ hover
24
+ ? s.pinOrder.some((id) => {
25
+ const pin = s.pins[id]
26
+ return (
27
+ pin &&
28
+ (pin.model.state === 'queued' || pin.model.state === 'working') &&
29
+ pin.model.targetIds.includes(hover.target.id)
30
+ )
31
+ })
32
+ : false,
33
+ )
34
+ const seen = useRef(false)
35
+
36
+ useEffect(() => {
37
+ const place = (x: number, y: number) => {
38
+ if (wrap.current)
39
+ wrap.current.style.transform = `translate3d(${x + CHIP_OFFSET}px,${y + CHIP_OFFSET}px,0)`
40
+ }
41
+ const at = getPointer()
42
+ if (at) place(at.x, at.y)
43
+ if (!active) return
44
+ return subscribePointer(place)
45
+ }, [active])
46
+
47
+ const visible = active && hover !== null && !draggingRegion
48
+ useEffect(() => {
49
+ const el = label.current
50
+ if (!el) return
51
+ if (!visible) {
52
+ seen.current = false
53
+ el.classList.remove('show')
54
+ return
55
+ }
56
+ if (seen.current) {
57
+ el.style.transitionDuration = '0ms'
58
+ el.classList.add('show')
59
+ return
60
+ }
61
+ el.style.transitionDuration = ''
62
+ const since = performance.now()
63
+ const stop = subscribeFrame((_dt, now) => {
64
+ if (now - since < DWELL_MS) return
65
+ el.classList.add('show')
66
+ seen.current = true
67
+ stop()
68
+ })
69
+ return stop
70
+ }, [visible])
71
+
72
+ const plus = shift && bubbleOpen ? '+ ' : ''
73
+ return (
74
+ <div className="pa-chipwrap" ref={wrap} style={{ opacity: visible ? 1 : 0 }}>
75
+ <div className="pa-hlabel" ref={label}>
76
+ {hover ? (
77
+ busy ? (
78
+ 'Pascal is working on this'
79
+ ) : (
80
+ <>
81
+ {plus}
82
+ {hover.target.name}
83
+ {hover.target.size ? <span className="sz">{hover.target.size}</span> : null}
84
+ {hover.ambiguous ? <span className="sub">⌥ the wall</span> : null}
85
+ </>
86
+ )
87
+ ) : null}
88
+ </div>
89
+ </div>
90
+ )
91
+ }