@pascal-app/editor 1.0.0-beta.4 → 1.0.0
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/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +15 -8
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
- package/src/components/editor/bake-exporter.tsx +1 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +17 -5
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +182 -57
- package/src/components/editor/floating-action-menu.tsx +55 -55
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +81 -57
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/linear-resize-drag.ts +67 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +18 -1
- package/src/components/editor/index.tsx +252 -46
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +42 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +132 -47
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +295 -232
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +494 -0
- package/src/components/tools/item/placement-strategies.ts +192 -24
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +6 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +26 -4
- package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +63 -8
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +45 -8
- package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +527 -4
- package/src/components/tools/wall/wall-drafting.ts +55 -462
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +12 -13
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +19 -3
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +23 -6
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/panel-wrapper.tsx +228 -12
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +29 -162
- package/src/components/ui/panels/reference-panel.tsx +31 -1
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
- package/src/components/ui/sidebar/tab-bar.tsx +9 -1
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +221 -0
- package/src/components/viewer-overlay.tsx +13 -30
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +122 -1
- package/src/hooks/use-auto-save.ts +119 -13
- package/src/hooks/use-ceiling-events.test.ts +122 -0
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +164 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +109 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +397 -1
- package/src/lib/floorplan/floorplan-export.tsx +111 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +85 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/glb-export.test.ts +1169 -21
- package/src/lib/glb-export.ts +629 -101
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +523 -77
- package/src/lib/history.ts +133 -14
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +366 -0
- package/src/lib/portable-export.ts +969 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +30 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +125 -6
- package/src/lib/selection-routing.ts +34 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- package/src/lib/usdz-export.ts +38 -0
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/store/use-session-groups.ts +103 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -2,55 +2,107 @@
|
|
|
2
2
|
|
|
3
3
|
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
|
4
4
|
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
-
import { Copy, Trash2 } from 'lucide-react'
|
|
5
|
+
import { Copy, Group, Trash2, Ungroup } from 'lucide-react'
|
|
6
|
+
import { useMemo } from 'react'
|
|
6
7
|
import { deleteSelection, duplicateSelectionAndPickUp } from '../../editor/group-actions'
|
|
8
|
+
import {
|
|
9
|
+
canCreateSessionGroup,
|
|
10
|
+
selectionIntersectsSessionGroup,
|
|
11
|
+
selectionMatchesSessionGroup,
|
|
12
|
+
} from '../../../lib/session-groups'
|
|
13
|
+
import useSessionGroups, {
|
|
14
|
+
groupCurrentSelection,
|
|
15
|
+
ungroupCurrentSelection,
|
|
16
|
+
} from '../../../store/use-session-groups'
|
|
7
17
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
8
18
|
import { PanelWrapper } from './panel-wrapper'
|
|
9
19
|
import { formatSelectionBreakdown } from './selection-breakdown'
|
|
10
20
|
|
|
21
|
+
export function MultiSelectionActions() {
|
|
22
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
23
|
+
const sessionGroups = useSessionGroups((s) => s.groups)
|
|
24
|
+
const sceneNodes = useScene((s) => s.nodes)
|
|
25
|
+
const liveIds = useMemo(() => new Set(Object.keys(sceneNodes)), [sceneNodes])
|
|
26
|
+
const showGroup = useMemo(
|
|
27
|
+
() => canCreateSessionGroup(sessionGroups, selectedIds, liveIds),
|
|
28
|
+
[sessionGroups, selectedIds, liveIds],
|
|
29
|
+
)
|
|
30
|
+
const showUngroup = useMemo(
|
|
31
|
+
() => selectionIntersectsSessionGroup(sessionGroups, selectedIds, liveIds),
|
|
32
|
+
[sessionGroups, selectedIds, liveIds],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<ActionGroup>
|
|
37
|
+
{showGroup && (
|
|
38
|
+
<ActionButton
|
|
39
|
+
icon={<Group className="h-4 w-4" />}
|
|
40
|
+
label="Group"
|
|
41
|
+
onClick={() => groupCurrentSelection()}
|
|
42
|
+
title="Group (Ctrl/Cmd+G)"
|
|
43
|
+
/>
|
|
44
|
+
)}
|
|
45
|
+
{showUngroup && (
|
|
46
|
+
<ActionButton
|
|
47
|
+
icon={<Ungroup className="h-4 w-4" />}
|
|
48
|
+
label="Ungroup"
|
|
49
|
+
onClick={() => ungroupCurrentSelection()}
|
|
50
|
+
title="Ungroup (Ctrl/Cmd+Shift+G)"
|
|
51
|
+
/>
|
|
52
|
+
)}
|
|
53
|
+
<ActionButton
|
|
54
|
+
icon={<Copy className="h-4 w-4" />}
|
|
55
|
+
label="Duplicate"
|
|
56
|
+
onClick={() => duplicateSelectionAndPickUp()}
|
|
57
|
+
/>
|
|
58
|
+
<ActionButton
|
|
59
|
+
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
|
|
60
|
+
icon={<Trash2 className="h-4 w-4 text-red-400" />}
|
|
61
|
+
label="Delete"
|
|
62
|
+
onClick={() => deleteSelection()}
|
|
63
|
+
/>
|
|
64
|
+
</ActionGroup>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
11
68
|
/**
|
|
12
|
-
* Docked
|
|
13
|
-
* single-node inspector, rendered by `PanelManager` when more than one node
|
|
14
|
-
* is selected. Same collapsed-by-default `PanelWrapper` shell (header always
|
|
15
|
-
* visible; the shared desktop collapse state carries across single ↔ multi
|
|
16
|
-
* swaps). Actions mirror the floating group pill: Duplicate clones the
|
|
17
|
-
* selection and picks the clones up, Delete removes the whole selection
|
|
18
|
-
* (including its bulk-delete confirm). Unlike the pill, the docked panel
|
|
19
|
-
* stays visible during interactions. `footer` is the host-injected slot
|
|
20
|
-
* (e.g. community's "Save to my catalog").
|
|
69
|
+
* Docked multi-selection panel. Includes Group / Ungroup for session selection sets.
|
|
21
70
|
*/
|
|
22
71
|
export function MultiSelectionPanel({ footer }: { footer?: React.ReactNode }) {
|
|
23
72
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
24
73
|
const setSelection = useViewer((s) => s.setSelection)
|
|
25
|
-
|
|
26
|
-
// unrelated mutations from re-rendering the panel.
|
|
74
|
+
const sessionGroups = useSessionGroups((s) => s.groups)
|
|
27
75
|
const breakdown = useScene((s) =>
|
|
28
76
|
formatSelectionBreakdown(selectedIds.map((id) => s.nodes[id as AnyNodeId]?.type)),
|
|
29
77
|
)
|
|
78
|
+
const sceneNodes = useScene((s) => s.nodes)
|
|
79
|
+
const liveIds = useMemo(() => new Set(Object.keys(sceneNodes)), [sceneNodes])
|
|
80
|
+
const matchedGroup = useMemo(
|
|
81
|
+
() => selectionMatchesSessionGroup(sessionGroups, selectedIds, liveIds),
|
|
82
|
+
[sessionGroups, selectedIds, liveIds],
|
|
83
|
+
)
|
|
30
84
|
|
|
31
85
|
return (
|
|
32
86
|
<PanelWrapper
|
|
33
87
|
footer={footer}
|
|
34
88
|
icon="/icons/select.webp"
|
|
35
89
|
onClose={() => setSelection({ selectedIds: [] })}
|
|
36
|
-
title={
|
|
90
|
+
title={
|
|
91
|
+
matchedGroup
|
|
92
|
+
? `${matchedGroup.label} · ${selectedIds.length}`
|
|
93
|
+
: `${selectedIds.length} selected`
|
|
94
|
+
}
|
|
37
95
|
width={320}
|
|
38
96
|
>
|
|
39
97
|
{breakdown && <div className="px-3 py-3 text-muted-foreground text-xs">{breakdown}</div>}
|
|
98
|
+
{matchedGroup && (
|
|
99
|
+
<div className="border-border/50 border-t px-3 py-2 text-muted-foreground text-xs">
|
|
100
|
+
{matchedGroup.label} (session only). Plain click reselects all members. Not saved with the
|
|
101
|
+
project.
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
40
104
|
<div className="border-border/50 border-t p-3">
|
|
41
|
-
<
|
|
42
|
-
<ActionButton
|
|
43
|
-
icon={<Copy className="h-4 w-4" />}
|
|
44
|
-
label="Duplicate"
|
|
45
|
-
onClick={() => duplicateSelectionAndPickUp()}
|
|
46
|
-
/>
|
|
47
|
-
<ActionButton
|
|
48
|
-
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
|
|
49
|
-
icon={<Trash2 className="h-4 w-4" />}
|
|
50
|
-
label="Delete"
|
|
51
|
-
onClick={() => deleteSelection()}
|
|
52
|
-
/>
|
|
53
|
-
</ActionGroup>
|
|
105
|
+
<MultiSelectionActions />
|
|
54
106
|
</div>
|
|
55
107
|
</PanelWrapper>
|
|
56
108
|
)
|
|
@@ -23,6 +23,10 @@ const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
|
|
|
23
23
|
guide: { icon: '/icons/floorplan.webp', label: 'Guide image' },
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export function getTypeDisplay(type: string): NodeDisplay {
|
|
27
|
+
return TYPE_DEFAULTS[type] ?? { icon: '/icons/select.webp', label: type }
|
|
28
|
+
}
|
|
29
|
+
|
|
26
30
|
export function getNodeDisplay(node: AnyNode | null | undefined): NodeDisplay {
|
|
27
31
|
if (!node) return { icon: '/icons/select.webp', label: 'Selection' }
|
|
28
32
|
const fallback = TYPE_DEFAULTS[node.type] ?? { icon: '/icons/select.webp', label: node.type }
|
|
@@ -24,15 +24,20 @@ import {
|
|
|
24
24
|
import { useViewer } from '@pascal-app/viewer'
|
|
25
25
|
import { useCallback, useEffect, useState } from 'react'
|
|
26
26
|
import { useIsMobile } from '../../../hooks/use-mobile'
|
|
27
|
+
import { shouldShowEditingControls } from '../../../lib/interaction/overlay-policy'
|
|
27
28
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
28
29
|
import useEditor from '../../../store/use-editor'
|
|
30
|
+
import { deleteSelection, duplicateSelectionAndPickUp, startGroupPickUp } from '../../editor/group-actions'
|
|
31
|
+
import { resolveHomogeneousSelection } from './homogeneous-selection'
|
|
29
32
|
import { MobilePanelSheet } from './mobile-panel-sheet'
|
|
30
33
|
import { MobileSelectionBar } from './mobile-selection-bar'
|
|
34
|
+
import { MultiParametricInspector } from './multi-parametric-inspector'
|
|
31
35
|
import { MultiSelectionPanel } from './multi-selection-panel'
|
|
32
|
-
import { getNodeDisplay } from './node-display'
|
|
36
|
+
import { getNodeDisplay, getTypeDisplay } from './node-display'
|
|
33
37
|
import { resetDesktopInspectorCollapsed } from './panel-wrapper'
|
|
34
38
|
import { ParametricInspector } from './parametric-inspector'
|
|
35
39
|
import { ReferencePanel } from './reference-panel'
|
|
40
|
+
import { formatSelectionBreakdown } from './selection-breakdown'
|
|
36
41
|
|
|
37
42
|
type MovableNode =
|
|
38
43
|
| ItemNode
|
|
@@ -169,6 +174,46 @@ function MobilePanelLayer({
|
|
|
169
174
|
)
|
|
170
175
|
}
|
|
171
176
|
|
|
177
|
+
function MobileMultiPanelLayer({
|
|
178
|
+
breakdown,
|
|
179
|
+
panel,
|
|
180
|
+
type,
|
|
181
|
+
}: {
|
|
182
|
+
breakdown: string
|
|
183
|
+
panel: React.ReactNode
|
|
184
|
+
type: string | null
|
|
185
|
+
}) {
|
|
186
|
+
const [isSheetOpen, setIsSheetOpen] = useState(false)
|
|
187
|
+
const display = type ? getTypeDisplay(type) : { icon: '/icons/select.webp', label: 'Selection' }
|
|
188
|
+
const title = breakdown || display.label
|
|
189
|
+
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
setIsSheetOpen(false)
|
|
192
|
+
}, [breakdown])
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<>
|
|
196
|
+
<MobileSelectionBar
|
|
197
|
+
icon={display.icon}
|
|
198
|
+
label={title}
|
|
199
|
+
node={null}
|
|
200
|
+
onDelete={() => deleteSelection()}
|
|
201
|
+
onDuplicate={() => duplicateSelectionAndPickUp()}
|
|
202
|
+
onEdit={() => setIsSheetOpen((v) => !v)}
|
|
203
|
+
onMove={() => startGroupPickUp()}
|
|
204
|
+
/>
|
|
205
|
+
<MobilePanelSheet
|
|
206
|
+
icon={display.icon}
|
|
207
|
+
onClose={() => setIsSheetOpen(false)}
|
|
208
|
+
open={isSheetOpen}
|
|
209
|
+
title={title}
|
|
210
|
+
>
|
|
211
|
+
{panel}
|
|
212
|
+
</MobilePanelSheet>
|
|
213
|
+
</>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
172
217
|
export function PanelManager({
|
|
173
218
|
inspectorFooter,
|
|
174
219
|
multiSelectionFooter,
|
|
@@ -181,6 +226,7 @@ export function PanelManager({
|
|
|
181
226
|
const selectedZoneId = useViewer((s) => s.selection.zoneId)
|
|
182
227
|
const setSelection = useViewer((s) => s.setSelection)
|
|
183
228
|
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
|
229
|
+
const readOnly = useScene((s) => s.readOnly)
|
|
184
230
|
// Only subscribe to the *type* of the single-selected node — string primitive
|
|
185
231
|
// so we don't re-render on unrelated scene mutations.
|
|
186
232
|
const selectedNodeType = useScene((s) => {
|
|
@@ -193,6 +239,14 @@ export function PanelManager({
|
|
|
193
239
|
const id = selectedIds[0]
|
|
194
240
|
return id ? (s.nodes[id as AnyNodeId] ?? null) : null
|
|
195
241
|
})
|
|
242
|
+
const homogeneousType = useScene((s) =>
|
|
243
|
+
selectedIds.length > 1 ? resolveHomogeneousSelection(selectedIds, s.nodes) : null,
|
|
244
|
+
)
|
|
245
|
+
const multiBreakdown = useScene((s) =>
|
|
246
|
+
selectedIds.length > 1
|
|
247
|
+
? formatSelectionBreakdown(selectedIds.map((id) => s.nodes[id as AnyNodeId]?.type))
|
|
248
|
+
: '',
|
|
249
|
+
)
|
|
196
250
|
|
|
197
251
|
// Node and reference selection are mutually exclusive: selecting a guide
|
|
198
252
|
// clears the node selection (handleGuideSelect), but node selection never
|
|
@@ -215,10 +269,27 @@ export function PanelManager({
|
|
|
215
269
|
}
|
|
216
270
|
}, [hasAnySelection])
|
|
217
271
|
|
|
272
|
+
if (!shouldShowEditingControls(readOnly)) return null
|
|
273
|
+
|
|
218
274
|
if (isMobile) {
|
|
219
275
|
if (selectedReferenceId) {
|
|
220
276
|
return <MobilePanelLayer isReference={true} node={null} panel={<ReferencePanel />} />
|
|
221
277
|
}
|
|
278
|
+
if (selectedIds.length > 1) {
|
|
279
|
+
return (
|
|
280
|
+
<MobileMultiPanelLayer
|
|
281
|
+
breakdown={multiBreakdown}
|
|
282
|
+
panel={
|
|
283
|
+
homogeneousType ? (
|
|
284
|
+
<MultiParametricInspector footer={multiSelectionFooter} />
|
|
285
|
+
) : (
|
|
286
|
+
<MultiSelectionPanel footer={multiSelectionFooter} />
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
type={homogeneousType}
|
|
290
|
+
/>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
222
293
|
return (
|
|
223
294
|
<MobilePanelLayer
|
|
224
295
|
isReference={false}
|
|
@@ -244,9 +315,12 @@ export function PanelManager({
|
|
|
244
315
|
)
|
|
245
316
|
}
|
|
246
317
|
|
|
247
|
-
// Multi-selection:
|
|
248
|
-
//
|
|
318
|
+
// Multi-selection: parametric inspector when every resolved id shares a type,
|
|
319
|
+
// otherwise the actions-only panel. Mobile uses the same panels in a sheet.
|
|
249
320
|
if (selectedIds.length > 1) {
|
|
321
|
+
if (homogeneousType) {
|
|
322
|
+
return <MultiParametricInspector footer={multiSelectionFooter} />
|
|
323
|
+
}
|
|
250
324
|
return <MultiSelectionPanel footer={multiSelectionFooter} />
|
|
251
325
|
}
|
|
252
326
|
|
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import { Icon } from '@iconify/react'
|
|
4
|
+
import {
|
|
5
|
+
type AnyNode,
|
|
6
|
+
type AnyNodeId,
|
|
7
|
+
getInspectorExtensions,
|
|
8
|
+
type IconRef,
|
|
9
|
+
type InspectorExtension,
|
|
10
|
+
useRegistryVersion,
|
|
11
|
+
useScene,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
14
|
import { ChevronDown, ChevronLeft, GripHorizontal, RotateCcw, X } from 'lucide-react'
|
|
4
15
|
import Image from 'next/image'
|
|
5
16
|
import {
|
|
17
|
+
type ComponentType,
|
|
6
18
|
createContext,
|
|
19
|
+
lazy,
|
|
20
|
+
Suspense,
|
|
7
21
|
useCallback,
|
|
8
22
|
useContext,
|
|
23
|
+
useEffect,
|
|
9
24
|
useLayoutEffect,
|
|
25
|
+
useMemo,
|
|
10
26
|
useRef,
|
|
11
27
|
useState,
|
|
12
28
|
} from 'react'
|
|
13
29
|
import { useIsMobile } from '../../../hooks/use-mobile'
|
|
30
|
+
import {
|
|
31
|
+
resolveActiveExtension,
|
|
32
|
+
toggleCard,
|
|
33
|
+
toggleExtension,
|
|
34
|
+
} from '../../../lib/inspector-card-mode'
|
|
14
35
|
import { cn } from '../../../lib/utils'
|
|
36
|
+
import { PanelSection } from '../controls/panel-section'
|
|
37
|
+
import { ErrorBoundary } from '../primitives/error-boundary'
|
|
15
38
|
|
|
16
39
|
const DRAG_MARGIN = 8
|
|
17
40
|
// Pointer travel (px) below which a header press is treated as a click
|
|
@@ -92,6 +115,36 @@ export function PanelWrapper({
|
|
|
92
115
|
|
|
93
116
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
94
117
|
|
|
118
|
+
// ── Plugin inspector extensions ────────────────────────────────────
|
|
119
|
+
// The wrapper self-resolves the selected node instead of taking a prop:
|
|
120
|
+
// kind-owned `customPanel`s (wall, slab, …) render their own
|
|
121
|
+
// <PanelWrapper>, so this is the one spot every inspector card flows
|
|
122
|
+
// through. Extensions only apply to a single-node selection.
|
|
123
|
+
const registryVersion = useRegistryVersion()
|
|
124
|
+
const selectedId = useViewer((s) =>
|
|
125
|
+
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
|
|
126
|
+
) as AnyNodeId | undefined
|
|
127
|
+
// Subscribe to the selected node's *type* only — a string primitive that
|
|
128
|
+
// doesn't change as fields are edited (same trick as ParametricInspector).
|
|
129
|
+
const selectedType = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
|
|
130
|
+
const installedPlugins = useScene((s) => s.installedPlugins)
|
|
131
|
+
const extensions = useMemo(() => {
|
|
132
|
+
// re-derive when plugin extensions register after mount (async plugin load)
|
|
133
|
+
void registryVersion
|
|
134
|
+
if (!selectedType) return []
|
|
135
|
+
return getInspectorExtensions(selectedType).filter(
|
|
136
|
+
(extension) => !extension.pluginId || installedPlugins.includes(extension.pluginId),
|
|
137
|
+
)
|
|
138
|
+
}, [selectedType, installedPlugins, registryVersion])
|
|
139
|
+
|
|
140
|
+
// Which extension's content fills the card body (extension mode). The two
|
|
141
|
+
// expanded modes are EITHER/OR: extension mode replaces the regular
|
|
142
|
+
// controls; null shows the regular controls with no extension sections.
|
|
143
|
+
// See `lib/inspector-card-mode.ts` for the transition table.
|
|
144
|
+
const [activeExtensionId, setActiveExtensionId] = useState<string | null>(null)
|
|
145
|
+
// Stale ids (kind changed, plugin gated off) fall back to regular mode.
|
|
146
|
+
const activeExtension = resolveActiveExtension(activeExtensionId, extensions)
|
|
147
|
+
|
|
95
148
|
// The whole panel is collapsed to just its header by default; the chevron
|
|
96
149
|
// expands it to reveal the inspector body. Keep the desktop value shared
|
|
97
150
|
// across inspector swaps (roof ↔ segment, etc.) so navigating between
|
|
@@ -109,6 +162,26 @@ export function PanelWrapper({
|
|
|
109
162
|
[],
|
|
110
163
|
)
|
|
111
164
|
|
|
165
|
+
const applyMode = useCallback(
|
|
166
|
+
(next: { collapsed: boolean; activeExtensionId: string | null }) => {
|
|
167
|
+
setCollapsed(next.collapsed)
|
|
168
|
+
setActiveExtensionId(next.activeExtensionId)
|
|
169
|
+
},
|
|
170
|
+
[setCollapsed],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
// Chevron / header press — collapsed → regular, regular → collapsed,
|
|
174
|
+
// extension mode → regular (exit the extension first, stay expanded).
|
|
175
|
+
const handleCardToggle = useCallback(() => {
|
|
176
|
+
applyMode(toggleCard({ collapsed, activeExtensionId }))
|
|
177
|
+
}, [applyMode, collapsed, activeExtensionId])
|
|
178
|
+
|
|
179
|
+
// Folding the card forgets the active extension — extension mode is a
|
|
180
|
+
// one-shot affordance of the header icon, not sticky panel state.
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (collapsed) setActiveExtensionId(null)
|
|
183
|
+
}, [collapsed])
|
|
184
|
+
|
|
112
185
|
// Drag-to-reposition from the header. `offset` is a translation applied on
|
|
113
186
|
// top of the default `top-20 right-4` anchor; null until first dragged.
|
|
114
187
|
// Dragging is clamped so no edge of the panel leaves the viewport.
|
|
@@ -173,15 +246,19 @@ export function PanelWrapper({
|
|
|
173
246
|
setOffset({ x: drag.baseX + (left - drag.rectLeft), y: drag.baseY + (top - drag.rectTop) })
|
|
174
247
|
}, [])
|
|
175
248
|
|
|
176
|
-
const handleHeaderPointerUp = useCallback(
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
249
|
+
const handleHeaderPointerUp = useCallback(
|
|
250
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
251
|
+
const drag = dragRef.current
|
|
252
|
+
if (!drag) return
|
|
253
|
+
dragRef.current = null
|
|
254
|
+
setIsDragging(false)
|
|
255
|
+
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
256
|
+
// A press that never turned into a drag is a click → same mode toggle
|
|
257
|
+
// as the chevron.
|
|
258
|
+
if (!drag.moved) handleCardToggle()
|
|
259
|
+
},
|
|
260
|
+
[handleCardToggle],
|
|
261
|
+
)
|
|
185
262
|
|
|
186
263
|
// Expanding can grow the panel past an edge if it was dragged there while
|
|
187
264
|
// collapsed — nudge it back inside the viewer bounds.
|
|
@@ -278,11 +355,39 @@ export function PanelWrapper({
|
|
|
278
355
|
<RotateCcw className="h-4 w-4" />
|
|
279
356
|
</button>
|
|
280
357
|
)}
|
|
358
|
+
{/* Extension mode buttons — one icon per registered inspector
|
|
359
|
+
extension, left of the chevron. Click swaps the card body to
|
|
360
|
+
ONLY that extension's content (either/or with the regular
|
|
361
|
+
controls); the active icon (highlighted) or the chevron
|
|
362
|
+
returns to the regular controls. */}
|
|
363
|
+
{extensions.map((extension) => {
|
|
364
|
+
const isActive = !collapsed && activeExtensionId === extension.id
|
|
365
|
+
return (
|
|
366
|
+
<button
|
|
367
|
+
aria-label={isActive ? `Close ${extension.title}` : `Open ${extension.title}`}
|
|
368
|
+
aria-pressed={isActive}
|
|
369
|
+
className={cn(
|
|
370
|
+
'flex h-7 w-7 items-center justify-center rounded-md transition-colors',
|
|
371
|
+
isActive
|
|
372
|
+
? 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30'
|
|
373
|
+
: 'bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
|
374
|
+
)}
|
|
375
|
+
key={extension.id}
|
|
376
|
+
onClick={() =>
|
|
377
|
+
applyMode(toggleExtension({ collapsed, activeExtensionId }, extension.id))
|
|
378
|
+
}
|
|
379
|
+
title={extension.title}
|
|
380
|
+
type="button"
|
|
381
|
+
>
|
|
382
|
+
{renderExtensionIcon(extension.icon)}
|
|
383
|
+
</button>
|
|
384
|
+
)
|
|
385
|
+
})}
|
|
281
386
|
<button
|
|
282
387
|
aria-expanded={!collapsed}
|
|
283
388
|
aria-label={collapsed ? 'Expand panel' : 'Collapse panel'}
|
|
284
389
|
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
|
|
285
|
-
onClick={
|
|
390
|
+
onClick={handleCardToggle}
|
|
286
391
|
type="button"
|
|
287
392
|
>
|
|
288
393
|
<ChevronDown
|
|
@@ -302,9 +407,37 @@ export function PanelWrapper({
|
|
|
302
407
|
</div>
|
|
303
408
|
)}
|
|
304
409
|
|
|
305
|
-
{/* Content — hidden while the panel is collapsed (desktop).
|
|
410
|
+
{/* Content — hidden while the panel is collapsed (desktop). The two
|
|
411
|
+
expanded modes are EITHER/OR: extension mode renders ONLY that
|
|
412
|
+
extension's content; regular mode renders ONLY the kind's own
|
|
413
|
+
controls (`children`). A stale extension id falls back to regular
|
|
414
|
+
via `resolveActiveExtension`. */}
|
|
306
415
|
{!(collapsed && !isMobile) && (
|
|
307
|
-
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">
|
|
416
|
+
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">
|
|
417
|
+
{!isMobile && selectedId && activeExtension ? (
|
|
418
|
+
<InspectorExtensionSection
|
|
419
|
+
extension={activeExtension}
|
|
420
|
+
key={activeExtension.id}
|
|
421
|
+
nodeId={selectedId}
|
|
422
|
+
/>
|
|
423
|
+
) : (
|
|
424
|
+
<>
|
|
425
|
+
{children}
|
|
426
|
+
{/* Mobile sheet has no header icons to swap modes — keep the
|
|
427
|
+
plugin sections appended after the kind's controls there. */}
|
|
428
|
+
{isMobile &&
|
|
429
|
+
selectedId &&
|
|
430
|
+
extensions.map((extension) => (
|
|
431
|
+
<InspectorExtensionSection
|
|
432
|
+
defaultExpanded={false}
|
|
433
|
+
extension={extension}
|
|
434
|
+
key={extension.id}
|
|
435
|
+
nodeId={selectedId}
|
|
436
|
+
/>
|
|
437
|
+
))}
|
|
438
|
+
</>
|
|
439
|
+
)}
|
|
440
|
+
</div>
|
|
308
441
|
)}
|
|
309
442
|
|
|
310
443
|
{resolvedFooter && !(collapsed && !isMobile) && (
|
|
@@ -313,3 +446,86 @@ export function PanelWrapper({
|
|
|
313
446
|
</div>
|
|
314
447
|
)
|
|
315
448
|
}
|
|
449
|
+
|
|
450
|
+
// ─── Plugin inspector extensions ──────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
/** 16px icon for an extension's header button — mirrors the parametric
|
|
453
|
+
* inspector's `renderIcon` (plain <img> so no next/image server deps). */
|
|
454
|
+
function renderExtensionIcon(ref: IconRef): React.ReactNode {
|
|
455
|
+
if (ref.kind === 'url') {
|
|
456
|
+
return <img alt="" className="h-4 w-4 shrink-0 object-contain" src={ref.src} />
|
|
457
|
+
}
|
|
458
|
+
if (ref.kind === 'iconify') {
|
|
459
|
+
return <Icon height={16} icon={ref.name} width={16} />
|
|
460
|
+
}
|
|
461
|
+
if (ref.kind === 'svg') {
|
|
462
|
+
return (
|
|
463
|
+
<svg height={16} viewBox={ref.viewBox} width={16}>
|
|
464
|
+
<path d={ref.path} fill="currentColor" />
|
|
465
|
+
</svg>
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
const LazyIcon = lazy(ref.module)
|
|
469
|
+
return (
|
|
470
|
+
<Suspense fallback={null}>
|
|
471
|
+
<LazyIcon />
|
|
472
|
+
</Suspense>
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// `React.lazy` once per loader so the resolved component keeps a stable
|
|
477
|
+
// identity across renders — same WeakMap pattern as `resolveCustomPanel`
|
|
478
|
+
// in parametric-inspector.tsx.
|
|
479
|
+
const extensionComponentCache = new WeakMap<
|
|
480
|
+
InspectorExtension['component'],
|
|
481
|
+
ComponentType<{ node: AnyNode }>
|
|
482
|
+
>()
|
|
483
|
+
|
|
484
|
+
function resolveExtensionComponent(
|
|
485
|
+
extension: InspectorExtension,
|
|
486
|
+
): ComponentType<{ node: AnyNode }> {
|
|
487
|
+
const cached = extensionComponentCache.get(extension.component)
|
|
488
|
+
if (cached) return cached
|
|
489
|
+
// `LazyComponent` is prop-agnostic; the inspector-extension contract is
|
|
490
|
+
// that the default export accepts `{ node }` (see the type's docs).
|
|
491
|
+
const Comp = lazy(extension.component) as unknown as ComponentType<{ node: AnyNode }>
|
|
492
|
+
extensionComponentCache.set(extension.component, Comp)
|
|
493
|
+
return Comp
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* One plugin-contributed inspector section. Subscribes to the full node —
|
|
498
|
+
* the section body reflects any edit — and hands it to the extension's
|
|
499
|
+
* lazy component inside its own error boundary so a crashing plugin
|
|
500
|
+
* section can't take down the inspector card. On desktop this is the
|
|
501
|
+
* card's SOLE body while its extension is active (either/or with the
|
|
502
|
+
* regular controls); the mobile sheet appends it after them instead.
|
|
503
|
+
*/
|
|
504
|
+
function InspectorExtensionSection({
|
|
505
|
+
defaultExpanded = true,
|
|
506
|
+
extension,
|
|
507
|
+
nodeId,
|
|
508
|
+
}: {
|
|
509
|
+
defaultExpanded?: boolean
|
|
510
|
+
extension: InspectorExtension
|
|
511
|
+
nodeId: AnyNodeId
|
|
512
|
+
}) {
|
|
513
|
+
const node = useScene((s) => s.nodes[nodeId])
|
|
514
|
+
if (!node) return null
|
|
515
|
+
const Extension = resolveExtensionComponent(extension)
|
|
516
|
+
return (
|
|
517
|
+
<PanelSection defaultExpanded={defaultExpanded} title={extension.title}>
|
|
518
|
+
<ErrorBoundary
|
|
519
|
+
fallback={
|
|
520
|
+
<p className="p-1 text-muted-foreground text-xs">
|
|
521
|
+
“{extension.title}” hit an error and was unloaded for this session.
|
|
522
|
+
</p>
|
|
523
|
+
}
|
|
524
|
+
>
|
|
525
|
+
<Suspense fallback={null}>
|
|
526
|
+
<Extension node={node} />
|
|
527
|
+
</Suspense>
|
|
528
|
+
</ErrorBoundary>
|
|
529
|
+
</PanelSection>
|
|
530
|
+
)
|
|
531
|
+
}
|