@pascal-app/editor 1.0.4 → 1.0.6
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.
- package/README.md +106 -0
- package/package.json +40 -38
- package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
- package/src/components/editor/bake-thumbnail.tsx +6 -15
- package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
- package/src/components/editor/construction-reveal-audio.tsx +40 -0
- package/src/components/editor/custom-camera-controls.tsx +81 -8
- package/src/components/editor/editor-layout-mobile.tsx +1 -1
- package/src/components/editor/editor-layout-v2.tsx +1 -1
- package/src/components/editor/export-manager.tsx +14 -19
- package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
- package/src/components/editor/first-person/build-collider-world.ts +22 -5
- package/src/components/editor/first-person/ground-holes.test.ts +128 -0
- package/src/components/editor/first-person/ground-holes.ts +130 -0
- package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
- package/src/components/editor/first-person/mechanism-targets.ts +52 -0
- package/src/components/editor/first-person-controls.tsx +19 -11
- package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
- package/src/components/editor/floorplan-panel.tsx +72 -23
- package/src/components/editor/follow-pascal.test.tsx +912 -0
- package/src/components/editor/follow-pascal.tsx +354 -0
- package/src/components/editor/group-actions.ts +2 -0
- package/src/components/editor/group-move-3d.ts +2 -0
- package/src/components/editor/group-rotate-handle.tsx +2 -0
- package/src/components/editor/group-transform-shared.test.ts +460 -0
- package/src/components/editor/index.tsx +166 -79
- package/src/components/editor/riser-diagram-panel.tsx +14 -7
- package/src/components/editor/room-highlight.tsx +13 -5
- package/src/components/editor/room-labels.test.ts +621 -128
- package/src/components/editor/room-labels.tsx +1090 -190
- package/src/components/editor/selection-manager.tsx +38 -117
- package/src/components/editor/slab-hole-highlights.tsx +2 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
- package/src/components/editor/snapshot-capture.test.ts +69 -58
- package/src/components/editor/snapshot-capture.ts +7 -1
- package/src/components/editor/thumbnail-generator.test.ts +350 -4
- package/src/components/editor/thumbnail-generator.tsx +128 -54
- package/src/components/editor/use-floorplan-background-placement.ts +3 -1
- package/src/components/editor/views/plugins-view.tsx +1 -8
- package/src/components/editor/views/view-bar.test.tsx +54 -0
- package/src/components/editor/views/view-bar.tsx +140 -17
- package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
- package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
- package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
- package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
- package/src/components/point-ask/bubble.tsx +538 -0
- package/src/components/point-ask/hover-chip.tsx +91 -0
- package/src/components/point-ask/mode-pill.tsx +92 -0
- package/src/components/point-ask/optional-motion.ts +22 -0
- package/src/components/point-ask/outline-corners.tsx +93 -0
- package/src/components/point-ask/pins.tsx +711 -0
- package/src/components/point-ask/point-ask-layer.tsx +83 -0
- package/src/components/point-ask/point-ask-styles.ts +105 -0
- package/src/components/point-ask/region-marquee-styles.ts +15 -0
- package/src/components/point-ask/region-marquee.tsx +64 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
- package/src/components/systems/roof/roof-edit-system.tsx +1 -0
- package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
- package/src/components/tools/distribution/connection-compatibility.ts +43 -0
- package/src/components/tools/distribution/distribution-public.ts +9 -0
- package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
- package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
- package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
- package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
- package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
- package/src/components/tools/distribution/draw-alignment.ts +35 -0
- package/src/components/tools/distribution/ports.test.ts +56 -0
- package/src/components/tools/distribution/ports.ts +378 -0
- package/src/components/tools/distribution/run-cursor.test.ts +135 -0
- package/src/components/tools/distribution/run-cursor.ts +36 -0
- package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
- package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
- package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
- package/src/components/tools/distribution/run-port-snap.ts +25 -0
- package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
- package/src/components/tools/distribution/run-screen-direction.ts +30 -0
- package/src/components/tools/elevator/elevator-tool.tsx +3 -6
- package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
- package/src/components/tools/item/placement-history.test.tsx +38 -0
- package/src/components/tools/item/placement-strategies.test.ts +46 -1
- package/src/components/tools/item/placement-strategies.ts +3 -6
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
- package/src/components/tools/item/use-draft-node.test.tsx +94 -94
- package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
- package/src/components/tools/registry/item-surface-move.test.ts +62 -63
- package/src/components/tools/registry/item-surface-move.ts +2 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
- package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
- package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
- package/src/components/tools/tool-manager.tsx +14 -5
- package/src/components/tools/wall/wall-drafting.test.ts +170 -57
- package/src/components/ui/action-menu/action-button.tsx +6 -1
- package/src/components/ui/action-menu/camera-actions.tsx +7 -3
- package/src/components/ui/action-menu/control-modes.tsx +13 -7
- package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
- package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
- package/src/components/ui/action-menu/index.tsx +56 -33
- package/src/components/ui/action-menu/measurement-control.tsx +11 -9
- package/src/components/ui/action-menu/placement.ts +20 -0
- package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
- package/src/components/ui/action-menu/view-toggles.tsx +47 -42
- package/src/components/ui/build-panel/build-panel.tsx +10 -3
- package/src/components/ui/command-palette/editor-commands.tsx +3 -12
- package/src/components/ui/command-palette/index.tsx +45 -15
- package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
- package/src/components/ui/controls/action-button.tsx +5 -2
- package/src/components/ui/controls/metric-control.tsx +1 -1
- package/src/components/ui/controls/segmented-control.tsx +21 -8
- package/src/components/ui/controls/select-control.tsx +1 -1
- package/src/components/ui/controls/slider-control.tsx +1 -1
- package/src/components/ui/controls/toggle-control.tsx +3 -3
- package/src/components/ui/controls/tool-options-panel.tsx +1 -1
- package/src/components/ui/floating-level-selector.tsx +150 -45
- package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
- package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
- package/src/components/ui/helpers/helper-manager.tsx +7 -1
- package/src/components/ui/helpers/item-helper.tsx +9 -1
- package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
- package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
- package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
- package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
- package/src/components/ui/panels/panel-wrapper.tsx +53 -27
- package/src/components/ui/panels/parametric-field-control.tsx +2 -2
- package/src/components/ui/panels/parametric-inspector.tsx +3 -3
- package/src/components/ui/panels/room-panel.tsx +65 -0
- package/src/components/ui/primitives/badge.tsx +8 -0
- package/src/components/ui/primitives/sidebar.tsx +1 -1
- package/src/components/ui/right-stack.tsx +21 -2
- package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
- package/src/components/ui/sidebar/icon-rail.tsx +3 -3
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
- package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
- package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
- package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
- package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
- package/src/components/ui/sidebar/tab-bar.tsx +4 -4
- package/src/components/viewer/viewer-controls-bar.tsx +18 -6
- package/src/components/viewer/viewer-rooms.tsx +16 -0
- package/src/hooks/use-auto-save.integration.test.tsx +96 -38
- package/src/hooks/use-auto-save.ts +26 -1
- package/src/hooks/use-ceiling-events.test.ts +123 -118
- package/src/hooks/use-grid-events.test.ts +105 -105
- package/src/hooks/use-keyboard.test.ts +65 -16
- package/src/hooks/use-keyboard.ts +29 -12
- package/src/hooks/use-placement-notice.test.ts +24 -1
- package/src/hooks/use-placement-notice.ts +37 -6
- package/src/index.tsx +71 -24
- package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
- package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
- package/src/lib/add-storey.test.ts +177 -0
- package/src/lib/add-storey.ts +59 -0
- package/src/lib/below-grade.test.tsx +171 -0
- package/src/lib/below-grade.ts +133 -0
- package/src/lib/camera-facing.test.ts +24 -0
- package/src/lib/camera-facing.ts +24 -0
- package/src/lib/direct-manipulation.ts +2 -0
- package/src/lib/download-project-file.ts +17 -0
- package/src/lib/editor-level-display.ts +9 -2
- package/src/lib/editor-views.ts +35 -1
- package/src/lib/export-texture-utils.ts +2 -4
- package/src/lib/floor-foundation-support.test.ts +1 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
- package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
- package/src/lib/floorplan/sheet-export.test.ts +24 -0
- package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
- package/src/lib/floorplan/site-plan/coverage.ts +2 -4
- package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
- package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
- package/src/lib/floorplan/site-plan/geometry.ts +1 -416
- package/src/lib/floorplan/site-plan/index.ts +0 -16
- package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
- package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
- package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
- package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
- package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
- package/src/lib/follow-pascal-exterior.test.ts +171 -0
- package/src/lib/follow-pascal-exterior.ts +39 -0
- package/src/lib/follow-pascal-presentation.test.ts +203 -0
- package/src/lib/follow-pascal-presentation.ts +129 -0
- package/src/lib/follow-pascal-sight.ts +84 -0
- package/src/lib/follow-pascal-targets.test.ts +406 -0
- package/src/lib/follow-pascal-targets.ts +198 -0
- package/src/lib/follow-pascal.test.ts +350 -0
- package/src/lib/follow-pascal.ts +284 -0
- package/src/lib/fresh-planar-placement.test.ts +112 -0
- package/src/lib/fresh-planar-placement.ts +57 -3
- package/src/lib/geometry-script/author.ts +6 -1
- package/src/lib/geometry-script/client.ts +8 -1
- package/src/lib/geometry-script/finish-scripts.ts +127 -0
- package/src/lib/geometry-script/protocol.ts +1 -0
- package/src/lib/glb-export.test.ts +187 -162
- package/src/lib/glb-export.ts +4 -1
- package/src/lib/history.test.ts +12 -7
- package/src/lib/interaction/scope.ts +4 -0
- package/src/lib/level-selection.test.ts +57 -0
- package/src/lib/level-selection.ts +16 -1
- package/src/lib/lot/clean-ring.test.ts +2 -1
- package/src/lib/lot/drop-in.ts +9 -24
- package/src/lib/lot/lot-patch.test.ts +1 -1
- package/src/lib/lot/lot-patch.ts +2 -2
- package/src/lib/measurement-kind.test.ts +6 -8
- package/src/lib/measurements.test.ts +23 -0
- package/src/lib/orbit-floor-clearance.test.ts +36 -0
- package/src/lib/orbit-floor-clearance.ts +48 -0
- package/src/lib/paint-pass-through.test.ts +11 -0
- package/src/lib/paint-pass-through.ts +4 -2
- package/src/lib/paint-scope.ts +1 -0
- package/src/lib/parametric-node-update.ts +16 -7
- package/src/lib/plugin-chat.ts +15 -0
- package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
- package/src/lib/point-ask/__fixtures__/house.ts +81 -0
- package/src/lib/point-ask/ask-created.test.ts +195 -0
- package/src/lib/point-ask/ask-flow.test.ts +380 -0
- package/src/lib/point-ask/build-context.test.ts +173 -0
- package/src/lib/point-ask/build-context.ts +94 -0
- package/src/lib/point-ask/capture-crop.ts +129 -0
- package/src/lib/point-ask/choreography.ts +69 -0
- package/src/lib/point-ask/compare.test.ts +145 -0
- package/src/lib/point-ask/compare.ts +73 -0
- package/src/lib/point-ask/describe-target.test.ts +165 -0
- package/src/lib/point-ask/describe-target.ts +234 -0
- package/src/lib/point-ask/dictation.ts +79 -0
- package/src/lib/point-ask/edge-marker.test.ts +61 -0
- package/src/lib/point-ask/edge-marker.ts +27 -0
- package/src/lib/point-ask/frame-loop.ts +25 -0
- package/src/lib/point-ask/history-marks.test.ts +94 -0
- package/src/lib/point-ask/history-marks.ts +39 -0
- package/src/lib/point-ask/host-api.ts +32 -0
- package/src/lib/point-ask/outline-box.test.ts +30 -0
- package/src/lib/point-ask/outline-box.ts +11 -0
- package/src/lib/point-ask/overlay-metrics.ts +6 -0
- package/src/lib/point-ask/pick.test.ts +220 -0
- package/src/lib/point-ask/pick.ts +121 -0
- package/src/lib/point-ask/pin-machine.test.ts +494 -0
- package/src/lib/point-ask/pin-machine.ts +217 -0
- package/src/lib/point-ask/placement.test.ts +203 -0
- package/src/lib/point-ask/placement.ts +141 -0
- package/src/lib/point-ask/pointer-tracker.ts +34 -0
- package/src/lib/point-ask/projector.ts +83 -0
- package/src/lib/point-ask/region.test.ts +157 -0
- package/src/lib/point-ask/region.ts +136 -0
- package/src/lib/point-ask/retry.test.ts +217 -0
- package/src/lib/point-ask/retry.ts +37 -0
- package/src/lib/point-ask/scene-commit.test.ts +312 -0
- package/src/lib/point-ask/scene-commit.ts +32 -0
- package/src/lib/point-ask/session.ts +1391 -0
- package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
- package/src/lib/point-ask/spring-keyframes.ts +38 -0
- package/src/lib/point-ask/spring.test.ts +63 -0
- package/src/lib/point-ask/spring.ts +107 -0
- package/src/lib/point-ask/suggestions.test.ts +80 -0
- package/src/lib/point-ask/suggestions.ts +125 -0
- package/src/lib/point-ask/target-scope.test.ts +622 -0
- package/src/lib/point-ask/target-scope.ts +338 -0
- package/src/lib/point-ask/types.ts +48 -0
- package/src/lib/portable-export.test.ts +34 -1
- package/src/lib/portable-export.ts +119 -53
- package/src/lib/print-feature-thickness.ts +10 -1
- package/src/lib/print-roof-solids.test.ts +39 -1
- package/src/lib/print-roof-solids.ts +43 -10
- package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
- package/src/lib/room-assembly-overlay.ts +2 -3
- package/src/lib/room-selection.test.ts +45 -1
- package/src/lib/scene-clipboard.test.ts +246 -1
- package/src/lib/scene-clipboard.ts +44 -14
- package/src/lib/selection-routing.ts +1 -1
- package/src/lib/sfx-bus.ts +13 -0
- package/src/lib/sfx-player.test.ts +70 -9
- package/src/lib/sfx-player.ts +6 -0
- package/src/lib/stair-levels.test.ts +25 -0
- package/src/lib/stair-levels.ts +22 -3
- package/src/lib/terrace-draft.ts +2 -1
- package/src/lib/viewer-selection.ts +22 -11
- package/src/lib/wall-placement-pointer.test.ts +42 -0
- package/src/lib/wall-placement-pointer.ts +57 -0
- package/src/lib/window-changes.ts +35 -0
- package/src/store/use-audio.test.ts +146 -0
- package/src/store/use-audio.tsx +127 -3
- package/src/store/use-construction-reveal.test.ts +173 -0
- package/src/store/use-construction-reveal.ts +145 -0
- package/src/store/use-corner-snap-hint.test.ts +24 -0
- package/src/store/use-corner-snap-hint.ts +47 -0
- package/src/store/use-follow-pascal.test.ts +62 -0
- package/src/store/use-follow-pascal.ts +53 -0
- package/src/store/use-point-ask.ts +247 -0
- package/src/store/use-stair-build-preview.ts +25 -5
- package/src/store/view-layout.test.ts +27 -1
- package/src/store/view-layout.ts +4 -1
- package/src/test-utils/render-current-markup.tsx +16 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
- package/src/components/tools/stair/stair-click-guard.ts +0 -72
- package/src/components/tools/stair/stair-defaults.ts +0 -15
- 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(
|
|
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 &&
|
|
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 &&
|
|
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 {
|
|
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',
|
package/src/store/view-layout.ts
CHANGED
|
@@ -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)
|
|
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
|
-
}
|