@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.
- package/README.md +106 -0
- package/package.json +39 -37
- 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,146 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import useAudio, { AUDIO_MUTED_STORAGE_KEY, AUDIO_SETTINGS_STORAGE_KEY } from './use-audio'
|
|
3
|
+
|
|
4
|
+
function memoryStorage() {
|
|
5
|
+
const items = new Map<string, string>()
|
|
6
|
+
return {
|
|
7
|
+
items,
|
|
8
|
+
getItem: (key: string) => items.get(key) ?? null,
|
|
9
|
+
setItem: (key: string, value: string) => void items.set(key, value),
|
|
10
|
+
removeItem: (key: string) => void items.delete(key),
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const originals = {
|
|
15
|
+
localStorage: Object.getOwnPropertyDescriptor(globalThis, 'localStorage'),
|
|
16
|
+
sessionStorage: Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage'),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function use(name: 'localStorage' | 'sessionStorage', storage: ReturnType<typeof memoryStorage>) {
|
|
20
|
+
Object.defineProperty(globalThis, name, { configurable: true, value: storage })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One browser profile: its tabs share `localStorage`, each has its own `sessionStorage`. */
|
|
24
|
+
let browser = memoryStorage()
|
|
25
|
+
|
|
26
|
+
/** Another tab of the profile saves the settings: the browser stores them and tells this tab. */
|
|
27
|
+
function savedInAnotherTab(state: Record<string, unknown>) {
|
|
28
|
+
const value = JSON.stringify({ state, version: 0 })
|
|
29
|
+
browser.setItem(AUDIO_SETTINGS_STORAGE_KEY, value)
|
|
30
|
+
globalThis.dispatchEvent(
|
|
31
|
+
Object.assign(new Event('storage'), { key: AUDIO_SETTINGS_STORAGE_KEY, newValue: value }),
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The editor loading in a tab: a new one, or the same one reloaded (its session kept). */
|
|
36
|
+
async function loadIn(tab: ReturnType<typeof memoryStorage>) {
|
|
37
|
+
use('sessionStorage', tab)
|
|
38
|
+
await useAudio.persist.rehydrate()
|
|
39
|
+
return useAudio.getState()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
browser = memoryStorage()
|
|
44
|
+
use('localStorage', browser)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterAll(() => {
|
|
48
|
+
for (const [name, descriptor] of Object.entries(originals)) {
|
|
49
|
+
if (descriptor) Object.defineProperty(globalThis, name, descriptor)
|
|
50
|
+
else Reflect.deleteProperty(globalThis, name)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('muting a tab', () => {
|
|
55
|
+
test('a tab keeps its own mute across reloads, and a new tab starts from the last choice', async () => {
|
|
56
|
+
const first = memoryStorage()
|
|
57
|
+
const second = memoryStorage()
|
|
58
|
+
|
|
59
|
+
expect((await loadIn(first)).muted).toBe(false)
|
|
60
|
+
useAudio.getState().toggleMute()
|
|
61
|
+
|
|
62
|
+
expect((await loadIn(second)).muted).toBe(true)
|
|
63
|
+
useAudio.getState().setMuted(false)
|
|
64
|
+
|
|
65
|
+
expect((await loadIn(first)).muted).toBe(true)
|
|
66
|
+
expect((await loadIn(memoryStorage())).muted).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("another tab's choice does not change a tab that never chose", async () => {
|
|
70
|
+
const quiet = memoryStorage()
|
|
71
|
+
expect((await loadIn(quiet)).muted).toBe(false)
|
|
72
|
+
|
|
73
|
+
await loadIn(memoryStorage())
|
|
74
|
+
useAudio.getState().setMuted(true)
|
|
75
|
+
|
|
76
|
+
expect((await loadIn(quiet)).muted).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("volumes are every tab's and never carry a mute", async () => {
|
|
80
|
+
const muting = memoryStorage()
|
|
81
|
+
await loadIn(muting)
|
|
82
|
+
useAudio.getState().setMuted(true)
|
|
83
|
+
|
|
84
|
+
await loadIn(memoryStorage())
|
|
85
|
+
useAudio.getState().setMuted(false)
|
|
86
|
+
await loadIn(muting)
|
|
87
|
+
useAudio.getState().setSfxVolume(20)
|
|
88
|
+
|
|
89
|
+
expect(JSON.parse(browser.getItem(AUDIO_SETTINGS_STORAGE_KEY)!).state).not.toHaveProperty(
|
|
90
|
+
'muted',
|
|
91
|
+
)
|
|
92
|
+
expect(browser.getItem(AUDIO_MUTED_STORAGE_KEY)).toBe('false')
|
|
93
|
+
const next = await loadIn(memoryStorage())
|
|
94
|
+
expect(next.sfxVolume).toBe(20)
|
|
95
|
+
expect(next.muted).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test("a volume another tab sets reaches this one at once, and this tab's saves carry it", async () => {
|
|
99
|
+
await loadIn(memoryStorage())
|
|
100
|
+
useAudio.getState().setSfxVolume(30)
|
|
101
|
+
useAudio.getState().setRadioPlaying(true)
|
|
102
|
+
const stored = JSON.parse(browser.getItem(AUDIO_SETTINGS_STORAGE_KEY)!)
|
|
103
|
+
|
|
104
|
+
savedInAnotherTab({ ...stored.state, isRadioPlaying: false, muted: true, sfxVolume: 80 })
|
|
105
|
+
|
|
106
|
+
expect(useAudio.getState()).toMatchObject({ isRadioPlaying: true, muted: false, sfxVolume: 80 })
|
|
107
|
+
const setElsewhere = browser.getItem(AUDIO_SETTINGS_STORAGE_KEY)
|
|
108
|
+
useAudio.getState().setMuted(true)
|
|
109
|
+
expect(browser.getItem(AUDIO_SETTINGS_STORAGE_KEY)).toBe(setElsewhere)
|
|
110
|
+
// The radio pausing as the tab is hidden saves every setting: the other tab's volume stays.
|
|
111
|
+
useAudio.getState().setRadioPlaying(false)
|
|
112
|
+
useAudio.getState().setMasterVolume(60)
|
|
113
|
+
expect(JSON.parse(browser.getItem(AUDIO_SETTINGS_STORAGE_KEY)!).state).toMatchObject({
|
|
114
|
+
masterVolume: 60,
|
|
115
|
+
sfxVolume: 80,
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test("a mute saved when it was every tab's still holds, in settings the old build still reads", async () => {
|
|
120
|
+
browser.setItem(
|
|
121
|
+
AUDIO_SETTINGS_STORAGE_KEY,
|
|
122
|
+
JSON.stringify({ state: { masterVolume: 40, muted: true, sfxVolume: 50 }, version: 0 }),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const state = await loadIn(memoryStorage())
|
|
126
|
+
|
|
127
|
+
expect(state.muted).toBe(true)
|
|
128
|
+
expect(state.masterVolume).toBe(40)
|
|
129
|
+
expect((await loadIn(memoryStorage())).muted).toBe(true)
|
|
130
|
+
useAudio.getState().setSfxVolume(45)
|
|
131
|
+
const saved = JSON.parse(browser.getItem(AUDIO_SETTINGS_STORAGE_KEY)!)
|
|
132
|
+
expect(saved).toMatchObject({ state: { masterVolume: 40, sfxVolume: 45 }, version: 0 })
|
|
133
|
+
expect(saved.state).not.toHaveProperty('muted')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('a tab still on the old settings does not undo a choice made since', async () => {
|
|
137
|
+
await loadIn(memoryStorage())
|
|
138
|
+
useAudio.getState().setMuted(false)
|
|
139
|
+
browser.setItem(
|
|
140
|
+
AUDIO_SETTINGS_STORAGE_KEY,
|
|
141
|
+
JSON.stringify({ state: { muted: true, sfxVolume: 50 }, version: 0 }),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
expect((await loadIn(memoryStorage())).muted).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
})
|
package/src/store/use-audio.tsx
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { create } from 'zustand'
|
|
4
|
-
import { persist } from 'zustand/middleware'
|
|
4
|
+
import { createJSONStorage, persist } from 'zustand/middleware'
|
|
5
|
+
|
|
6
|
+
export const AUDIO_SETTINGS_STORAGE_KEY = 'pascal-audio-settings'
|
|
7
|
+
/**
|
|
8
|
+
* Mute is each tab's own, so someone working in two projects can silence one and hear the other.
|
|
9
|
+
* Under this key `sessionStorage` holds this tab's mute (kept across its reloads) and
|
|
10
|
+
* `localStorage` the last choice made in any tab, which a new tab starts from.
|
|
11
|
+
*/
|
|
12
|
+
export const AUDIO_MUTED_STORAGE_KEY = 'pascal-audio-muted'
|
|
5
13
|
|
|
6
14
|
interface AudioState {
|
|
7
15
|
masterVolume: number
|
|
8
16
|
sfxVolume: number
|
|
9
17
|
radioVolume: number
|
|
10
18
|
isRadioPlaying: boolean
|
|
19
|
+
/** This tab's mute; the volumes are every tab's. */
|
|
11
20
|
muted: boolean
|
|
12
21
|
autoplay: boolean
|
|
13
22
|
setMasterVolume: (v: number) => void
|
|
@@ -15,10 +24,73 @@ interface AudioState {
|
|
|
15
24
|
setRadioVolume: (v: number) => void
|
|
16
25
|
setRadioPlaying: (v: boolean) => void
|
|
17
26
|
toggleRadioPlaying: () => void
|
|
27
|
+
setMuted: (muted: boolean) => void
|
|
18
28
|
toggleMute: () => void
|
|
19
29
|
setAutoplay: (v: boolean) => void
|
|
20
30
|
}
|
|
21
31
|
|
|
32
|
+
type StoredSettings = Pick<
|
|
33
|
+
AudioState,
|
|
34
|
+
'masterVolume' | 'sfxVolume' | 'radioVolume' | 'isRadioPlaying' | 'autoplay'
|
|
35
|
+
>
|
|
36
|
+
|
|
37
|
+
type BrowserStorage = 'localStorage' | 'sessionStorage'
|
|
38
|
+
|
|
39
|
+
// Storage can be missing (a test, a server render) or refuse access (site data blocked): the page
|
|
40
|
+
// then keeps its choices in memory only.
|
|
41
|
+
function readStored(storage: BrowserStorage, key: string) {
|
|
42
|
+
try {
|
|
43
|
+
return globalThis[storage]?.getItem(key) ?? null
|
|
44
|
+
} catch {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeStored(storage: BrowserStorage, key: string, value: string) {
|
|
50
|
+
try {
|
|
51
|
+
globalThis[storage]?.setItem(key, value)
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** This tab's mute. A tab without one takes the last choice made in any tab, and keeps it. */
|
|
56
|
+
function tabMuted(): boolean {
|
|
57
|
+
const own = readStored('sessionStorage', AUDIO_MUTED_STORAGE_KEY)
|
|
58
|
+
if (own !== null) return own === 'true'
|
|
59
|
+
const muted = readStored('localStorage', AUDIO_MUTED_STORAGE_KEY) === 'true'
|
|
60
|
+
writeStored('sessionStorage', AUDIO_MUTED_STORAGE_KEY, String(muted))
|
|
61
|
+
return muted
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A mute chosen in this tab: this tab's, and where the next new tab starts. Open tabs keep theirs. */
|
|
65
|
+
function chooseMuted(muted: boolean) {
|
|
66
|
+
writeStored('sessionStorage', AUDIO_MUTED_STORAGE_KEY, String(muted))
|
|
67
|
+
writeStored('localStorage', AUDIO_MUTED_STORAGE_KEY, String(muted))
|
|
68
|
+
return { muted }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The settings as this tab last read, wrote or took from another tab. A save carries every
|
|
72
|
+
// setting, so a save that changes none (a mute, which is this tab's own) is skipped.
|
|
73
|
+
let heldSettings: string | null = null
|
|
74
|
+
// Set while this tab takes another tab's volumes: that is no choice of its own to save back.
|
|
75
|
+
let adopting = false
|
|
76
|
+
|
|
77
|
+
const settingsStorage = createJSONStorage<StoredSettings>(() => ({
|
|
78
|
+
getItem: (key) => {
|
|
79
|
+
heldSettings = readStored('localStorage', key)
|
|
80
|
+
return heldSettings
|
|
81
|
+
},
|
|
82
|
+
setItem: (key, value) => {
|
|
83
|
+
if (value === heldSettings) return
|
|
84
|
+
heldSettings = value
|
|
85
|
+
if (!adopting) writeStored('localStorage', key, value)
|
|
86
|
+
},
|
|
87
|
+
removeItem: (key) => {
|
|
88
|
+
try {
|
|
89
|
+
globalThis.localStorage?.removeItem(key)
|
|
90
|
+
} catch {}
|
|
91
|
+
},
|
|
92
|
+
}))
|
|
93
|
+
|
|
22
94
|
const useAudio = create<AudioState>()(
|
|
23
95
|
persist(
|
|
24
96
|
(set) => ({
|
|
@@ -33,13 +105,65 @@ const useAudio = create<AudioState>()(
|
|
|
33
105
|
setRadioVolume: (v) => set({ radioVolume: v }),
|
|
34
106
|
setRadioPlaying: (v) => set({ isRadioPlaying: v }),
|
|
35
107
|
toggleRadioPlaying: () => set((state) => ({ isRadioPlaying: !state.isRadioPlaying })),
|
|
36
|
-
|
|
108
|
+
setMuted: (muted) => set(chooseMuted(muted)),
|
|
109
|
+
toggleMute: () => set((state) => chooseMuted(!state.muted)),
|
|
37
110
|
setAutoplay: (v) => set({ autoplay: v }),
|
|
38
111
|
}),
|
|
39
112
|
{
|
|
40
|
-
name:
|
|
113
|
+
name: AUDIO_SETTINGS_STORAGE_KEY,
|
|
114
|
+
storage: settingsStorage,
|
|
115
|
+
partialize: ({ masterVolume, sfxVolume, radioVolume, isRadioPlaying, autoplay }) => ({
|
|
116
|
+
masterVolume,
|
|
117
|
+
sfxVolume,
|
|
118
|
+
radioVolume,
|
|
119
|
+
isRadioPlaying,
|
|
120
|
+
autoplay,
|
|
121
|
+
}),
|
|
122
|
+
// The settings once held one mute for every tab: whoever had muted stays muted, unless a tab
|
|
123
|
+
// has chosen since (a tab still on that build can write it again). The format is unchanged,
|
|
124
|
+
// so that build reads these settings back with only the mute gone.
|
|
125
|
+
merge: (persisted, current) => {
|
|
126
|
+
const { muted, ...settings } = (persisted ?? {}) as Partial<StoredSettings> & {
|
|
127
|
+
muted?: unknown
|
|
128
|
+
}
|
|
129
|
+
if (
|
|
130
|
+
typeof muted === 'boolean' &&
|
|
131
|
+
readStored('localStorage', AUDIO_MUTED_STORAGE_KEY) === null
|
|
132
|
+
) {
|
|
133
|
+
writeStored('localStorage', AUDIO_MUTED_STORAGE_KEY, String(muted))
|
|
134
|
+
}
|
|
135
|
+
return { ...current, ...settings, muted: tabMuted() }
|
|
136
|
+
},
|
|
41
137
|
},
|
|
42
138
|
),
|
|
43
139
|
)
|
|
44
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Another tab saved the settings: the volumes are every tab's, so this tab takes them at once, and
|
|
143
|
+
* a later save of its own carries them. Its mute and whether its radio plays stay its own.
|
|
144
|
+
*/
|
|
145
|
+
function adoptOtherTabSettings(event: StorageEvent) {
|
|
146
|
+
if (event.key !== AUDIO_SETTINGS_STORAGE_KEY || event.newValue === null) return
|
|
147
|
+
let stored: Partial<Record<keyof StoredSettings, unknown>> | undefined
|
|
148
|
+
try {
|
|
149
|
+
stored = JSON.parse(event.newValue)?.state
|
|
150
|
+
} catch {
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
const shared: Partial<StoredSettings> = {}
|
|
154
|
+
for (const key of ['masterVolume', 'sfxVolume', 'radioVolume'] as const) {
|
|
155
|
+
const value = stored?.[key]
|
|
156
|
+
if (typeof value === 'number') shared[key] = value
|
|
157
|
+
}
|
|
158
|
+
if (typeof stored?.autoplay === 'boolean') shared.autoplay = stored.autoplay
|
|
159
|
+
adopting = true
|
|
160
|
+
try {
|
|
161
|
+
useAudio.setState(shared)
|
|
162
|
+
} finally {
|
|
163
|
+
adopting = false
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
globalThis.addEventListener?.('storage', adoptOtherTabSettings)
|
|
168
|
+
|
|
45
169
|
export default useAudio
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { AnyNodeDefinition } from '@pascal-app/core'
|
|
3
|
+
import { BaseNode, loadPlugin, nodeRegistry, nodeType, objectId, useScene } from '@pascal-app/core'
|
|
4
|
+
import { ANIMATION_SPEEDS } from '@pascal-app/viewer'
|
|
5
|
+
import { createJSONStorage } from 'zustand/middleware'
|
|
6
|
+
import useConstructionReveal, {
|
|
7
|
+
CONSTRUCTION_REVEAL_LEVELS,
|
|
8
|
+
CONSTRUCTION_REVEAL_STORAGE_KEY,
|
|
9
|
+
FRAMING_KIND,
|
|
10
|
+
getConstructionRevealLevel,
|
|
11
|
+
isFramingAvailable,
|
|
12
|
+
resolveConstructionLevel,
|
|
13
|
+
} from './use-construction-reveal'
|
|
14
|
+
|
|
15
|
+
function memoryStorage() {
|
|
16
|
+
const items = new Map<string, string>()
|
|
17
|
+
return {
|
|
18
|
+
items,
|
|
19
|
+
storage: createJSONStorage(() => ({
|
|
20
|
+
getItem: (key: string) => items.get(key) ?? null,
|
|
21
|
+
setItem: (key: string, value: string) => void items.set(key, value),
|
|
22
|
+
removeItem: (key: string) => void items.delete(key),
|
|
23
|
+
})),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
useConstructionReveal.setState({ level: 'full', chosen: false, speed: 1 })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('the construction animation preference', () => {
|
|
32
|
+
test('starts at full, with no choice of the person’s own, and offers four levels', () => {
|
|
33
|
+
expect(useConstructionReveal.getState().level).toBe('full')
|
|
34
|
+
expect(useConstructionReveal.getState().chosen).toBe(false)
|
|
35
|
+
expect(CONSTRUCTION_REVEAL_LEVELS).toEqual(['off', 'simple', 'full', 'framing'])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('persists the chosen level and the speed under its own key', () => {
|
|
39
|
+
const { items, storage } = memoryStorage()
|
|
40
|
+
useConstructionReveal.persist.setOptions({ storage })
|
|
41
|
+
useConstructionReveal.getState().setLevel('simple')
|
|
42
|
+
expect(JSON.parse(items.get(CONSTRUCTION_REVEAL_STORAGE_KEY)!).state).toEqual({
|
|
43
|
+
level: 'simple',
|
|
44
|
+
chosen: true,
|
|
45
|
+
speed: 1,
|
|
46
|
+
})
|
|
47
|
+
expect(CONSTRUCTION_REVEAL_STORAGE_KEY).not.toBe('pascal-audio-settings')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('restores a saved level, and an unknown one falls back to full', async () => {
|
|
51
|
+
const { items, storage } = memoryStorage()
|
|
52
|
+
useConstructionReveal.persist.setOptions({ storage })
|
|
53
|
+
items.set(CONSTRUCTION_REVEAL_STORAGE_KEY, JSON.stringify({ state: { level: 'off' } }))
|
|
54
|
+
await useConstructionReveal.persist.rehydrate()
|
|
55
|
+
expect(useConstructionReveal.getState().level).toBe('off')
|
|
56
|
+
|
|
57
|
+
items.set(CONSTRUCTION_REVEAL_STORAGE_KEY, JSON.stringify({ state: { level: 'cinematic' } }))
|
|
58
|
+
await useConstructionReveal.persist.rehydrate()
|
|
59
|
+
expect(useConstructionReveal.getState().level).toBe('full')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('the animation speed preference', () => {
|
|
64
|
+
test('starts at ×1', () => {
|
|
65
|
+
expect(useConstructionReveal.getState().speed).toBe(1)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test.each([...ANIMATION_SPEEDS])('×%d persists and reloads', async (speed) => {
|
|
69
|
+
const { items, storage } = memoryStorage()
|
|
70
|
+
useConstructionReveal.persist.setOptions({ storage })
|
|
71
|
+
useConstructionReveal.getState().setSpeed(speed)
|
|
72
|
+
const saved = items.get(CONSTRUCTION_REVEAL_STORAGE_KEY)!
|
|
73
|
+
expect(JSON.parse(saved).state.speed).toBe(speed)
|
|
74
|
+
// A new page: the store starts from its defaults and reads what was saved.
|
|
75
|
+
useConstructionReveal.setState({ speed: 1 })
|
|
76
|
+
items.set(CONSTRUCTION_REVEAL_STORAGE_KEY, saved)
|
|
77
|
+
await useConstructionReveal.persist.rehydrate()
|
|
78
|
+
expect(useConstructionReveal.getState().speed).toBe(speed)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('a person saved before the setting is at ×1, and a stray value snaps to a step', async () => {
|
|
82
|
+
const { items, storage } = memoryStorage()
|
|
83
|
+
useConstructionReveal.persist.setOptions({ storage })
|
|
84
|
+
items.set(CONSTRUCTION_REVEAL_STORAGE_KEY, JSON.stringify({ state: { level: 'full' } }))
|
|
85
|
+
await useConstructionReveal.persist.rehydrate()
|
|
86
|
+
expect(useConstructionReveal.getState().speed).toBe(1)
|
|
87
|
+
items.set(CONSTRUCTION_REVEAL_STORAGE_KEY, JSON.stringify({ state: { speed: 1.6 } }))
|
|
88
|
+
await useConstructionReveal.persist.rehydrate()
|
|
89
|
+
expect(useConstructionReveal.getState().speed).toBe(1.5)
|
|
90
|
+
items.set(CONSTRUCTION_REVEAL_STORAGE_KEY, JSON.stringify({ state: { speed: 'fast' } }))
|
|
91
|
+
await useConstructionReveal.persist.rehydrate()
|
|
92
|
+
expect(useConstructionReveal.getState().speed).toBe(1)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('the level that plays follows the project', () => {
|
|
97
|
+
const none = { level: 'full', chosen: false } as const
|
|
98
|
+
|
|
99
|
+
test('with no choice of the person’s own: Framing with the framing plugin, Full without', () => {
|
|
100
|
+
expect(resolveConstructionLevel(none, true)).toBe('framing')
|
|
101
|
+
expect(resolveConstructionLevel(none, false)).toBe('full')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('a choice is kept while the plugin stays installed', () => {
|
|
105
|
+
for (const level of CONSTRUCTION_REVEAL_LEVELS)
|
|
106
|
+
expect(resolveConstructionLevel({ level, chosen: true }, true)).toBe(level)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('without the plugin Framing falls back to Full; every other choice stays', () => {
|
|
110
|
+
expect(resolveConstructionLevel({ level: 'framing', chosen: true }, false)).toBe('full')
|
|
111
|
+
expect(resolveConstructionLevel({ level: 'off', chosen: true }, false)).toBe('off')
|
|
112
|
+
expect(resolveConstructionLevel({ level: 'simple', chosen: true }, false)).toBe('simple')
|
|
113
|
+
// The saved choice is not erased: the plugin coming back brings Framing back.
|
|
114
|
+
expect(resolveConstructionLevel({ level: 'framing', chosen: true }, true)).toBe('framing')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('a level saved before the default followed the project is a choice', async () => {
|
|
118
|
+
const { items, storage } = memoryStorage()
|
|
119
|
+
useConstructionReveal.persist.setOptions({ storage })
|
|
120
|
+
items.set(CONSTRUCTION_REVEAL_STORAGE_KEY, JSON.stringify({ state: { level: 'full' } }))
|
|
121
|
+
await useConstructionReveal.persist.rehydrate()
|
|
122
|
+
expect(useConstructionReveal.getState().chosen).toBe(true)
|
|
123
|
+
items.set(CONSTRUCTION_REVEAL_STORAGE_KEY, JSON.stringify({ state: {} }))
|
|
124
|
+
await useConstructionReveal.persist.rehydrate()
|
|
125
|
+
expect(useConstructionReveal.getState().chosen).toBe(false)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('the framing plugin of a project', () => {
|
|
130
|
+
const framing = {
|
|
131
|
+
kind: FRAMING_KIND,
|
|
132
|
+
schemaVersion: 1,
|
|
133
|
+
schema: BaseNode.extend({ id: objectId('framing'), type: nodeType(FRAMING_KIND) }),
|
|
134
|
+
category: 'structure',
|
|
135
|
+
defaults: () => ({}),
|
|
136
|
+
capabilities: {},
|
|
137
|
+
geometry: () => null,
|
|
138
|
+
} as unknown as AnyNodeDefinition
|
|
139
|
+
let restore: () => void = () => {}
|
|
140
|
+
const wasInstalled = useScene.getState().installedPlugins
|
|
141
|
+
|
|
142
|
+
beforeAll(async () => {
|
|
143
|
+
restore = nodeRegistry._snapshot()
|
|
144
|
+
nodeRegistry._reset()
|
|
145
|
+
await loadPlugin({ id: 'fixture:framing', apiVersion: 1, nodes: [framing] })
|
|
146
|
+
})
|
|
147
|
+
afterAll(() => {
|
|
148
|
+
restore()
|
|
149
|
+
useScene.setState({ installedPlugins: wasInstalled })
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('is available where the plugin that carries the kind is installed', () => {
|
|
153
|
+
expect(isFramingAvailable([])).toBe(false)
|
|
154
|
+
expect(isFramingAvailable(['fixture:other'])).toBe(false)
|
|
155
|
+
expect(isFramingAvailable(['fixture:framing'])).toBe(true)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('is not available where no loaded plugin carries the kind at all', () => {
|
|
159
|
+
const snapshot = nodeRegistry._snapshot()
|
|
160
|
+
nodeRegistry._reset()
|
|
161
|
+
expect(isFramingAvailable(['fixture:framing'])).toBe(false)
|
|
162
|
+
snapshot()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('the level that plays is read from the open project', () => {
|
|
166
|
+
useScene.setState({ installedPlugins: [] })
|
|
167
|
+
expect(getConstructionRevealLevel()).toBe('full')
|
|
168
|
+
useScene.setState({ installedPlugins: ['fixture:framing'] })
|
|
169
|
+
expect(getConstructionRevealLevel()).toBe('framing')
|
|
170
|
+
useConstructionReveal.getState().setLevel('full')
|
|
171
|
+
expect(getConstructionRevealLevel()).toBe('full')
|
|
172
|
+
})
|
|
173
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { isNodeKindEnabled, nodeRegistry, useRegistryVersion, useScene } from '@pascal-app/core'
|
|
4
|
+
import {
|
|
5
|
+
type AnimationSpeed,
|
|
6
|
+
type ConstructionRevealLevel,
|
|
7
|
+
DEFAULT_ANIMATION_SPEED,
|
|
8
|
+
normalizeAnimationSpeed,
|
|
9
|
+
} from '@pascal-app/viewer'
|
|
10
|
+
import { create } from 'zustand'
|
|
11
|
+
import { createJSONStorage, persist } from 'zustand/middleware'
|
|
12
|
+
|
|
13
|
+
export const CONSTRUCTION_REVEAL_LEVELS: readonly ConstructionRevealLevel[] = [
|
|
14
|
+
'off',
|
|
15
|
+
'simple',
|
|
16
|
+
'full',
|
|
17
|
+
'framing',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
export const CONSTRUCTION_REVEAL_STORAGE_KEY = 'pascal-construction-reveal'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The kind the Framing level draws: it plays only in a project where this kind is registered and
|
|
24
|
+
* its plugin is installed. Read through the kind, not a plugin id, so it holds wherever the framing
|
|
25
|
+
* kinds live (the Bones plugin, or the procedural plugin that carries them).
|
|
26
|
+
*/
|
|
27
|
+
export const FRAMING_KIND = 'bones:framing'
|
|
28
|
+
|
|
29
|
+
const DEFAULT_LEVEL: ConstructionRevealLevel = 'full'
|
|
30
|
+
|
|
31
|
+
function normalizeLevel(value: unknown): ConstructionRevealLevel {
|
|
32
|
+
return CONSTRUCTION_REVEAL_LEVELS.includes(value as ConstructionRevealLevel)
|
|
33
|
+
? (value as ConstructionRevealLevel)
|
|
34
|
+
: DEFAULT_LEVEL
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Reads `localStorage` on each call, so a page without it (a test, a sandbox) still has the store's persist API. */
|
|
38
|
+
const browserStorage = createJSONStorage(() => ({
|
|
39
|
+
getItem: (key: string) => globalThis.localStorage?.getItem(key) ?? null,
|
|
40
|
+
setItem: (key: string, value: string) => globalThis.localStorage?.setItem(key, value),
|
|
41
|
+
removeItem: (key: string) => globalThis.localStorage?.removeItem(key),
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
type ConstructionRevealState = {
|
|
45
|
+
/**
|
|
46
|
+
* The level the person last chose (see `chosen`). Hosts read the level that plays through
|
|
47
|
+
* `useConstructionRevealLevel`, which also knows whether the framing plugin is installed.
|
|
48
|
+
*/
|
|
49
|
+
level: ConstructionRevealLevel
|
|
50
|
+
/** Whether the person has chosen a level themselves; until then the default follows the project. */
|
|
51
|
+
chosen: boolean
|
|
52
|
+
setLevel: (level: ConstructionRevealLevel) => void
|
|
53
|
+
/**
|
|
54
|
+
* How fast builds play, a multiple of the shipped pace (×0.5 to ×2, higher is faster); the host
|
|
55
|
+
* passes it to `ConstructionReveal`. A change applies to the next build, never to one playing.
|
|
56
|
+
*/
|
|
57
|
+
speed: AnimationSpeed
|
|
58
|
+
setSpeed: (speed: AnimationSpeed) => void
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The person's construction-animation preferences (level and speed), kept across sessions. */
|
|
62
|
+
const useConstructionReveal = create<ConstructionRevealState>()(
|
|
63
|
+
persist(
|
|
64
|
+
(set) => ({
|
|
65
|
+
level: DEFAULT_LEVEL,
|
|
66
|
+
chosen: false,
|
|
67
|
+
setLevel: (level) => set({ level, chosen: true }),
|
|
68
|
+
speed: DEFAULT_ANIMATION_SPEED,
|
|
69
|
+
setSpeed: (speed) => set({ speed: normalizeAnimationSpeed(speed) }),
|
|
70
|
+
}),
|
|
71
|
+
{
|
|
72
|
+
name: CONSTRUCTION_REVEAL_STORAGE_KEY,
|
|
73
|
+
storage: browserStorage,
|
|
74
|
+
partialize: (state) => ({ level: state.level, chosen: state.chosen, speed: state.speed }),
|
|
75
|
+
merge: (persisted, current) => {
|
|
76
|
+
const saved = persisted as
|
|
77
|
+
| { level?: unknown; chosen?: unknown; speed?: unknown }
|
|
78
|
+
| undefined
|
|
79
|
+
return {
|
|
80
|
+
...current,
|
|
81
|
+
level: normalizeLevel(saved?.level),
|
|
82
|
+
// A level saved before the default followed the project was the person's own choice.
|
|
83
|
+
chosen: typeof saved?.chosen === 'boolean' ? saved.chosen : saved?.level !== undefined,
|
|
84
|
+
// A person saved before the setting existed is at ×1; a stray value snaps to a step.
|
|
85
|
+
speed: normalizeAnimationSpeed(saved?.speed),
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The level that plays. With no choice of the person's own it is Framing where the framing plugin
|
|
94
|
+
* is available in the project and Full where it is not; a choice is kept, except that Framing falls
|
|
95
|
+
* back to Full while the plugin is missing (the choice comes back with the plugin).
|
|
96
|
+
*/
|
|
97
|
+
export function resolveConstructionLevel(
|
|
98
|
+
saved: Pick<ConstructionRevealState, 'level' | 'chosen'>,
|
|
99
|
+
framingInstalled: boolean,
|
|
100
|
+
): ConstructionRevealLevel {
|
|
101
|
+
if (!saved.chosen) return framingInstalled ? 'framing' : 'full'
|
|
102
|
+
return saved.level === 'framing' && !framingInstalled ? 'full' : saved.level
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A click on a level in the control. The level that plays is not always the saved one (a saved Framing
|
|
107
|
+
* plays as Full while the plugin is gone), and a click on that pressed level is not a choice: it keeps
|
|
108
|
+
* the saved one, so it plays again with the plugin.
|
|
109
|
+
*/
|
|
110
|
+
export function chooseConstructionLevel(
|
|
111
|
+
level: ConstructionRevealLevel,
|
|
112
|
+
framingInstalled: boolean,
|
|
113
|
+
): void {
|
|
114
|
+
const state = useConstructionReveal.getState()
|
|
115
|
+
if (state.chosen && resolveConstructionLevel(state, framingInstalled) === level) return
|
|
116
|
+
state.setLevel(level)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether the project has what draws the framing: the kind is registered and its plugin installed. */
|
|
120
|
+
export function isFramingAvailable(installedPlugins: readonly string[]): boolean {
|
|
121
|
+
return nodeRegistry.has(FRAMING_KIND) && isNodeKindEnabled(FRAMING_KIND, installedPlugins)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The same, for a component: it re-reads when a plugin registers after the first render. */
|
|
125
|
+
export function useFramingAvailable(): boolean {
|
|
126
|
+
useRegistryVersion()
|
|
127
|
+
return useScene((state) => isFramingAvailable(state.installedPlugins))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The level that plays now, for a component: the person's choice or the project's default. */
|
|
131
|
+
export function useConstructionRevealLevel(): ConstructionRevealLevel {
|
|
132
|
+
const level = useConstructionReveal((state) => state.level)
|
|
133
|
+
const chosen = useConstructionReveal((state) => state.chosen)
|
|
134
|
+
return resolveConstructionLevel({ level, chosen }, useFramingAvailable())
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The level that plays now, read once (a tool's executor, not a component). */
|
|
138
|
+
export function getConstructionRevealLevel(): ConstructionRevealLevel {
|
|
139
|
+
return resolveConstructionLevel(
|
|
140
|
+
useConstructionReveal.getState(),
|
|
141
|
+
isFramingAvailable(useScene.getState().installedPlugins),
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export default useConstructionReveal
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { CORNER_BLOCK_NOTES, cornerHintRows } from './use-corner-snap-hint'
|
|
3
|
+
|
|
4
|
+
// The HUD near a wall end: what a click does where a window can join, and why not where it cannot,
|
|
5
|
+
// in one wording for the placement HUD and the move HUD.
|
|
6
|
+
describe('cornerHintRows', () => {
|
|
7
|
+
test('says nothing away from a wall end', () => {
|
|
8
|
+
expect(cornerHintRows(null)).toEqual([])
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('says what a click does where the window can join, and how to skip it with Alt', () => {
|
|
12
|
+
for (const hint of ['join', 'new'] as const)
|
|
13
|
+
expect(cornerHintRows(hint).map((row) => row.keys)).toEqual([['Left click'], ['Alt']])
|
|
14
|
+
expect(cornerHintRows('join')[0]?.label).not.toBe(cornerHintRows('new')[0]?.label)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('gives each reason its own sentence where the end is no corner, and offers no click', () => {
|
|
18
|
+
const reasons = Object.keys(CORNER_BLOCK_NOTES) as (keyof typeof CORNER_BLOCK_NOTES)[]
|
|
19
|
+
const rows = reasons.map((reason) => cornerHintRows(reason))
|
|
20
|
+
|
|
21
|
+
expect(new Set(rows.map((row) => row[0]?.label)).size).toBe(reasons.length)
|
|
22
|
+
expect(rows.flat().every((row) => !row.keys.includes('Left click'))).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
})
|