@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,711 @@
1
+ 'use client'
2
+
3
+ import { emitter, sceneRegistry } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { type PointerEvent, useEffect, useRef, useState } from 'react'
6
+ import { Vector3 } from 'three'
7
+ import {
8
+ CARD_AUTO_MS,
9
+ CARD_CARRIED_MS,
10
+ CARD_CLOSE_DELAY_MS,
11
+ CARD_MS,
12
+ CARD_W,
13
+ CHECK_PULSE_MS,
14
+ EASE_OUT,
15
+ PIN_DROP_MS,
16
+ PIN_DROP_PX,
17
+ PIN_HOLD_MS,
18
+ PIN_HOLD_SLOP_PX,
19
+ PIN_RETIRE_FADE_MS,
20
+ PIN_RETIRE_MS,
21
+ PIN_RING_DELAY_MS,
22
+ PIN_RING_MS,
23
+ } from '../../lib/point-ask/choreography'
24
+ import { edgeMarker } from '../../lib/point-ask/edge-marker'
25
+ import { subscribeFrame } from '../../lib/point-ask/frame-loop'
26
+ import { alsoChangedLine, cardCarriedFromPointer, cardTitle } from '../../lib/point-ask/pin-machine'
27
+ import { canvasRect, freeViewport, projectWorld } from '../../lib/point-ask/projector'
28
+ import {
29
+ changeMore,
30
+ endCompare,
31
+ endCompareWhenPointerDrifts,
32
+ handEditsSince,
33
+ lookAtAsk,
34
+ redoAsk,
35
+ retryAsk,
36
+ startCompare,
37
+ undoAsk,
38
+ } from '../../lib/point-ask/session'
39
+ import { type PinRecord, usePointAsk } from '../../store/use-point-ask'
40
+ import { finishOptionalAnimations, pointMotionCalm, usePointMotionCalm } from './optional-motion'
41
+
42
+ const COMPARE_REFUSAL_WORDS = {
43
+ 'history-held': 'Another change is in progress. Try again in a moment.',
44
+ 'no-before': 'The scene has changed too much since to show how it was.',
45
+ 'turn-working': "Pascal is still working. Try again when it's done.",
46
+ } as const
47
+
48
+ const SETTLED = new Set(['done', 'answered', 'no-change', 'undone'])
49
+ const scratch = new Vector3()
50
+
51
+ /** The pin's spot now: in its first target's own frame while that exists, else where it was planted. */
52
+ function pinWorld(pin: PinRecord): [number, number, number] {
53
+ if (pin.local) {
54
+ const object = sceneRegistry.nodes.get(pin.local.nodeId)
55
+ if (object) {
56
+ object.updateWorldMatrix(true, false)
57
+ scratch.set(...pin.local.offset)
58
+ object.localToWorld(scratch)
59
+ return [scratch.x, scratch.y, scratch.z]
60
+ }
61
+ }
62
+ return pin.anchor
63
+ }
64
+
65
+ const PIN_SVG = (
66
+ <>
67
+ <span className="pa-ring" />
68
+ <span className="pa-pdisc">
69
+ <svg aria-hidden viewBox="0 0 24 24">
70
+ <circle className="pa-pq" cx="12" cy="12" r="6.5" />
71
+ <g className="pa-parcs">
72
+ <circle
73
+ className="pa-pa"
74
+ cx="12"
75
+ cy="12"
76
+ pathLength="100"
77
+ r="6.5"
78
+ strokeDasharray="26 74"
79
+ />
80
+ <circle
81
+ className="pa-pa"
82
+ cx="12"
83
+ cy="12"
84
+ pathLength="100"
85
+ r="6.5"
86
+ strokeDasharray="12 88"
87
+ strokeDashoffset="-50"
88
+ />
89
+ </g>
90
+ <path className="pa-pk" d="M8 12.4l2.6 2.6 5.4-5.6" />
91
+ <path className="pa-sg" d="M7.5 8.5h9v6h-5l-2.5 2.2v-2.2h-1.5z" />
92
+ </svg>
93
+ </span>
94
+ </>
95
+ )
96
+
97
+ export function PointAskPins() {
98
+ const order = usePointAsk((s) => s.pinOrder)
99
+ return (
100
+ <>
101
+ {order.map((id) => (
102
+ <PinView askId={id} key={id} />
103
+ ))}
104
+ <PinCard />
105
+ </>
106
+ )
107
+ }
108
+
109
+ function PinView({ askId }: { askId: string }) {
110
+ const pin = usePointAsk((s) => s.pins[askId])
111
+ // "Change more" in flight: this pin gives way to the ask that will stand where it stood.
112
+ const gaveWay = usePointAsk(
113
+ (s) => Boolean(s.pins[askId]?.supersededBy) || s.bubble?.changesAsk?.askId === askId,
114
+ )
115
+ const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
116
+ const held = useRef(false)
117
+ const el = useRef<HTMLDivElement>(null)
118
+ const marker = useRef<HTMLButtonElement>(null)
119
+ const arcs = useRef<SVGGElement | null>(null)
120
+ const state = useRef({ angle: 0, boost: 0, landed: 0, sentence: '' })
121
+ const retire = useRef<ReturnType<typeof setTimeout> | null>(null)
122
+ const prev = useRef<string | null>(null)
123
+ const model = pin?.model
124
+ const optional = useRef<Animation[]>([])
125
+ const calm = usePointMotionCalm()
126
+
127
+ useEffect(() => {
128
+ if (!calm) return
129
+ finishOptionalAnimations(optional.current)
130
+ state.current.boost = 0
131
+ state.current.angle = 0
132
+ arcs.current?.setAttribute('transform', 'rotate(0 12 12)')
133
+ }, [calm])
134
+
135
+ useEffect(() => {
136
+ return () => {
137
+ for (const animation of optional.current) animation.cancel()
138
+ }
139
+ }, [])
140
+
141
+ // Follows its element every frame; the arcs speed up with the agent's real progress, not a timer.
142
+ useEffect(() => {
143
+ return subscribeFrame((dt) => {
144
+ const record = usePointAsk.getState().pins[askId]
145
+ const node = el.current
146
+ if (!(record && node)) return
147
+ const at = projectWorld(pinWorld(record))
148
+ const levelHidden =
149
+ record.levelId !== null && useViewer.getState().selection.levelId !== record.levelId
150
+ // Out of the view: the pin gives way to an edge marker that points at it and offers to look.
151
+ const frame = canvasRect()
152
+ const away =
153
+ frame && !levelHidden
154
+ ? edgeMarker(at.ok ? { x: at.x, y: at.y } : null, {
155
+ x0: frame.left,
156
+ y0: frame.top,
157
+ x1: frame.right,
158
+ y1: frame.bottom,
159
+ })
160
+ : null
161
+ const mark = marker.current
162
+ if (mark) {
163
+ const show = away !== null && !levelHidden && record.model.state !== 'queued'
164
+ mark.style.display = show ? '' : 'none'
165
+ if (show && away) {
166
+ const width = mark.offsetWidth
167
+ const half = width / 2
168
+ const x = frame
169
+ ? Math.min(Math.max(away.x, frame.left + 8 + half), frame.right - 8 - half)
170
+ : away.x
171
+ mark.style.transform = `translate3d(${x - half}px,${away.y - 14}px,0)`
172
+ const arrow = mark.querySelector<SVGElement>('svg')
173
+ if (arrow) arrow.style.transform = `rotate(${away.angle}deg)`
174
+ }
175
+ }
176
+ node.style.visibility = levelHidden || away !== null ? 'hidden' : ''
177
+ node.style.transform = `translate3d(${at.x}px,${at.y}px,0)`
178
+ const s = state.current
179
+ if (pointMotionCalm()) {
180
+ s.boost = 0
181
+ s.angle = 0
182
+ } else {
183
+ s.boost = Math.max(0, s.boost - 520 * dt)
184
+ s.angle = (s.angle + (110 + s.boost) * dt) % 360
185
+ }
186
+ if (
187
+ arcs.current &&
188
+ (record.model.state === 'working' || record.model.state === 'needs-input')
189
+ ) {
190
+ arcs.current.setAttribute('transform', `rotate(${s.angle} 12 12)`)
191
+ }
192
+ })
193
+ }, [askId])
194
+
195
+ // A step landing, or a new sentence, bumps the arcs.
196
+ useEffect(() => {
197
+ if (!model) return
198
+ const s = state.current
199
+ if (model.landed !== s.landed || (model.sentence ?? '') !== s.sentence) {
200
+ if (!pointMotionCalm()) s.boost = Math.min(420, s.boost + 260)
201
+ s.landed = model.landed
202
+ s.sentence = model.sentence ?? ''
203
+ }
204
+ }, [model])
205
+
206
+ // The pin drops from 8 px with a small squash and one ring ripple (the send, planted).
207
+ useEffect(() => {
208
+ const disc = el.current?.querySelector<HTMLElement>('.pa-pdisc')
209
+ const ring = el.current?.querySelector<HTMLElement>('.pa-ring')
210
+ if (!disc) return
211
+ if (pointMotionCalm()) {
212
+ disc.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 180 })
213
+ return
214
+ }
215
+ optional.current.push(
216
+ disc.animate(
217
+ [
218
+ { transform: `translateY(-${PIN_DROP_PX}px)`, opacity: 0 },
219
+ { transform: 'translateY(0) scale(1.14, .82)', opacity: 1, offset: 0.55 },
220
+ { transform: 'none', opacity: 1 },
221
+ ],
222
+ { duration: PIN_DROP_MS, easing: EASE_OUT },
223
+ ),
224
+ )
225
+ if (ring)
226
+ optional.current.push(
227
+ ring.animate(
228
+ [
229
+ { transform: 'scale(.6)', opacity: 0.7 },
230
+ { transform: 'scale(2.4)', opacity: 0 },
231
+ ],
232
+ { duration: PIN_RING_MS, delay: PIN_RING_DELAY_MS, easing: EASE_OUT, fill: 'backwards' },
233
+ ),
234
+ )
235
+ }, [])
236
+
237
+ // The moments: done pulses once, an error shakes once (2 cycles, 6 px).
238
+ useEffect(() => {
239
+ const state2 = model?.state
240
+ if (!state2 || prev.current === state2) {
241
+ prev.current = state2 ?? null
242
+ return
243
+ }
244
+ const before = prev.current
245
+ prev.current = state2
246
+ const disc = el.current?.querySelector<HTMLElement>('.pa-pdisc')
247
+ if (!disc || before === null) return
248
+ const quiet = pointMotionCalm()
249
+ if (state2 === 'done' && !quiet) {
250
+ optional.current.push(
251
+ disc.animate(
252
+ [
253
+ { transform: 'scale(1)' },
254
+ { transform: 'scale(1.18)', offset: 0.4 },
255
+ { transform: 'scale(1)' },
256
+ ],
257
+ {
258
+ duration: CHECK_PULSE_MS,
259
+ delay: 100,
260
+ easing: EASE_OUT,
261
+ },
262
+ ),
263
+ )
264
+ // One outline flash on the element: 180 ms in, 220 ms out.
265
+ const viewer = useViewer.getState()
266
+ viewer.setPointedIds(model?.targetIds as never)
267
+ setTimeout(() => useViewer.getState().setPointedIds(null), 400)
268
+ }
269
+ if (state2 === 'error' && !quiet && el.current) {
270
+ optional.current.push(
271
+ el.current.animate(
272
+ [
273
+ { translate: '0 0' },
274
+ { translate: '-6px 0' },
275
+ { translate: '6px 0' },
276
+ { translate: '-6px 0' },
277
+ { translate: '6px 0' },
278
+ { translate: '0 0' },
279
+ ],
280
+ { duration: 360, easing: 'ease-in-out' },
281
+ ),
282
+ )
283
+ }
284
+ // The card opens by itself once, for 4 s, when a result lands and the person is idle.
285
+ if (SETTLED.has(state2) || state2 === 'error' || state2 === 'needs-input') {
286
+ setTimeout(() => {
287
+ const s = usePointAsk.getState()
288
+ if (s.cardFor === null && s.bubble === null && s.pins[askId]) {
289
+ s.openCard(askId)
290
+ setTimeout(() => {
291
+ if (usePointAsk.getState().cardFor === askId && !cardHeld(askId))
292
+ usePointAsk.getState().openCard(null)
293
+ }, CARD_AUTO_MS)
294
+ }
295
+ }, 900)
296
+ }
297
+ }, [model?.state, askId, model?.targetIds])
298
+
299
+ const hovering = useRef(false)
300
+
301
+ // A settled pin retires 8 s after it was last in the person's way, with a short fade.
302
+ useEffect(() => {
303
+ if (retire.current) clearTimeout(retire.current)
304
+ retire.current = null
305
+ if (!model || !SETTLED.has(model.state)) return
306
+ const arm = () => {
307
+ retire.current = setTimeout(() => {
308
+ const s = usePointAsk.getState()
309
+ if (s.cardFor === askId || hovering.current) return arm()
310
+ el.current
311
+ ?.animate([{ opacity: 1 }, { opacity: 0 }], {
312
+ duration: PIN_RETIRE_FADE_MS,
313
+ fill: 'forwards',
314
+ })
315
+ .finished.then(
316
+ () => usePointAsk.getState().removePin(askId),
317
+ () => undefined,
318
+ )
319
+ }, PIN_RETIRE_MS)
320
+ }
321
+ arm()
322
+ return () => {
323
+ if (retire.current) clearTimeout(retire.current)
324
+ }
325
+ }, [model?.state, askId, model])
326
+
327
+ if (!(pin && model) || gaveWay) return null
328
+ const changes = model.changedTargetIds.length
329
+ const overHere =
330
+ model.state === 'needs-input'
331
+ ? 'Pascal needs you over here'
332
+ : model.state === 'error'
333
+ ? 'Something failed over here'
334
+ : changes > 0
335
+ ? `${changes} change${changes === 1 ? '' : 's'} over here`
336
+ : model.state === 'working'
337
+ ? 'Pascal is working over here'
338
+ : 'Over here'
339
+ return (
340
+ <>
341
+ <button
342
+ className="pa-edgemark"
343
+ onClick={() => lookAtAsk(askId)}
344
+ ref={marker}
345
+ style={{ display: 'none' }}
346
+ type="button"
347
+ >
348
+ <svg
349
+ aria-hidden
350
+ fill="none"
351
+ stroke="currentColor"
352
+ strokeLinecap="round"
353
+ strokeLinejoin="round"
354
+ strokeWidth="1.8"
355
+ viewBox="0 0 16 16"
356
+ >
357
+ <path d="M3 8h9M8.5 4l4 4-4 4" />
358
+ </svg>
359
+ {overHere}
360
+ </button>
361
+ <div
362
+ className={`pa-pin ${model.state === 'sent' ? 'queued' : model.state}`}
363
+ onClick={() => {
364
+ // The click that ends a hold is not a click on the pin.
365
+ if (held.current) {
366
+ held.current = false
367
+ return
368
+ }
369
+ emitter.emit('point-ask:pin', { askId, action: 'open' })
370
+ usePointAsk.getState().openCard(askId)
371
+ }}
372
+ onLostPointerCapture={() => endCompare()}
373
+ onPointerCancel={() => {
374
+ if (holdTimer.current) clearTimeout(holdTimer.current)
375
+ holdTimer.current = null
376
+ endCompare()
377
+ }}
378
+ onPointerDown={(event) => {
379
+ held.current = false
380
+ if (event.button !== 0 || model.state !== 'done') return
381
+ const target = event.currentTarget
382
+ const pointerId = event.pointerId
383
+ const origin = { x: event.clientX, y: event.clientY }
384
+ // A press that stays down shows the scene as it was before the turn; a click opens the card.
385
+ holdTimer.current = setTimeout(() => {
386
+ holdTimer.current = null
387
+ if (!startCompare(askId)) return
388
+ held.current = true
389
+ target.setPointerCapture?.(pointerId)
390
+ // Capture keeps the release, so leaving the pin is a drift away from where it was pressed.
391
+ endCompareWhenPointerDrifts(origin, PIN_HOLD_SLOP_PX)
392
+ }, PIN_HOLD_MS)
393
+ }}
394
+ onPointerUp={() => {
395
+ if (holdTimer.current) clearTimeout(holdTimer.current)
396
+ holdTimer.current = null
397
+ endCompare()
398
+ }}
399
+ onPointerEnter={() => {
400
+ hovering.current = true
401
+ pinHover.askId = askId
402
+ emitter.emit('point-ask:pin', { askId, action: 'hover' })
403
+ usePointAsk.getState().openCard(askId)
404
+ }}
405
+ onPointerLeave={() => {
406
+ hovering.current = false
407
+ if (pinHover.askId === askId) pinHover.askId = null
408
+ if (holdTimer.current) clearTimeout(holdTimer.current)
409
+ holdTimer.current = null
410
+ emitter.emit('point-ask:pin', { askId, action: 'leave' })
411
+ window.setTimeout(() => {
412
+ const s = usePointAsk.getState()
413
+ if (s.cardFor === askId && !cardHeld(askId)) s.openCard(null)
414
+ }, CARD_CLOSE_DELAY_MS)
415
+ }}
416
+ ref={(node) => {
417
+ el.current = node
418
+ arcs.current = node?.querySelector<SVGGElement>('.pa-parcs') ?? null
419
+ }}
420
+ title={model.state === 'working' && model.sentence ? model.sentence : 'Sent to Pascal'}
421
+ >
422
+ {PIN_SVG}
423
+ </div>
424
+ </>
425
+ )
426
+ }
427
+
428
+ const cardHover = { current: false }
429
+
430
+ /**
431
+ * Whether the pointer is on the card. The flag follows the card's enter and leave events, but a card
432
+ * that closes under the pointer (a click on Redo, Keep) never gets its leave, and one that opens
433
+ * under a still pointer never gets its enter, so the browser's own :hover is asked as well.
434
+ */
435
+ const cardHeld = (askId: string) =>
436
+ cardHover.current ||
437
+ pinHover.askId === askId ||
438
+ document.querySelector('.pa-card:hover, .pa-pin:hover') !== null
439
+
440
+ /** The pin the pointer is on, so a card's close timer does not shut what the pin just opened. */
441
+ const pinHover: { askId: string | null } = { askId: null }
442
+
443
+ /** The pin's card: what Pascal did, and Keep or Undo; it rises from the pin. */
444
+ function PinCard() {
445
+ const askId = usePointAsk((s) => s.cardFor)
446
+ const pin = usePointAsk((s) => (askId ? s.pins[askId] : undefined))
447
+ const wrap = useRef<HTMLDivElement>(null)
448
+ const card = useRef<HTMLDivElement>(null)
449
+ const [warn, setWarn] = useState(false)
450
+ const [note, setNote] = useState<string | null>(null)
451
+ const comparing = usePointAsk((s) => s.comparing === askId)
452
+ const refusal = usePointAsk((s) =>
453
+ s.compareRefusal?.askId === askId ? s.compareRefusal.reason : null,
454
+ )
455
+ const lastMove = useRef<{ x: number; y: number } | null>(null)
456
+ const carriedTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
457
+ const optional = useRef<Animation[]>([])
458
+ const calm = usePointMotionCalm()
459
+ useEffect(() => {
460
+ if (calm) finishOptionalAnimations(optional.current)
461
+ }, [calm])
462
+ useEffect(() => {
463
+ const note = (event: globalThis.PointerEvent) => {
464
+ lastMove.current = { x: event.clientX, y: event.clientY }
465
+ }
466
+ window.addEventListener('pointermove', note, { passive: true, capture: true })
467
+ return () => window.removeEventListener('pointermove', note, { capture: true })
468
+ }, [])
469
+ // A card that goes under the pointer never hears its leave: the next one starts unheld.
470
+ // biome-ignore lint/correctness/useExhaustiveDependencies: tied to which card is open
471
+ useEffect(() => {
472
+ cardHover.current = false
473
+ if (carriedTimer.current) clearTimeout(carriedTimer.current)
474
+ carriedTimer.current = null
475
+ }, [askId])
476
+ // biome-ignore lint/correctness/useExhaustiveDependencies: a new card starts without the warning
477
+ useEffect(() => {
478
+ setWarn(false)
479
+ setNote(null)
480
+ }, [askId])
481
+
482
+ useEffect(() => {
483
+ if (!askId) return
484
+ return subscribeFrame(() => {
485
+ const record = usePointAsk.getState().pins[askId]
486
+ const node = wrap.current
487
+ const cardEl = card.current
488
+ if (!(record && node && cardEl)) return
489
+ const at = projectWorld(pinWorld(record))
490
+ node.style.visibility = at.ok ? '' : 'hidden'
491
+ // Above the pin; below it when there is no room above (clear of the top bar and camera hints).
492
+ const view = freeViewport()
493
+ const height = cardEl.offsetHeight
494
+ const above = at.y - 46 - height
495
+ const y = view && above < view.y0 ? at.y + 14 : above
496
+ const x = view
497
+ ? Math.min(Math.max(at.x - CARD_W / 2, view.x0), Math.max(view.x0, view.x1 - CARD_W))
498
+ : at.x - CARD_W / 2
499
+ node.style.transform = `translate3d(${x}px,${y}px,0)`
500
+ })
501
+ }, [askId])
502
+
503
+ useEffect(() => {
504
+ const el = card.current
505
+ if (!(askId && el)) return
506
+ const quiet = pointMotionCalm()
507
+ const animation = el.animate(
508
+ quiet
509
+ ? [{ opacity: 0 }, { opacity: 1 }]
510
+ : [
511
+ { opacity: 0, transform: 'translateY(6px) scale(.96)' },
512
+ { opacity: 1, transform: 'none' },
513
+ ],
514
+ { duration: CARD_MS, easing: EASE_OUT },
515
+ )
516
+ if (!quiet) optional.current.push(animation)
517
+ return () => {
518
+ animation.cancel()
519
+ optional.current = []
520
+ }
521
+ }, [askId])
522
+
523
+ if (!(askId && pin)) return null
524
+ const { model } = pin
525
+ const title =
526
+ model.state === 'done'
527
+ ? cardTitle(model)
528
+ : model.state === 'answered'
529
+ ? (model.answer ?? '').split('\n').slice(0, 2).join(' ')
530
+ : model.state === 'no-change'
531
+ ? `Pascal didn't change ${pin.label}.`
532
+ : model.state === 'error'
533
+ ? (model.error ?? 'Pascal could not do this.')
534
+ : model.state === 'needs-input'
535
+ ? (model.question ?? 'Pascal needs a word from you.')
536
+ : model.state === 'undone'
537
+ ? 'Undone'
538
+ : model.sentence || 'Pascal is on it'
539
+ const also = alsoChangedLine(model)
540
+ const edits = handEditsSince(askId)
541
+ const keepOpen = () => {
542
+ cardHover.current = true
543
+ if (carriedTimer.current) clearTimeout(carriedTimer.current)
544
+ carriedTimer.current = null
545
+ }
546
+ const leave = (event: PointerEvent<HTMLDivElement>) => {
547
+ cardHover.current = false
548
+ if (
549
+ cardCarriedFromPointer({
550
+ pointerType: event.pointerType,
551
+ at: { x: event.clientX, y: event.clientY },
552
+ last: lastMove.current,
553
+ })
554
+ ) {
555
+ // The pointer did not leave, the card was taken from it: it waits a while for the pointer.
556
+ if (carriedTimer.current) clearTimeout(carriedTimer.current)
557
+ carriedTimer.current = setTimeout(() => {
558
+ carriedTimer.current = null
559
+ const s = usePointAsk.getState()
560
+ if (s.cardFor === askId && !cardHeld(askId)) s.openCard(null)
561
+ }, CARD_CARRIED_MS)
562
+ return
563
+ }
564
+ window.setTimeout(() => {
565
+ const s = usePointAsk.getState()
566
+ if (s.cardFor === askId && !cardHeld(askId)) s.openCard(null)
567
+ }, CARD_CLOSE_DELAY_MS)
568
+ }
569
+ return (
570
+ <div className="pa-cardwrap" ref={wrap}>
571
+ <div className="pa-card" onPointerEnter={keepOpen} onPointerLeave={leave} ref={card}>
572
+ {model.state === 'done' ? (
573
+ <div className="pa-card-head">
574
+ <div className="pa-card-t">{title}</div>
575
+ {pin.after?.dataUrl || pin.before?.dataUrl ? (
576
+ <div className="pa-card-thumb" title="After">
577
+ {/* biome-ignore lint/performance/noImgElement: data URLs the capture just made */}
578
+ <img alt="After" src={pin.after?.dataUrl ?? pin.before?.dataUrl} />
579
+ </div>
580
+ ) : null}
581
+ </div>
582
+ ) : (
583
+ <div className="pa-card-t">{title}</div>
584
+ )}
585
+ {model.state === 'working' || model.state === 'queued' || model.state === 'sent' ? (
586
+ <div className="pa-card-s">“{model.text}”</div>
587
+ ) : null}
588
+ {also ? (
589
+ <div
590
+ className="pa-card-a"
591
+ onPointerEnter={() => useViewer.getState().setPointedIds(model.alsoChangedIds as never)}
592
+ onPointerLeave={() => useViewer.getState().setPointedIds(null)}
593
+ >
594
+ {also}
595
+ </div>
596
+ ) : null}
597
+ {note ? <div className="pa-card-s">{note}</div> : null}
598
+ {model.state === 'done' && edits > 0 && warn ? (
599
+ <div className="pa-card-s">
600
+ You've changed the scene since. Undoing also removes those changes.
601
+ </div>
602
+ ) : null}
603
+ {refusal ? <div className="pa-card-s">{COMPARE_REFUSAL_WORDS[refusal]}</div> : null}
604
+ {model.state === 'done' ? (
605
+ <button
606
+ aria-pressed={comparing}
607
+ className={`pa-hold${comparing ? ' on' : ''}`}
608
+ onKeyDown={(event) => {
609
+ if (event.repeat || (event.key !== ' ' && event.key !== 'Enter')) return
610
+ event.preventDefault()
611
+ startCompare(askId)
612
+ }}
613
+ onKeyUp={(event) => {
614
+ if (event.key === ' ' || event.key === 'Enter') endCompare()
615
+ }}
616
+ onPointerCancel={() => endCompare()}
617
+ onPointerDown={(event) => {
618
+ if (event.button !== 0) return
619
+ startCompare(askId)
620
+ }}
621
+ onPointerLeave={() => endCompare()}
622
+ onPointerUp={() => endCompare()}
623
+ type="button"
624
+ >
625
+ {comparing ? 'Showing before' : 'Hold to see before'}
626
+ </button>
627
+ ) : null}
628
+ <div className="pa-card-row">
629
+ {model.state === 'done' ? (
630
+ <>
631
+ <button
632
+ className="pa-btn primary"
633
+ onClick={() => {
634
+ usePointAsk.getState().dispatchPin(askId, { type: 'keep' })
635
+ usePointAsk.getState().openCard(null)
636
+ }}
637
+ type="button"
638
+ >
639
+ Keep
640
+ </button>
641
+ <button
642
+ className="pa-btn"
643
+ onClick={() => {
644
+ changeMore(askId)
645
+ }}
646
+ type="button"
647
+ >
648
+ Change more
649
+ </button>
650
+ <button
651
+ className="pa-btn"
652
+ onClick={async () => {
653
+ const result = await undoAsk(askId, { force: edits > 0 && warn })
654
+ if (result === 'needs-confirm') setWarn(true)
655
+ else if (result === 'unavailable')
656
+ setNote("This can't be undone from here: the scene's history has moved on.")
657
+ }}
658
+ type="button"
659
+ >
660
+ {edits > 0 && warn ? 'Undo anyway' : 'Undo'}
661
+ </button>
662
+ </>
663
+ ) : null}
664
+ {model.state === 'undone' ? (
665
+ <button
666
+ className="pa-btn primary"
667
+ onClick={() => {
668
+ redoAsk(askId)
669
+ usePointAsk.getState().openCard(null)
670
+ }}
671
+ type="button"
672
+ >
673
+ Redo
674
+ </button>
675
+ ) : null}
676
+ {model.state === 'error' ? (
677
+ <>
678
+ <button
679
+ className="pa-btn primary"
680
+ onClick={() => {
681
+ void retryAsk(askId)
682
+ }}
683
+ type="button"
684
+ >
685
+ Try again
686
+ </button>
687
+ <button
688
+ className="pa-btn"
689
+ onClick={() => emitter.emit('point-ask:pin', { askId, action: 'open' })}
690
+ type="button"
691
+ >
692
+ Open in chat
693
+ </button>
694
+ </>
695
+ ) : null}
696
+ {model.state === 'answered' ||
697
+ model.state === 'no-change' ||
698
+ model.state === 'needs-input' ? (
699
+ <button
700
+ className="pa-btn"
701
+ onClick={() => emitter.emit('point-ask:pin', { askId, action: 'open' })}
702
+ type="button"
703
+ >
704
+ {model.state === 'no-change' ? 'See why' : 'Open in chat'}
705
+ </button>
706
+ ) : null}
707
+ </div>
708
+ </div>
709
+ </div>
710
+ )
711
+ }