@volter/editor-core 0.5.57
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/BUNDLED_NOTICES +1527 -0
- package/LICENSE +686 -0
- package/LICENSE-APACHE +202 -0
- package/NOTICE +18 -0
- package/README.md +24 -0
- package/dist/build/chunk-JS4D4XZ3.js +175 -0
- package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
- package/dist/build/vite-plugin-product-contributions.js +100 -0
- package/dist/build/vite-plugin-product-contributions.js.map +7 -0
- package/dist/build/vite-plugin-shared-react.js +25 -0
- package/dist/build/vite-plugin-shared-react.js.map +7 -0
- package/dist/build/vite-plugin-shared-three.js +86 -0
- package/dist/build/vite-plugin-shared-three.js.map +7 -0
- package/dist/server/chunk-KMJC7AGO.js +98 -0
- package/dist/server/chunk-KMJC7AGO.js.map +7 -0
- package/dist/server/chunk-PSILUMGS.js +46 -0
- package/dist/server/chunk-PSILUMGS.js.map +7 -0
- package/dist/server/frame-proxy.js +255 -0
- package/dist/server/frame-proxy.js.map +7 -0
- package/dist/server/open-browser.js +53 -0
- package/dist/server/open-browser.js.map +7 -0
- package/dist/server/process-shutdown.js +127 -0
- package/dist/server/process-shutdown.js.map +7 -0
- package/dist/server/session-registry.js +141 -0
- package/dist/server/session-registry.js.map +7 -0
- package/dist/server/spawn-opener.js +11 -0
- package/dist/server/spawn-opener.js.map +7 -0
- package/dist/server/worktree-identity.js +11 -0
- package/dist/server/worktree-identity.js.map +7 -0
- package/dist-server/packaged.mjs +88816 -0
- package/package.json +118 -0
- package/scripts/build-plugins.mjs +14 -0
- package/scripts/build-server.mjs +26 -0
- package/scripts/build-session.mjs +9 -0
- package/server/account-credentials.ts +60 -0
- package/server/account-service.ts +1434 -0
- package/server/adapter-region-includes.ts +47 -0
- package/server/asset-catalog-v2.ts +175 -0
- package/server/asset-history-snapshots.ts +196 -0
- package/server/asset-ledger-store.ts +195 -0
- package/server/asset-library-routes.ts +1595 -0
- package/server/blender-wasm-artifact.ts +430 -0
- package/server/boot-timings.ts +106 -0
- package/server/canonical-path.ts +50 -0
- package/server/checkout-workspace-preflight.ts +349 -0
- package/server/cloud-asset-catalog.ts +158 -0
- package/server/coding-inference-launch.ts +237 -0
- package/server/collaboration-account-client.ts +202 -0
- package/server/collaboration-attribution.ts +122 -0
- package/server/collaboration-session.ts +934 -0
- package/server/comfyui-bridge.ts +248 -0
- package/server/console-ledger.ts +499 -0
- package/server/creation-site-transform.ts +304 -0
- package/server/creation-site-write.ts +648 -0
- package/server/data-file-serialize.ts +158 -0
- package/server/editor-brand-html.ts +72 -0
- package/server/editor-control-socket.ts +366 -0
- package/server/editor-server-options.ts +190 -0
- package/server/editor-server.ts +1483 -0
- package/server/editor-sse.ts +352 -0
- package/server/engine-provenance.ts +169 -0
- package/server/engine-source-restart.ts +117 -0
- package/server/frame-bridge.ts +115 -0
- package/server/frame-proxy.ts +490 -0
- package/server/frame-workbench.ts +259 -0
- package/server/frontend-handoff.ts +229 -0
- package/server/game-globals-shadow.ts +113 -0
- package/server/gameplay-session-retention.ts +149 -0
- package/server/gameplay-sessions.ts +123 -0
- package/server/generation-jobs.ts +201 -0
- package/server/generation-reconciler.ts +137 -0
- package/server/generative-execution-context.ts +59 -0
- package/server/git-workflow.ts +612 -0
- package/server/harness-chat-caller.ts +36 -0
- package/server/harness-chat-service.ts +1681 -0
- package/server/idle-shutdown.ts +170 -0
- package/server/js-profiling-policy.ts +52 -0
- package/server/launcher-settings.ts +84 -0
- package/server/local-asset-catalog.ts +410 -0
- package/server/managed-account-defaults.ts +22 -0
- package/server/mock-control-plane.ts +540 -0
- package/server/model-import-conversion.ts +345 -0
- package/server/native-credential-store.ts +162 -0
- package/server/open-browser.ts +118 -0
- package/server/packaged.ts +1192 -0
- package/server/play-stall.ts +126 -0
- package/server/process-shutdown.ts +218 -0
- package/server/product-presets.ts +55 -0
- package/server/project-build-artifact.ts +95 -0
- package/server/project-components.ts +100 -0
- package/server/project-dependency-invalidation.ts +114 -0
- package/server/project-file-scan.ts +235 -0
- package/server/project-hmr-files.ts +215 -0
- package/server/project-install-roots.ts +210 -0
- package/server/project-kinds.ts +71 -0
- package/server/project-mcp-servers.ts +82 -0
- package/server/project-module-freshness.ts +109 -0
- package/server/project-module-instance.ts +375 -0
- package/server/project-optimize-deps-entries.ts +827 -0
- package/server/project-output-writer.ts +679 -0
- package/server/project-package-origin.ts +63 -0
- package/server/project-root-surface.ts +831 -0
- package/server/project-scratch-path.ts +47 -0
- package/server/project-script-hmr.ts +619 -0
- package/server/project-serving-plugins.ts +236 -0
- package/server/project-tools.ts +653 -0
- package/server/project-validation.ts +250 -0
- package/server/project-verbs.ts +127 -0
- package/server/project-view.ts +52 -0
- package/server/project-watch.ts +1134 -0
- package/server/project-work-coordinator.ts +288 -0
- package/server/provider-credentials.ts +331 -0
- package/server/recent-projects-store.ts +98 -0
- package/server/redact-secrets.ts +71 -0
- package/server/repository-presence.ts +267 -0
- package/server/routes/account.ts +357 -0
- package/server/routes/agents.ts +60 -0
- package/server/routes/assets.ts +154 -0
- package/server/routes/build.ts +13 -0
- package/server/routes/collaboration.ts +595 -0
- package/server/routes/configurations.ts +419 -0
- package/server/routes/context.ts +264 -0
- package/server/routes/control-plane.ts +1811 -0
- package/server/routes/logs.ts +272 -0
- package/server/routes/project-identity.ts +302 -0
- package/server/routes/project-open.ts +429 -0
- package/server/routes/project-source.ts +686 -0
- package/server/routes/project-state.ts +383 -0
- package/server/routes/relay.ts +1029 -0
- package/server/routes/route-helpers.ts +29 -0
- package/server/routes/served-modules.ts +138 -0
- package/server/routes/session-tabs.ts +196 -0
- package/server/routes/settings.ts +121 -0
- package/server/routes/share-control.ts +486 -0
- package/server/routes/themes.ts +116 -0
- package/server/routes/tools.ts +230 -0
- package/server/routes/worktrees.ts +595 -0
- package/server/scoped-game-css.ts +461 -0
- package/server/server-utils.ts +1194 -0
- package/server/session-product.ts +112 -0
- package/server/session-registry.ts +230 -0
- package/server/share-claims.ts +138 -0
- package/server/share-host.ts +526 -0
- package/server/share-session-gateway.ts +1362 -0
- package/server/share-tunnel.ts +123 -0
- package/server/spawn-opener.ts +74 -0
- package/server/support/play/log-format.ts +76 -0
- package/server/support/play/session-record.ts +199 -0
- package/server/support/project/build-discipline.ts +718 -0
- package/server/support/project/inspection-node.ts +119 -0
- package/server/support/project/log-naming.ts +54 -0
- package/server/support/project/provenance.ts +290 -0
- package/server/support/project/run-name.ts +18 -0
- package/server/support/project/session-journal.ts +830 -0
- package/server/support/project/shared.ts +327 -0
- package/server/tab-bootstrap.ts +62 -0
- package/server/tab-heartbeat.ts +477 -0
- package/server/tab-lifecycle.ts +550 -0
- package/server/tab-presence.ts +1508 -0
- package/server/team-agent-mirror.ts +95 -0
- package/server/twin-auth.ts +138 -0
- package/server/vendored-lock-recorder.ts +968 -0
- package/server/worktree-identity.ts +121 -0
- package/server/worktree-management.ts +398 -0
- package/server/worktree-retention.d.mts +13 -0
- package/server/worktree-retention.mjs +205 -0
- package/src/EditorContext.tsx +169 -0
- package/src/account.ts +333 -0
- package/src/action-registry.ts +532 -0
- package/src/active-product.ts +74 -0
- package/src/active-project.ts +155 -0
- package/src/adapter-editor-config.ts +25 -0
- package/src/adapter-observation.ts +49 -0
- package/src/animation/stage-transport.ts +379 -0
- package/src/animation/three-clips-subject.ts +172 -0
- package/src/api/asset-library-wire.ts +45 -0
- package/src/api/assets.ts +365 -0
- package/src/api/base.ts +10 -0
- package/src/api/build.ts +99 -0
- package/src/api/git-wire.ts +56 -0
- package/src/api/logs.ts +92 -0
- package/src/api/project-identity.ts +74 -0
- package/src/api/project-open.ts +355 -0
- package/src/api/project-source.ts +162 -0
- package/src/api/project-state.ts +107 -0
- package/src/api/relay.ts +268 -0
- package/src/api/settings.ts +36 -0
- package/src/api/themes.ts +45 -0
- package/src/api/worktrees.ts +257 -0
- package/src/asset-compare-core.ts +171 -0
- package/src/asset-compare.ts +294 -0
- package/src/asset-editor-context.tsx +100 -0
- package/src/asset-events.ts +97 -0
- package/src/asset-inspector-actions.ts +87 -0
- package/src/asset-preview-framing.ts +357 -0
- package/src/asset-preview.ts +2802 -0
- package/src/asset-selection-viewer-registry.ts +113 -0
- package/src/asset-selection.ts +133 -0
- package/src/asset-workflow/asset-capabilities.ts +344 -0
- package/src/asset-workflow/asset-import-jobs.ts +106 -0
- package/src/asset-workflow/asset-ledger-backend.ts +126 -0
- package/src/asset-workflow/asset-ledger.ts +156 -0
- package/src/asset-workflow/asset-materialization-report.ts +140 -0
- package/src/asset-workflow/asset-pack-manifest.ts +320 -0
- package/src/asset-workflow/asset-types.ts +142 -0
- package/src/asset-workflow/asset-workflow-quality.ts +61 -0
- package/src/asset-workflow/audio-preview-player.ts +193 -0
- package/src/asset-workflow/audio-waveform.ts +22 -0
- package/src/asset-workflow/cloud-asset-client.ts +263 -0
- package/src/asset-workflow/folder-preview.ts +345 -0
- package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
- package/src/asset-workflow/image-view-scale.ts +32 -0
- package/src/asset-workflow/import-contract.ts +124 -0
- package/src/asset-workflow/ledger-write-lock.ts +244 -0
- package/src/asset-workflow/model-inspection.ts +818 -0
- package/src/asset-workflow/pixi-spritesheet.ts +197 -0
- package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
- package/src/asset-workflow/project-asset-commands.ts +23 -0
- package/src/asset-workflow/project-asset-health.ts +139 -0
- package/src/asset-workflow/project-asset-operations.ts +154 -0
- package/src/asset-workflow/project-asset-roots.ts +68 -0
- package/src/asset-workflow/project-content.ts +288 -0
- package/src/asset-workflow/project-source-index.ts +550 -0
- package/src/asset-workflow/thumbnail-system.ts +256 -0
- package/src/authoring/active-adapter.ts +199 -0
- package/src/authoring/active-systems.ts +422 -0
- package/src/authoring/adapter-key.ts +18 -0
- package/src/authoring/authoring-asset-url.ts +27 -0
- package/src/authoring/bootstrap-state.ts +49 -0
- package/src/authoring/boundary-authoring-adapter.ts +184 -0
- package/src/authoring/breakpoint-state.ts +43 -0
- package/src/authoring/canvas-scene-guides.ts +76 -0
- package/src/authoring/component-instance-root.ts +171 -0
- package/src/authoring/composite-authoring-adapter.ts +2110 -0
- package/src/authoring/consumer-actions.ts +520 -0
- package/src/authoring/css-numeric-style.ts +97 -0
- package/src/authoring/design-time-layers.ts +849 -0
- package/src/authoring/design-time-mount-registry.ts +235 -0
- package/src/authoring/design-time-settle.ts +343 -0
- package/src/authoring/edit-mode-authoring.ts +619 -0
- package/src/authoring/eyedropper-session.ts +60 -0
- package/src/authoring/instance-source-menu-register.ts +13 -0
- package/src/authoring/instance-source-menu.ts +135 -0
- package/src/authoring/layered-pick.ts +183 -0
- package/src/authoring/live-gesture-lock.ts +50 -0
- package/src/authoring/live-object-transform.ts +62 -0
- package/src/authoring/mount-failure-report.ts +154 -0
- package/src/authoring/mounted-root-subjects.ts +144 -0
- package/src/authoring/no-authoring-adapter.ts +55 -0
- package/src/authoring/null-inspection-subjects.tsx +75 -0
- package/src/authoring/object3d-document-persistence.ts +106 -0
- package/src/authoring/object3d-document-session-registry.ts +120 -0
- package/src/authoring/object3d-document-session.ts +1326 -0
- package/src/authoring/object3d-gesture-controller.ts +113 -0
- package/src/authoring/panel-authoring.ts +121 -0
- package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
- package/src/authoring/prefab-instance-section-model.ts +55 -0
- package/src/authoring/project-authoring-session.ts +105 -0
- package/src/authoring/provenance.ts +99 -0
- package/src/authoring/quarks-particle-systems.ts +19 -0
- package/src/authoring/react-canvas-navigation.ts +255 -0
- package/src/authoring/react-design-canvas-style.ts +20 -0
- package/src/authoring/react-story-board.ts +937 -0
- package/src/authoring/selection-scope.ts +195 -0
- package/src/authoring/shell-document-ops.ts +169 -0
- package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
- package/src/authoring/shell-viewport-policy.ts +48 -0
- package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
- package/src/authoring/stories-scope.ts +35 -0
- package/src/authoring/story-board-chrome-fit.ts +107 -0
- package/src/authoring/story-board-presentation.ts +111 -0
- package/src/authoring/three-projection-core.ts +226 -0
- package/src/authoring/viewport-pick-context.ts +40 -0
- package/src/authoring/viewport-raycast.ts +240 -0
- package/src/authoring/viewport-tool-context.ts +73 -0
- package/src/authoring/world-canvas-viewport-state.ts +35 -0
- package/src/authoring/world-hidden-viewport.ts +152 -0
- package/src/authoring/world-pan-state.ts +198 -0
- package/src/authoring/world-session-state.ts +101 -0
- package/src/authoring/write-pipe.ts +173 -0
- package/src/availability-tick.ts +66 -0
- package/src/bitmap-label.ts +120 -0
- package/src/blender-tab-metrics.ts +161 -0
- package/src/board-open-actions.ts +20 -0
- package/src/boot-routing.ts +391 -0
- package/src/build-identity.ts +15 -0
- package/src/bytes-codec.ts +62 -0
- package/src/camera-authoring.ts +175 -0
- package/src/cancellation-reason.ts +58 -0
- package/src/canvas-preview-frames.ts +482 -0
- package/src/capture-camera-pose.ts +77 -0
- package/src/chrome-registry.ts +159 -0
- package/src/chrome-slot-registry.ts +91 -0
- package/src/collaboration-client.ts +264 -0
- package/src/collaboration-presence.ts +41 -0
- package/src/command-dispatch.ts +19 -0
- package/src/command-listener.ts +2743 -0
- package/src/command-registry.ts +70 -0
- package/src/component-board-registry.ts +205 -0
- package/src/component-states-registry.ts +200 -0
- package/src/components/AgentPresentationNotice.tsx +75 -0
- package/src/components/AlignToolbar.tsx +204 -0
- package/src/components/AppRoot.tsx +394 -0
- package/src/components/ApplicationMenus.tsx +415 -0
- package/src/components/AssetBrowser.tsx +2843 -0
- package/src/components/AssetEditorShell.tsx +216 -0
- package/src/components/AssetInspectorToolSection.tsx +124 -0
- package/src/components/BoardRulers.tsx +354 -0
- package/src/components/CameraInfo.tsx +81 -0
- package/src/components/CanvasSceneViewport.tsx +556 -0
- package/src/components/CapabilityCoverageSection.tsx +185 -0
- package/src/components/CenterDocuments.tsx +22 -0
- package/src/components/ChromeSlot.tsx +20 -0
- package/src/components/CodeView.tsx +470 -0
- package/src/components/CollaborationHeaderControl.css +74 -0
- package/src/components/CompactInspectorCard.tsx +190 -0
- package/src/components/CompactInspectorShell.tsx +39 -0
- package/src/components/ConsolePanel.css +55 -0
- package/src/components/ConsolePanel.tsx +270 -0
- package/src/components/DefaultEditorLayout.tsx +106 -0
- package/src/components/DocumentHeaderStrip.tsx +96 -0
- package/src/components/DocumentShelfRail.tsx +45 -0
- package/src/components/DocumentThumbnail.tsx +345 -0
- package/src/components/EditorLeaseGuard.tsx +372 -0
- package/src/components/ErrorBanner.tsx +123 -0
- package/src/components/FolderPreviewTile.tsx +159 -0
- package/src/components/GameHierarchy.tsx +3842 -0
- package/src/components/GameplaySessionTimeline.tsx +295 -0
- package/src/components/InspectionProjection.tsx +968 -0
- package/src/components/Inspector.tsx +233 -0
- package/src/components/InspectorCanvasPreview.tsx +35 -0
- package/src/components/InspectorFieldsSection.tsx +290 -0
- package/src/components/InspectorObjectPreview.tsx +57 -0
- package/src/components/InspectorStoriesSection.tsx +92 -0
- package/src/components/InspectorToolSection.tsx +96 -0
- package/src/components/InspectorTransformSection.tsx +245 -0
- package/src/components/LightExplorerPanel.tsx +433 -0
- package/src/components/MediaProperties.tsx +145 -0
- package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
- package/src/components/Object3DDocumentToolbar.css +100 -0
- package/src/components/Object3DDocumentToolbar.tsx +454 -0
- package/src/components/Object3DDocumentViewport.tsx +58 -0
- package/src/components/ProjectHeader.css +617 -0
- package/src/components/ProjectHeader.tsx +328 -0
- package/src/components/ProjectLayout.tsx +249 -0
- package/src/components/ReactCanvasControls.tsx +284 -0
- package/src/components/RootSelectionOverlay.tsx +3162 -0
- package/src/components/RootTextEditor.tsx +79 -0
- package/src/components/SaveStatus.tsx +70 -0
- package/src/components/StageHost.tsx +1954 -0
- package/src/components/StageOverlays.tsx +21 -0
- package/src/components/StartupErrorScreen.tsx +76 -0
- package/src/components/StartupLoadingScreen.tsx +65 -0
- package/src/components/StatsOverlay.tsx +78 -0
- package/src/components/SurfaceStateOverlay.tsx +24 -0
- package/src/components/ToolContributionSurfaces.tsx +65 -0
- package/src/components/ToolHost.tsx +370 -0
- package/src/components/ToolObject3DPreview.tsx +39 -0
- package/src/components/Toolbar.tsx +595 -0
- package/src/components/TransientHint.tsx +44 -0
- package/src/components/TransportStrip.tsx +174 -0
- package/src/components/VersionControlSection.tsx +470 -0
- package/src/components/VgaiLogo.css +83 -0
- package/src/components/VgaiLogo.tsx +35 -0
- package/src/components/ViewportControlsHint.tsx +60 -0
- package/src/components/ViewportFurniture.tsx +401 -0
- package/src/components/ViewportOverlay.tsx +145 -0
- package/src/components/ViewportOverlaysMenu.tsx +153 -0
- package/src/components/ViewportShadingMenu.tsx +300 -0
- package/src/components/ViewportViewMenu.tsx +101 -0
- package/src/components/WorkspaceDocumentSurface.tsx +204 -0
- package/src/components/WorkspaceUtilitySurface.tsx +22 -0
- package/src/components/WorktreeSwitcher.css +239 -0
- package/src/components/WorktreeSwitcher.tsx +890 -0
- package/src/components/account-documents.tsx +1162 -0
- package/src/components/asset-documents.tsx +812 -0
- package/src/components/asset-editor-persistence.ts +216 -0
- package/src/components/asset-selection-section.tsx +535 -0
- package/src/components/asset-thumbnails.tsx +312 -0
- package/src/components/asset-viewers/AudioViewer.tsx +201 -0
- package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
- package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
- package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
- package/src/components/asset-viewers/ImageViewer.tsx +300 -0
- package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
- package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
- package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
- package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
- package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
- package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
- package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
- package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
- package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
- package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
- package/src/components/asset-viewers/VideoViewer.tsx +101 -0
- package/src/components/asset-viewers/shader-source.ts +144 -0
- package/src/components/asset-workflow.css +731 -0
- package/src/components/board-guides.ts +150 -0
- package/src/components/compact-inspector.css +539 -0
- package/src/components/core-utilities.tsx +90 -0
- package/src/components/editor-notifications.css +40 -0
- package/src/components/engine-workspace.css +274 -0
- package/src/components/inspector-preview-section.tsx +238 -0
- package/src/components/inspector-property-grouping.ts +64 -0
- package/src/components/inspector-revert-label.ts +20 -0
- package/src/components/inspector-selection.ts +42 -0
- package/src/components/inspector-stories-gating.ts +171 -0
- package/src/components/inspector-transform-subject.ts +11 -0
- package/src/components/inspector-transform.ts +75 -0
- package/src/components/kind-documents.tsx +500 -0
- package/src/components/palette-action-publisher.tsx +140 -0
- package/src/components/primitives/DraftColorInput.tsx +74 -0
- package/src/components/product-shell.css +1829 -0
- package/src/components/project-tool-documents.tsx +410 -0
- package/src/components/scene-documents.tsx +223 -0
- package/src/components/stage-keyboard.tsx +37 -0
- package/src/components/stage-overlay-set.tsx +107 -0
- package/src/components/stage-presence-markers.ts +478 -0
- package/src/components/standard-viewport-dressing.ts +417 -0
- package/src/components/status-contributions.tsx +403 -0
- package/src/components/tool-documents.tsx +309 -0
- package/src/components/tool-schema-form.tsx +262 -0
- package/src/components/use-after-paint.ts +41 -0
- package/src/components/use-project-image-assets.ts +86 -0
- package/src/components/viewport-header-controls.css +90 -0
- package/src/components/viewport-surface-status.tsx +55 -0
- package/src/components/workspace-history.ts +32 -0
- package/src/components/workspace-static-panel-registry.tsx +152 -0
- package/src/components/workspace-surfaces.css +621 -0
- package/src/components/world-documents.tsx +586 -0
- package/src/components/world-overlay-gestures.ts +1694 -0
- package/src/composite-screenshot.ts +1641 -0
- package/src/console-sync.ts +131 -0
- package/src/constraint-helper.ts +338 -0
- package/src/content-entry-source-registry.ts +184 -0
- package/src/coverage/authoring-seam-evidence.ts +300 -0
- package/src/coverage/canvas-reveal.ts +192 -0
- package/src/coverage/design-time-surfaces.ts +101 -0
- package/src/coverage/live-seam-evidence.ts +11 -0
- package/src/coverage/ontology-invariants.ts +466 -0
- package/src/coverage/session-vitals.ts +501 -0
- package/src/coverage/system-seam-evidence.ts +72 -0
- package/src/crash-null-boundary.ts +36 -0
- package/src/creation-site-edit.ts +1479 -0
- package/src/creation-site-registry.ts +160 -0
- package/src/delegate-harness-registry.ts +143 -0
- package/src/document-context-registry.ts +177 -0
- package/src/document-open-registry.ts +200 -0
- package/src/document-preview-source.ts +20 -0
- package/src/document-renderer-session.ts +138 -0
- package/src/editor-api.ts +46 -0
- package/src/editor-chrome-capture.ts +139 -0
- package/src/editor-commands.ts +164 -0
- package/src/editor-console.ts +563 -0
- package/src/editor-current-view.ts +82 -0
- package/src/editor-document-probe.ts +881 -0
- package/src/editor-git-client.ts +115 -0
- package/src/editor-host-door.ts +462 -0
- package/src/editor-hotkeys.ts +842 -0
- package/src/editor-lease-view.ts +39 -0
- package/src/editor-lease.ts +415 -0
- package/src/editor-mode.ts +19 -0
- package/src/editor-notifications.ts +140 -0
- package/src/editor-presence.ts +563 -0
- package/src/editor-presentation-activity.ts +50 -0
- package/src/editor-presentation-notice.ts +42 -0
- package/src/editor-runtime.tsx +145 -0
- package/src/editor-server-response.ts +86 -0
- package/src/editor-session-attribution.ts +75 -0
- package/src/editor-session-mode.ts +54 -0
- package/src/editor-shell-store.ts +1304 -0
- package/src/editor-state-facets.ts +74 -0
- package/src/editor-styles.css +33 -0
- package/src/editor-view-presentation.ts +810 -0
- package/src/editor-viewport.ts +5302 -0
- package/src/entity-lod.ts +31 -0
- package/src/entity-object.ts +91 -0
- package/src/files/file-provider.ts +62 -0
- package/src/files/project-files.ts +264 -0
- package/src/finders/index.ts +136 -0
- package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
- package/src/frame/bridge.tsx +1408 -0
- package/src/frame/product.ts +77 -0
- package/src/gameplay-dom-recording.ts +319 -0
- package/src/gameplay-export-state.ts +14 -0
- package/src/gameplay-replay.ts +417 -0
- package/src/gameplay-session-time.ts +9 -0
- package/src/gameplay-sessions.ts +204 -0
- package/src/harness-chat-types.ts +253 -0
- package/src/hierarchy-component-marks.ts +298 -0
- package/src/hierarchy-drop.ts +91 -0
- package/src/hierarchy-expansion-state.ts +80 -0
- package/src/hierarchy-header-slot.ts +52 -0
- package/src/hierarchy-internals.ts +197 -0
- package/src/hierarchy-kind-icon.ts +217 -0
- package/src/hierarchy-mark-reader.ts +73 -0
- package/src/hierarchy-menu-registry.ts +67 -0
- package/src/hierarchy-node-rows.ts +307 -0
- package/src/hierarchy-panel-view.ts +280 -0
- package/src/hierarchy-projection.ts +76 -0
- package/src/hierarchy-row-cache.ts +243 -0
- package/src/hierarchy-row-model.ts +308 -0
- package/src/hierarchy-rows.ts +11 -0
- package/src/hierarchy-walk.ts +86 -0
- package/src/history/editor-session.ts +25 -0
- package/src/history/history-commands.ts +147 -0
- package/src/history/history-delegate.ts +163 -0
- package/src/history/history-limit-notices.ts +43 -0
- package/src/history/history-service.ts +1173 -0
- package/src/history/persistence-coordinator.ts +35 -0
- package/src/history/project-file-history.ts +386 -0
- package/src/history/project-root-history-backends.ts +139 -0
- package/src/history/resource-registry.ts +209 -0
- package/src/history/snapshot-store.ts +103 -0
- package/src/history/source-history-backend.ts +546 -0
- package/src/history/types.ts +124 -0
- package/src/hmr-registration-group.ts +67 -0
- package/src/hmr-stable-react-context.ts +23 -0
- package/src/hotkeys.ts +188 -0
- package/src/inference-diagnostics.ts +69 -0
- package/src/initial-project.ts +80 -0
- package/src/inspection/active-subject.ts +578 -0
- package/src/inspection/active-surface.ts +142 -0
- package/src/inspection/compose.ts +1064 -0
- package/src/inspection/display.ts +170 -0
- package/src/inspection/document-subject.ts +109 -0
- package/src/inspection/game-subject.ts +85 -0
- package/src/inspection/model.ts +542 -0
- package/src/inspection/null-subject.ts +115 -0
- package/src/inspection/serialize.ts +357 -0
- package/src/inspection/use-active-inspection.ts +180 -0
- package/src/inspector-presentation.ts +201 -0
- package/src/inspector-section-registry.ts +221 -0
- package/src/instance-source-actions.ts +163 -0
- package/src/instanced-presentation.ts +164 -0
- package/src/js-heap.ts +71 -0
- package/src/key-actions.ts +91 -0
- package/src/keymap-presets.ts +401 -0
- package/src/layout-policy.ts +31 -0
- package/src/learn-links.ts +73 -0
- package/src/light-explorer-model.ts +134 -0
- package/src/live-canvas-frame.ts +55 -0
- package/src/live-document.ts +279 -0
- package/src/live-module-source.ts +230 -0
- package/src/live-session-registry.ts +220 -0
- package/src/live-transition.ts +633 -0
- package/src/manifest-project.ts +107 -0
- package/src/model-thumbnail.ts +523 -0
- package/src/native-selection-style.ts +202 -0
- package/src/object3d-document-write-policy.ts +137 -0
- package/src/packaged-runtime.ts +108 -0
- package/src/palettes/maya.palette.json +57 -0
- package/src/palettes/substance.palette.json +57 -0
- package/src/pasteboard-module.ts +81 -0
- package/src/performance-profiler.ts +367 -0
- package/src/performance-sources.ts +69 -0
- package/src/play-boot-phase.ts +145 -0
- package/src/presentation-surface.ts +248 -0
- package/src/project-adapter.ts +1143 -0
- package/src/project-asset-refresh.ts +26 -0
- package/src/project-declaration-refresh.ts +31 -0
- package/src/project-local-state.ts +118 -0
- package/src/project-manager.ts +243 -0
- package/src/project-module-changes.ts +163 -0
- package/src/project-module-split.ts +266 -0
- package/src/project-provenance.ts +115 -0
- package/src/project-ready.ts +42 -0
- package/src/project-session-reset.ts +67 -0
- package/src/project-shape.ts +68 -0
- package/src/project-tool-discovery.ts +66 -0
- package/src/project-tools.ts +107 -0
- package/src/project-work-types.ts +149 -0
- package/src/projection/three.ts +898 -0
- package/src/projection/types.ts +44 -0
- package/src/readiness.ts +113 -0
- package/src/reflection-probe-helper.ts +142 -0
- package/src/reported-play-state.ts +90 -0
- package/src/resolve-relative-specifier.ts +33 -0
- package/src/scene-document-plan.ts +315 -0
- package/src/scene-framing.ts +315 -0
- package/src/scene-live-open.ts +210 -0
- package/src/scene-view-fog.ts +89 -0
- package/src/scoped-game-css.ts +152 -0
- package/src/session-orphan-record.ts +193 -0
- package/src/session-tombstone.ts +126 -0
- package/src/settings/settings-provider.ts +82 -0
- package/src/settings-store.ts +345 -0
- package/src/shared-view-restore.ts +42 -0
- package/src/shell-store-door.ts +45 -0
- package/src/source-conflict.ts +122 -0
- package/src/spatial-handle-visuals.ts +332 -0
- package/src/stage-context.ts +341 -0
- package/src/stage-store-registry.ts +63 -0
- package/src/stale-chunk-recovery.ts +34 -0
- package/src/state-report-deferral.ts +73 -0
- package/src/storage/host-files-storage.ts +97 -0
- package/src/storage/http-storage.ts +174 -0
- package/src/storage/index.ts +75 -0
- package/src/storage/mem-storage.ts +149 -0
- package/src/storage/path-lock.ts +44 -0
- package/src/storage/paths.ts +26 -0
- package/src/storage/types.ts +116 -0
- package/src/stories/StoryComponentThumbnail.tsx +184 -0
- package/src/stories/StoryPreviewMount.tsx +306 -0
- package/src/stories/component-content-source.tsx +106 -0
- package/src/stories/component-states-source.ts +78 -0
- package/src/stories/compose-project-stories.ts +255 -0
- package/src/stories/pixi-story-model.ts +30 -0
- package/src/stories/prefabs-finder.ts +54 -0
- package/src/stories/prefabs-from-stories.ts +182 -0
- package/src/stories/project-story-discovery.ts +78 -0
- package/src/stories/project-story-regions.ts +24 -0
- package/src/stories/story-actions.ts +54 -0
- package/src/stories/story-capture-command.ts +124 -0
- package/src/stories/story-capture.ts +584 -0
- package/src/stories/story-declared-medium.ts +126 -0
- package/src/stories/story-discovery.ts +176 -0
- package/src/stories/story-document-openers.ts +36 -0
- package/src/stories/story-dom-runtime.ts +78 -0
- package/src/stories/story-grouping.ts +111 -0
- package/src/stories/story-lane.ts +88 -0
- package/src/stories/story-mount-turn.ts +27 -0
- package/src/stories/story-opener.ts +260 -0
- package/src/stories/story-pixi-preview.ts +408 -0
- package/src/stories/story-presentation.ts +215 -0
- package/src/stories/story-registry.ts +517 -0
- package/src/stories/story-three-preview.ts +807 -0
- package/src/stories/three-story-model.ts +96 -0
- package/src/story-three-preview-runtime.ts +56 -0
- package/src/surface-keyboard.ts +112 -0
- package/src/surface-state.ts +124 -0
- package/src/tab-bootstrap.js +628 -0
- package/src/tab-census.ts +198 -0
- package/src/tab-lifecycle-client.ts +196 -0
- package/src/theme-library.ts +844 -0
- package/src/theme-preference.ts +378 -0
- package/src/theme.css +3437 -0
- package/src/three-viewport/bone-selection-highlight.ts +119 -0
- package/src/three-viewport/camera-fit.ts +41 -0
- package/src/three-viewport/interactive-renderer.ts +132 -0
- package/src/three-viewport/selection-brackets.ts +310 -0
- package/src/three-viewport/selection-outline.ts +183 -0
- package/src/three-viewport/skeleton-helper.ts +61 -0
- package/src/three-viewport/source-color.ts +167 -0
- package/src/three-viewport/studio-environment.ts +96 -0
- package/src/three-viewport-presentation.ts +23 -0
- package/src/tool-contribution-play.ts +74 -0
- package/src/tool-loader.ts +1798 -0
- package/src/transform-mode-request.ts +57 -0
- package/src/transient-hint.ts +78 -0
- package/src/trigger-volume-helper.ts +116 -0
- package/src/ui-source/adapter-region-includes.ts +241 -0
- package/src/ui-source/ensure-import.ts +132 -0
- package/src/ui-source/file-region-resolver.ts +302 -0
- package/src/ui-source/inspect.ts +775 -0
- package/src/ui-source/oid-transform.ts +1845 -0
- package/src/ui-source/plan-csf-story.ts +196 -0
- package/src/ui-source/plan-extract-component.ts +421 -0
- package/src/ui-source/plan-fork-component.ts +621 -0
- package/src/ui-source/plan-named-style.ts +95 -0
- package/src/ui-source/plan-source-edit.ts +353 -0
- package/src/ui-source/r3f-contract-resolver.ts +327 -0
- package/src/ui-source/r3f-diagnostic-index.ts +165 -0
- package/src/ui-source/r3f-environment-binding.ts +133 -0
- package/src/ui-source/r3f-joint-binding.ts +251 -0
- package/src/ui-source/r3f-lod-binding.ts +91 -0
- package/src/ui-source/r3f-particle-binding.ts +235 -0
- package/src/ui-source/r3f-physics-binding.ts +483 -0
- package/src/ui-source/r3f-project-contracts.ts +444 -0
- package/src/ui-source/relative-import-specifier.ts +34 -0
- package/src/ui-source/reparent-guard.ts +544 -0
- package/src/ui-source/source-edit-request.ts +97 -0
- package/src/ui-source/source-write-backend.ts +618 -0
- package/src/ui-source/syntactic-prop-specs.ts +296 -0
- package/src/ui-source/tier-source-write-backend.ts +279 -0
- package/src/ui-source/ts-ast.ts +171 -0
- package/src/ui-source/utility-class-support.ts +169 -0
- package/src/ui-source/write-component-default.ts +133 -0
- package/src/ui-source/writer.ts +1989 -0
- package/src/viewport-activation-timings.ts +840 -0
- package/src/viewport-authoring-policy.ts +186 -0
- package/src/viewport-controls-hint.ts +57 -0
- package/src/viewport-door.ts +237 -0
- package/src/viewport-shading-boundary.ts +12 -0
- package/src/vite-error-surface.ts +141 -0
- package/src/wait-until.ts +37 -0
- package/src/workspace-areas.ts +156 -0
- package/src/workspace-aux-commands.ts +11 -0
- package/src/workspace-available-documents.ts +127 -0
- package/src/workspace-core-utilities.ts +31 -0
- package/src/workspace-document-ids.ts +59 -0
- package/src/workspace-document-registry.ts +612 -0
- package/src/workspace-document-restore.ts +145 -0
- package/src/workspace-host-commands.ts +141 -0
- package/src/workspace-persistence-gate.ts +40 -0
- package/src/workspace-play-utilities.ts +44 -0
- package/src/workspace-presets.ts +425 -0
- package/src/workspace-regions.ts +294 -0
- package/src/workspace-state-persistence.ts +536 -0
- package/src/workspace-static-panels.ts +73 -0
- package/src/workspace-status-registry.ts +121 -0
- package/src/workspace-style.ts +239 -0
- package/src/workspace-utility-commands.ts +74 -0
- package/src/workspace-utility-registry.ts +263 -0
- package/src/workspace-viewport-rect.ts +97 -0
- package/src/world-adoption.ts +115 -0
- package/src/world-document-routing.ts +104 -0
- package/vite-plugin-creation-site-write.ts +184 -0
- package/vite-plugin-creation-site.ts +80 -0
- package/vite-plugin-game-static.ts +303 -0
- package/vite-plugin-module-doorways.ts +336 -0
- package/vite-plugin-product-contributions.ts +197 -0
- package/vite-plugin-project-game-static.ts +125 -0
- package/vite-plugin-project-jsx-js.ts +65 -0
- package/vite-plugin-project-root-absolute-assets.ts +359 -0
- package/vite-plugin-shared-react.ts +420 -0
- package/vite-plugin-shared-three.ts +203 -0
- package/vite-plugin-ui-oid.ts +2106 -0
|
@@ -0,0 +1,3842 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
chromeSize,
|
|
4
|
+
IconButton,
|
|
5
|
+
Menu,
|
|
6
|
+
MenuItem,
|
|
7
|
+
space,
|
|
8
|
+
TextInput,
|
|
9
|
+
ThemeRootPortal,
|
|
10
|
+
themeVars,
|
|
11
|
+
zIndex,
|
|
12
|
+
} from '@volter/editor-sdk/widgets';
|
|
13
|
+
import { authoringAdapterKey } from '../authoring/adapter-key';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* GameHierarchy — the ONE hierarchy panel (A2). Replaces the former pair of
|
|
17
|
+
* per-format hierarchy panels (first-party single-document, ingested-game) — a
|
|
18
|
+
* single contract-only shell driven ENTIRELY through the active {@link
|
|
19
|
+
* AuthoringAdapter}: `hierarchy`/`selection`/`inspector`/ `structure`/`assetDrop`.
|
|
20
|
+
* Every row affordance is gated on the PRESENCE of a provider or a reserved
|
|
21
|
+
* inspector path, never on which concrete adapter is active.
|
|
22
|
+
*
|
|
23
|
+
* Rule zero (spec §0): this file imports ONLY from `@volter/editor-project/adapter` types
|
|
24
|
+
* and editor-shell modules — no private first-party entity type, no direct
|
|
25
|
+
* 3D-library value import, no first-party document-format string literal.
|
|
26
|
+
* First-party-only affordances not yet contract-expressible live behind
|
|
27
|
+
* `hierarchy-menu-registry.ts`'s escape hatch, registered elsewhere by the
|
|
28
|
+
* owning adapter module (formerly prefab unpack/open/save-as; currently
|
|
29
|
+
* unregistered).
|
|
30
|
+
*
|
|
31
|
+
* Shape handling: project authoring is a `CompositeAuthoringAdapter` for every
|
|
32
|
+
* N >= 1 composition. The Game document projects that whole composite; an
|
|
33
|
+
* authored root document projects only its owning child. In the composite,
|
|
34
|
+
* each runtime world is a synthetic `world:<id>` group; adapter-owned surfaces
|
|
35
|
+
* such as scene UI may be nested beneath their owning world without pretending
|
|
36
|
+
* to be runtime roots. The same generic row engine also handles bare adapters
|
|
37
|
+
* used by root documents, bounded ingest and compatibility sessions. Both
|
|
38
|
+
* shapes are walked by the SAME generic row engine
|
|
39
|
+
* (`flattenHierarchyRows` — the format-neutral `EditorNode`-typed row
|
|
40
|
+
* flattener, not the legacy first-party-typed sibling module); a group node
|
|
41
|
+
* only ever gets a small set of cosmetic extras (kind/zOrder badge, a
|
|
42
|
+
* session-local eye) layered on TOP of the same row component.
|
|
43
|
+
*
|
|
44
|
+
* Testids: every row carries BOTH the
|
|
45
|
+
* `data-entity-name`/`data-testid="scene-name"` family (what
|
|
46
|
+
* `EditorPage.getEntityNames` reads) and the full `ingest-*` family
|
|
47
|
+
* (`ingest-row`, `ingest-row-caret`, `ingest-row-visibility`,
|
|
48
|
+
* `ingest-search`, `ingest-hierarchy`, `data-ingest-name`,
|
|
49
|
+
* `ingest-context-menu`), so a probe can address any row by either
|
|
50
|
+
* vocabulary regardless of which adapter produced it.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
|
54
|
+
import {
|
|
55
|
+
faCaretDown,
|
|
56
|
+
faCaretRight,
|
|
57
|
+
faChevronDown,
|
|
58
|
+
faCircleDot,
|
|
59
|
+
faCirclePlay,
|
|
60
|
+
faCrosshairs,
|
|
61
|
+
faEye,
|
|
62
|
+
faEyeSlash,
|
|
63
|
+
faFileCode,
|
|
64
|
+
faFolder,
|
|
65
|
+
faHandPointer,
|
|
66
|
+
faLayerGroup,
|
|
67
|
+
faLock,
|
|
68
|
+
faLockOpen,
|
|
69
|
+
faMagnifyingGlass,
|
|
70
|
+
faObjectGroup,
|
|
71
|
+
faPlus,
|
|
72
|
+
faPuzzlePiece,
|
|
73
|
+
faTriangleExclamation,
|
|
74
|
+
} from '@fortawesome/free-solid-svg-icons';
|
|
75
|
+
import type { CollaborationSnapshot } from '@volter/editor-sdk/session/collaboration-types';
|
|
76
|
+
import {
|
|
77
|
+
clampRectToViewport,
|
|
78
|
+
DisclosureIcon,
|
|
79
|
+
DropIndicator,
|
|
80
|
+
EditorIcon,
|
|
81
|
+
EditorToolbar,
|
|
82
|
+
lineHeightVar,
|
|
83
|
+
Panel,
|
|
84
|
+
spaceVar,
|
|
85
|
+
} from '@volter/editor-sdk/widgets';
|
|
86
|
+
import type {
|
|
87
|
+
AuthoringAdapter,
|
|
88
|
+
AuthoringProvenance,
|
|
89
|
+
EditorNode,
|
|
90
|
+
EditorNodeRole,
|
|
91
|
+
HierarchyProvider,
|
|
92
|
+
} from '@volter/editor-project/adapter';
|
|
93
|
+
import {
|
|
94
|
+
memo,
|
|
95
|
+
useCallback,
|
|
96
|
+
useDeferredValue,
|
|
97
|
+
useEffect,
|
|
98
|
+
useLayoutEffect,
|
|
99
|
+
useMemo,
|
|
100
|
+
useReducer,
|
|
101
|
+
useRef,
|
|
102
|
+
useState,
|
|
103
|
+
useSyncExternalStore,
|
|
104
|
+
} from 'react';
|
|
105
|
+
import { createPortal } from 'react-dom';
|
|
106
|
+
import {
|
|
107
|
+
activeAuthoringVersion,
|
|
108
|
+
hasAuthoringOverride,
|
|
109
|
+
subscribeActiveAuthoring,
|
|
110
|
+
} from '../authoring/active-adapter';
|
|
111
|
+
import { CompositeAuthoringAdapter } from '../authoring/composite-authoring-adapter';
|
|
112
|
+
import {
|
|
113
|
+
copyAuthoringNodes,
|
|
114
|
+
createAuthoringNode,
|
|
115
|
+
cutAuthoringNodes,
|
|
116
|
+
dropAuthoringAsset,
|
|
117
|
+
duplicateAuthoringNode,
|
|
118
|
+
groupAuthoringNodes,
|
|
119
|
+
pasteAuthoringNodes,
|
|
120
|
+
reorderAuthoringNode,
|
|
121
|
+
reparentAuthoringNode,
|
|
122
|
+
setAuthoringSelection,
|
|
123
|
+
ungroupAuthoringNode,
|
|
124
|
+
unwrapAuthoringNode,
|
|
125
|
+
wrapAuthoringNode,
|
|
126
|
+
} from '../authoring/consumer-actions';
|
|
127
|
+
import { enterInstanceRow } from '../authoring/instance-source-menu';
|
|
128
|
+
import { resolvePanelAuthoring } from '../authoring/panel-authoring';
|
|
129
|
+
import {
|
|
130
|
+
authoringDestination,
|
|
131
|
+
provenanceForNode,
|
|
132
|
+
unavailableReason,
|
|
133
|
+
} from '../authoring/provenance';
|
|
134
|
+
import {
|
|
135
|
+
selectionScopeStack,
|
|
136
|
+
selectionScopeVersion,
|
|
137
|
+
setSelectionScope,
|
|
138
|
+
subscribeSelectionScope,
|
|
139
|
+
} from '../authoring/selection-scope';
|
|
140
|
+
import { resolveThreeViewportRootId } from '../authoring/world-hidden-viewport';
|
|
141
|
+
import {
|
|
142
|
+
isRootHidden,
|
|
143
|
+
isRootInteractive,
|
|
144
|
+
isRootPickLocked,
|
|
145
|
+
toggleRootHidden,
|
|
146
|
+
toggleRootInteractive,
|
|
147
|
+
toggleRootPickLock,
|
|
148
|
+
} from '../authoring/world-session-state';
|
|
149
|
+
import { collaborationSnapshot, connectCollaboration } from '../collaboration-client';
|
|
150
|
+
import { openRegisteredDocument } from '../document-open-registry';
|
|
151
|
+
import { deleteSelection, duplicateSelection } from '../editor-hotkeys';
|
|
152
|
+
import { EDITOR_PARTICIPANT_ID } from '../editor-presence';
|
|
153
|
+
import { useEditorStore } from '../editor-runtime';
|
|
154
|
+
import type { EditorShellStore } from '../editor-shell-store';
|
|
155
|
+
import { entityObject3D } from '../entity-object';
|
|
156
|
+
import {
|
|
157
|
+
applyComponentRootExpansionDefaults,
|
|
158
|
+
componentMarkView,
|
|
159
|
+
type MarkedTreeSource,
|
|
160
|
+
} from '../hierarchy-component-marks';
|
|
161
|
+
import { hierarchyDropRefusal, isSameRootDrop } from '../hierarchy-drop';
|
|
162
|
+
import {
|
|
163
|
+
clearHierarchyExpansionPreferences,
|
|
164
|
+
hierarchyExpansionPreference,
|
|
165
|
+
setHierarchyExpansionPreferences,
|
|
166
|
+
} from '../hierarchy-expansion-state';
|
|
167
|
+
import { hierarchyHeaderSlot, subscribeHierarchyHeaderSlot } from '../hierarchy-header-slot';
|
|
168
|
+
import { applyRevealExpansionDefaults, internalsProjection } from '../hierarchy-internals';
|
|
169
|
+
import {
|
|
170
|
+
hierarchyKindIcon,
|
|
171
|
+
isDatablockKind,
|
|
172
|
+
OUTLINER_EXCLUDE_OFF,
|
|
173
|
+
OUTLINER_EXCLUDE_ON,
|
|
174
|
+
OUTLINER_RENDER_OFF,
|
|
175
|
+
OUTLINER_RENDER_ON,
|
|
176
|
+
} from '../hierarchy-kind-icon';
|
|
177
|
+
import { markReaderFor } from '../hierarchy-mark-reader';
|
|
178
|
+
import { getHierarchyMenuItems } from '../hierarchy-menu-registry';
|
|
179
|
+
import {
|
|
180
|
+
flattenHierarchyRows,
|
|
181
|
+
type HierarchyNodeRow,
|
|
182
|
+
hierarchyRowKey,
|
|
183
|
+
hierarchyRowWindow,
|
|
184
|
+
patchUniqueHierarchyRows,
|
|
185
|
+
} from '../hierarchy-node-rows';
|
|
186
|
+
import {
|
|
187
|
+
clearHierarchyPanelSnapshot,
|
|
188
|
+
type HierarchyPanelSnapshot,
|
|
189
|
+
publishHierarchyPanelSnapshot,
|
|
190
|
+
} from '../hierarchy-panel-view';
|
|
191
|
+
import {
|
|
192
|
+
ancestorPathKeys,
|
|
193
|
+
HierarchyRowCache,
|
|
194
|
+
indexRowPaths,
|
|
195
|
+
type RowPathIndex,
|
|
196
|
+
revealSignature,
|
|
197
|
+
selectedAncestorNodeIds,
|
|
198
|
+
selectedRevealCounts,
|
|
199
|
+
} from '../hierarchy-row-cache';
|
|
200
|
+
import {
|
|
201
|
+
type RowDiagnosticsSource,
|
|
202
|
+
RowWarningCache,
|
|
203
|
+
rowAffordances,
|
|
204
|
+
rowIdentity,
|
|
205
|
+
TransformLockCache,
|
|
206
|
+
UNLOCKED,
|
|
207
|
+
} from '../hierarchy-row-model';
|
|
208
|
+
import { CHILD_CAP } from '../hierarchy-rows';
|
|
209
|
+
import { setActiveScope } from '../hotkeys';
|
|
210
|
+
import { instancedRowDetail } from '../instanced-presentation';
|
|
211
|
+
import { getCurrentProject } from '../project-manager';
|
|
212
|
+
import { focusedStageStore } from '../stage-context';
|
|
213
|
+
import { editorPaintedRegions, subscribeEditorTheme } from '../theme-preference';
|
|
214
|
+
import { showTransientHint } from '../transient-hint';
|
|
215
|
+
import {
|
|
216
|
+
subscribeWorkspaceDocuments,
|
|
217
|
+
workspaceDocumentRegistryVersion,
|
|
218
|
+
} from '../workspace-document-registry';
|
|
219
|
+
import {
|
|
220
|
+
activeChromeRegions,
|
|
221
|
+
type ChromeRegions,
|
|
222
|
+
chromeRegionsKey,
|
|
223
|
+
subscribeChromeRegions,
|
|
224
|
+
} from '../workspace-regions';
|
|
225
|
+
import { activateRootDocument } from '../world-document-routing';
|
|
226
|
+
|
|
227
|
+
const ASSET_MIME = 'application/x-editor-asset';
|
|
228
|
+
const REORDER_MIME = 'application/x-hierarchy-reorder';
|
|
229
|
+
/**
|
|
230
|
+
* The row's height as the ACTIVE material declares it.
|
|
231
|
+
*
|
|
232
|
+
* `chromeSize.treeRow` is the editor's OWN default (24). A material may
|
|
233
|
+
* retune it — Blender's does, to the 20px its Outliner rows measure — and a
|
|
234
|
+
* constant read at module load never learns that: the row was painted at the
|
|
235
|
+
* inline 24 while `--vgai-tree-row-height` said 20, so the density
|
|
236
|
+
* contribution reached nothing. The spacer math, hit-testing and reveal
|
|
237
|
+
* scrolling must all use the number the row is actually painted at.
|
|
238
|
+
*
|
|
239
|
+
* Measured from a live row FIRST (the paint is the truth), then the declared
|
|
240
|
+
* var, then the default — the shape the workspace host's `bottomTabStripHeight`
|
|
241
|
+
* uses for the dock's own tab strip.
|
|
242
|
+
*
|
|
243
|
+
* THE ROW HEIGHT IS NOT WHY THE MODEL OUTLINER LOOKS SHORT — do not come here
|
|
244
|
+
* to fix that. Measured 2026-09-21 through the chrome door on a live model
|
|
245
|
+
* session (`editor.document.query('.vgai-tree-row', { scope: 'outliner',
|
|
246
|
+
* styles: [...] })`): computed `height: 20px`, rendered rect 20, row pitch
|
|
247
|
+
* exactly 20, `--vgai-tree-row-height: "20px"` — and Blender's own Outliner in
|
|
248
|
+
* `blender-reference/modeling-object-none.png` steps 40 device px at native 2x,
|
|
249
|
+
* i.e. the same 20. The two agree exactly, and `blender.style.ts`'s
|
|
250
|
+
* `treeRow: 20` is what puts them there.
|
|
251
|
+
*
|
|
252
|
+
* What is short is the PANEL. On a first boot in a 953-CSS-tall window the
|
|
253
|
+
* Outliner view's pane is 155 px of an 860 px sidebar column — 18.0 %, which is
|
|
254
|
+
* the `weight: 18` that `packages/model-editor/workbench/src/
|
|
255
|
+
* product.contribution.ts` declares from Blender's own area measurement
|
|
256
|
+
* (189/1028). Of those 155, 22 go to the workbench's pane title bar (Blender's
|
|
257
|
+
* area has none) and ~22 to this panel's header, leaving 109 px = 5.45 rows
|
|
258
|
+
* where Blender at the same column height shows ~6.6. The missing row is that
|
|
259
|
+
* title bar, and the one line that could buy it back is the product's weight —
|
|
260
|
+
* which is compiled into the Code-OSS release, so it cannot be changed and
|
|
261
|
+
* verified from this repo alone.
|
|
262
|
+
*/
|
|
263
|
+
function treeRowHeight(container: HTMLElement | null): number {
|
|
264
|
+
const row = container?.querySelector<HTMLElement>('.vgai-tree-row');
|
|
265
|
+
const measured = row?.getBoundingClientRect().height ?? 0;
|
|
266
|
+
if (measured > 0) return Math.round(measured);
|
|
267
|
+
const declared = container
|
|
268
|
+
? Number.parseFloat(getComputedStyle(container).getPropertyValue('--vgai-tree-row-height'))
|
|
269
|
+
: Number.NaN;
|
|
270
|
+
return Number.isFinite(declared) && declared > 0 ? declared : chromeSize.treeRow;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* ONE LEVEL OF TREE INDENT, as the ACTIVE material declares it
|
|
274
|
+
* (`chromeSize.treeIndent`, default 14 — the number this constant always
|
|
275
|
+
* held). A material may retune it, and Blender's does: its Outliner steps by
|
|
276
|
+
* its ROW HEIGHT, a square grid, measured at 20 on `outliner.png`. Spelled as
|
|
277
|
+
* the CSS variable rather than read once at module load, for the reason
|
|
278
|
+
* `treeRowHeight` above records — a constant read at load never learns what a
|
|
279
|
+
* density contribution declares.
|
|
280
|
+
*/
|
|
281
|
+
const INDENT = 'var(--vgai-tree-indent)';
|
|
282
|
+
/** `left`/`padding-left` for a row at `depth`, in the material's own step. */
|
|
283
|
+
function indentPx(depth: number): string {
|
|
284
|
+
return `calc(${INDENT} * ${depth})`;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* How far the INDENT GUIDE is held back from the first and last row of the
|
|
288
|
+
* block it spans. Measured on `modeling-object-none.png` (Blender 5.2, native
|
|
289
|
+
* 2x, object mode): the rule runs y 143..242 over child rows 133..172,
|
|
290
|
+
* 173..212 and 213..252 — 10 device px in at the top, 10 short at the bottom,
|
|
291
|
+
* and unbroken in between. See `.vgai-tree-indent-guide` in `theme.css` for
|
|
292
|
+
* the ink, the column and where the frame stops answering.
|
|
293
|
+
*/
|
|
294
|
+
const GUIDE_BLOCK_INSET = 5;
|
|
295
|
+
/** One ancestor column's rule, as this row draws its slice of it. */
|
|
296
|
+
interface IndentGuide {
|
|
297
|
+
/** The ancestor's depth — the caret column the rule sits on. */
|
|
298
|
+
readonly column: number;
|
|
299
|
+
/** This row is the first of that ancestor's block, so the rule starts here. */
|
|
300
|
+
readonly starts: boolean;
|
|
301
|
+
/** This row is the last of that ancestor's block, so the rule ends here. */
|
|
302
|
+
readonly ends: boolean;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* The guide slices a row at `depth` draws, given its neighbours' depths in the
|
|
306
|
+
* flattened list (`-1` where there is no neighbour). A visible row's every
|
|
307
|
+
* ancestor is open by construction, so it crosses one rule per level above it;
|
|
308
|
+
* the ends are local facts — the row starts a block when the row above it IS
|
|
309
|
+
* that ancestor, and ends every block the row below it has left.
|
|
310
|
+
*/
|
|
311
|
+
function indentGuidesFor(depth: number, prevDepth: number, nextDepth: number): IndentGuide[] {
|
|
312
|
+
const guides: IndentGuide[] = [];
|
|
313
|
+
for (let column = 0; column < depth; column++) {
|
|
314
|
+
guides.push({
|
|
315
|
+
column,
|
|
316
|
+
starts: prevDepth === column,
|
|
317
|
+
ends: nextDepth <= column,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return guides;
|
|
321
|
+
}
|
|
322
|
+
const EMPTY_REMOTE_SELECTION_COLORS: readonly string[] = [];
|
|
323
|
+
/**
|
|
324
|
+
* How many of a collapsed row's children the datablock summary looks at. An
|
|
325
|
+
* object carries a handful of datablocks and a scene group carries thousands
|
|
326
|
+
* of children; this is what keeps the second case from costing thousands of
|
|
327
|
+
* adapter reads per painted row. No reference frame shows more than one
|
|
328
|
+
* datablock on a row, so what a row with several draws is OUR reading — they
|
|
329
|
+
* sit side by side at the same cell pitch, in child order, and a row that
|
|
330
|
+
* somehow carries more than this many shows the first few.
|
|
331
|
+
*/
|
|
332
|
+
const DATABLOCK_SUMMARY_SCAN = 6;
|
|
333
|
+
|
|
334
|
+
// Scroll-window sizing:
|
|
335
|
+
// `treeRowHeight()` above is THIS panel's row height. Every rendered row
|
|
336
|
+
// (Row/MoreRow) is painted by `.vgai-tree-row`'s own `height:
|
|
337
|
+
// var(--vgai-tree-row-height)`, so the spacer-div trick only keeps the
|
|
338
|
+
// scrollbar honest as long as that resolved number is what the math uses —
|
|
339
|
+
// which is why it is measured rather than restated. OVERSCAN keeps a small buffer
|
|
340
|
+
// of already-rendered rows on each side of the visible band so a fast scroll/keyboard-nav
|
|
341
|
+
// doesn't flash empty space before the next paint catches up. DEFAULT_VIEWPORT_PX is used
|
|
342
|
+
// whenever the container hasn't reported a real (nonzero) `clientHeight` yet — jsdom never
|
|
343
|
+
// performs real layout, so unit tests never measure anything unless they explicitly mock it;
|
|
344
|
+
// this generous fallback also keeps every pre-existing small-tree render byte-identical
|
|
345
|
+
// without any test needing to.
|
|
346
|
+
const OVERSCAN = 8;
|
|
347
|
+
const DEFAULT_VIEWPORT_PX = 2400;
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The active document is already named by the viewport tab/panel. Adapters
|
|
351
|
+
* still need their document node as the stable source/persistence boundary,
|
|
352
|
+
* but repeating that boundary as the first hierarchy row makes the authored
|
|
353
|
+
* tree read `Three.js > src/world.tsx > Stage` instead of `Three.js > Stage`.
|
|
354
|
+
*
|
|
355
|
+
* Keep the node addressable through `node()` (selection, inspection and
|
|
356
|
+
* structure routing continue to use its real id/parentage) while promoting
|
|
357
|
+
* its children in the display walk. A bare adapter's sole document root is
|
|
358
|
+
* likewise promoted because the panel header already names it. In a
|
|
359
|
+
* composite, `secondaryLabel` is the adapter-owned source path that
|
|
360
|
+
* distinguishes this redundant boundary from meaningful document-like
|
|
361
|
+
* surface rows such as a scene UI canvas.
|
|
362
|
+
*
|
|
363
|
+
* WHY THE PROMOTION IS MEMOIZED, and what the memo is allowed to hold.
|
|
364
|
+
* Deciding a node's promoted child list READS EVERY CHILD of that node — one
|
|
365
|
+
* `hierarchy.node()` per child, and on the canvas lane each of those is a
|
|
366
|
+
* projection lookup plus a native-node read. The row walk asks this provider
|
|
367
|
+
* for every row it emits AND for every child it descends into, so an
|
|
368
|
+
* unmemoized provider makes one walk cost `O(rows × fan-out)` rather than
|
|
369
|
+
* `O(rows)`. Measured on the scale harness's canvas N=20000 world: the row
|
|
370
|
+
* walk that a click re-runs emitted 1124 rows and its top self frames were
|
|
371
|
+
* `promoteSourceDocumentChildren`/`projectNode` and the `toNode` reads under
|
|
372
|
+
* them.
|
|
373
|
+
*
|
|
374
|
+
* The memo holds the PROMOTION ONLY — the child-id list, keyed by node id —
|
|
375
|
+
* never the node record. Node DATA (label, role, parentage) is re-read from
|
|
376
|
+
* the underlying provider on every call, so a live label or visibility change
|
|
377
|
+
* is never served stale from here. What is frozen is whether each child is a
|
|
378
|
+
* source-document boundary, which is a STRUCTURAL fact: it can only change
|
|
379
|
+
* through a structural edit, and a structural edit advances
|
|
380
|
+
* `contentVersion`, which rebuilds this provider along with the structural
|
|
381
|
+
* snapshot it was built for (see the row pipeline's two memoized halves).
|
|
382
|
+
*/
|
|
383
|
+
function authoredContentHierarchy(hierarchy: HierarchyProvider): HierarchyProvider {
|
|
384
|
+
/** node id → promoted child ids, or `null` for "unchanged, use the node's own". */
|
|
385
|
+
const promoted = new Map<string, string[] | null>();
|
|
386
|
+
|
|
387
|
+
const computePromotion = (node: EditorNode): string[] | null => {
|
|
388
|
+
const childIds = node.childIds.flatMap((childId) => {
|
|
389
|
+
const child = hierarchy.node(childId);
|
|
390
|
+
return child?.role === 'document' && child.secondaryLabel ? child.childIds : [childId];
|
|
391
|
+
});
|
|
392
|
+
return childIds.length === node.childIds.length &&
|
|
393
|
+
childIds.every((childId, index) => childId === node.childIds[index])
|
|
394
|
+
? null
|
|
395
|
+
: childIds;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const projectNode = (node: EditorNode): EditorNode => {
|
|
399
|
+
let childIds = promoted.get(node.id);
|
|
400
|
+
if (childIds === undefined) {
|
|
401
|
+
childIds = computePromotion(node);
|
|
402
|
+
promoted.set(node.id, childIds);
|
|
403
|
+
}
|
|
404
|
+
return childIds === null ? node : { ...node, childIds };
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
...hierarchy,
|
|
409
|
+
roots: () => {
|
|
410
|
+
const roots = hierarchy.roots();
|
|
411
|
+
if (roots.length === 1 && roots[0]?.role === 'document') {
|
|
412
|
+
return roots[0].childIds
|
|
413
|
+
.map((childId) => hierarchy.node(childId))
|
|
414
|
+
.filter((node): node is EditorNode => node !== null)
|
|
415
|
+
.map(projectNode);
|
|
416
|
+
}
|
|
417
|
+
return roots.map(projectNode);
|
|
418
|
+
},
|
|
419
|
+
node: (id) => {
|
|
420
|
+
const node = hierarchy.node(id);
|
|
421
|
+
return node ? projectNode(node) : null;
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
// Only scroll-window ABOVE this row count. Small/medium trees (every real
|
|
426
|
+
// multi-world browse tree — world-group rows + CHILD_CAP-bounded children)
|
|
427
|
+
// render in full, immune to a transient/small `clientHeight` measurement
|
|
428
|
+
// windowing out later world-group rows (which broke 19-multi-world/22-tri-world
|
|
429
|
+
// when virtualization first landed). Windowing only kicks in for pathologically
|
|
430
|
+
// large lists (e.g. an ingested game's uncapped SEARCH result — react-rpg's
|
|
431
|
+
// dungeon is ~1900 rows), which is exactly the case this targets.
|
|
432
|
+
const VIRTUALIZE_MIN_ROWS = 300;
|
|
433
|
+
|
|
434
|
+
/** Shared empty reveal map — a fresh `Map` per render would defeat nothing,
|
|
435
|
+
* but there is no reason to allocate one for the uncapped path. */
|
|
436
|
+
const EMPTY_REVEAL_COUNTS: ReadonlyMap<string, number> = new Map();
|
|
437
|
+
|
|
438
|
+
const ROLE_ICON: Partial<Record<EditorNodeRole, IconDefinition>> = {
|
|
439
|
+
folder: faFolder,
|
|
440
|
+
document: faFileCode,
|
|
441
|
+
story: faCirclePlay,
|
|
442
|
+
component: faPuzzlePiece,
|
|
443
|
+
instance: faObjectGroup,
|
|
444
|
+
boundary: faTriangleExclamation,
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
function iconForNode(node: EditorNode): IconDefinition {
|
|
448
|
+
if (node.role === 'root') return hierarchyKindIcon(node.kind, faLayerGroup);
|
|
449
|
+
if (node.role === 'entity' || node.role === 'element') return hierarchyKindIcon(node.kind);
|
|
450
|
+
return (node.role ? ROLE_ICON[node.role] : undefined) ?? hierarchyKindIcon(node.kind);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const STRUCTURAL_ROLES = new Set<EditorNodeRole>([
|
|
454
|
+
'folder',
|
|
455
|
+
'root',
|
|
456
|
+
'document',
|
|
457
|
+
'story',
|
|
458
|
+
'boundary',
|
|
459
|
+
]);
|
|
460
|
+
|
|
461
|
+
type DropZone = 'before' | 'child' | 'after';
|
|
462
|
+
interface DropTarget {
|
|
463
|
+
nodeId: string;
|
|
464
|
+
zone: DropZone;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Walk UP from `targetId` via `parentId` — true if any ancestor (or itself)
|
|
468
|
+
* is in `dragIds` (prevents dropping a subtree into its own descendant). */
|
|
469
|
+
function isDescendantOfAny(
|
|
470
|
+
targetId: string,
|
|
471
|
+
dragIds: string[],
|
|
472
|
+
adapter: AuthoringAdapter,
|
|
473
|
+
): boolean {
|
|
474
|
+
let current: string | null = targetId;
|
|
475
|
+
let guard = 0;
|
|
476
|
+
while (current && guard++ < 1000) {
|
|
477
|
+
if (dragIds.includes(current)) return true;
|
|
478
|
+
current = adapter.hierarchy.node(current)?.parentId ?? null;
|
|
479
|
+
}
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** The ids of `parentId`'s children, in order — `null` parentId means "the
|
|
484
|
+
* adapter's own roots" (NOT necessarily the document root of a composite —
|
|
485
|
+
* callers pass the group-node id for "this world's roots"). */
|
|
486
|
+
function siblingIdsOf(adapter: AuthoringAdapter, parentId: string | null): string[] {
|
|
487
|
+
if (parentId === null) return adapter.hierarchy.roots().map((n) => n.id);
|
|
488
|
+
return adapter.hierarchy.node(parentId)?.childIds ?? [];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* `CompositeAuthoringAdapter`'s zOrder badge for a `world:<id>` group row —
|
|
493
|
+
* `null` when the active adapter isn't a composite (bare shape) or the id
|
|
494
|
+
* isn't one of its synthetic group nodes.
|
|
495
|
+
*
|
|
496
|
+
* IMPORTANT: "is a group node" is answered by ASKING THE COMPOSITE, never by
|
|
497
|
+
* any per-node marker. Only the composite manufactures the synthetic
|
|
498
|
+
* `world:<id>` group rows this panel treats specially (session-local eye per
|
|
499
|
+
* D9, kind/zOrder badge, non-draggable); an ingested game's own rows are
|
|
500
|
+
* ordinary rows whose eye/lock/rename route through the contract like any
|
|
501
|
+
* other. Keying this off a per-node flag is the hazard: adapters set node
|
|
502
|
+
* flags to mean "not an authored first-party entity", so every ingest/react
|
|
503
|
+
* row reads as a world wrapper and the ingest eye toggle regresses to a
|
|
504
|
+
* session-local shell hide instead of the adapter's
|
|
505
|
+
* `inspector.set(id,'visible',…)`.
|
|
506
|
+
*/
|
|
507
|
+
function compositeGroupBadge(
|
|
508
|
+
adapter: AuthoringAdapter,
|
|
509
|
+
nodeId: string,
|
|
510
|
+
): {
|
|
511
|
+
zOrder: number;
|
|
512
|
+
worldId: string;
|
|
513
|
+
role: 'world' | 'surface';
|
|
514
|
+
kind: string;
|
|
515
|
+
provenance: AuthoringProvenance | null;
|
|
516
|
+
} | null {
|
|
517
|
+
if (!(adapter instanceof CompositeAuthoringAdapter)) return null;
|
|
518
|
+
for (const child of adapter.childAdapters()) {
|
|
519
|
+
if (adapter.groupNodeId(child.worldId) === nodeId) {
|
|
520
|
+
// A4 (D8) — prefer the LIVE manifest zOrder (editable on the root row,
|
|
521
|
+
// `composite.inspector.get(groupId, 'zOrder')`) over the array-index
|
|
522
|
+
// fallback `child.zOrder`, so editing zOrder through the generic
|
|
523
|
+
// inspector is reflected on this badge immediately (the write path
|
|
524
|
+
// calls `store.notifyIngestEdit()`, which re-renders this panel).
|
|
525
|
+
// Falls back to the array index when there's no manifest surface
|
|
526
|
+
// (play-mode composites, or any adapter that doesn't report a
|
|
527
|
+
// `zOrder` inspector property for its own group-node id) — unchanged
|
|
528
|
+
// from before A4.
|
|
529
|
+
const manifestZOrder = adapter.inspector?.get(nodeId, 'zOrder');
|
|
530
|
+
return {
|
|
531
|
+
zOrder: typeof manifestZOrder === 'number' ? manifestZOrder : child.zOrder,
|
|
532
|
+
// B1 — the RAW manifest world id (`CompositeChild.worldId`), so
|
|
533
|
+
// callers can key session-local world state (`world-session-state.ts`
|
|
534
|
+
// — eye/interactive/pick-lock) consistently with `design-time-layers.ts`,
|
|
535
|
+
// which reads the SAME id off `composite.childAdapters()` directly.
|
|
536
|
+
worldId: child.worldId,
|
|
537
|
+
role: child.role,
|
|
538
|
+
kind: child.kind,
|
|
539
|
+
// Spec 29 §5 — the world's own seam-level provenance declaration
|
|
540
|
+
// (null when the child adapter declared none; the row shows no chip).
|
|
541
|
+
provenance: child.adapter.provenance ?? null,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** True only for a composite's synthetic `world:<id>` group row (see
|
|
549
|
+
* {@link compositeGroupBadge}) — never for an ordinary (even runtime-only)
|
|
550
|
+
* ingest/react/canvas node. */
|
|
551
|
+
function isCompositeGroupNode(adapter: AuthoringAdapter, nodeId: string): boolean {
|
|
552
|
+
return compositeGroupBadge(adapter, nodeId) !== null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Editor-only kind/semantic projection rows have no native entity owner. */
|
|
556
|
+
function isCompositeOrganizationNode(adapter: AuthoringAdapter, nodeId: string): boolean {
|
|
557
|
+
return adapter instanceof CompositeAuthoringAdapter && adapter.isOrganizationNode(nodeId);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The composite child adapter that OWNS `nodeId` — the adapter whose
|
|
562
|
+
* PROVIDERS answer for it — or the active adapter itself when bare. Affordance
|
|
563
|
+
* GATES must ask the owner (the composite's own `structure`/`inspector` are
|
|
564
|
+
* never-undefined routers, so probing them says "yes" for a row whose owning
|
|
565
|
+
* child would refuse — the false-claim pattern spec 29 §3 forbids); ACTIONS
|
|
566
|
+
* still go through the ACTIVE adapter, which routes by ownership.
|
|
567
|
+
*/
|
|
568
|
+
function ownerAdapterOf(adapter: AuthoringAdapter, nodeId: string): AuthoringAdapter {
|
|
569
|
+
if (!(adapter instanceof CompositeAuthoringAdapter)) return adapter;
|
|
570
|
+
const worldId = adapter.ownerOf(nodeId);
|
|
571
|
+
return adapter.childAdapters().find((c) => c.worldId === worldId)?.adapter ?? adapter;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Whether the adapter that OWNS this row can reparent at all.
|
|
576
|
+
*
|
|
577
|
+
* Asked of the owner, not the composite: the composite's `structure` is a
|
|
578
|
+
* never-undefined router, so probing it says "yes" for a row whose owning child
|
|
579
|
+
* has no structure provider — the false-claim pattern spec 29 §3 forbids, and
|
|
580
|
+
* exactly the shape of the dead drag this cycle fixes (a three world
|
|
581
|
+
* deliberately ships NO `structure`: "a running world's structure belongs to
|
|
582
|
+
* its source, not to a document this adapter could write").
|
|
583
|
+
*/
|
|
584
|
+
function hasStructureWritePathFor(adapter: AuthoringAdapter, nodeId: string | undefined): boolean {
|
|
585
|
+
if (nodeId === undefined) return adapter.structure !== undefined;
|
|
586
|
+
return ownerAdapterOf(adapter, nodeId).structure !== undefined;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function isStructuralHierarchyNode(
|
|
590
|
+
adapter: AuthoringAdapter,
|
|
591
|
+
nodeId: string,
|
|
592
|
+
role: EditorNodeRole | undefined,
|
|
593
|
+
): boolean {
|
|
594
|
+
return (
|
|
595
|
+
isCompositeGroupNode(adapter, nodeId) ||
|
|
596
|
+
isCompositeOrganizationNode(adapter, nodeId) ||
|
|
597
|
+
(role !== undefined && STRUCTURAL_ROLES.has(role))
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* First-open policy: organizational rows stay open, as does the first real
|
|
603
|
+
* authored node below them. Single-child wrapper chains continue open until
|
|
604
|
+
* the first meaningful branch; branches beneath that point start folded.
|
|
605
|
+
* This is shape-based — never label- or kind-based — so the same rule covers
|
|
606
|
+
* Three.js, React, PixiJS, and arbitrary projections.
|
|
607
|
+
*
|
|
608
|
+
* THE ONE KIND-BASED CLAUSE, and why it is not an exception to that sentence.
|
|
609
|
+
* A row whose children are ALL DATABLOCKS starts CLOSED. A datablock is not a
|
|
610
|
+
* branch of the tree — it is the thing the row already IS, hung off it the way
|
|
611
|
+
* Blender's Outliner hangs a mesh off its object — so opening it by default
|
|
612
|
+
* shows one row twice. Measured on `outliner.png`: at rest every object row in
|
|
613
|
+
* the frame is collapsed behind a `>` and carries its datablock as an inline
|
|
614
|
+
* glyph after the label (`Row`'s summary below is the other half of this). The
|
|
615
|
+
* shape rule alone cannot see it: our object row is at authored depth 1 with a
|
|
616
|
+
* single child, so it matched TWO of the clauses above and opened.
|
|
617
|
+
*
|
|
618
|
+
* Nothing else in the estate can reach this clause: `isDatablockKind` names
|
|
619
|
+
* exactly the kinds `hierarchy-kind-icon.ts` gives the DATA glyph, and no
|
|
620
|
+
* first-party three/React/Pixi/ingest adapter sends one — only `@volter/editor-blender`'s
|
|
621
|
+
* hierarchy decorator does.
|
|
622
|
+
*/
|
|
623
|
+
function defaultExpansionByNode(
|
|
624
|
+
rows: readonly HierarchyNodeRow[],
|
|
625
|
+
adapter: AuthoringAdapter,
|
|
626
|
+
): Map<string, boolean> {
|
|
627
|
+
const result = new Map<string, boolean>();
|
|
628
|
+
const authoredDepthAtTreeDepth: number[] = [];
|
|
629
|
+
const nodeAtTreeDepth: HierarchyNodeRow['node'][] = [];
|
|
630
|
+
const kindById = new Map<string, string>();
|
|
631
|
+
for (const row of rows) if (!row.more) kindById.set(row.node.id, row.node.kind);
|
|
632
|
+
for (const row of rows) {
|
|
633
|
+
if (row.more) continue;
|
|
634
|
+
const parentAuthoredDepth =
|
|
635
|
+
row.depth === 0 ? 0 : (authoredDepthAtTreeDepth[row.depth - 1] ?? 0);
|
|
636
|
+
const parentNode = row.depth === 0 ? null : nodeAtTreeDepth[row.depth - 1];
|
|
637
|
+
const structural = isStructuralHierarchyNode(adapter, row.node.id, row.node.role);
|
|
638
|
+
const authoredDepth = parentAuthoredDepth + (structural ? 0 : 1);
|
|
639
|
+
authoredDepthAtTreeDepth[row.depth] = authoredDepth;
|
|
640
|
+
authoredDepthAtTreeDepth.length = row.depth + 1;
|
|
641
|
+
nodeAtTreeDepth[row.depth] = row.node;
|
|
642
|
+
nodeAtTreeDepth.length = row.depth + 1;
|
|
643
|
+
const onlyDatablockChildren =
|
|
644
|
+
row.node.childIds.length > 0 &&
|
|
645
|
+
row.node.childIds.every((id) => isDatablockKind(kindById.get(id)));
|
|
646
|
+
result.set(
|
|
647
|
+
row.node.id,
|
|
648
|
+
// AN ADAPTER THAT KNOWS ITS OWN DEFAULT WINS. `EditorNode.defaultExpanded`
|
|
649
|
+
// is stated only where the tree carries a real per-element flag rather
|
|
650
|
+
// than a shape a heuristic could read — Blender's Outliner is the case
|
|
651
|
+
// it was added for (see the field's own docblock). Everything else falls
|
|
652
|
+
// through to the shape rule below exactly as before.
|
|
653
|
+
row.node.defaultExpanded ??
|
|
654
|
+
(!onlyDatablockChildren &&
|
|
655
|
+
(structural || authoredDepth <= 1 || parentNode?.childIds.length === 1)),
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
return result;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* H6 — THE ONE PLACE search exclusion is enforced. A revealed internal is in
|
|
663
|
+
* `rows` like any other row (that is the point of injecting internals into the
|
|
664
|
+
* hierarchy provider rather than forking the flattener), so it would otherwise
|
|
665
|
+
* be searchable: one `mixamorig` query across four revealed enemies would bury
|
|
666
|
+
* every real row under 240 bones. `rowAffordances(...).searchable` is the
|
|
667
|
+
* predicate, the same one that refuses the row's writes.
|
|
668
|
+
*
|
|
669
|
+
* Ancestors still get revealed for the matches that DO count, and no non-internal
|
|
670
|
+
* row can hide underneath an internal one: a nested instance is always surfaced
|
|
671
|
+
* as a projected child of its owner FIRST (projected children are listed before
|
|
672
|
+
* appended internals, and the flattener keeps the first occurrence), so a
|
|
673
|
+
* revealed subtree is made only of internal rows.
|
|
674
|
+
*/
|
|
675
|
+
function searchPathKeys(
|
|
676
|
+
rows: readonly HierarchyNodeRow[],
|
|
677
|
+
term: string,
|
|
678
|
+
isInternal: (id: string) => boolean,
|
|
679
|
+
): Set<string> {
|
|
680
|
+
const visible = new Set<string>();
|
|
681
|
+
for (const row of rows) {
|
|
682
|
+
if (
|
|
683
|
+
row.more ||
|
|
684
|
+
!rowAffordances({ structural: false, internal: isInternal(row.node.id) }).searchable
|
|
685
|
+
)
|
|
686
|
+
continue;
|
|
687
|
+
if (!row.node.label.toLowerCase().includes(term)) continue;
|
|
688
|
+
visible.add(row.pathKey);
|
|
689
|
+
for (const ancestor of ancestorPathKeys(row.pathKey)) visible.add(ancestor);
|
|
690
|
+
}
|
|
691
|
+
return visible;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Fold the selection-driven reveal counts (see `selectedRevealCounts`) into the
|
|
695
|
+
* author's own persisted "show more" counts, taking the larger of each. */
|
|
696
|
+
function mergeRevealCounts(
|
|
697
|
+
persisted: ReadonlyMap<string, number>,
|
|
698
|
+
temporary: ReadonlyMap<string, number>,
|
|
699
|
+
): Map<string, number> {
|
|
700
|
+
const merged = new Map(persisted);
|
|
701
|
+
for (const [pathKey, count] of temporary) {
|
|
702
|
+
merged.set(pathKey, Math.max(merged.get(pathKey) ?? 0, count));
|
|
703
|
+
}
|
|
704
|
+
return merged;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The Outliner's search — a PERSISTENT field, on a header row it has to SHARE.
|
|
709
|
+
*
|
|
710
|
+
* WHAT THIS CONTROL IS: a live incremental filter over the tree. The term is
|
|
711
|
+
* this panel's own state (`GameHierarchy`'s `search`) and filters on every
|
|
712
|
+
* keystroke through `searchPathKeys`; nothing about that changed when the
|
|
713
|
+
* field stopped hiding. ESCAPE clears the term; a second Escape on an empty
|
|
714
|
+
* field blurs, handing focus back to the tree. There is no "close" any more,
|
|
715
|
+
* which is the point: a collapsed field cannot be photographed, so it cannot
|
|
716
|
+
* be proved through any door the product has (no sanctioned door clicks
|
|
717
|
+
* editor chrome — `command-listener.ts`'s `collapse-hierarchy-all` note),
|
|
718
|
+
* and a control that cannot be seen is a control that cannot be judged
|
|
719
|
+
* against the frame.
|
|
720
|
+
*
|
|
721
|
+
* MEASURED, native 2x `outliner.png`, CSS = half. Blender's header, x from the
|
|
722
|
+
* area's left edge: editor-type well 8.5..40.5 (32x20), display-mode well
|
|
723
|
+
* 45.5..77.5 (32x20), SEARCH FIELD 93.5..208.5 (115x20), then 43 px of flexible
|
|
724
|
+
* gap, filter chevron well 251.5..271.5 (20x20), New Collection button
|
|
725
|
+
* 276.5..296.5 (20x20), 8 px right pad. Every widget is 20 px tall, top at 2.5.
|
|
726
|
+
* The ORDER is the finding: the field sits with the LEFT cluster, right after
|
|
727
|
+
* the two selectors, and only the chevron and the button are pinned right.
|
|
728
|
+
*
|
|
729
|
+
* THE FIELD'S PAINT, re-verified against the frame this unit: 115x20 border
|
|
730
|
+
* box; fill 28/28/28 = `#1c1c1c` = the palette's `widget.field` = our
|
|
731
|
+
* `--vgai-bg-inset`; one-pixel 60/60/60 = `#3c3c3c` = `boundary.default` =
|
|
732
|
+
* `--vgai-border-1`; corner arc ~3 CSS px; magnifier 14x14 of 229/229/229 ink,
|
|
733
|
+
* its left edge 5.0 CSS px inside the border box; placeholder "Search" (no
|
|
734
|
+
* ellipsis — read off the frame) at 94/94/94 = `#5e5e5e`, its left edge 26.5
|
|
735
|
+
* CSS px inside the border box. `.vgai-input` already paints all of that from
|
|
736
|
+
* the palette, so this control adds GEOMETRY only: the leading glyph and the
|
|
737
|
+
* inset that clears it.
|
|
738
|
+
*
|
|
739
|
+
* ITS WIDTH IS STRIP-AWARE: Blender's 115 where the dock hides the tab strip,
|
|
740
|
+
* 64 where it does not. A field that fits its header is not an inconsistency —
|
|
741
|
+
* Blender's own is 115 because Blender's header spends 32 px on an editor-type
|
|
742
|
+
* WELL where ours spends 203 on a tab STRIP, so the two numbers are the same
|
|
743
|
+
* answer to two different headers. The 64 below is the constant's home and the
|
|
744
|
+
* fallback; the raise is one declaration in `workspace-dock.css`, under the
|
|
745
|
+
* `[data-vgai-tabstrip="hidden"]` attribute the dock already publishes, and it
|
|
746
|
+
* arrives here through {@link SEARCH_FIELD_BASIS_VAR}.
|
|
747
|
+
*
|
|
748
|
+
* WHY 64 IS THE BEHIND-A-STRIP NUMBER, and the arithmetic. MEASURED
|
|
749
|
+
* live on the 308 px Model-workspace Outliner under this look: 5 px of area
|
|
750
|
+
* groove at x 1420..1424, then a tab strip that needs 203 px (three labels of
|
|
751
|
+
* 41/54/36 px of ink inside `--vgai-space-5` padding), then the strip's drag
|
|
752
|
+
* void, then the actions. The four verbs this replaced occupied exactly 90 px
|
|
753
|
+
* of that row (x 1638..1728: four 20 px buttons, three 2 px gaps, 4 px of
|
|
754
|
+
* right padding) with 10 px of void beside them — so 90 is the footprint that
|
|
755
|
+
* is KNOWN to leave the strip whole, and the field takes what is left of it
|
|
756
|
+
* after the chevron's 20, its 2 px gap and the row's 4 px right padding: 64.
|
|
757
|
+
*
|
|
758
|
+
* 74 WAS TRIED AND MEASURED WRONG. It fits arithmetically (the row would be
|
|
759
|
+
* 100 px against 303 of header) but it leaves the tab container at exactly its
|
|
760
|
+
* own content width, and the strip declares overflow at that boundary: it
|
|
761
|
+
* shifted 8 px right, "Library" collapsed to "l" and its own `⌄ 1`
|
|
762
|
+
* overflow dropdown appeared. The 10 px of void is the headroom that keeps
|
|
763
|
+
* that from happening — and it is also the group's drag handle, so spending
|
|
764
|
+
* it costs twice.
|
|
765
|
+
*
|
|
766
|
+
* Our editor-type selector spends 203 px where Blender's spends 32, which is
|
|
767
|
+
* the whole of the remaining distance. The one lever that would buy more is
|
|
768
|
+
* `.dv-tab`'s `--vgai-space-5` padding: at Blender's own ~5.5 px a side it
|
|
769
|
+
* would return ~24 px and put the field at 88. Declined — the rule would
|
|
770
|
+
* either repaint every dock tab in the editor, or (scoped to the Outliner's
|
|
771
|
+
* region) leave two tab strips in one window at two different paddings, for a
|
|
772
|
+
* field still 27 px short of Blender's.
|
|
773
|
+
*
|
|
774
|
+
* AND IT DOES NOT FLEX UP. Tried first and MEASURED as wrong: `flex: 1 1 auto`
|
|
775
|
+
* capped at 115 inside the actions container (`display:flex` with
|
|
776
|
+
* `min-width: auto`) did not shrink to fit — the row overflowed the group's
|
|
777
|
+
* right edge, the chevron went off screen entirely and the tab strip
|
|
778
|
+
* collapsed to "L" behind its own overflow dropdown. So
|
|
779
|
+
* the field carries its OWN basis and only shrinks, down to
|
|
780
|
+
* {@link SEARCH_FIELD_MIN}, for a genuinely narrower column. That is still
|
|
781
|
+
* true — the strip-hidden case raises the BASIS, it does not make the field
|
|
782
|
+
* flexible.
|
|
783
|
+
*/
|
|
784
|
+
function HierarchySearch({ value, onChange }: { value: string; onChange: (next: string) => void }) {
|
|
785
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
786
|
+
return (
|
|
787
|
+
<div
|
|
788
|
+
style={{
|
|
789
|
+
position: 'relative',
|
|
790
|
+
display: 'flex',
|
|
791
|
+
alignItems: 'center',
|
|
792
|
+
// Its own basis, shrinkable but never growing: the actions container
|
|
793
|
+
// is content-sized, so a growing field overflows the group instead of
|
|
794
|
+
// taking width from the tab strip (measured). The basis is a
|
|
795
|
+
// variable so the HOST — the only thing that knows whether a tab strip
|
|
796
|
+
// is beside this row — can raise it to Blender's 115; the constant
|
|
797
|
+
// below is its home and the value every other header gets.
|
|
798
|
+
flex: `0 1 var(${SEARCH_FIELD_BASIS_VAR}, ${SEARCH_FIELD_WIDTH}px)`,
|
|
799
|
+
minWidth: SEARCH_FIELD_MIN,
|
|
800
|
+
}}
|
|
801
|
+
>
|
|
802
|
+
{/* The leading magnifier, 5 px inside the border box per the frame. It is
|
|
803
|
+
decoration over the field, not a button: clicking it must land in the
|
|
804
|
+
input, so it takes no pointer events. */}
|
|
805
|
+
<EditorIcon
|
|
806
|
+
icon={faMagnifyingGlass}
|
|
807
|
+
aria-hidden="true"
|
|
808
|
+
style={{
|
|
809
|
+
position: 'absolute',
|
|
810
|
+
left: SEARCH_GLYPH_INSET,
|
|
811
|
+
pointerEvents: 'none',
|
|
812
|
+
color: 'var(--vgai-text-1)',
|
|
813
|
+
// MEASURED, not chosen: at the chrome's `sm` rung (14 under Blender)
|
|
814
|
+
// this glyph inked 12x12 against the frame's 14x14, because these
|
|
815
|
+
// faces ink at ~0.85 of their box. `lg` is the rung that lands on 14.
|
|
816
|
+
fontSize: 'var(--vgai-icon-lg)',
|
|
817
|
+
}}
|
|
818
|
+
/>
|
|
819
|
+
{/* L-6 — `.vgai-input` supplies a real `:focus-visible` border/outline
|
|
820
|
+
(this box previously did `outline:'none'` with no replacement). */}
|
|
821
|
+
<TextInput
|
|
822
|
+
ref={inputRef}
|
|
823
|
+
type="text"
|
|
824
|
+
data-testid="ingest-search"
|
|
825
|
+
placeholder="Search"
|
|
826
|
+
className="vgai-input"
|
|
827
|
+
value={value}
|
|
828
|
+
onChange={(e) => onChange(e.target.value)}
|
|
829
|
+
onKeyDown={(e) => {
|
|
830
|
+
if (e.key !== 'Escape') return;
|
|
831
|
+
// A persistent field has nothing to close, so Escape means CLEAR —
|
|
832
|
+
// and on an already-empty field it means "give me the tree back",
|
|
833
|
+
// which is a blur. Stopped here either way so the editor's own
|
|
834
|
+
// Escape (deselect) does not also fire from inside the field.
|
|
835
|
+
e.stopPropagation();
|
|
836
|
+
if (value !== '') onChange('');
|
|
837
|
+
else inputRef.current?.blur();
|
|
838
|
+
}}
|
|
839
|
+
style={{
|
|
840
|
+
flex: 1,
|
|
841
|
+
minWidth: 0,
|
|
842
|
+
paddingLeft: `var(${SEARCH_TEXT_INSET_VAR}, ${SEARCH_TEXT_INSET}px)`,
|
|
843
|
+
paddingRight: SEARCH_FIELD_END_GUTTER,
|
|
844
|
+
}}
|
|
845
|
+
/>
|
|
846
|
+
</div>
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** The 90 px footprint the four view buttons used to occupy, less the
|
|
851
|
+
* chevron's 20, its 2 px gap and the row's 4 px right padding. Blender's own
|
|
852
|
+
* field is 115 (`outliner.png`, x 93.5..208.5 at matched scale) in a header
|
|
853
|
+
* whose editor-type selector spends 32 px where our tab strip spends 203.
|
|
854
|
+
* This is the value behind a STRIP; `workspace-dock.css` raises it to
|
|
855
|
+
* Blender's 115 through {@link SEARCH_FIELD_BASIS_VAR} where there is none. */
|
|
856
|
+
const SEARCH_FIELD_WIDTH = 64;
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* The dock's handle on the two numbers above and below — the field's basis and
|
|
860
|
+
* its text inset — because whether a tab strip sits beside this row is a fact
|
|
861
|
+
* only the dock has (`workspace-dock.css`, `[data-vgai-tabstrip="hidden"]`,
|
|
862
|
+
* written by the workspace host's `syncCenterTabHeaderVisibility`). Declared
|
|
863
|
+
* here, where the constants live, so the fallback and the raise are read
|
|
864
|
+
* together; nothing outside `workspace-dock.css` sets either.
|
|
865
|
+
*/
|
|
866
|
+
const SEARCH_FIELD_BASIS_VAR = '--vgai-hierarchy-search-basis';
|
|
867
|
+
|
|
868
|
+
/** Below this a field stops reading as a field — there is no room for the
|
|
869
|
+
* glyph plus a syllable of the term. Our 308 px Outliner lands just above it. */
|
|
870
|
+
const SEARCH_FIELD_MIN = 56;
|
|
871
|
+
|
|
872
|
+
/** The glyph BOX's left offset, tuned so its INK lands where Blender's does —
|
|
873
|
+
* 5.0 CSS px inside the border box (`outliner.png`, bright ink at x 98.5 of a
|
|
874
|
+
* field whose border box starts at 93.5). The box is wider than the ink, so
|
|
875
|
+
* this is 2, not 5. */
|
|
876
|
+
const SEARCH_GLYPH_INSET = 2;
|
|
877
|
+
|
|
878
|
+
/** Where the text starts, same origin — the value for a field behind a TAB
|
|
879
|
+
* STRIP, where 64 px is all there is. Blender's own is 26.5, and at 64 px
|
|
880
|
+
* that leaves 35 px for a word that sets 37 at the 11 px UI face, so "Search"
|
|
881
|
+
* rendered as "Searc". What is preserved there is the glyph and the text
|
|
882
|
+
* INSET ORDER; what is spent is the gap between them (3.5 px here against
|
|
883
|
+
* Blender's 7.5). Where the strip is hidden the field IS Blender's 115 and
|
|
884
|
+
* nothing is short, so `workspace-dock.css` returns the inset to the one that
|
|
885
|
+
* lands our first INK on Blender's 26.5, through {@link SEARCH_TEXT_INSET_VAR}
|
|
886
|
+
* — that number lives THERE, in the declaration that also raises the width,
|
|
887
|
+
* because the two are one decision and a copy here would drift. */
|
|
888
|
+
const SEARCH_TEXT_INSET = 21;
|
|
889
|
+
|
|
890
|
+
/** The dock's handle on the inset — same ownership story as
|
|
891
|
+
* {@link SEARCH_FIELD_BASIS_VAR}, and set in the same declaration. */
|
|
892
|
+
const SEARCH_TEXT_INSET_VAR = '--vgai-hierarchy-search-text-inset';
|
|
893
|
+
|
|
894
|
+
/** The field's own right gutter. `.vgai-input`'s shared 6 px is a third of the
|
|
895
|
+
* word's room in a field this narrow. */
|
|
896
|
+
const SEARCH_FIELD_END_GUTTER = 2;
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* The three view verbs, folded into ONE chevron — Blender's own arrangement.
|
|
900
|
+
*
|
|
901
|
+
* Blender's Outliner header spends this slot on a filter chevron and keeps
|
|
902
|
+
* Collapse/Reset/Expand behind its View menu. Ours now does the same: nothing
|
|
903
|
+
* is removed, the three verbs are one click away, and the 66 px four buttons
|
|
904
|
+
* were spending goes to the search field that had no room before.
|
|
905
|
+
*
|
|
906
|
+
* THE WELL IS THE HEADER'S OWN, measured on the native 2x `outliner.png`: the
|
|
907
|
+
* chevron sits in a 20x20 box at x 251.5..271.5 whose INTERIOR is 39/39/39 —
|
|
908
|
+
* the header band's own fill, `widget.menu` — inside a one-pixel 60/60/60
|
|
909
|
+
* `boundary.default`, with a ~3 px corner. That is a different widget class
|
|
910
|
+
* from the button beside it (New Collection draws the lighter 83/83/83
|
|
911
|
+
* `widget.regular`), and it needs no rule of its own: `theme.css` already
|
|
912
|
+
* paints a `secondary` button that carries `aria-haspopup="menu"` as a menu
|
|
913
|
+
* well, because a dropdown TRIGGER is what the class is for. The mark itself
|
|
914
|
+
* is small — 6.5 x 4.0 CSS px of 216-ink — which is why the glyph takes the
|
|
915
|
+
* icon scale's SMALLEST rung (`--vgai-icon-xs`, 12 under Blender) rather than
|
|
916
|
+
* the 14 px rung the rest of the chrome's glyphs use.
|
|
917
|
+
*/
|
|
918
|
+
function HierarchyViewMenu({
|
|
919
|
+
onCollapseAll,
|
|
920
|
+
onResetExpansion,
|
|
921
|
+
onExpandAll,
|
|
922
|
+
}: {
|
|
923
|
+
onCollapseAll: () => void;
|
|
924
|
+
onResetExpansion: () => void;
|
|
925
|
+
onExpandAll: () => void;
|
|
926
|
+
}) {
|
|
927
|
+
const [open, setOpen] = useState(false);
|
|
928
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
929
|
+
return (
|
|
930
|
+
<div ref={triggerRef} style={{ position: 'relative', flexShrink: 0 }}>
|
|
931
|
+
<IconButton
|
|
932
|
+
title="View"
|
|
933
|
+
aria-label="View"
|
|
934
|
+
aria-haspopup="menu"
|
|
935
|
+
aria-expanded={open}
|
|
936
|
+
data-testid="hierarchy-view-menu"
|
|
937
|
+
onClick={() => setOpen((was) => !was)}
|
|
938
|
+
variant="secondary"
|
|
939
|
+
size="compact"
|
|
940
|
+
>
|
|
941
|
+
<EditorIcon
|
|
942
|
+
icon={faChevronDown}
|
|
943
|
+
aria-hidden="true"
|
|
944
|
+
// Blender's own filter chevron is a SMALL mark — 6.5 x 4.0 CSS px of
|
|
945
|
+
// ink in its 20 px well, measured on `outliner.png` — so this takes
|
|
946
|
+
// the icon scale's smallest rung (12 under Blender) rather than the
|
|
947
|
+
// 14 the rest of the chrome's glyphs use; at the type scale's `xs`
|
|
948
|
+
// it inked 5 x 3.
|
|
949
|
+
style={{ fontSize: 'var(--vgai-icon-xs)' }}
|
|
950
|
+
/>
|
|
951
|
+
</IconButton>
|
|
952
|
+
{open && (
|
|
953
|
+
<Menu
|
|
954
|
+
autoFocusFirst
|
|
955
|
+
dismissBoundaryRef={triggerRef}
|
|
956
|
+
onDismiss={() => setOpen(false)}
|
|
957
|
+
style={{
|
|
958
|
+
position: 'absolute',
|
|
959
|
+
top: '100%',
|
|
960
|
+
right: 0,
|
|
961
|
+
marginTop: space[2],
|
|
962
|
+
minWidth: 170,
|
|
963
|
+
}}
|
|
964
|
+
>
|
|
965
|
+
<MenuItem
|
|
966
|
+
data-testid="hierarchy-collapse-all"
|
|
967
|
+
onSelect={() => {
|
|
968
|
+
onCollapseAll();
|
|
969
|
+
setOpen(false);
|
|
970
|
+
}}
|
|
971
|
+
>
|
|
972
|
+
Collapse All
|
|
973
|
+
</MenuItem>
|
|
974
|
+
<MenuItem
|
|
975
|
+
data-testid="hierarchy-reset-expansion"
|
|
976
|
+
onSelect={() => {
|
|
977
|
+
onResetExpansion();
|
|
978
|
+
setOpen(false);
|
|
979
|
+
}}
|
|
980
|
+
>
|
|
981
|
+
Reset Openness
|
|
982
|
+
</MenuItem>
|
|
983
|
+
<MenuItem
|
|
984
|
+
data-testid="hierarchy-expand-all"
|
|
985
|
+
onSelect={() => {
|
|
986
|
+
onExpandAll();
|
|
987
|
+
setOpen(false);
|
|
988
|
+
}}
|
|
989
|
+
>
|
|
990
|
+
Expand All
|
|
991
|
+
</MenuItem>
|
|
992
|
+
</Menu>
|
|
993
|
+
)}
|
|
994
|
+
</div>
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* The Outliner's controls — the search field, the View menu, Create.
|
|
1000
|
+
*
|
|
1001
|
+
* They render into the dock group's header-actions slot when one is published
|
|
1002
|
+
* (`hierarchy-header-slot.ts`), which is what makes the tab strip the panel's
|
|
1003
|
+
* ONE header row. With no slot — the design-system stories, any bounded host
|
|
1004
|
+
* that renders this panel without a dock — they fall back to the panel's own
|
|
1005
|
+
* toolbar row, which is why this is a portal and not a second copy of the
|
|
1006
|
+
* controls living in the dock.
|
|
1007
|
+
*
|
|
1008
|
+
* THE INVENTORY IS BLENDER'S, as of 2026-09-18: a persistent search field, one
|
|
1009
|
+
* chevron well opening the View popover that holds Collapse All / Reset
|
|
1010
|
+
* Openness / Expand All, and Create. Four icon buttons in a row read louder
|
|
1011
|
+
* than Blender's one and spent 86 px of a 308 px header on verbs Blender keeps
|
|
1012
|
+
* in a menu; the fold returns 66 of those to the field, which is the only
|
|
1013
|
+
* thing that lets the field be on screen at all.
|
|
1014
|
+
*
|
|
1015
|
+
* WHAT DID NOT CHANGE, and must not be "fixed": Create keeps the lighter
|
|
1016
|
+
* 83/83/83 `widget.regular` fill inside its one-pixel 60/60/60 outline,
|
|
1017
|
+
* because that is exactly what Blender's own header BUTTON draws (New
|
|
1018
|
+
* Collection, `outliner.png` x 553..592, y 5..44: border 60, fill 83). The
|
|
1019
|
+
* chevron beside it is a different widget class and draws the darker
|
|
1020
|
+
* `widget.menu` well — see `HierarchyViewMenu`.
|
|
1021
|
+
*
|
|
1022
|
+
* WHERE THIS ROW LIVES, and who owns its INSETS. It is portalled into the dock
|
|
1023
|
+
* group's header, so the row's leading/trailing padding depends on something
|
|
1024
|
+
* only the dock knows — whether the tab strip is beside it. Under a look whose
|
|
1025
|
+
* `regions.tabs` is `hidden` (Blender) a lone panel loses the strip and this
|
|
1026
|
+
* row takes the whole band, in Blender's own two groups: the search field left
|
|
1027
|
+
* at 8 px, the chevron and Create pushed to the trailing edge. Behind a strip
|
|
1028
|
+
* it is content-sized with a 4 px trailing gutter. Both live in
|
|
1029
|
+
* `workspace-dock.css` beside `.vgai-dock-header-actions`; nothing here sets a
|
|
1030
|
+
* padding.
|
|
1031
|
+
*
|
|
1032
|
+
* (Until 2026-09-18 that same rule hid the whole header, band and controls
|
|
1033
|
+
* together, so the Blender Game workspace's Outliner had NO header at all.
|
|
1034
|
+
* Fixed in `syncCenterTabHeaderVisibility`, which now takes the strip and
|
|
1035
|
+
* leaves the band whenever the lone panel owns controls in it.)
|
|
1036
|
+
*
|
|
1037
|
+
* WHAT THIS COSTS IN PROOF: the popover's own opening is not drivable — no
|
|
1038
|
+
* sanctioned door clicks editor chrome — so Collapse All and Expand All are
|
|
1039
|
+
* verified through their existing commands (`collapse-hierarchy-all`,
|
|
1040
|
+
* `expand-hierarchy-all`, which call the panel's own actions and never the
|
|
1041
|
+
* buttons), and Reset Openness is verified by reading the same handler both
|
|
1042
|
+
* paths share. Reset had no command before this move and still has none; the
|
|
1043
|
+
* fold made it one click further away, not less proved.
|
|
1044
|
+
*/
|
|
1045
|
+
function HierarchyControls({
|
|
1046
|
+
slot,
|
|
1047
|
+
search,
|
|
1048
|
+
setSearch,
|
|
1049
|
+
adapter,
|
|
1050
|
+
createTargetId,
|
|
1051
|
+
onCollapseAll,
|
|
1052
|
+
onResetExpansion,
|
|
1053
|
+
onExpandAll,
|
|
1054
|
+
}: {
|
|
1055
|
+
slot: HTMLElement | null;
|
|
1056
|
+
search: string;
|
|
1057
|
+
setSearch: (next: string) => void;
|
|
1058
|
+
adapter: AuthoringAdapter;
|
|
1059
|
+
createTargetId: string | null;
|
|
1060
|
+
onCollapseAll: () => void;
|
|
1061
|
+
onResetExpansion: () => void;
|
|
1062
|
+
onExpandAll: () => void;
|
|
1063
|
+
}) {
|
|
1064
|
+
const controls = (
|
|
1065
|
+
<>
|
|
1066
|
+
<HierarchySearch value={search} onChange={setSearch} />
|
|
1067
|
+
<HierarchyViewMenu
|
|
1068
|
+
onCollapseAll={onCollapseAll}
|
|
1069
|
+
onResetExpansion={onResetExpansion}
|
|
1070
|
+
onExpandAll={onExpandAll}
|
|
1071
|
+
/>
|
|
1072
|
+
<CreateMenu adapter={adapter} parentId={createTargetId} />
|
|
1073
|
+
</>
|
|
1074
|
+
);
|
|
1075
|
+
if (slot) {
|
|
1076
|
+
return createPortal(
|
|
1077
|
+
<div
|
|
1078
|
+
data-testid="hierarchy-header-controls"
|
|
1079
|
+
style={{
|
|
1080
|
+
display: 'flex',
|
|
1081
|
+
alignItems: 'center',
|
|
1082
|
+
gap: spaceVar[1],
|
|
1083
|
+
height: '100%',
|
|
1084
|
+
minWidth: 0,
|
|
1085
|
+
}}
|
|
1086
|
+
>
|
|
1087
|
+
{controls}
|
|
1088
|
+
</div>,
|
|
1089
|
+
slot,
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
return (
|
|
1093
|
+
<EditorToolbar label="Hierarchy controls" compact style={{ gap: spaceVar[2] }}>
|
|
1094
|
+
{controls}
|
|
1095
|
+
</EditorToolbar>
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// --- Context menu ---
|
|
1100
|
+
|
|
1101
|
+
interface ContextMenuState {
|
|
1102
|
+
x: number;
|
|
1103
|
+
y: number;
|
|
1104
|
+
nodeId: string;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function ContextMenu({
|
|
1108
|
+
state,
|
|
1109
|
+
adapter,
|
|
1110
|
+
store,
|
|
1111
|
+
isGroupNode,
|
|
1112
|
+
isInternal,
|
|
1113
|
+
onRename,
|
|
1114
|
+
onClose,
|
|
1115
|
+
}: {
|
|
1116
|
+
state: ContextMenuState;
|
|
1117
|
+
adapter: AuthoringAdapter;
|
|
1118
|
+
store: EditorShellStore;
|
|
1119
|
+
isGroupNode: boolean;
|
|
1120
|
+
/** H6 — a revealed internal row: every WRITE item is withheld (not disabled),
|
|
1121
|
+
* and the adapter's own refusal sentence is shown in their place. */
|
|
1122
|
+
isInternal: boolean;
|
|
1123
|
+
onRename: (id: string) => void;
|
|
1124
|
+
onClose: () => void;
|
|
1125
|
+
}) {
|
|
1126
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
1127
|
+
// KEEP THE WHOLE MENU ON SCREEN. It is `position: fixed` at the pointer, so
|
|
1128
|
+
// a right-click on a row near the bottom of the panel ran the menu off the
|
|
1129
|
+
// viewport and took its LAST items with it — Delete is last, and a tester
|
|
1130
|
+
// reported exactly that ("the delete gets hidden over here on the last
|
|
1131
|
+
// line", runhuman pass 105). The shared clamp already solves this; this
|
|
1132
|
+
// menu just did not use it.
|
|
1133
|
+
const [menuPosition, setMenuPosition] = useState<{ left: number; top: number }>({
|
|
1134
|
+
left: state.x,
|
|
1135
|
+
top: state.y,
|
|
1136
|
+
});
|
|
1137
|
+
useLayoutEffect(() => {
|
|
1138
|
+
const el = menuRef.current;
|
|
1139
|
+
if (!el) return;
|
|
1140
|
+
const rect = el.getBoundingClientRect();
|
|
1141
|
+
setMenuPosition(
|
|
1142
|
+
clampRectToViewport({
|
|
1143
|
+
top: state.y,
|
|
1144
|
+
left: state.x,
|
|
1145
|
+
width: rect.width,
|
|
1146
|
+
height: rect.height,
|
|
1147
|
+
}),
|
|
1148
|
+
);
|
|
1149
|
+
}, [state.x, state.y]);
|
|
1150
|
+
const [createOpen, setCreateOpen] = useState(false);
|
|
1151
|
+
// Same at-most-once-per-open guard as CreateMenu: `onClose()` on select is
|
|
1152
|
+
// async, so a double-dispatched click/Enter on a create item could otherwise
|
|
1153
|
+
// double-invoke `create` before the menu unmounts. Reset on each open.
|
|
1154
|
+
const createGuardRef = useRef(false);
|
|
1155
|
+
|
|
1156
|
+
useEffect(() => {
|
|
1157
|
+
if (createOpen) createGuardRef.current = false;
|
|
1158
|
+
}, [createOpen]);
|
|
1159
|
+
|
|
1160
|
+
useEffect(() => {
|
|
1161
|
+
const handler = (e: MouseEvent) => {
|
|
1162
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
|
|
1163
|
+
};
|
|
1164
|
+
const keyHandler = (e: KeyboardEvent) => {
|
|
1165
|
+
if (e.key === 'Escape') onClose();
|
|
1166
|
+
};
|
|
1167
|
+
document.addEventListener('mousedown', handler);
|
|
1168
|
+
document.addEventListener('keydown', keyHandler);
|
|
1169
|
+
return () => {
|
|
1170
|
+
document.removeEventListener('mousedown', handler);
|
|
1171
|
+
document.removeEventListener('keydown', keyHandler);
|
|
1172
|
+
};
|
|
1173
|
+
}, [onClose]);
|
|
1174
|
+
|
|
1175
|
+
const node = adapter.hierarchy.node(state.nodeId);
|
|
1176
|
+
if (!node) return null;
|
|
1177
|
+
|
|
1178
|
+
const creatableKinds = adapter.structure?.creatableKinds?.(state.nodeId) ?? [];
|
|
1179
|
+
// Gates ask the OWNING child (see ownerAdapterOf) — the composite's own
|
|
1180
|
+
// structure/inspector are never-undefined routers, so probing THEM would
|
|
1181
|
+
// advertise operations the owner refuses (live-caught on a Boundary row:
|
|
1182
|
+
// Duplicate/Hide/Delete rendered enabled and no-op'd). `visible` is a
|
|
1183
|
+
// VALUE probe like `name`/`locked`: every live adapter answers it, a
|
|
1184
|
+
// Boundary answers undefined.
|
|
1185
|
+
const owner = ownerAdapterOf(adapter, state.nodeId);
|
|
1186
|
+
const ownerStructure = owner.structure;
|
|
1187
|
+
const hasStructure = !!ownerStructure;
|
|
1188
|
+
const nameKnown = adapter.inspector?.get(state.nodeId, 'name') !== undefined;
|
|
1189
|
+
const lockedKnown = adapter.inspector?.get(state.nodeId, 'locked') !== undefined;
|
|
1190
|
+
const locked = adapter.inspector?.get(state.nodeId, 'locked') === true;
|
|
1191
|
+
const visibleKnown = adapter.inspector?.get(state.nodeId, 'visible') !== undefined;
|
|
1192
|
+
const visible = adapter.inspector?.get(state.nodeId, 'visible') !== false;
|
|
1193
|
+
const lockedEditability = adapter.inspector?.editability?.(state.nodeId, 'locked');
|
|
1194
|
+
const visibleEditability = adapter.inspector?.editability?.(state.nodeId, 'visible');
|
|
1195
|
+
const lockedWritable = lockedKnown && (lockedEditability?.writable ?? true);
|
|
1196
|
+
const visibleWritable = visibleKnown && (visibleEditability?.writable ?? true);
|
|
1197
|
+
// The FOCUSED stage's selection (ARCHITECTURE-CORE §One stage unit 4): a
|
|
1198
|
+
// context menu opened over a model document's row must copy that stage's
|
|
1199
|
+
// selection, not the world's.
|
|
1200
|
+
const selectedIds = [...focusedStageStore(store).selectedEntityIds];
|
|
1201
|
+
const clipboardIds = selectedIds.includes(state.nodeId) ? selectedIds : [state.nodeId];
|
|
1202
|
+
|
|
1203
|
+
// Spec 29 §5 disabled-with-reason: when the SUBJECT of an operation is
|
|
1204
|
+
// present (the row exists) but the adapter can't perform it, the item
|
|
1205
|
+
// renders DISABLED with the world's own provenance explanation as its
|
|
1206
|
+
// tooltip — the complement of spec 28's rule (chrome whose subject is
|
|
1207
|
+
// absent is hidden, not disabled).
|
|
1208
|
+
const reason = unavailableReason(provenanceForNode(adapter, state.nodeId));
|
|
1209
|
+
const copyDisabledReason = ownerStructure?.copy
|
|
1210
|
+
? 'Copy needs source-addressable entities from one world.'
|
|
1211
|
+
: reason;
|
|
1212
|
+
const pasteDisabledReason = ownerStructure?.paste
|
|
1213
|
+
? 'Copy or cut a source-addressable entity in this world first.'
|
|
1214
|
+
: reason;
|
|
1215
|
+
const items: {
|
|
1216
|
+
label: string;
|
|
1217
|
+
testId?: string;
|
|
1218
|
+
action: () => void;
|
|
1219
|
+
color?: string;
|
|
1220
|
+
disabledReason?: string;
|
|
1221
|
+
}[] = [];
|
|
1222
|
+
|
|
1223
|
+
const affordances = rowAffordances({ structural: isGroupNode, internal: isInternal });
|
|
1224
|
+
// H6 — the only verb a revealed internal keeps is one that writes nothing:
|
|
1225
|
+
// walking UP to the thing that actually is authored. Everything else in the
|
|
1226
|
+
// block below is a write.
|
|
1227
|
+
if (isInternal && node.parentId !== null) {
|
|
1228
|
+
items.push({
|
|
1229
|
+
label: 'Select Parent',
|
|
1230
|
+
testId: 'hierarchy-context-menu-select-parent',
|
|
1231
|
+
action: () => {
|
|
1232
|
+
setAuthoringSelection(adapter, [node.parentId!]);
|
|
1233
|
+
onClose();
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
if (!isGroupNode && affordances.writable) {
|
|
1239
|
+
items.push({
|
|
1240
|
+
label: 'Rename',
|
|
1241
|
+
testId: 'hierarchy-context-menu-rename',
|
|
1242
|
+
...(nameKnown ? {} : { disabledReason: reason }),
|
|
1243
|
+
action: () => {
|
|
1244
|
+
onRename(state.nodeId);
|
|
1245
|
+
onClose();
|
|
1246
|
+
},
|
|
1247
|
+
});
|
|
1248
|
+
items.push({
|
|
1249
|
+
label: 'Cut',
|
|
1250
|
+
testId: 'hierarchy-context-menu-cut',
|
|
1251
|
+
...(ownerStructure?.cut && (adapter.structure?.canCopy?.(clipboardIds) ?? true)
|
|
1252
|
+
? {}
|
|
1253
|
+
: { disabledReason: copyDisabledReason }),
|
|
1254
|
+
action: () => {
|
|
1255
|
+
void cutAuthoringNodes(adapter, clipboardIds);
|
|
1256
|
+
onClose();
|
|
1257
|
+
},
|
|
1258
|
+
});
|
|
1259
|
+
items.push({
|
|
1260
|
+
label: 'Copy',
|
|
1261
|
+
testId: 'hierarchy-context-menu-copy',
|
|
1262
|
+
...(ownerStructure?.copy && (adapter.structure?.canCopy?.(clipboardIds) ?? true)
|
|
1263
|
+
? {}
|
|
1264
|
+
: { disabledReason: copyDisabledReason }),
|
|
1265
|
+
action: () => {
|
|
1266
|
+
void copyAuthoringNodes(adapter, clipboardIds);
|
|
1267
|
+
onClose();
|
|
1268
|
+
},
|
|
1269
|
+
});
|
|
1270
|
+
items.push({
|
|
1271
|
+
label: 'Paste',
|
|
1272
|
+
testId: 'hierarchy-context-menu-paste',
|
|
1273
|
+
...(ownerStructure?.paste && (adapter.structure?.canPaste?.(node.parentId) ?? true)
|
|
1274
|
+
? {}
|
|
1275
|
+
: { disabledReason: pasteDisabledReason }),
|
|
1276
|
+
action: () => {
|
|
1277
|
+
void pasteAuthoringNodes(adapter, node.parentId);
|
|
1278
|
+
onClose();
|
|
1279
|
+
},
|
|
1280
|
+
});
|
|
1281
|
+
items.push({
|
|
1282
|
+
label: 'Duplicate',
|
|
1283
|
+
testId: 'hierarchy-context-menu-duplicate',
|
|
1284
|
+
...(hasStructure ? {} : { disabledReason: reason }),
|
|
1285
|
+
action: () => {
|
|
1286
|
+
// ON THE WHOLE SELECTION when the clicked row is part of one — the
|
|
1287
|
+
// menu acts on what is selected, exactly as the hotkey does. Acting on
|
|
1288
|
+
// the clicked row alone silently dropped the other members (runhuman
|
|
1289
|
+
// pass 68: "select all this, click Delete — just one gets deleted").
|
|
1290
|
+
if (selectedIds.length > 1 && selectedIds.includes(state.nodeId)) {
|
|
1291
|
+
void duplicateSelection(store);
|
|
1292
|
+
} else {
|
|
1293
|
+
void duplicateAuthoringNode(adapter, state.nodeId).ack;
|
|
1294
|
+
}
|
|
1295
|
+
onClose();
|
|
1296
|
+
},
|
|
1297
|
+
});
|
|
1298
|
+
if (ownerStructure?.group && selectedIds.length > 1 && selectedIds.includes(state.nodeId)) {
|
|
1299
|
+
items.push({
|
|
1300
|
+
label: 'Group Selection',
|
|
1301
|
+
testId: 'hierarchy-context-menu-group',
|
|
1302
|
+
action: () => {
|
|
1303
|
+
void groupAuthoringNodes(adapter, selectedIds).ack;
|
|
1304
|
+
onClose();
|
|
1305
|
+
},
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
if (ownerStructure?.ungroup && ownerStructure.canUngroup?.(state.nodeId)) {
|
|
1309
|
+
items.push({
|
|
1310
|
+
label: 'Ungroup',
|
|
1311
|
+
testId: 'hierarchy-context-menu-ungroup',
|
|
1312
|
+
action: () => {
|
|
1313
|
+
void ungroupAuthoringNode(adapter, state.nodeId).ack;
|
|
1314
|
+
onClose();
|
|
1315
|
+
},
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
if (ownerStructure?.wrap) {
|
|
1319
|
+
items.push({
|
|
1320
|
+
label: 'Wrap',
|
|
1321
|
+
testId: 'hierarchy-context-menu-wrap',
|
|
1322
|
+
action: () => {
|
|
1323
|
+
void wrapAuthoringNode(adapter, state.nodeId);
|
|
1324
|
+
onClose();
|
|
1325
|
+
},
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
if (ownerStructure?.unwrap) {
|
|
1329
|
+
items.push({
|
|
1330
|
+
label: 'Unwrap',
|
|
1331
|
+
testId: 'hierarchy-context-menu-unwrap',
|
|
1332
|
+
action: () => {
|
|
1333
|
+
void unwrapAuthoringNode(adapter, state.nodeId);
|
|
1334
|
+
onClose();
|
|
1335
|
+
},
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
if (node.parentId !== null) {
|
|
1339
|
+
items.push({
|
|
1340
|
+
label: 'Select Parent',
|
|
1341
|
+
testId: 'hierarchy-context-menu-select-parent',
|
|
1342
|
+
action: () => {
|
|
1343
|
+
setAuthoringSelection(adapter, [node.parentId!]);
|
|
1344
|
+
onClose();
|
|
1345
|
+
},
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
items.push({
|
|
1349
|
+
label: visibleKnown && !visible ? 'Show' : 'Hide',
|
|
1350
|
+
...(visibleWritable ? {} : { disabledReason: visibleEditability?.reason ?? reason }),
|
|
1351
|
+
action: () => {
|
|
1352
|
+
adapter.inspector!.set(state.nodeId, 'visible', !visible);
|
|
1353
|
+
store.notifyIngestEdit();
|
|
1354
|
+
onClose();
|
|
1355
|
+
},
|
|
1356
|
+
});
|
|
1357
|
+
items.push({
|
|
1358
|
+
label: locked ? 'Unlock' : 'Lock',
|
|
1359
|
+
...(lockedWritable ? {} : { disabledReason: lockedEditability?.reason ?? reason }),
|
|
1360
|
+
action: () => {
|
|
1361
|
+
adapter.inspector!.set(state.nodeId, 'locked', !locked);
|
|
1362
|
+
store.notifyIngestEdit();
|
|
1363
|
+
onClose();
|
|
1364
|
+
},
|
|
1365
|
+
});
|
|
1366
|
+
// First-party escape-hatch extras (formerly prefab unpack/open/save-as,
|
|
1367
|
+
// currently unregistered) — permanent adapter-module contribution seam
|
|
1368
|
+
// (see hierarchy-menu-registry.ts).
|
|
1369
|
+
for (const extra of getHierarchyMenuItems({ nodeId: state.nodeId, store })) {
|
|
1370
|
+
items.push({
|
|
1371
|
+
label: extra.label,
|
|
1372
|
+
...(extra.color !== undefined ? { color: extra.color } : {}),
|
|
1373
|
+
action: () => {
|
|
1374
|
+
extra.action();
|
|
1375
|
+
onClose();
|
|
1376
|
+
},
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
items.push({
|
|
1380
|
+
label: 'Delete',
|
|
1381
|
+
testId: 'hierarchy-context-menu-delete',
|
|
1382
|
+
color: themeVars.semantic.danger,
|
|
1383
|
+
...(hasStructure ? {} : { disabledReason: reason }),
|
|
1384
|
+
action: () => {
|
|
1385
|
+
// Same selection rule as Duplicate above — and the selection path also
|
|
1386
|
+
// carries the ordering/serialization a multi-delete needs (see
|
|
1387
|
+
// `deleteSelection`'s source-corruption note).
|
|
1388
|
+
if (selectedIds.length > 1 && selectedIds.includes(state.nodeId)) {
|
|
1389
|
+
void deleteSelection(store);
|
|
1390
|
+
} else {
|
|
1391
|
+
adapter.structure!.remove(state.nodeId);
|
|
1392
|
+
}
|
|
1393
|
+
onClose();
|
|
1394
|
+
},
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
return (
|
|
1399
|
+
// Portaled to the theme root (W6): `position: fixed` inside a
|
|
1400
|
+
// backdrop-filtered floating card would resolve against the card and be
|
|
1401
|
+
// clipped by its overflow — see `ThemeRootPortal`'s rationale.
|
|
1402
|
+
<ThemeRootPortal>
|
|
1403
|
+
<Menu
|
|
1404
|
+
ref={menuRef}
|
|
1405
|
+
data-testid="ingest-context-menu"
|
|
1406
|
+
style={{
|
|
1407
|
+
// U6a (P6 glass-native chrome): chrome paint (fill/border/radius/
|
|
1408
|
+
// shadow/frost/ink) comes from the shared `.vgai-menu` overlay
|
|
1409
|
+
// material — the same Regular-glass vocabulary every other menu
|
|
1410
|
+
// consumes — so only geometry stays inline.
|
|
1411
|
+
position: 'fixed',
|
|
1412
|
+
left: menuPosition.left,
|
|
1413
|
+
top: menuPosition.top,
|
|
1414
|
+
minWidth: 150,
|
|
1415
|
+
maxWidth: 260,
|
|
1416
|
+
fontSize: 'var(--vgai-font-base)',
|
|
1417
|
+
}}
|
|
1418
|
+
>
|
|
1419
|
+
{/* A4 (D8): also suppressed for a root-group MEMBER row, same as an
|
|
1420
|
+
existing composite group row — `isGroupNode` covers both (see the
|
|
1421
|
+
caller's computation below). The Create submenu on a member id would silently
|
|
1422
|
+
land at the scene root instead of doing anything meaningful. */}
|
|
1423
|
+
{!isGroupNode && affordances.writable && creatableKinds.length > 0 && (
|
|
1424
|
+
<div>
|
|
1425
|
+
<MenuItem
|
|
1426
|
+
data-testid="hierarchy-context-menu-create"
|
|
1427
|
+
onSelect={() => setCreateOpen((v) => !v)}
|
|
1428
|
+
style={{
|
|
1429
|
+
justifyContent: 'space-between',
|
|
1430
|
+
gap: space[4],
|
|
1431
|
+
}}
|
|
1432
|
+
>
|
|
1433
|
+
<span>Create</span>
|
|
1434
|
+
<DisclosureIcon direction="right" tone="dim" />
|
|
1435
|
+
</MenuItem>
|
|
1436
|
+
{createOpen &&
|
|
1437
|
+
creatableKinds.map((k) => (
|
|
1438
|
+
<MenuItem
|
|
1439
|
+
key={k.kind}
|
|
1440
|
+
data-testid={`hierarchy-context-menu-create-${k.kind}`}
|
|
1441
|
+
onSelect={() => {
|
|
1442
|
+
if (createGuardRef.current) return;
|
|
1443
|
+
createGuardRef.current = true;
|
|
1444
|
+
void createAuthoringNode(adapter, k.kind, state.nodeId).ack;
|
|
1445
|
+
onClose();
|
|
1446
|
+
}}
|
|
1447
|
+
style={{
|
|
1448
|
+
paddingLeft: spaceVar[12],
|
|
1449
|
+
}}
|
|
1450
|
+
>
|
|
1451
|
+
{k.label}
|
|
1452
|
+
</MenuItem>
|
|
1453
|
+
))}
|
|
1454
|
+
</div>
|
|
1455
|
+
)}
|
|
1456
|
+
{items.map((item) =>
|
|
1457
|
+
item.disabledReason !== undefined ? (
|
|
1458
|
+
// Same shape as before the Menu-primitive rebuild (U6a): an
|
|
1459
|
+
// aria-disabled row (NOT a :disabled button — the reason tooltip
|
|
1460
|
+
// must keep showing, spec 29 §5) whose click is a no-op.
|
|
1461
|
+
<MenuItem
|
|
1462
|
+
key={item.label}
|
|
1463
|
+
data-testid={item.testId}
|
|
1464
|
+
aria-disabled="true"
|
|
1465
|
+
title={item.disabledReason}
|
|
1466
|
+
style={{
|
|
1467
|
+
// L-21 — disabled rows get a dedicated muted color (not just
|
|
1468
|
+
// opacity-faded active text) plus a not-allowed cursor.
|
|
1469
|
+
cursor: 'not-allowed',
|
|
1470
|
+
color: themeVars.content.dim,
|
|
1471
|
+
opacity: 0.7,
|
|
1472
|
+
}}
|
|
1473
|
+
>
|
|
1474
|
+
{item.label}
|
|
1475
|
+
</MenuItem>
|
|
1476
|
+
) : (
|
|
1477
|
+
<MenuItem
|
|
1478
|
+
key={item.label}
|
|
1479
|
+
data-testid={item.testId}
|
|
1480
|
+
onSelect={item.action}
|
|
1481
|
+
style={item.color !== undefined ? { color: item.color } : undefined}
|
|
1482
|
+
>
|
|
1483
|
+
{item.label}
|
|
1484
|
+
</MenuItem>
|
|
1485
|
+
),
|
|
1486
|
+
)}
|
|
1487
|
+
{/* H6 — refusal is VISIBLE and NAMED (doctrine rule 2). The sentence is
|
|
1488
|
+
the ADAPTER's own `editability` reason, the identical string the row's
|
|
1489
|
+
lock glyph, the inspector field and the gizmo-denial hint show; this
|
|
1490
|
+
menu never writes one of its own. */}
|
|
1491
|
+
{isInternal && (
|
|
1492
|
+
<div
|
|
1493
|
+
data-testid="hierarchy-context-menu-internal-note"
|
|
1494
|
+
style={{
|
|
1495
|
+
padding: `${space[4]}px ${space[6]}px`,
|
|
1496
|
+
color: themeVars.content.dim,
|
|
1497
|
+
maxWidth: 240,
|
|
1498
|
+
lineHeight: lineHeightVar.normal,
|
|
1499
|
+
}}
|
|
1500
|
+
>
|
|
1501
|
+
{adapter.transforms?.editability?.(state.nodeId, 'position')?.reason ??
|
|
1502
|
+
'This rendered part has no authored source identity.'}
|
|
1503
|
+
</div>
|
|
1504
|
+
)}
|
|
1505
|
+
{/* Bounded-authoring honest degrade (landmine f): the ACTIVE adapter has
|
|
1506
|
+
no `structure` provider at all (an ingested/foreign game owns its
|
|
1507
|
+
own scene graph) — Rename/Show-Hide/Lock above still work (they're
|
|
1508
|
+
gated independently, on `inspector`), but Create/Duplicate/Delete/
|
|
1509
|
+
Reparent are not, so say so plainly instead of hiding the menu. */}
|
|
1510
|
+
{!hasStructure && !isInternal && (
|
|
1511
|
+
<div
|
|
1512
|
+
style={{
|
|
1513
|
+
padding: `${space[4]}px ${space[6]}px`,
|
|
1514
|
+
color: themeVars.semantic.warning,
|
|
1515
|
+
maxWidth: 240,
|
|
1516
|
+
lineHeight: lineHeightVar.normal,
|
|
1517
|
+
}}
|
|
1518
|
+
>
|
|
1519
|
+
<strong>Create / Delete / Reparent — not available.</strong> This game owns its own
|
|
1520
|
+
scene graph, so structural authoring isn't reapplicable. You can edit transform /
|
|
1521
|
+
material / visibility, or capture a subtree into the first-party editable format.
|
|
1522
|
+
</div>
|
|
1523
|
+
)}
|
|
1524
|
+
</Menu>
|
|
1525
|
+
</ThemeRootPortal>
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// --- Add / Create menu (shared by the toolbar "+" and reused create list) ---
|
|
1530
|
+
|
|
1531
|
+
function CreateMenu({ adapter, parentId }: { adapter: AuthoringAdapter; parentId: string | null }) {
|
|
1532
|
+
const [open, setOpen] = useState(false);
|
|
1533
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
1534
|
+
// At-most-once-per-open guard. `setOpen(false)` on select is async — the menu
|
|
1535
|
+
// item stays mounted until the next render, so under CI input jank a single
|
|
1536
|
+
// menu-open can receive two `click` dispatches on the same item before it
|
|
1537
|
+
// unmounts, double-invoking `create` (the "expected 3, saw 8" overshoot in
|
|
1538
|
+
// 29-game-hierarchy). React state can't gate this because both clicks land in
|
|
1539
|
+
// the same commit; a ref reset on each open bounds it to one create per open.
|
|
1540
|
+
const creatingRef = useRef(false);
|
|
1541
|
+
const kinds = adapter.structure?.creatableKinds?.(parentId) ?? [];
|
|
1542
|
+
const targetLabel = parentId ? adapter.hierarchy.node(parentId)?.label : null;
|
|
1543
|
+
|
|
1544
|
+
useEffect(() => {
|
|
1545
|
+
if (!open) return;
|
|
1546
|
+
creatingRef.current = false; // fresh open → allow exactly one create
|
|
1547
|
+
const handler = (e: MouseEvent) => {
|
|
1548
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
1549
|
+
};
|
|
1550
|
+
const keyHandler = (e: KeyboardEvent) => {
|
|
1551
|
+
if (e.key === 'Escape') setOpen(false);
|
|
1552
|
+
};
|
|
1553
|
+
document.addEventListener('mousedown', handler);
|
|
1554
|
+
document.addEventListener('keydown', keyHandler);
|
|
1555
|
+
return () => {
|
|
1556
|
+
document.removeEventListener('mousedown', handler);
|
|
1557
|
+
document.removeEventListener('keydown', keyHandler);
|
|
1558
|
+
};
|
|
1559
|
+
}, [open]);
|
|
1560
|
+
|
|
1561
|
+
if (kinds.length === 0) return null;
|
|
1562
|
+
|
|
1563
|
+
return (
|
|
1564
|
+
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
|
|
1565
|
+
{/* L-11 — same fixed 20×20 boxed treatment as its `ViewButton` siblings
|
|
1566
|
+
in the toolbar row (collapse/expand/reset), instead of an unboxed
|
|
1567
|
+
inline span with mismatched padding. */}
|
|
1568
|
+
<Button
|
|
1569
|
+
title={targetLabel ? `Add to ${targetLabel}` : 'Add'}
|
|
1570
|
+
aria-label={targetLabel ? `Add to ${targetLabel}` : 'Add'}
|
|
1571
|
+
aria-expanded={open}
|
|
1572
|
+
data-testid="hierarchy-add-menu"
|
|
1573
|
+
onClick={() => setOpen(!open)}
|
|
1574
|
+
variant={open ? 'primary' : 'secondary'}
|
|
1575
|
+
size="compact"
|
|
1576
|
+
>
|
|
1577
|
+
<EditorIcon icon={faPlus} style={{ fontSize: 'var(--vgai-font-sm)' }} />{' '}
|
|
1578
|
+
<EditorIcon icon={faCaretDown} style={{ fontSize: 'var(--vgai-font-xs)' }} />
|
|
1579
|
+
</Button>
|
|
1580
|
+
{open && (
|
|
1581
|
+
<Menu
|
|
1582
|
+
style={{
|
|
1583
|
+
// U6a: same Menu-primitive rebuild as the ingest context menu
|
|
1584
|
+
// above — chrome paint arrives through `.vgai-menu`'s shared
|
|
1585
|
+
// overlay material; geometry stays inline.
|
|
1586
|
+
position: 'absolute',
|
|
1587
|
+
top: '100%',
|
|
1588
|
+
right: 0,
|
|
1589
|
+
marginTop: space[2],
|
|
1590
|
+
minWidth: 130,
|
|
1591
|
+
maxHeight: 'min(480px, 70vh)',
|
|
1592
|
+
overflowY: 'auto',
|
|
1593
|
+
}}
|
|
1594
|
+
>
|
|
1595
|
+
{kinds.map((k) => (
|
|
1596
|
+
<MenuItem
|
|
1597
|
+
key={k.kind}
|
|
1598
|
+
data-testid={`hierarchy-add-${k.kind}`}
|
|
1599
|
+
onSelect={() => {
|
|
1600
|
+
if (creatingRef.current) return; // ignore a double-dispatched click
|
|
1601
|
+
creatingRef.current = true;
|
|
1602
|
+
void createAuthoringNode(adapter, k.kind, parentId ?? undefined).ack;
|
|
1603
|
+
setOpen(false);
|
|
1604
|
+
}}
|
|
1605
|
+
>
|
|
1606
|
+
{k.label}
|
|
1607
|
+
</MenuItem>
|
|
1608
|
+
))}
|
|
1609
|
+
</Menu>
|
|
1610
|
+
)}
|
|
1611
|
+
</div>
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// --- Row ---
|
|
1616
|
+
|
|
1617
|
+
function MoreRow({
|
|
1618
|
+
row,
|
|
1619
|
+
onReveal,
|
|
1620
|
+
}: {
|
|
1621
|
+
row: HierarchyNodeRow;
|
|
1622
|
+
onReveal: (parentPathKey: string) => void;
|
|
1623
|
+
}) {
|
|
1624
|
+
const hidden = row.more!.hidden;
|
|
1625
|
+
return (
|
|
1626
|
+
<Button
|
|
1627
|
+
type="button"
|
|
1628
|
+
variant="ghost"
|
|
1629
|
+
className="vgai-hierarchy-more"
|
|
1630
|
+
data-testid="ingest-row-more"
|
|
1631
|
+
onClick={() => onReveal(row.more!.parentPathKey)}
|
|
1632
|
+
style={{
|
|
1633
|
+
paddingLeft: indentPx(row.depth + 1),
|
|
1634
|
+
}}
|
|
1635
|
+
>
|
|
1636
|
+
… {hidden} more
|
|
1637
|
+
</Button>
|
|
1638
|
+
);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/**
|
|
1642
|
+
* D4 (spec27 §6 D4, layer-tree "search filters + HIGHLIGHTS") — split
|
|
1643
|
+
* `label` into `<mark>`-wrapped/plain segments around every case-insensitive
|
|
1644
|
+
* occurrence of `term`, or the plain label unchanged when `term` is empty
|
|
1645
|
+
* (the pre-D4 render, byte-identical). Pure text math — no adapter/DOM
|
|
1646
|
+
* access — factored out purely so `Row` itself stays simple.
|
|
1647
|
+
*/
|
|
1648
|
+
function highlightSegments(label: string, term: string): React.ReactNode {
|
|
1649
|
+
if (!term) return label;
|
|
1650
|
+
const lower = label.toLowerCase();
|
|
1651
|
+
const needle = term.toLowerCase();
|
|
1652
|
+
const parts: React.ReactNode[] = [];
|
|
1653
|
+
let cursor = 0;
|
|
1654
|
+
let idx = lower.indexOf(needle, cursor);
|
|
1655
|
+
if (idx < 0) return label;
|
|
1656
|
+
let key = 0;
|
|
1657
|
+
while (idx >= 0) {
|
|
1658
|
+
if (idx > cursor) parts.push(label.slice(cursor, idx));
|
|
1659
|
+
parts.push(
|
|
1660
|
+
<mark
|
|
1661
|
+
key={key++}
|
|
1662
|
+
data-testid="ingest-row-search-highlight"
|
|
1663
|
+
style={{
|
|
1664
|
+
background: themeVars.semantic.warning,
|
|
1665
|
+
color: themeVars.surface.shell,
|
|
1666
|
+
borderRadius: themeVars.shape.small,
|
|
1667
|
+
}}
|
|
1668
|
+
>
|
|
1669
|
+
{label.slice(idx, idx + needle.length)}
|
|
1670
|
+
</mark>,
|
|
1671
|
+
);
|
|
1672
|
+
cursor = idx + needle.length;
|
|
1673
|
+
idx = lower.indexOf(needle, cursor);
|
|
1674
|
+
}
|
|
1675
|
+
if (cursor < label.length) parts.push(label.slice(cursor));
|
|
1676
|
+
return parts;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
interface RowProps {
|
|
1680
|
+
row: HierarchyNodeRow;
|
|
1681
|
+
adapter: AuthoringAdapter;
|
|
1682
|
+
store: EditorShellStore;
|
|
1683
|
+
isSelected: boolean;
|
|
1684
|
+
/** Is this the selection's ACTIVE row — Blender's active object, and here
|
|
1685
|
+
* `EditorShellStore.selectedEntityId`: the last id the selection took, which
|
|
1686
|
+
* is already the gizmo's binding and the inspector's subject. Never true
|
|
1687
|
+
* without {@link isSelected}. Within a single selection it is simply the one
|
|
1688
|
+
* row; within a multi-selection it is the only thing on screen that says
|
|
1689
|
+
* which row those two panels are answering about. */
|
|
1690
|
+
isSelectionActive: boolean;
|
|
1691
|
+
isEditing: boolean;
|
|
1692
|
+
isCollapsed: boolean;
|
|
1693
|
+
dropZone: DropZone | null;
|
|
1694
|
+
isDragged: boolean;
|
|
1695
|
+
/** The row's ABSOLUTE index in the flattened tree — the alternating stripe's
|
|
1696
|
+
* phase. Not its DOM position: this list is virtualized behind a spacer
|
|
1697
|
+
* div, so `nth-child` counts the wrong thing (see the call site). */
|
|
1698
|
+
rowIndex: number;
|
|
1699
|
+
/** The depths of the rows immediately above and below this one in the
|
|
1700
|
+
* FLATTENED list, `-1` where there is none — the only two facts the indent
|
|
1701
|
+
* guides need beyond the row's own depth ({@link indentGuidesFor}). Passed
|
|
1702
|
+
* rather than derived here because a virtualized row cannot see its
|
|
1703
|
+
* neighbours; the caller holds the whole list. */
|
|
1704
|
+
prevDepth: number;
|
|
1705
|
+
nextDepth: number;
|
|
1706
|
+
/** H6 — this row exists only because its owner's internals were revealed:
|
|
1707
|
+
* a rendered part with no authored source identity. Read-only, dim, and
|
|
1708
|
+
* invisible to search; see `rowAffordances`. */
|
|
1709
|
+
isInternal: boolean;
|
|
1710
|
+
/** The manifest's sole live Three viewport world, recomputed by the panel
|
|
1711
|
+
* per notify (`resolveThreeViewportRootId`); its group row renders a
|
|
1712
|
+
* live-content marker. */
|
|
1713
|
+
threeRootId: string | null;
|
|
1714
|
+
/** D4 — the trimmed, lowercased-by-the-caller search term (empty string
|
|
1715
|
+
* when the search box is empty), for {@link highlightSegments}. */
|
|
1716
|
+
searchTerm: string;
|
|
1717
|
+
/** H2 — the panel-owned memo of "are all three transform channels refused".
|
|
1718
|
+
* `editability` is NOT free per render (the R3F adapter re-parses JSX
|
|
1719
|
+
* attributes), so no row is allowed to call it directly. */
|
|
1720
|
+
lockCache: TransformLockCache;
|
|
1721
|
+
/** H5 — the panel-owned memo of "does this row carry authorability
|
|
1722
|
+
* warnings". Same key as {@link lockCache}; see `RowWarningCache`. */
|
|
1723
|
+
warningCache: RowWarningCache;
|
|
1724
|
+
/** H2 + H5 — the caches' shared invalidation key; see `TransformLockCache`. */
|
|
1725
|
+
editabilityVersion: number;
|
|
1726
|
+
/** The ACTIVE ADAPTER's own change epoch — see the `adapter.subscribe` effect.
|
|
1727
|
+
* A row reads eye/lock/name/warning state straight off the adapter, so a
|
|
1728
|
+
* change the adapter reports with no change to this row's NODE fields is
|
|
1729
|
+
* invisible without it. */
|
|
1730
|
+
adapterVersion: number;
|
|
1731
|
+
onToggleOpen: (row: HierarchyNodeRow, recursive: boolean) => void;
|
|
1732
|
+
onStartEditing: (id: string) => void;
|
|
1733
|
+
onContextMenu: (e: React.MouseEvent, id: string) => void;
|
|
1734
|
+
onDragStart: (e: React.DragEvent, id: string) => void;
|
|
1735
|
+
onClick: (e: React.MouseEvent, id: string) => void;
|
|
1736
|
+
onEnterScope: (id: string) => void;
|
|
1737
|
+
remoteSelectionColors: readonly string[];
|
|
1738
|
+
/** The `hierarchyTypeSuffix` chrome region, resolved ONCE by the panel
|
|
1739
|
+
* (`workspace-regions.ts`): does a component-instance row print `·Type`
|
|
1740
|
+
* after its name. A look that says `hidden` — Blender's, whose rows carry
|
|
1741
|
+
* the name alone — drops the text only; see the label span for where the
|
|
1742
|
+
* two facts it carried go instead. */
|
|
1743
|
+
typeSuffixShown: boolean;
|
|
1744
|
+
/** The `hierarchyInstanceRule` chrome region, resolved ONCE by the panel
|
|
1745
|
+
* (`workspace-regions.ts`): does a component-instance row draw the dotted
|
|
1746
|
+
* rule under its name. A look that says `hidden` — Blender's, whose Outliner
|
|
1747
|
+
* marks an instance nowhere — drops the rule on every row, selected or not. */
|
|
1748
|
+
instanceRuleShown: boolean;
|
|
1749
|
+
/** The `hierarchyRestrictions` chrome region, resolved ONCE by the panel
|
|
1750
|
+
* (`workspace-regions.ts`): which toggle columns the row's right edge
|
|
1751
|
+
* carries. `viewport+render` drops the selection lock and reserves the
|
|
1752
|
+
* outer column so the eye sits in Blender's own eye slot. */
|
|
1753
|
+
restrictionColumns: NonNullable<ChromeRegions['hierarchyRestrictions']>;
|
|
1754
|
+
/**
|
|
1755
|
+
* THE EXCLUDE CHECKBOX COLUMN, present when ANY row of this tree answers the
|
|
1756
|
+
* reserved `exclude` path — never a chrome knob (owner ruling, 2026-09-19:
|
|
1757
|
+
* restriction columns follow what the adapter ANSWERS; the render column
|
|
1758
|
+
* already worked this way). Blender draws it for `LayerCollection` rows and
|
|
1759
|
+
* it is the LEFTMOST of the restriction columns — `restrict_offsets` is built
|
|
1760
|
+
* right-to-left and `enable` is taken last (`outliner_draw.cc:1218-1245`),
|
|
1761
|
+
* with `SO_RESTRICT_ENABLE` in the default set (`space_outliner.cc:399`). A
|
|
1762
|
+
* row that answers nothing gets the reserved blank cell, so the eye and the
|
|
1763
|
+
* camera stay in one column down the whole tree.
|
|
1764
|
+
*/
|
|
1765
|
+
excludeColumnShown: boolean;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
|
|
1769
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
function sameIngestRow(left: HierarchyNodeRow, right: HierarchyNodeRow): boolean {
|
|
1773
|
+
const leftNode = left.node;
|
|
1774
|
+
const rightNode = right.node;
|
|
1775
|
+
return (
|
|
1776
|
+
left.depth === right.depth &&
|
|
1777
|
+
left.pathKey === right.pathKey &&
|
|
1778
|
+
left.hasChildren === right.hasChildren &&
|
|
1779
|
+
left.hiddenByCollapse === right.hiddenByCollapse &&
|
|
1780
|
+
left.more?.parentPathKey === right.more?.parentPathKey &&
|
|
1781
|
+
left.more?.hidden === right.more?.hidden &&
|
|
1782
|
+
leftNode.id === rightNode.id &&
|
|
1783
|
+
leftNode.label === rightNode.label &&
|
|
1784
|
+
leftNode.role === rightNode.role &&
|
|
1785
|
+
leftNode.secondaryLabel === rightNode.secondaryLabel &&
|
|
1786
|
+
leftNode.kind === rightNode.kind &&
|
|
1787
|
+
leftNode.typeLabel === rightNode.typeLabel &&
|
|
1788
|
+
leftNode.parentId === rightNode.parentId &&
|
|
1789
|
+
sameStrings(leftNode.childIds, rightNode.childIds) &&
|
|
1790
|
+
leftNode.crossSurfaceId === rightNode.crossSurfaceId &&
|
|
1791
|
+
leftNode.crossSurfaceParentId === rightNode.crossSurfaceParentId &&
|
|
1792
|
+
leftNode.crossSurfaceOrder === rightNode.crossSurfaceOrder &&
|
|
1793
|
+
leftNode.crossSurfaceGroupLabel === rightNode.crossSurfaceGroupLabel
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function sameRowProps(left: RowProps, right: RowProps): boolean {
|
|
1798
|
+
return (
|
|
1799
|
+
sameIngestRow(left.row, right.row) &&
|
|
1800
|
+
left.adapter === right.adapter &&
|
|
1801
|
+
left.store === right.store &&
|
|
1802
|
+
left.isSelected === right.isSelected &&
|
|
1803
|
+
left.isSelectionActive === right.isSelectionActive &&
|
|
1804
|
+
left.isEditing === right.isEditing &&
|
|
1805
|
+
left.isCollapsed === right.isCollapsed &&
|
|
1806
|
+
left.dropZone === right.dropZone &&
|
|
1807
|
+
left.isDragged === right.isDragged &&
|
|
1808
|
+
left.rowIndex === right.rowIndex &&
|
|
1809
|
+
left.prevDepth === right.prevDepth &&
|
|
1810
|
+
left.nextDepth === right.nextDepth &&
|
|
1811
|
+
left.isInternal === right.isInternal &&
|
|
1812
|
+
left.searchTerm === right.searchTerm &&
|
|
1813
|
+
left.lockCache === right.lockCache &&
|
|
1814
|
+
left.warningCache === right.warningCache &&
|
|
1815
|
+
left.editabilityVersion === right.editabilityVersion &&
|
|
1816
|
+
left.adapterVersion === right.adapterVersion &&
|
|
1817
|
+
left.threeRootId === right.threeRootId &&
|
|
1818
|
+
left.onToggleOpen === right.onToggleOpen &&
|
|
1819
|
+
left.onStartEditing === right.onStartEditing &&
|
|
1820
|
+
left.onContextMenu === right.onContextMenu &&
|
|
1821
|
+
left.onDragStart === right.onDragStart &&
|
|
1822
|
+
left.onClick === right.onClick &&
|
|
1823
|
+
left.onEnterScope === right.onEnterScope &&
|
|
1824
|
+
left.typeSuffixShown === right.typeSuffixShown &&
|
|
1825
|
+
left.instanceRuleShown === right.instanceRuleShown &&
|
|
1826
|
+
left.restrictionColumns === right.restrictionColumns &&
|
|
1827
|
+
left.excludeColumnShown === right.excludeColumnShown &&
|
|
1828
|
+
sameStrings(left.remoteSelectionColors, right.remoteSelectionColors)
|
|
1829
|
+
);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
const Row = memo(function Row({
|
|
1833
|
+
row,
|
|
1834
|
+
adapter,
|
|
1835
|
+
store,
|
|
1836
|
+
isSelected,
|
|
1837
|
+
isSelectionActive,
|
|
1838
|
+
isEditing,
|
|
1839
|
+
isCollapsed,
|
|
1840
|
+
dropZone,
|
|
1841
|
+
isDragged,
|
|
1842
|
+
rowIndex,
|
|
1843
|
+
prevDepth,
|
|
1844
|
+
nextDepth,
|
|
1845
|
+
isInternal,
|
|
1846
|
+
searchTerm,
|
|
1847
|
+
lockCache,
|
|
1848
|
+
warningCache,
|
|
1849
|
+
editabilityVersion,
|
|
1850
|
+
adapterVersion: _adapterVersion,
|
|
1851
|
+
onToggleOpen,
|
|
1852
|
+
onStartEditing,
|
|
1853
|
+
threeRootId,
|
|
1854
|
+
onContextMenu,
|
|
1855
|
+
onDragStart,
|
|
1856
|
+
onClick,
|
|
1857
|
+
onEnterScope,
|
|
1858
|
+
remoteSelectionColors,
|
|
1859
|
+
typeSuffixShown,
|
|
1860
|
+
instanceRuleShown,
|
|
1861
|
+
restrictionColumns,
|
|
1862
|
+
excludeColumnShown,
|
|
1863
|
+
}: RowProps) {
|
|
1864
|
+
const { node, depth, hasChildren } = row;
|
|
1865
|
+
// A composite `world:<id>` group row (session-local eye per D9, zOrder badge,
|
|
1866
|
+
// non-draggable) — NOT merely a runtime-only node (see `isCompositeGroupNode`).
|
|
1867
|
+
const badge = compositeGroupBadge(adapter, node.id);
|
|
1868
|
+
const isGroupNode = badge !== null;
|
|
1869
|
+
const isOrganizationRow = isCompositeOrganizationNode(adapter, node.id);
|
|
1870
|
+
const isStructural =
|
|
1871
|
+
isGroupNode ||
|
|
1872
|
+
isOrganizationRow ||
|
|
1873
|
+
(node.role !== undefined && STRUCTURAL_ROLES.has(node.role));
|
|
1874
|
+
// H6 — the ONE gate every affordance below consults. A revealed internal is
|
|
1875
|
+
// non-writable BY CONSTRUCTION (no authored source identity to write to), so
|
|
1876
|
+
// it refuses drag, rename and every write control; selection stays open, and
|
|
1877
|
+
// the H2 lock glyph below carries the adapter's own reason for the refusal.
|
|
1878
|
+
const affordances = rowAffordances({ structural: isStructural, internal: isInternal });
|
|
1879
|
+
// Reserved-path reads, resolved ONCE per row and only for non-structural
|
|
1880
|
+
// rows: an adapter that doesn't support a reserved path returns
|
|
1881
|
+
// `undefined` (its affordance simply doesn't render). Cheap for every
|
|
1882
|
+
// stock adapter — the ingest/react adapters early-return `undefined` for a
|
|
1883
|
+
// non-style path before touching their tree; the first-party adapter
|
|
1884
|
+
// reads a plain descriptor.
|
|
1885
|
+
const lockedValue = isStructural ? undefined : adapter.inspector?.get(node.id, 'locked');
|
|
1886
|
+
const nameValue = isStructural ? undefined : adapter.inspector?.get(node.id, 'name');
|
|
1887
|
+
// VALUE probe like `locked`/`name` above (not provider presence — the
|
|
1888
|
+
// composite's inspector is a never-undefined router, which put a no-op eye
|
|
1889
|
+
// on Boundary rows): every live adapter answers `visible`; a Boundary
|
|
1890
|
+
// answers undefined and gets no eye.
|
|
1891
|
+
const visibleKnown = !isStructural && adapter.inspector?.get(node.id, 'visible') !== undefined;
|
|
1892
|
+
const lockedEditability = isStructural
|
|
1893
|
+
? undefined
|
|
1894
|
+
: adapter.inspector?.editability?.(node.id, 'locked');
|
|
1895
|
+
const visibleEditability = isStructural
|
|
1896
|
+
? undefined
|
|
1897
|
+
: adapter.inspector?.editability?.(node.id, 'visible');
|
|
1898
|
+
// THE RENDER COLUMN's own reserved path, probed the same way `visible` is.
|
|
1899
|
+
// Almost every adapter answers `undefined` — three.js has ONE
|
|
1900
|
+
// `Object3D.visible` governing viewport and render alike — and the column
|
|
1901
|
+
// then stays the reserved blank cell it has been. An adapter whose truth
|
|
1902
|
+
// really does separate the two fills it in, which is what the region's own
|
|
1903
|
+
// note (`workspace-regions.ts`, `hierarchyRestrictions`) said would happen:
|
|
1904
|
+
// Blender's Outliner draws `hide_viewport` and `hide_render` as two columns
|
|
1905
|
+
// (`outliner_draw.cc:1291-1384`), and `@volter/editor-blender`'s tree answers both.
|
|
1906
|
+
const renderValue = isStructural ? undefined : adapter.inspector?.get(node.id, 'renderVisible');
|
|
1907
|
+
const renderKnown = renderValue !== undefined;
|
|
1908
|
+
const renderVisible = renderValue !== false;
|
|
1909
|
+
const renderEditability = isStructural
|
|
1910
|
+
? undefined
|
|
1911
|
+
: adapter.inspector?.editability?.(node.id, 'renderVisible');
|
|
1912
|
+
const renderWritable = renderKnown && (renderEditability?.writable ?? true);
|
|
1913
|
+
// THE EXCLUDE COLUMN's own reserved path, probed exactly as `renderVisible`
|
|
1914
|
+
// is. `true` means EXCLUDED, the sense Blender's own property carries
|
|
1915
|
+
// (`LayerCollection.exclude`), so the checkbox is CHECKED when the value is
|
|
1916
|
+
// false — which is also how Blender draws it: the button is an icon toggle
|
|
1917
|
+
// over a `hide_`-sense property and `outliner_draw.cc:561` inverts it.
|
|
1918
|
+
const excludeValue = isStructural ? undefined : adapter.inspector?.get(node.id, 'exclude');
|
|
1919
|
+
const excludeKnown = excludeValue !== undefined;
|
|
1920
|
+
const excluded = excludeValue === true;
|
|
1921
|
+
const excludeEditability = isStructural
|
|
1922
|
+
? undefined
|
|
1923
|
+
: adapter.inspector?.editability?.(node.id, 'exclude');
|
|
1924
|
+
const excludeWritable = excludeKnown && (excludeEditability?.writable ?? true);
|
|
1925
|
+
// B1 (D9) — a group row's eye/interactive/pick-lock read the LIFTED,
|
|
1926
|
+
// module-level session store (`world-session-state.ts`), keyed by the
|
|
1927
|
+
// RAW manifest world id `badge.worldId` — not the group-node id itself.
|
|
1928
|
+
const worldHidden = badge ? isRootHidden(badge.worldId) : false;
|
|
1929
|
+
const worldInteractive = badge ? isRootInteractive(badge.worldId) : false;
|
|
1930
|
+
const worldPickLocked = badge ? isRootPickLocked(badge.worldId) : false;
|
|
1931
|
+
const visible = isGroupNode ? !worldHidden : adapter.inspector?.get(node.id, 'visible') !== false;
|
|
1932
|
+
const lockedKnown = lockedValue !== undefined;
|
|
1933
|
+
const locked = lockedValue === true;
|
|
1934
|
+
const lockedWritable = lockedKnown && (lockedEditability?.writable ?? true);
|
|
1935
|
+
const visibleWritable = visibleKnown && (visibleEditability?.writable ?? true);
|
|
1936
|
+
const nameKnown = nameValue !== undefined;
|
|
1937
|
+
// H1 — a component-instance row prints its type as a dim suffix
|
|
1938
|
+
// (`EnemyBravo ·Enemy`) and tints its own label. Detached `#n` pockets carry
|
|
1939
|
+
// no `typeLabel` by construction (#635), so they stay plain.
|
|
1940
|
+
const identity = rowIdentity(node);
|
|
1941
|
+
// Gated like its H2/H5 siblings below: a structural row (a world group, a
|
|
1942
|
+
// member root, a document/folder/story) is not an instance of anything by
|
|
1943
|
+
// construction, so asking is a question with no possible answer.
|
|
1944
|
+
const prefabOverrideCount = isStructural
|
|
1945
|
+
? 0
|
|
1946
|
+
: (adapter.instances?.describe(node.id)?.overrides.length ?? 0);
|
|
1947
|
+
// H2 — one memoized verdict per row; structural rows (world groups, member
|
|
1948
|
+
// roots, documents/folders/stories) own no transform and are never probed.
|
|
1949
|
+
const transformLock = isStructural
|
|
1950
|
+
? UNLOCKED
|
|
1951
|
+
: lockCache.summaryFor(adapter, editabilityVersion, node.id);
|
|
1952
|
+
// H5 — authorability warnings badge the row they are about. Structural rows
|
|
1953
|
+
// (world groups, member roots, documents/folders/stories) address no source
|
|
1954
|
+
// element, so they are never probed.
|
|
1955
|
+
// The cast is the STRUCTURAL probe, and the same one `instance-source-menu.ts`
|
|
1956
|
+
// uses for H3: `diagnosticsFor` is an optional adapter capability that
|
|
1957
|
+
// deliberately isn't in the `AuthoringAdapter` contract, so an adapter without
|
|
1958
|
+
// it simply produces no badges (the cache reads `undefined` and stops).
|
|
1959
|
+
const warning = isStructural
|
|
1960
|
+
? null
|
|
1961
|
+
: warningCache.badgeFor(adapter as RowDiagnosticsSource, editabilityVersion, node.id);
|
|
1962
|
+
// An `InstancedMesh` is ONE object drawing N units, so this row — and every
|
|
1963
|
+
// count derived from the same walk — reports 1 where the reader sees N, with
|
|
1964
|
+
// no error anywhere. The detail is the only place the shortfall is
|
|
1965
|
+
// expressible. Cheap by construction (a type check and a number read, see
|
|
1966
|
+
// `instancedRowDetail`), so it costs a structural row nothing to ask.
|
|
1967
|
+
const instancedDetail = isStructural
|
|
1968
|
+
? null
|
|
1969
|
+
: instancedRowDetail(entityObject3D(adapter, store.objectMap, node.id));
|
|
1970
|
+
// THE COLLAPSED ROW'S DATABLOCK SUMMARY — Blender's rest state, the first
|
|
1971
|
+
// difference the eye catches in the column. Measured on `outliner.png`:
|
|
1972
|
+
// every object row in the frame sits behind a `>` with its datablock drawn
|
|
1973
|
+
// as an inline glyph one cell past the label's end. Expanding is what
|
|
1974
|
+
// produces the child row; the two never show at once.
|
|
1975
|
+
//
|
|
1976
|
+
// BOUNDED BY CONSTRUCTION. Only a collapsed row with children asks, and it
|
|
1977
|
+
// reads at most the first few child ids — a collapsed group with twenty
|
|
1978
|
+
// thousand scene children must not turn one row into twenty thousand
|
|
1979
|
+
// adapter reads, and it cannot: none of the first ids is a datablock, so
|
|
1980
|
+
// the summary is empty and nothing more is looked up.
|
|
1981
|
+
const datablockSummary =
|
|
1982
|
+
isCollapsed && hasChildren
|
|
1983
|
+
? node.childIds
|
|
1984
|
+
.slice(0, DATABLOCK_SUMMARY_SCAN)
|
|
1985
|
+
.map((id) => adapter.hierarchy.node(id))
|
|
1986
|
+
.filter((child): child is EditorNode => child !== null && isDatablockKind(child.kind))
|
|
1987
|
+
: [];
|
|
1988
|
+
|
|
1989
|
+
return (
|
|
1990
|
+
<div
|
|
1991
|
+
className="vgai-tree-row"
|
|
1992
|
+
// R5 (P6-U7): gate on the STABLE `isStructural` (which includes the
|
|
1993
|
+
// `role: 'root'`/`document`/`folder`/`story`/`boundary` STRUCTURAL_ROLES),
|
|
1994
|
+
// not just member/organization rows. A world-root navigator row
|
|
1995
|
+
// (role 'root') must never pollute `getEntityNames()` — and unlike the
|
|
1996
|
+
// async-settling `isOrganizationRow`, the role check doesn't flicker, so
|
|
1997
|
+
// search results stay entity-only deterministically.
|
|
1998
|
+
data-entity-name={isStructural ? undefined : node.label}
|
|
1999
|
+
data-testid="ingest-row"
|
|
2000
|
+
data-ingest-name={node.label}
|
|
2001
|
+
data-node-role={node.role}
|
|
2002
|
+
// THE ROW'S OWN DEPTH, as a fact and not as a padding. It is drawn as
|
|
2003
|
+
// `padding-left: calc(var(--vgai-tree-indent) * depth)`, so until this
|
|
2004
|
+
// attribute existed a reader (the product's chrome door,
|
|
2005
|
+
// `editor-document-probe.ts` scope `'outliner'`) could only recover the
|
|
2006
|
+
// tree's SHAPE by dividing one measured pixel number by another — which
|
|
2007
|
+
// is a reading of the paint, not of the tree. NOT `aria-level`: that
|
|
2008
|
+
// attribute is only valid under a `treeitem`/`row`/`listitem` role, and
|
|
2009
|
+
// this row declares none (it is a `div` the panel hit-tests itself), so
|
|
2010
|
+
// writing it would be invalid ARIA for the sake of a reader that has
|
|
2011
|
+
// `data-depth`.
|
|
2012
|
+
data-depth={depth}
|
|
2013
|
+
data-selected={isSelected ? 'true' : undefined}
|
|
2014
|
+
// The selection's ACTIVE row (see `isSelectionActive`). Its own attribute
|
|
2015
|
+
// rather than a second value on `data-selected`, because that value is
|
|
2016
|
+
// queried by name elsewhere in this file and by the e2e estate.
|
|
2017
|
+
data-selection-active={isSelectionActive ? 'true' : undefined}
|
|
2018
|
+
data-remotely-selected={remoteSelectionColors.length > 0 ? 'true' : undefined}
|
|
2019
|
+
data-dragging={isDragged ? 'true' : undefined}
|
|
2020
|
+
// The alternating stripe — see `rowIndex`'s own comment.
|
|
2021
|
+
data-row-alt={rowIndex % 2 === 1 ? 'true' : undefined}
|
|
2022
|
+
// H6 — de-emphasized through the SAME muted token the panel already uses
|
|
2023
|
+
// for a hidden/inactive row (`.vgai-tree-row[data-muted]` →
|
|
2024
|
+
// `--vgai-content-dim`), rather than a second dimming vocabulary. The
|
|
2025
|
+
// separate `data-internal` marker is what identifies the row's KIND.
|
|
2026
|
+
data-internal={isInternal ? 'true' : undefined}
|
|
2027
|
+
// AN EXCLUDED SUBTREE IS FADED, which is Blender's own answer:
|
|
2028
|
+
// `element_should_draw_faded` returns true for a `TSE_LAYER_COLLECTION`
|
|
2029
|
+
// whose `LAYER_COLLECTION_EXCLUDE` is set (`outliner_draw.cc:3331-3334`)
|
|
2030
|
+
// and the row's icon and text are then drawn at `alpha_fac` 0.5
|
|
2031
|
+
// (`:3376`, `:3525`). It rides the SAME muted token every other
|
|
2032
|
+
// de-emphasized row uses rather than a second dimming vocabulary — see
|
|
2033
|
+
// H6 above — and the whole SUBTREE follows because Blender's own
|
|
2034
|
+
// recursive write sets the flag on every descendant collection
|
|
2035
|
+
// (`view_layer__layer_collection_set_flag_recursive_fn`, `:1648-1651`),
|
|
2036
|
+
// so each descendant row answers `exclude` for itself.
|
|
2037
|
+
data-muted={isInternal || excluded || (!isStructural && !visible) ? 'true' : undefined}
|
|
2038
|
+
draggable={affordances.draggable}
|
|
2039
|
+
onDragStart={(e) => onDragStart(e, node.id)}
|
|
2040
|
+
onClick={(e) => {
|
|
2041
|
+
if (badge?.role === 'world') {
|
|
2042
|
+
// Runtime composition belongs to Game. In edit mode a manifest
|
|
2043
|
+
// world row is a document navigator: one click reveals that
|
|
2044
|
+
// world's isolated authoring surface, matching scene/file trees in
|
|
2045
|
+
// established editors. Nothing is swapped underneath: an
|
|
2046
|
+
// entry-based three root has no separate document to load.
|
|
2047
|
+
activateRootDocument(badge.worldId);
|
|
2048
|
+
}
|
|
2049
|
+
if (node.role === 'story') {
|
|
2050
|
+
// React edit mode is a Figma-style board, not a single selected
|
|
2051
|
+
// story pretending to be the runtime root. A story click activates
|
|
2052
|
+
// its owning world document and focuses that already-mounted frame.
|
|
2053
|
+
// Always apply—even when already active—so a second click recenters
|
|
2054
|
+
// a panned board. Double-click remains the explicit route to the
|
|
2055
|
+
// standalone Storybook document and its addon utilities.
|
|
2056
|
+
const storyRootId =
|
|
2057
|
+
adapter instanceof CompositeAuthoringAdapter ? adapter.ownerOf(node.id) : null;
|
|
2058
|
+
if (storyRootId) activateRootDocument(storyRootId);
|
|
2059
|
+
adapter.stories?.apply(node.id, node.id);
|
|
2060
|
+
}
|
|
2061
|
+
onClick(e, node.id);
|
|
2062
|
+
}}
|
|
2063
|
+
onDoubleClick={() => {
|
|
2064
|
+
if (node.role === 'story') {
|
|
2065
|
+
// A story row's node id IS the composed story id, which is the
|
|
2066
|
+
// whole address (`document-open-registry.ts`): resolving it, and
|
|
2067
|
+
// choosing the medium's document, is the story kind's own job.
|
|
2068
|
+
openRegisteredDocument('story', store, { storyId: node.id });
|
|
2069
|
+
} else if (
|
|
2070
|
+
node.role === 'component' ||
|
|
2071
|
+
node.role === 'instance' ||
|
|
2072
|
+
node.role === 'boundary'
|
|
2073
|
+
) {
|
|
2074
|
+
onEnterScope(node.id);
|
|
2075
|
+
} else if (affordances.renamable && nameKnown) {
|
|
2076
|
+
onStartEditing(node.id);
|
|
2077
|
+
}
|
|
2078
|
+
}}
|
|
2079
|
+
onContextMenu={(e) => {
|
|
2080
|
+
e.preventDefault();
|
|
2081
|
+
onContextMenu(e, node.id);
|
|
2082
|
+
}}
|
|
2083
|
+
style={{
|
|
2084
|
+
// No `height` here: `.vgai-tree-row` already paints
|
|
2085
|
+
// `var(--vgai-tree-row-height)`, and an inline copy silently outranked
|
|
2086
|
+
// the active material's density.
|
|
2087
|
+
position: 'relative',
|
|
2088
|
+
paddingLeft: indentPx(depth),
|
|
2089
|
+
paddingRight: spaceVar[2],
|
|
2090
|
+
gap: spaceVar[2],
|
|
2091
|
+
cursor: 'pointer',
|
|
2092
|
+
outline: dropZone === 'child' ? `1px dashed ${themeVars.accent.default}` : 'none',
|
|
2093
|
+
whiteSpace: 'nowrap',
|
|
2094
|
+
overflow: 'hidden',
|
|
2095
|
+
// The tree is body text at the material's own face size (Blender's
|
|
2096
|
+
// Outliner is 11px); `font-lg` made every row a size larger than the
|
|
2097
|
+
// material asked for.
|
|
2098
|
+
fontSize: 'var(--vgai-font-base)',
|
|
2099
|
+
}}
|
|
2100
|
+
>
|
|
2101
|
+
{dropZone === 'before' && (
|
|
2102
|
+
<DropIndicator position="before" style={{ left: indentPx(depth) }} />
|
|
2103
|
+
)}
|
|
2104
|
+
{dropZone === 'after' && <DropIndicator position="after" style={{ left: indentPx(depth) }} />}
|
|
2105
|
+
|
|
2106
|
+
{/* One rule per ancestor column, on the caret centre of each. Always
|
|
2107
|
+
* rendered: `--vgai-tree-indent-guide` is `transparent` for every
|
|
2108
|
+
* palette that names no `color.boundary.indent`, so a skin without the
|
|
2109
|
+
* member paints nothing and the spans cost it no pixels. */}
|
|
2110
|
+
{indentGuidesFor(depth, prevDepth, nextDepth).map((guide) => (
|
|
2111
|
+
<span
|
|
2112
|
+
key={guide.column}
|
|
2113
|
+
className="vgai-tree-indent-guide"
|
|
2114
|
+
aria-hidden="true"
|
|
2115
|
+
style={{
|
|
2116
|
+
left: `calc(${INDENT} * ${guide.column + 0.5} - 0.5px)`,
|
|
2117
|
+
top: guide.starts ? GUIDE_BLOCK_INSET : 0,
|
|
2118
|
+
bottom: guide.ends ? GUIDE_BLOCK_INSET : 0,
|
|
2119
|
+
}}
|
|
2120
|
+
/>
|
|
2121
|
+
))}
|
|
2122
|
+
|
|
2123
|
+
{hasChildren ? (
|
|
2124
|
+
<IconButton
|
|
2125
|
+
type="button"
|
|
2126
|
+
variant="ghost"
|
|
2127
|
+
size="compact"
|
|
2128
|
+
aria-label={isCollapsed ? `Expand ${node.label}` : `Collapse ${node.label}`}
|
|
2129
|
+
// The state, not only the next gesture's name: a reader asking "is
|
|
2130
|
+
// this branch open" had to parse the English of the label above.
|
|
2131
|
+
aria-expanded={!isCollapsed}
|
|
2132
|
+
data-testid="ingest-row-caret"
|
|
2133
|
+
onClick={(e) => {
|
|
2134
|
+
e.stopPropagation();
|
|
2135
|
+
onToggleOpen(row, e.altKey);
|
|
2136
|
+
}}
|
|
2137
|
+
title="Expand/collapse (Option/Alt-click applies to the branch)"
|
|
2138
|
+
className="vgai-tree-caret"
|
|
2139
|
+
>
|
|
2140
|
+
<EditorIcon icon={isCollapsed ? faCaretRight : faCaretDown} aria-hidden="true" />
|
|
2141
|
+
</IconButton>
|
|
2142
|
+
) : (
|
|
2143
|
+
<span
|
|
2144
|
+
data-testid="ingest-row-caret"
|
|
2145
|
+
aria-hidden="true"
|
|
2146
|
+
// The same cell the caret button occupies, so a leaf's type glyph
|
|
2147
|
+
// lands in the SAME column as an expandable sibling's. One indent
|
|
2148
|
+
// cell, as Blender's is — see `.vgai-tree-caret` in `theme.css`.
|
|
2149
|
+
style={{ width: INDENT, flexShrink: 0 }}
|
|
2150
|
+
/>
|
|
2151
|
+
)}
|
|
2152
|
+
|
|
2153
|
+
{remoteSelectionColors.length > 0 && (
|
|
2154
|
+
<span
|
|
2155
|
+
className="vgai-tree-remote-selection"
|
|
2156
|
+
role="img"
|
|
2157
|
+
title={`Selected by ${remoteSelectionColors.length} collaborator${remoteSelectionColors.length === 1 ? '' : 's'}`}
|
|
2158
|
+
aria-label="Selected by a collaborator"
|
|
2159
|
+
>
|
|
2160
|
+
{remoteSelectionColors.slice(0, 3).map((color) => (
|
|
2161
|
+
<span key={color} style={{ background: color }} aria-hidden="true" />
|
|
2162
|
+
))}
|
|
2163
|
+
</span>
|
|
2164
|
+
)}
|
|
2165
|
+
|
|
2166
|
+
{/* The live-content marker appears only on the manifest's sole Three
|
|
2167
|
+
* root. Other media open their own authored documents when selected. */}
|
|
2168
|
+
{badge?.role === 'world' && badge.worldId === threeRootId && (
|
|
2169
|
+
<span
|
|
2170
|
+
data-testid="world-focus-radio"
|
|
2171
|
+
data-world-focused="true"
|
|
2172
|
+
title="Live Three world — shown in the viewport"
|
|
2173
|
+
style={{ fontSize: 'var(--vgai-font-xs)', opacity: 0.8, flexShrink: 0 }}
|
|
2174
|
+
>
|
|
2175
|
+
<EditorIcon icon={faCircleDot} aria-hidden="true" />
|
|
2176
|
+
</span>
|
|
2177
|
+
)}
|
|
2178
|
+
|
|
2179
|
+
{/* THE TYPE GLYPH, and why its opacity is a measurement. This is where
|
|
2180
|
+
a glyph's own category ink lands (`EditorIcon` paints a toned glyph
|
|
2181
|
+
in `var(--vgai-category-…)`, and every category token is INK — see
|
|
2182
|
+
`theme.ts`'s category docblock), and the opacity is the SITE's half
|
|
2183
|
+
of Blender's model: the Outliner composites its glyphs at 0.80 over
|
|
2184
|
+
whatever the row paints. At 0.65 the Blender palette's `object`
|
|
2185
|
+
#e09557 composites to #a27149 over the panel, a washed tan against
|
|
2186
|
+
the #bb7f4d Blender actually draws; at 0.80 it composites to exactly
|
|
2187
|
+
#bb7f4d on the row's #272727 and exactly #c48856 on the plated
|
|
2188
|
+
#525252 — every channel of both, which is what proves the alpha.
|
|
2189
|
+
(It read 0.79 until 2026-09-18, from the two-background form
|
|
2190
|
+
(196−187)/(82−39) whose numerator carried one rounding step; 0.80
|
|
2191
|
+
is the direct fit against the ink photographed at full in
|
|
2192
|
+
`properties-object.png`.) It is a glyph's weight against its row,
|
|
2193
|
+
not a de-emphasis: do not round it away. */}
|
|
2194
|
+
<span
|
|
2195
|
+
// The class is what `theme.css`'s ACTIVE-ROW PLATE hangs its rule on;
|
|
2196
|
+
// the plate is drawn as this span's own `::before` so the glyph's box
|
|
2197
|
+
// — and with it every row measurement already fitted to the frame —
|
|
2198
|
+
// does not move.
|
|
2199
|
+
className="vgai-tree-type-glyph"
|
|
2200
|
+
title={node.role ? `${node.role}: ${node.kind}` : node.kind}
|
|
2201
|
+
style={{
|
|
2202
|
+
// ICONS HAVE THEIR OWN SIZE AXIS (`theme.ts`'s `iconSize`), and this
|
|
2203
|
+
// call site was still on the TYPE axis, so a skin that tightened its
|
|
2204
|
+
// text shrank the Outliner's type glyphs with it. The icon rungs
|
|
2205
|
+
// default to the type scale's values, so nothing moves for a skin
|
|
2206
|
+
// that declares no `density.icon`.
|
|
2207
|
+
//
|
|
2208
|
+
// Blender's own is larger still: on the native 2x `outliner.png` the
|
|
2209
|
+
// Camera row's glyph inks 30x30 device px — 15 CSS — where our set
|
|
2210
|
+
// draws ink at ~0.85 of its box (`blender-icons.source.mjs`'s `S`),
|
|
2211
|
+
// so 15 of ink is an 18px box and `icon.sm` (14) leaves ~3 px on the
|
|
2212
|
+
// table. That is a rung this site cannot spend without moving every
|
|
2213
|
+
// other skin's tree; it is a measurement, recorded, not a paint-over.
|
|
2214
|
+
width: 'var(--vgai-icon-md)',
|
|
2215
|
+
fontSize: 'var(--vgai-icon-sm)',
|
|
2216
|
+
// THE 0.80 MOVED TO THE GLYPH (`theme.css`'s
|
|
2217
|
+
// `.vgai-tree-type-glyph > *`), for the reason its datablock sibling
|
|
2218
|
+
// already carries: the active row's plate is this span's own
|
|
2219
|
+
// `::before`, so a group opacity here multiplied the plate's
|
|
2220
|
+
// measured alphas by 0.8 as well — measured 0.203 against the
|
|
2221
|
+
// declared 0.26 before the move. The glyph's own weight is
|
|
2222
|
+
// unchanged, and so is every row that draws no plate. */
|
|
2223
|
+
flexShrink: 0,
|
|
2224
|
+
textAlign: 'center',
|
|
2225
|
+
}}
|
|
2226
|
+
>
|
|
2227
|
+
<EditorIcon icon={iconForNode(node)} aria-hidden="true" />
|
|
2228
|
+
</span>
|
|
2229
|
+
|
|
2230
|
+
{isEditing ? (
|
|
2231
|
+
<TextInput
|
|
2232
|
+
autoFocus
|
|
2233
|
+
defaultValue={node.label}
|
|
2234
|
+
onBlur={(e) => {
|
|
2235
|
+
adapter.inspector!.set(node.id, 'name', e.currentTarget.value);
|
|
2236
|
+
store.notifyIngestEdit();
|
|
2237
|
+
onStartEditing('');
|
|
2238
|
+
}}
|
|
2239
|
+
onKeyDown={(e) => {
|
|
2240
|
+
if (e.key === 'Enter') {
|
|
2241
|
+
adapter.inspector!.set(node.id, 'name', e.currentTarget.value);
|
|
2242
|
+
store.notifyIngestEdit();
|
|
2243
|
+
onStartEditing('');
|
|
2244
|
+
}
|
|
2245
|
+
if (e.key === 'Escape') onStartEditing('');
|
|
2246
|
+
}}
|
|
2247
|
+
onClick={(e) => e.stopPropagation()}
|
|
2248
|
+
data-variant="tree-edit"
|
|
2249
|
+
style={{
|
|
2250
|
+
flex: 1,
|
|
2251
|
+
minWidth: 0,
|
|
2252
|
+
}}
|
|
2253
|
+
/>
|
|
2254
|
+
) : (
|
|
2255
|
+
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
2256
|
+
{/* H1 — instance identity AT THE ROW, carried by an UNDERLINE rather
|
|
2257
|
+
than by recolored text (owner, 2026-07-31). Unity tints the label
|
|
2258
|
+
blue; a rule under the name says the same thing without spending
|
|
2259
|
+
the row's one text color, and it survives what color could not:
|
|
2260
|
+
it reads identically in every palette, needs no contrast budget
|
|
2261
|
+
against the row background, and does not compete with the
|
|
2262
|
+
warning badge and lock glyph that sit on this same row. It also
|
|
2263
|
+
says the right thing — an instance REFERENCES a definition
|
|
2264
|
+
elsewhere, which is what an underline has always meant, and what
|
|
2265
|
+
this row's Go to Callsite / Open Component Source actions do.
|
|
2266
|
+
`semantic.instance` survives as the rule's own color, so the hue
|
|
2267
|
+
is a hint rather than the whole signal.
|
|
2268
|
+
|
|
2269
|
+
The suffix rides INSIDE this ellipsizing span, so a long name
|
|
2270
|
+
still truncates with the row instead of pushing the eye/lock
|
|
2271
|
+
glyphs off it, and is deliberately NOT underlined: the underline
|
|
2272
|
+
marks the instance's NAME, and drawing it under `·Enemy` too
|
|
2273
|
+
would read as one hyperlink over both. Search is unaffected:
|
|
2274
|
+
`searchPathKeys`/`highlightSegments` both read `node.label` only,
|
|
2275
|
+
so the suffix can neither create nor highlight a match.
|
|
2276
|
+
|
|
2277
|
+
A LOOK MAY DROP THE SUFFIX (`typeSuffixShown`, the
|
|
2278
|
+
`hierarchyTypeSuffix` chrome region). Blender's does: measured on
|
|
2279
|
+
`outliner.png`, not one row in that frame carries type text after
|
|
2280
|
+
its name, because the type is the GLYPH there.
|
|
2281
|
+
|
|
2282
|
+
A LOOK MAY ALSO DROP THE RULE (`instanceRuleShown`, the
|
|
2283
|
+
`hierarchyInstanceRule` region), and Blender's does — measured
|
|
2284
|
+
across all three Outliner frames, it marks an instance nowhere in
|
|
2285
|
+
that panel, in any selection state. That is a separate decision
|
|
2286
|
+
from the suffix and it took its own region key: the earlier note
|
|
2287
|
+
here reasoned that a row with no suffix is the row that most needs
|
|
2288
|
+
the rule, which is true of THIS editor's row and says nothing
|
|
2289
|
+
about a look whose reference draws neither.
|
|
2290
|
+
|
|
2291
|
+
THE SELECTED NAME takes the palette's active ink where it declares
|
|
2292
|
+
one (`content.active`; Blender's `#ffae28`, an 82-pixel plateau on
|
|
2293
|
+
the Cube in `outliner.png` and again in
|
|
2294
|
+
`modeling-object-selected.png`). Under a palette that names none
|
|
2295
|
+
the token is empty, the declaration is invalid at computed-value
|
|
2296
|
+
time, and the name inherits the row's ink exactly as before.
|
|
2297
|
+
|
|
2298
|
+
A SELECTED-BUT-NOT-ACTIVE name takes `content.selected` instead —
|
|
2299
|
+
Blender's `#e86900` (232,105,0, a solid plateau on Camera and
|
|
2300
|
+
Light in `modeling-object-selected.png`), against `#ffae28` on the
|
|
2301
|
+
active Cube. The emitter falls `content.selected` back to
|
|
2302
|
+
`content.active`, so a palette naming only the one inks every
|
|
2303
|
+
selected row as before.
|
|
2304
|
+
|
|
2305
|
+
The instance rule yields on a selected row — see
|
|
2306
|
+
`--vgai-tree-active-name-underline` in `theme.ts` for why and for
|
|
2307
|
+
what a palette without an active ink gets; a look may also drop
|
|
2308
|
+
the rule outright, above. */}
|
|
2309
|
+
{identity.typeSuffix ? (
|
|
2310
|
+
<span
|
|
2311
|
+
data-testid="hierarchy-instance-label"
|
|
2312
|
+
// WHEN THE SUFFIX IS HIDDEN the name carries both facts the
|
|
2313
|
+
// suffix used to: the sentence moves onto this `title`, and the
|
|
2314
|
+
// override `*` rides the name rather than leaving with the span
|
|
2315
|
+
// it used to sit in. Nothing the row could say is lost — only
|
|
2316
|
+
// the type TEXT, which is what the region asked to drop.
|
|
2317
|
+
{...(typeSuffixShown
|
|
2318
|
+
? {}
|
|
2319
|
+
: { title: `Component instance of <${identity.typeSuffix}>` })}
|
|
2320
|
+
style={{
|
|
2321
|
+
// The LOOK decides first (`instanceRuleShown`); only where the
|
|
2322
|
+
// rule is drawn at all does the palette's active-ink rule get
|
|
2323
|
+
// to make the selected row yield.
|
|
2324
|
+
textDecorationLine: !instanceRuleShown
|
|
2325
|
+
? 'none'
|
|
2326
|
+
: isSelected
|
|
2327
|
+
? 'var(--vgai-tree-active-name-underline)'
|
|
2328
|
+
: 'underline',
|
|
2329
|
+
textDecorationStyle: 'dotted',
|
|
2330
|
+
textDecorationColor: themeVars.semantic.instance,
|
|
2331
|
+
textUnderlineOffset: '3px',
|
|
2332
|
+
...(isSelected
|
|
2333
|
+
? {
|
|
2334
|
+
color: isSelectionActive
|
|
2335
|
+
? themeVars.content.active
|
|
2336
|
+
: themeVars.content.selected,
|
|
2337
|
+
}
|
|
2338
|
+
: {}),
|
|
2339
|
+
}}
|
|
2340
|
+
>
|
|
2341
|
+
{highlightSegments(node.label, searchTerm)}
|
|
2342
|
+
{!typeSuffixShown && prefabOverrideCount > 0 ? '*' : ''}
|
|
2343
|
+
</span>
|
|
2344
|
+
) : (
|
|
2345
|
+
<span
|
|
2346
|
+
data-testid="hierarchy-plain-label"
|
|
2347
|
+
style={
|
|
2348
|
+
isSelected
|
|
2349
|
+
? {
|
|
2350
|
+
color: isSelectionActive
|
|
2351
|
+
? themeVars.content.active
|
|
2352
|
+
: themeVars.content.selected,
|
|
2353
|
+
}
|
|
2354
|
+
: undefined
|
|
2355
|
+
}
|
|
2356
|
+
>
|
|
2357
|
+
{highlightSegments(node.label, searchTerm)}
|
|
2358
|
+
</span>
|
|
2359
|
+
)}
|
|
2360
|
+
{/* THE DATABLOCK SUMMARY, and why it rides INSIDE the label span.
|
|
2361
|
+
The span is `flex: 1`, so anything placed after it lands against
|
|
2362
|
+
the row's right edge where the eye and lock columns are —
|
|
2363
|
+
Blender draws the glyph one cell past the NAME, not at the
|
|
2364
|
+
margin. The suffixes above solve the same problem the same way.
|
|
2365
|
+
|
|
2366
|
+
Measured on `outliner.png` (native 2x, halve for points): the
|
|
2367
|
+
plate is 40x36 device px — one 20-point cell wide, two points
|
|
2368
|
+
shorter than the 20-point row — with a ~10 device-px corner (the
|
|
2369
|
+
left edge is full at y=184 on a box that starts at y=175). Our
|
|
2370
|
+
radius is the material's own `shape.small`; Blender's widgets
|
|
2371
|
+
round at 4 where this plate rounds at 5, and we carry no token
|
|
2372
|
+
for the second number. The cell starts 40-41 device px past the
|
|
2373
|
+
label's last ink on all three of the frame's rows, which is one
|
|
2374
|
+
cell, so the gap is the indent step. */}
|
|
2375
|
+
{datablockSummary.map((child) => (
|
|
2376
|
+
<span
|
|
2377
|
+
key={child.id}
|
|
2378
|
+
className="vgai-tree-datablock"
|
|
2379
|
+
// THE PLATE FOLLOWS THE ACTIVE ROW, NOT THE SELECTION, and the
|
|
2380
|
+
// frames are unambiguous: in `modeling-object-selected.png` all
|
|
2381
|
+
// three rows are selected and NOT ONE carries a mesh-data plate,
|
|
2382
|
+
// while in `outliner.png` (edit mode) only the ACTIVE row's does.
|
|
2383
|
+
// Under `isSelected` a multi-selection plated every selected
|
|
2384
|
+
// row's datablock — a mark Blender never draws.
|
|
2385
|
+
data-active={isSelectionActive ? 'true' : undefined}
|
|
2386
|
+
title={`${child.kind}: ${child.label}`}
|
|
2387
|
+
>
|
|
2388
|
+
<EditorIcon icon={hierarchyKindIcon(child.kind)} aria-hidden="true" />
|
|
2389
|
+
</span>
|
|
2390
|
+
))}
|
|
2391
|
+
{identity.typeSuffix && typeSuffixShown && (
|
|
2392
|
+
<span
|
|
2393
|
+
data-testid="hierarchy-instance-type"
|
|
2394
|
+
data-type-label={identity.typeSuffix}
|
|
2395
|
+
title={`Component instance of <${identity.typeSuffix}>`}
|
|
2396
|
+
style={{
|
|
2397
|
+
marginLeft: spaceVar[2],
|
|
2398
|
+
fontSize: 'var(--vgai-font-sm)',
|
|
2399
|
+
color: themeVars.content.dim,
|
|
2400
|
+
}}
|
|
2401
|
+
>
|
|
2402
|
+
·{identity.typeSuffix}
|
|
2403
|
+
{prefabOverrideCount > 0 ? '*' : ''}
|
|
2404
|
+
</span>
|
|
2405
|
+
)}
|
|
2406
|
+
{node.secondaryLabel && (
|
|
2407
|
+
<span
|
|
2408
|
+
style={{ marginLeft: spaceVar[3], fontSize: 'var(--vgai-font-sm)', opacity: 0.5 }}
|
|
2409
|
+
>
|
|
2410
|
+
{node.secondaryLabel}
|
|
2411
|
+
</span>
|
|
2412
|
+
)}
|
|
2413
|
+
{instancedDetail && (
|
|
2414
|
+
<span
|
|
2415
|
+
data-testid="hierarchy-instanced-units"
|
|
2416
|
+
style={{ marginLeft: spaceVar[3], fontSize: 'var(--vgai-font-sm)', opacity: 0.5 }}
|
|
2417
|
+
>
|
|
2418
|
+
{instancedDetail}
|
|
2419
|
+
</span>
|
|
2420
|
+
)}
|
|
2421
|
+
{badge?.role === 'world' && badge.zOrder !== 0 && (
|
|
2422
|
+
<span
|
|
2423
|
+
title={`Z order: ${badge.zOrder}`}
|
|
2424
|
+
style={{ marginLeft: spaceVar[3], fontSize: 'var(--vgai-font-sm)', opacity: 0.6 }}
|
|
2425
|
+
>
|
|
2426
|
+
Z {badge.zOrder}
|
|
2427
|
+
</span>
|
|
2428
|
+
)}
|
|
2429
|
+
{badge?.provenance && (
|
|
2430
|
+
<span
|
|
2431
|
+
data-testid="world-provenance"
|
|
2432
|
+
data-provenance={badge.provenance.source}
|
|
2433
|
+
title={badge.provenance.detail}
|
|
2434
|
+
style={{
|
|
2435
|
+
marginLeft: spaceVar[3],
|
|
2436
|
+
fontSize: 'var(--vgai-font-xs)',
|
|
2437
|
+
padding: `0 ${space[2]}px`,
|
|
2438
|
+
borderRadius: themeVars.shape.small,
|
|
2439
|
+
border: `1px solid ${themeVars.boundary.strong}`,
|
|
2440
|
+
color: themeVars.content.muted,
|
|
2441
|
+
verticalAlign: 'middle',
|
|
2442
|
+
}}
|
|
2443
|
+
>
|
|
2444
|
+
{badge.provenance.label}
|
|
2445
|
+
</span>
|
|
2446
|
+
)}
|
|
2447
|
+
</span>
|
|
2448
|
+
)}
|
|
2449
|
+
|
|
2450
|
+
{/* H5 — "warnings live on the row" (Godot's yellow `!`). Placed BEFORE
|
|
2451
|
+
the H2 lock glyph deliberately: the two are read together far more
|
|
2452
|
+
often than either is read alone (an unmovable enemy is usually
|
|
2453
|
+
unmovable BECAUSE of an R3F002), and this order puts the cause
|
|
2454
|
+
immediately left of the effect, with both sitting between the label
|
|
2455
|
+
and the interactive action strip (lock/eye buttons) rather than
|
|
2456
|
+
inside it. Like the lock it is NOT a button — a warning is a
|
|
2457
|
+
statement about the source, and the fix is a source edit.
|
|
2458
|
+
|
|
2459
|
+
The tooltip is the analyzer's own sentence(s), verbatim and
|
|
2460
|
+
newline-joined by `rowWarningBadge`; nothing here rewords, ranks or
|
|
2461
|
+
truncates them. */}
|
|
2462
|
+
{warning && (
|
|
2463
|
+
<span
|
|
2464
|
+
data-testid="hierarchy-row-warning"
|
|
2465
|
+
data-warning-codes={warning.codes.join(',')}
|
|
2466
|
+
data-warning-count={warning.count}
|
|
2467
|
+
role="img"
|
|
2468
|
+
title={warning.tooltip}
|
|
2469
|
+
aria-label={`${node.label}: ${warning.tooltip}`}
|
|
2470
|
+
style={{
|
|
2471
|
+
fontSize: 'var(--vgai-font-sm)',
|
|
2472
|
+
color: themeVars.semantic.warning,
|
|
2473
|
+
flexShrink: 0,
|
|
2474
|
+
}}
|
|
2475
|
+
>
|
|
2476
|
+
<EditorIcon icon={faTriangleExclamation} aria-hidden="true" />
|
|
2477
|
+
</span>
|
|
2478
|
+
)}
|
|
2479
|
+
|
|
2480
|
+
{/* H2 — movability at a glance. NOT a button: this is a read-only
|
|
2481
|
+
statement about the source, not a toggle (the toggle is the
|
|
2482
|
+
`ingest-row-lock` control below, which writes the adapter's `locked`
|
|
2483
|
+
property). The tooltip is the adapter's own reason string, verbatim
|
|
2484
|
+
— the same sentence the inspector field and the gizmo-denial hint
|
|
2485
|
+
show. Never paraphrase it here. */}
|
|
2486
|
+
{transformLock.locked && transformLock.reason && (
|
|
2487
|
+
<span
|
|
2488
|
+
data-testid="hierarchy-transform-lock"
|
|
2489
|
+
role="img"
|
|
2490
|
+
title={transformLock.reason}
|
|
2491
|
+
aria-label={`${node.label}: ${transformLock.reason}`}
|
|
2492
|
+
style={{
|
|
2493
|
+
fontSize: 'var(--vgai-font-sm)',
|
|
2494
|
+
color: themeVars.content.dim,
|
|
2495
|
+
flexShrink: 0,
|
|
2496
|
+
}}
|
|
2497
|
+
>
|
|
2498
|
+
<EditorIcon icon={faLock} aria-hidden="true" />
|
|
2499
|
+
</span>
|
|
2500
|
+
)}
|
|
2501
|
+
|
|
2502
|
+
{/* THE SELECTION LOCK is a restriction COLUMN, and a look decides which
|
|
2503
|
+
columns a row carries (`restrictionColumns`, the
|
|
2504
|
+
`hierarchyRestrictions` chrome region — Blender calls the same
|
|
2505
|
+
decision its Outliner filter). Under `viewport+render` this column is
|
|
2506
|
+
not in the set: measured on `outliner.png`, no row in the frame draws
|
|
2507
|
+
a lock, Blender's Disable Selection column being off by default. The
|
|
2508
|
+
`locked` property itself is untouched — the inspector's own field
|
|
2509
|
+
still writes it, so nothing becomes unreachable. */}
|
|
2510
|
+
{restrictionColumns === 'select+viewport' && lockedKnown && affordances.writable && (
|
|
2511
|
+
<IconButton
|
|
2512
|
+
type="button"
|
|
2513
|
+
variant="ghost"
|
|
2514
|
+
size="compact"
|
|
2515
|
+
data-testid="ingest-row-lock"
|
|
2516
|
+
disabled={!lockedWritable}
|
|
2517
|
+
title={
|
|
2518
|
+
lockedWritable ? (locked ? 'Unlock' : 'Lock') : lockedEditability?.reason || 'Locked'
|
|
2519
|
+
}
|
|
2520
|
+
aria-label={locked ? `Unlock ${node.label}` : `Lock ${node.label}`}
|
|
2521
|
+
aria-pressed={locked}
|
|
2522
|
+
onClick={(e) => {
|
|
2523
|
+
e.stopPropagation();
|
|
2524
|
+
adapter.inspector!.set(node.id, 'locked', !locked);
|
|
2525
|
+
store.notifyIngestEdit();
|
|
2526
|
+
}}
|
|
2527
|
+
>
|
|
2528
|
+
<EditorIcon icon={locked ? faLock : faLockOpen} aria-hidden="true" />
|
|
2529
|
+
</IconButton>
|
|
2530
|
+
)}
|
|
2531
|
+
|
|
2532
|
+
{/* THE EXCLUDE COLUMN — Blender's leftmost restriction column, drawn for
|
|
2533
|
+
`LayerCollection` rows (`outliner_draw.cc:1634-1653`) and present in
|
|
2534
|
+
its default `show_restrict_flags` (`space_outliner.cc:399`).
|
|
2535
|
+
|
|
2536
|
+
IT FOLLOWS WHAT THE ADAPTER ANSWERS and nothing else (owner ruling,
|
|
2537
|
+
2026-09-19; the render column already worked this way). The column
|
|
2538
|
+
exists on every row of a tree in which ANY row answers `exclude`
|
|
2539
|
+
(`excludeColumnShown`, resolved once by the panel), and a row that
|
|
2540
|
+
answers nothing gets the reserved blank cell — so the eye and the
|
|
2541
|
+
camera stay in one column down the whole tree, which is the same
|
|
2542
|
+
reason the render cell is reserved.
|
|
2543
|
+
|
|
2544
|
+
The value's sense is Blender's: `true` is EXCLUDED, so the box is
|
|
2545
|
+
CHECKED when the collection is IN the view layer. Blender draws it the
|
|
2546
|
+
same way round — an icon toggle over a `hide_`-sense property, which
|
|
2547
|
+
`outliner_draw.cc:561` inverts. */}
|
|
2548
|
+
{excludeColumnShown &&
|
|
2549
|
+
(excludeKnown && affordances.writable ? (
|
|
2550
|
+
<IconButton
|
|
2551
|
+
type="button"
|
|
2552
|
+
variant="ghost"
|
|
2553
|
+
size="compact"
|
|
2554
|
+
data-testid="ingest-row-exclude"
|
|
2555
|
+
disabled={!excludeWritable}
|
|
2556
|
+
title={
|
|
2557
|
+
excludeWritable
|
|
2558
|
+
? excluded
|
|
2559
|
+
? 'Include in view layer'
|
|
2560
|
+
: 'Exclude from view layer'
|
|
2561
|
+
: excludeEditability?.reason || 'Exclusion is read-only'
|
|
2562
|
+
}
|
|
2563
|
+
aria-label={
|
|
2564
|
+
excluded
|
|
2565
|
+
? `Include ${node.label} in the view layer`
|
|
2566
|
+
: `Exclude ${node.label} from the view layer`
|
|
2567
|
+
}
|
|
2568
|
+
aria-checked={!excluded}
|
|
2569
|
+
role="checkbox"
|
|
2570
|
+
onClick={(e) => {
|
|
2571
|
+
e.stopPropagation();
|
|
2572
|
+
adapter.inspector!.set(node.id, 'exclude', !excluded);
|
|
2573
|
+
store.notifyIngestEdit();
|
|
2574
|
+
}}
|
|
2575
|
+
>
|
|
2576
|
+
<EditorIcon
|
|
2577
|
+
icon={excluded ? OUTLINER_EXCLUDE_OFF : OUTLINER_EXCLUDE_ON}
|
|
2578
|
+
aria-hidden="true"
|
|
2579
|
+
/>
|
|
2580
|
+
</IconButton>
|
|
2581
|
+
) : (
|
|
2582
|
+
<span aria-hidden="true" style={{ width: INDENT, flexShrink: 0 }} />
|
|
2583
|
+
))}
|
|
2584
|
+
|
|
2585
|
+
{/* H6 — the eye is SUPPRESSED on a revealed internal. This gate is
|
|
2586
|
+
contract-level: an adapter may implement `internalChildren` over
|
|
2587
|
+
source-backed or foreign content, and H6's promise is that revealing
|
|
2588
|
+
internals adds NO write surface at all. A row that refuses rename,
|
|
2589
|
+
drag, duplicate and delete but offers one working toggle reads as an
|
|
2590
|
+
accident, not a rule. */}
|
|
2591
|
+
{(visibleKnown || isGroupNode) && affordances.writable && (
|
|
2592
|
+
<IconButton
|
|
2593
|
+
type="button"
|
|
2594
|
+
variant="ghost"
|
|
2595
|
+
size="compact"
|
|
2596
|
+
data-testid="ingest-row-visibility"
|
|
2597
|
+
disabled={!isGroupNode && !visibleWritable}
|
|
2598
|
+
title={
|
|
2599
|
+
isGroupNode || visibleWritable
|
|
2600
|
+
? visible
|
|
2601
|
+
? 'Hide'
|
|
2602
|
+
: 'Show'
|
|
2603
|
+
: visibleEditability?.reason || 'Visibility is read-only'
|
|
2604
|
+
}
|
|
2605
|
+
aria-label={visible ? `Hide ${node.label}` : `Show ${node.label}`}
|
|
2606
|
+
aria-pressed={!visible}
|
|
2607
|
+
onClick={(e) => {
|
|
2608
|
+
e.stopPropagation();
|
|
2609
|
+
if (badge) {
|
|
2610
|
+
toggleRootHidden(badge.worldId);
|
|
2611
|
+
store.notifyIngestEdit();
|
|
2612
|
+
} else {
|
|
2613
|
+
adapter.inspector!.set(node.id, 'visible', !visible);
|
|
2614
|
+
store.notifyIngestEdit();
|
|
2615
|
+
}
|
|
2616
|
+
}}
|
|
2617
|
+
>
|
|
2618
|
+
<EditorIcon icon={visible ? faEye : faEyeSlash} aria-hidden="true" />
|
|
2619
|
+
</IconButton>
|
|
2620
|
+
)}
|
|
2621
|
+
|
|
2622
|
+
{/* THE RENDER COLUMN. Under `viewport+render` the row's column set is
|
|
2623
|
+
Blender's, whose outer slot is Disable in Renders
|
|
2624
|
+
(`outliner_draw.cc:1363-1384`, `Object.hide_render`).
|
|
2625
|
+
|
|
2626
|
+
BLANK UNLESS THE ADAPTER SEPARATES THE TWO, which almost none do:
|
|
2627
|
+
three.js has ONE `Object3D.visible` governing viewport and render
|
|
2628
|
+
alike, so a toggle over it would write nothing and fail "a control is
|
|
2629
|
+
accepted through its own click". It is then the POSITION that is
|
|
2630
|
+
load-bearing — Blender's columns are slots, its eye 32 CSS px in from
|
|
2631
|
+
the area's right edge and its camera 12, so an eye drawn last lands
|
|
2632
|
+
in the camera's slot and aligns with neither. One reserved cell (the
|
|
2633
|
+
tree's own indent step, which is Blender's 20 px column pitch) puts
|
|
2634
|
+
the eye where Blender's eye is.
|
|
2635
|
+
|
|
2636
|
+
An adapter that DOES separate them answers the reserved
|
|
2637
|
+
`renderVisible` path and gets the real toggle: `@volter/editor-blender`'s
|
|
2638
|
+
Outliner, whose rows carry `hide_viewport` and `hide_render` as two
|
|
2639
|
+
different facts. Not rendered at all under the editor's own column
|
|
2640
|
+
pair, so no other look gains a gutter. */}
|
|
2641
|
+
{restrictionColumns === 'viewport+render' &&
|
|
2642
|
+
(renderKnown && affordances.writable ? (
|
|
2643
|
+
<IconButton
|
|
2644
|
+
type="button"
|
|
2645
|
+
variant="ghost"
|
|
2646
|
+
size="compact"
|
|
2647
|
+
data-testid="ingest-row-render-visibility"
|
|
2648
|
+
disabled={!renderWritable}
|
|
2649
|
+
title={
|
|
2650
|
+
renderWritable
|
|
2651
|
+
? renderVisible
|
|
2652
|
+
? 'Disable in renders'
|
|
2653
|
+
: 'Enable in renders'
|
|
2654
|
+
: renderEditability?.reason || 'Render visibility is read-only'
|
|
2655
|
+
}
|
|
2656
|
+
aria-label={
|
|
2657
|
+
renderVisible ? `Disable ${node.label} in renders` : `Enable ${node.label} in renders`
|
|
2658
|
+
}
|
|
2659
|
+
aria-pressed={!renderVisible}
|
|
2660
|
+
onClick={(e) => {
|
|
2661
|
+
e.stopPropagation();
|
|
2662
|
+
adapter.inspector!.set(node.id, 'renderVisible', !renderVisible);
|
|
2663
|
+
store.notifyIngestEdit();
|
|
2664
|
+
}}
|
|
2665
|
+
>
|
|
2666
|
+
<EditorIcon
|
|
2667
|
+
icon={renderVisible ? OUTLINER_RENDER_ON : OUTLINER_RENDER_OFF}
|
|
2668
|
+
aria-hidden="true"
|
|
2669
|
+
/>
|
|
2670
|
+
</IconButton>
|
|
2671
|
+
) : (
|
|
2672
|
+
<span aria-hidden="true" style={{ width: INDENT, flexShrink: 0 }} />
|
|
2673
|
+
))}
|
|
2674
|
+
|
|
2675
|
+
{/* B1 (D9) — session-local, per-world design-time toggles: `interactive`
|
|
2676
|
+
* forwards real pointer events to the world's layer (design-time
|
|
2677
|
+
* hover/press testing, `design-time-layers.ts`); `pickLock` is stored
|
|
2678
|
+
* for B4's layered viewport picking to consume. Group rows only —
|
|
2679
|
+
* neither concept applies to an ordinary entity row. */}
|
|
2680
|
+
{badge && (
|
|
2681
|
+
<IconButton
|
|
2682
|
+
type="button"
|
|
2683
|
+
variant="ghost"
|
|
2684
|
+
size="compact"
|
|
2685
|
+
data-testid="world-interactive-toggle"
|
|
2686
|
+
title={
|
|
2687
|
+
worldInteractive
|
|
2688
|
+
? 'Interactive (layer forwards pointer events)'
|
|
2689
|
+
: 'Not interactive (layer is pointer-events:none)'
|
|
2690
|
+
}
|
|
2691
|
+
aria-label={`Forward pointer events to ${node.label}`}
|
|
2692
|
+
aria-pressed={worldInteractive}
|
|
2693
|
+
onClick={(e) => {
|
|
2694
|
+
e.stopPropagation();
|
|
2695
|
+
toggleRootInteractive(badge.worldId);
|
|
2696
|
+
store.notifyIngestEdit();
|
|
2697
|
+
}}
|
|
2698
|
+
>
|
|
2699
|
+
<EditorIcon icon={faHandPointer} aria-hidden="true" />
|
|
2700
|
+
</IconButton>
|
|
2701
|
+
)}
|
|
2702
|
+
{badge && (
|
|
2703
|
+
<IconButton
|
|
2704
|
+
type="button"
|
|
2705
|
+
variant="ghost"
|
|
2706
|
+
size="compact"
|
|
2707
|
+
data-testid="world-picklock-toggle"
|
|
2708
|
+
title={
|
|
2709
|
+
worldPickLocked ? 'Pick-locked (skipped by topmost-layer picking)' : 'Not pick-locked'
|
|
2710
|
+
}
|
|
2711
|
+
aria-label={`Exclude ${node.label} from viewport picking`}
|
|
2712
|
+
aria-pressed={worldPickLocked}
|
|
2713
|
+
onClick={(e) => {
|
|
2714
|
+
e.stopPropagation();
|
|
2715
|
+
toggleRootPickLock(badge.worldId);
|
|
2716
|
+
store.notifyIngestEdit();
|
|
2717
|
+
}}
|
|
2718
|
+
>
|
|
2719
|
+
<EditorIcon icon={faCrosshairs} aria-hidden="true" />
|
|
2720
|
+
</IconButton>
|
|
2721
|
+
)}
|
|
2722
|
+
</div>
|
|
2723
|
+
);
|
|
2724
|
+
}, sameRowProps);
|
|
2725
|
+
|
|
2726
|
+
// --- Main panel ---
|
|
2727
|
+
|
|
2728
|
+
export interface GameHierarchySurfaceProps {
|
|
2729
|
+
readonly store: EditorShellStore;
|
|
2730
|
+
readonly adapter: AuthoringAdapter;
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
/** The half of the row pipeline derived from the TREE alone — memoized on
|
|
2734
|
+
* `[adapter, store.contentVersion, adapterVersion]`. See the block that
|
|
2735
|
+
* builds it. */
|
|
2736
|
+
interface StructuralRowView {
|
|
2737
|
+
readonly adapter: AuthoringAdapter;
|
|
2738
|
+
readonly contentVersion: number;
|
|
2739
|
+
readonly objectMapEpoch: number;
|
|
2740
|
+
readonly marked: ReturnType<typeof componentMarkView>;
|
|
2741
|
+
readonly internals: ReturnType<typeof internalsProjection>;
|
|
2742
|
+
readonly hierarchyView: ReturnType<typeof authoredContentHierarchy>;
|
|
2743
|
+
readonly structuralRows: HierarchyNodeRow[];
|
|
2744
|
+
readonly defaultExpansion: Map<string, boolean>;
|
|
2745
|
+
readonly index: RowPathIndex;
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
/** The FOLD projected onto a {@link StructuralRowView} — the collapsed set
|
|
2749
|
+
* every row is drawn with, plus the scope row it is relative to. Memoized on
|
|
2750
|
+
* the structure, the fold generation and the scope; NOT on the selection. */
|
|
2751
|
+
interface FoldRowView {
|
|
2752
|
+
readonly scopeRow: HierarchyNodeRow | null;
|
|
2753
|
+
readonly effectiveCollapsedPaths: ReadonlySet<string>;
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
/** The capped/searched/scoped view built on top of a {@link StructuralRowView}
|
|
2757
|
+
* and a {@link FoldRowView}. */
|
|
2758
|
+
interface BrowseRowView {
|
|
2759
|
+
readonly rows: HierarchyNodeRow[];
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
/** Production hierarchy surface with its runtime dependencies made explicit.
|
|
2763
|
+
* The editor shell supplies the active store/adapter below; bounded hosts such
|
|
2764
|
+
* as Storybook can supply the same public authoring contract without booting a
|
|
2765
|
+
* project session or maintaining a second hierarchy implementation. */
|
|
2766
|
+
export function GameHierarchySurface({ store, adapter }: GameHierarchySurfaceProps) {
|
|
2767
|
+
useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
2768
|
+
const deferredContentVersion = useDeferredValue(store.contentVersion);
|
|
2769
|
+
useSyncExternalStore(subscribeSelectionScope, selectionScopeVersion);
|
|
2770
|
+
// THE ROW MARKS THE ACTIVE LOOK ASKS FOR, read ONCE per panel render and
|
|
2771
|
+
// handed down as ordinary props — never per row. Same shape as
|
|
2772
|
+
// `HeaderTelemetry.tsx`'s read of the same store; the key is a stable string
|
|
2773
|
+
// so `useSyncExternalStore` can compare snapshots by identity.
|
|
2774
|
+
useSyncExternalStore(subscribeChromeRegions, chromeRegionsKey, chromeRegionsKey);
|
|
2775
|
+
const typeSuffixShown = activeChromeRegions().hierarchyTypeSuffix !== 'hidden';
|
|
2776
|
+
const instanceRuleShown = activeChromeRegions().hierarchyInstanceRule !== 'hidden';
|
|
2777
|
+
const restrictionColumns =
|
|
2778
|
+
activeChromeRegions().hierarchyRestrictions ?? ('select+viewport' as const);
|
|
2779
|
+
const [adapterVersion, forceAdapterUpdate] = useReducer((value: number) => value + 1, 0);
|
|
2780
|
+
const observedStoreVersion = useRef(store.getSnapshot());
|
|
2781
|
+
observedStoreVersion.current = store.getSnapshot();
|
|
2782
|
+
const [collaboration, setCollaboration] = useState<CollaborationSnapshot | null>(
|
|
2783
|
+
collaborationSnapshot(),
|
|
2784
|
+
);
|
|
2785
|
+
// AN ADAPTER'S NOTIFICATION ALWAYS BUMPS `adapterVersion`, and that version is
|
|
2786
|
+
// a ROW PROP (see {@link sameRowProps}). It used to bump only when the store
|
|
2787
|
+
// had not moved, which was enough while every adapter mutated its truth
|
|
2788
|
+
// SYNCHRONOUSLY inside the click that changed it: the store's own re-render
|
|
2789
|
+
// then already carried the new value. An adapter whose truth is somewhere
|
|
2790
|
+
// else answers LATER — `@volter/editor-blender`'s Outliner writes a restriction column
|
|
2791
|
+
// into the engine and re-reads the tree a round trip afterwards — and by then
|
|
2792
|
+
// the store's version has long since moved, so the notification was dropped
|
|
2793
|
+
// and the row kept drawing the state it had before the click (measured
|
|
2794
|
+
// 2026-09-19: the eye wrote `hide_viewport` in Blender, the tree re-read, the
|
|
2795
|
+
// store published, and the row's eye never moved). One render per
|
|
2796
|
+
// notification is what a notification means.
|
|
2797
|
+
useEffect(
|
|
2798
|
+
() =>
|
|
2799
|
+
adapter.subscribe?.(() => {
|
|
2800
|
+
observedStoreVersion.current = store.getSnapshot();
|
|
2801
|
+
forceAdapterUpdate();
|
|
2802
|
+
}),
|
|
2803
|
+
[adapter, store],
|
|
2804
|
+
);
|
|
2805
|
+
useEffect(() => connectCollaboration(setCollaboration), []);
|
|
2806
|
+
const remoteSelectionColors = useMemo(() => {
|
|
2807
|
+
const byNode = new Map<string, string[]>();
|
|
2808
|
+
for (const participant of collaboration?.participants ?? []) {
|
|
2809
|
+
if (participant.participantId === EDITOR_PARTICIPANT_ID) continue;
|
|
2810
|
+
for (const selectedId of participant.presence.selection) {
|
|
2811
|
+
const colors = byNode.get(selectedId) ?? [];
|
|
2812
|
+
if (!colors.includes(participant.color)) colors.push(participant.color);
|
|
2813
|
+
byNode.set(selectedId, colors);
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
return byNode;
|
|
2817
|
+
}, [collaboration]);
|
|
2818
|
+
// H2 — one memo of the per-row transform-lock verdict for the WHOLE panel,
|
|
2819
|
+
// so `editability` (which the R3F adapter answers by re-parsing JSX
|
|
2820
|
+
// attributes out of cached source text) is called at most once per row per
|
|
2821
|
+
// change, not three times per row per render. The invalidation key is the
|
|
2822
|
+
// sum of the two structural counters: the shell store's deferred content
|
|
2823
|
+
// version and this component's own adapter-subscription counter. Both are
|
|
2824
|
+
// monotone, so the sum strictly
|
|
2825
|
+
// increases whenever either does — a stale entry cannot outlive a source
|
|
2826
|
+
// edit. The cache is a `useRef` (never module state, so it can't leak
|
|
2827
|
+
// between editor sessions) and additionally clears itself whenever the
|
|
2828
|
+
// adapter identity changes; see `TransformLockCache`.
|
|
2829
|
+
const lockCacheRef = useRef(new TransformLockCache());
|
|
2830
|
+
// H5 — the same memo shape and the SAME key for the row's authorability
|
|
2831
|
+
// warnings. Sharing the key is not a shortcut: diagnostics ride the OID index,
|
|
2832
|
+
// and the R3F adapter's `refreshSourceState()` ends by calling
|
|
2833
|
+
// `store.notifyIngestEdit()`, so a newly-fetched index bumps the content version
|
|
2834
|
+
// and clears this cache on the same tick it clears the lock cache.
|
|
2835
|
+
const warningCacheRef = useRef(new RowWarningCache());
|
|
2836
|
+
// Selection/play-state notifications still re-render synchronously through
|
|
2837
|
+
// useSyncExternalStore above, but cannot change transform editability. Keep
|
|
2838
|
+
// the expensive per-row lock/body queries on the store's explicit facet
|
|
2839
|
+
// epoch. An exact object-map delta admits/removes rows but cannot change the
|
|
2840
|
+
// reflected properties of survivors; new ids miss both caches naturally.
|
|
2841
|
+
const editabilityVersion = store.hierarchyRowFacetVersion;
|
|
2842
|
+
// A4 — recomputed per notify (never cached across notifies): drives the
|
|
2843
|
+
// world-group rows' ●/○ focus radio.
|
|
2844
|
+
const threeViewportRootId = resolveThreeViewportRootId(store);
|
|
2845
|
+
|
|
2846
|
+
const [search, setSearch] = useState('');
|
|
2847
|
+
// The dock group's header-actions element, when the shell published one —
|
|
2848
|
+
// the Outliner's controls ride the tab strip rather than a row of their own
|
|
2849
|
+
// (`hierarchy-header-slot.ts`).
|
|
2850
|
+
const headerSlot = useSyncExternalStore(
|
|
2851
|
+
subscribeHierarchyHeaderSlot,
|
|
2852
|
+
hierarchyHeaderSlot,
|
|
2853
|
+
() => null,
|
|
2854
|
+
);
|
|
2855
|
+
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
2856
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
2857
|
+
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
|
2858
|
+
const [tailDrop, setTailDrop] = useState(false);
|
|
2859
|
+
const [draggedIds, setDraggedIds] = useState<string[] | null>(null);
|
|
2860
|
+
/** The sentence explaining why the CURRENT hover would do nothing, or `null`.
|
|
2861
|
+
* A ref, not state: it must not re-render the panel on every dragover frame,
|
|
2862
|
+
* and it is only ever read once, at drop. */
|
|
2863
|
+
const dropRefusalRef = useRef<string | null>(null);
|
|
2864
|
+
// D9 — world-group visibility (eye) is SESSION-LOCAL shell state, never
|
|
2865
|
+
// routed through `inspector`/serialized. B1 LIFTED this out of a local
|
|
2866
|
+
// `useState` (which the design-time layer stack could never see) into the
|
|
2867
|
+
// module-level `world-session-state.ts` singleton, so `design-time-layers.ts`
|
|
2868
|
+
// can actually hide/show a world's DOM layer, not just dim its hierarchy
|
|
2869
|
+
// row — see that module's header comment.
|
|
2870
|
+
// Scroll-window state — the live `scrollTop`, updated by
|
|
2871
|
+
// `onScroll` below; `clientHeight` is read fresh from `containerRef` every
|
|
2872
|
+
// render rather than cached in state (see the constants' doc comment).
|
|
2873
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
2874
|
+
|
|
2875
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
2876
|
+
// THE OUTLINER, ONLY WHEN A LOOK PAINTS ONE. `data-vgai-region` is the
|
|
2877
|
+
// host's capability attribute for "this is that editor AREA", and it is
|
|
2878
|
+
// published here too because a panel's body can live in a render container
|
|
2879
|
+
// of the host's own, where the region's copy is not an ancestor of anything
|
|
2880
|
+
// inside the panel.
|
|
2881
|
+
//
|
|
2882
|
+
// The condition is the PALETTE's claim, never a look's name: a palette that
|
|
2883
|
+
// names `color.region.outliner` paints editor areas as areas, and the
|
|
2884
|
+
// frame-justified chrome that only reads as chrome inside one (the empty-row
|
|
2885
|
+
// zebra below, `theme.css`) is that look's. A host with none declared —
|
|
2886
|
+
// Classic's reference frame — gets no attribute and no zebra.
|
|
2887
|
+
const outlinerRegion = useSyncExternalStore(
|
|
2888
|
+
subscribeEditorTheme,
|
|
2889
|
+
editorPaintedRegions,
|
|
2890
|
+
editorPaintedRegions,
|
|
2891
|
+
).includes('outliner')
|
|
2892
|
+
? 'outliner'
|
|
2893
|
+
: undefined;
|
|
2894
|
+
const revealedRef = useRef(new Map<string, number>());
|
|
2895
|
+
const sessionExpansionRef = useRef(new Map<string, boolean>());
|
|
2896
|
+
// The row pipeline's two memo slots — see the block that reads them below.
|
|
2897
|
+
// A `useRef`, never module state, so nothing survives an editor session.
|
|
2898
|
+
const rowCacheRef = useRef(
|
|
2899
|
+
new HierarchyRowCache<StructuralRowView, BrowseRowView, FoldRowView>(),
|
|
2900
|
+
);
|
|
2901
|
+
/**
|
|
2902
|
+
* Bumped by every REAL write to the two fold stores this panel owns — the
|
|
2903
|
+
* session map above, the per-project persisted preferences, and the
|
|
2904
|
+
* "show more children" counts. It is the browse memo's invalidation key,
|
|
2905
|
+
* because neither store is a value the memo could compare: both are mutable
|
|
2906
|
+
* maps this component writes in place, and one of them (`localStorage`-backed
|
|
2907
|
+
* expansion preferences) is not even held here.
|
|
2908
|
+
*/
|
|
2909
|
+
const expansionGenerationRef = useRef(0);
|
|
2910
|
+
const sessionExpansionProjectRef = useRef<string | null>(null);
|
|
2911
|
+
const lastSelectionExpansionRef = useRef<{
|
|
2912
|
+
adapter: AuthoringAdapter;
|
|
2913
|
+
selection: string;
|
|
2914
|
+
} | null>(null);
|
|
2915
|
+
const lastClickedRef = useRef<string | null>(null);
|
|
2916
|
+
const lastAutoRevealRef = useRef<{ adapter: AuthoringAdapter; selection: string } | null>(null);
|
|
2917
|
+
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
|
2918
|
+
|
|
2919
|
+
const term = search.trim().toLowerCase();
|
|
2920
|
+
const projectIdentity = getCurrentProject()?.rootPath ?? '__unscoped__';
|
|
2921
|
+
const selected = adapter.selection?.get() ?? [];
|
|
2922
|
+
// The toolbar's generic Add menu targets the selected hierarchy node. The
|
|
2923
|
+
// composite adapter then routes the request to that node's owning world
|
|
2924
|
+
// adapter, whose `creatableKinds` is the sole source of menu contents.
|
|
2925
|
+
// Multi-selection has no unambiguous parent, so it intentionally falls
|
|
2926
|
+
// back to the active adapter's root-level insertion behavior.
|
|
2927
|
+
const createTargetId = selected.length === 1 ? selected[0]! : null;
|
|
2928
|
+
const selectionSignature = [...selected].sort().join('\0');
|
|
2929
|
+
const selectedIds = useMemo(
|
|
2930
|
+
() => new Set(selectionSignature === '' ? [] : selectionSignature.split('\0')),
|
|
2931
|
+
[selectionSignature],
|
|
2932
|
+
);
|
|
2933
|
+
// THE SELECTION'S ANCHOR, read from the UNSORTED array — the signature above
|
|
2934
|
+
// sorts, which is right for a memo key ("is it the same SET?") and destroys
|
|
2935
|
+
// the one thing this needs. `selection.get()` reports the adapter's own order
|
|
2936
|
+
// and every stock adapter hands back its insertion-ordered set, so the last
|
|
2937
|
+
// id is the one the selection took last. That id is already what the gizmo
|
|
2938
|
+
// binds to (`EditorShellStore.selectedEntityId`) and what the inspector
|
|
2939
|
+
// inspects (`resolveInspectionSubjectId`); the Outliner's active row is those
|
|
2940
|
+
// two panels saying which row they are answering about, not a new fact.
|
|
2941
|
+
const selectionAnchorId = selected.at(-1) ?? null;
|
|
2942
|
+
const scopeStack = selectionScopeStack(adapter);
|
|
2943
|
+
const scopeId = scopeStack.at(-1) ?? null;
|
|
2944
|
+
|
|
2945
|
+
// THE SELECTED ROW IS ON SCREEN. Reveal opened the ancestors, but the list
|
|
2946
|
+
// never scrolled: a dropped tower was selected at row 24 while the list
|
|
2947
|
+
// clipped at row ~15, so the author still had to hunt for what they had
|
|
2948
|
+
// just dropped (build-47 gate, 2026-09-02). Scroll once per selection
|
|
2949
|
+
// change — and retry across structure updates until the row exists, since
|
|
2950
|
+
// a drop selects its element before the hierarchy has absorbed the remount
|
|
2951
|
+
// — never on later edits, so an author who scrolled away is not dragged
|
|
2952
|
+
// back.
|
|
2953
|
+
const lastScrolledSelectionRef = useRef('');
|
|
2954
|
+
useEffect(() => {
|
|
2955
|
+
if (selectionSignature === '' || lastScrolledSelectionRef.current === selectionSignature) {
|
|
2956
|
+
return;
|
|
2957
|
+
}
|
|
2958
|
+
const container = containerRef.current;
|
|
2959
|
+
const row = container?.querySelector<HTMLElement>('[data-selected="true"]');
|
|
2960
|
+
if (!container || !row) return;
|
|
2961
|
+
const box = container.getBoundingClientRect();
|
|
2962
|
+
const at = row.getBoundingClientRect();
|
|
2963
|
+
if (at.top < box.top || at.bottom > box.bottom) row.scrollIntoView({ block: 'nearest' });
|
|
2964
|
+
lastScrolledSelectionRef.current = selectionSignature;
|
|
2965
|
+
});
|
|
2966
|
+
|
|
2967
|
+
if (sessionExpansionProjectRef.current !== projectIdentity) {
|
|
2968
|
+
sessionExpansionRef.current.clear();
|
|
2969
|
+
sessionExpansionProjectRef.current = projectIdentity;
|
|
2970
|
+
lastSelectionExpansionRef.current = null;
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
// H6 — the hierarchy the panel walks is the adapter's own, plus the internals
|
|
2974
|
+
// of whatever rows the author revealed. Everything downstream (cap, collapse,
|
|
2975
|
+
// search, selection reveal, virtualization) is unchanged and unaware: reveal
|
|
2976
|
+
// composes by feeding the SAME flattener a richer provider, never by forking
|
|
2977
|
+
// the row pipeline. Identity-equal to `adapter.hierarchy` when nothing is
|
|
2978
|
+
// revealed, which is the overwhelmingly common case.
|
|
2979
|
+
//
|
|
2980
|
+
// HIER-COMPONENT-TREE composes IN FRONT of that: the game's own
|
|
2981
|
+
// `vgaiComponentRoot`/`vgaiBuiltInternal` marks turn an instance into one
|
|
2982
|
+
// closed row and fold its runtime-built parts (bones, particle renderers)
|
|
2983
|
+
// into exactly the internals reveal already knows how to show. Feeding the
|
|
2984
|
+
// marked view to `internalsProjection` instead of the bare adapter is the
|
|
2985
|
+
// WHOLE integration — read-only, search exclusion, drag refusal and the
|
|
2986
|
+
// Reveal Internals menu item are the same code for both sources of internals.
|
|
2987
|
+
// ---------------------------------------------------------------------
|
|
2988
|
+
// THE ROW PIPELINE, IN TWO MEMOIZED HALVES.
|
|
2989
|
+
//
|
|
2990
|
+
// Everything from here to `rows` used to run on every render — and this
|
|
2991
|
+
// panel re-renders on every store notify, selection included. On a
|
|
2992
|
+
// 20 000-node world that is two uncapped flattens, a per-node first-open
|
|
2993
|
+
// policy and a per-row ancestor climb, paid for a click that moved a
|
|
2994
|
+
// highlight: 100-430ms of main-thread block AFTER a `select` whose reply
|
|
2995
|
+
// had already gone out in 3ms (scale-speed baseline, 22 of 43 offenders).
|
|
2996
|
+
//
|
|
2997
|
+
// The split is by what each half actually READS. `structure` reads the tree
|
|
2998
|
+
// and nothing else, so it keys on the adapter plus `store.contentVersion`
|
|
2999
|
+
// and the adapter's own structural notification counter —
|
|
3000
|
+
// the counter that stands still for a selection-only notify and moves for
|
|
3001
|
+
// every other mutation, the adapter's own `notifyIngestEdit` on a live
|
|
3002
|
+
// structural change included. `browse` reads the fold/scope/search/reveal
|
|
3003
|
+
// state on top of it. Selection reaches `browse` only through the reveal it
|
|
3004
|
+
// opens, which is why the two helpers below are now indexed rather than
|
|
3005
|
+
// scanned: a click that reveals nothing new leaves both halves untouched.
|
|
3006
|
+
// ---------------------------------------------------------------------
|
|
3007
|
+
const rowCache = rowCacheRef.current;
|
|
3008
|
+
const structuralKey = [adapter, deferredContentVersion, adapterVersion] as const;
|
|
3009
|
+
const structure = rowCache.structural(structuralKey, (previous) => {
|
|
3010
|
+
const marked = componentMarkView(
|
|
3011
|
+
adapter as unknown as MarkedTreeSource,
|
|
3012
|
+
markReaderFor(adapter.hierarchy),
|
|
3013
|
+
);
|
|
3014
|
+
const internals = internalsProjection(marked);
|
|
3015
|
+
const hierarchyView = authoredContentHierarchy(internals.hierarchy);
|
|
3016
|
+
// Always take one uncapped, uncollapsed structural snapshot first. It is
|
|
3017
|
+
// the source for stable defaults, search ancestry, selection reveal, and
|
|
3018
|
+
// branch operations. The final browse tree below is still capped and
|
|
3019
|
+
// virtualized, so this does not inflate rendered DOM for large ingests.
|
|
3020
|
+
const objectMapEpoch = store.gizmoEpoch;
|
|
3021
|
+
const contentDelta = previous ? deferredContentVersion - previous.contentVersion : 0;
|
|
3022
|
+
const objectMapDelta = previous ? objectMapEpoch - previous.objectMapEpoch : 0;
|
|
3023
|
+
const incrementalChanges =
|
|
3024
|
+
previous?.adapter === adapter &&
|
|
3025
|
+
deferredContentVersion === store.contentVersion &&
|
|
3026
|
+
contentDelta > 0 &&
|
|
3027
|
+
contentDelta === objectMapDelta
|
|
3028
|
+
? store.ingestObjectMapChangesSince(previous.objectMapEpoch)
|
|
3029
|
+
: null;
|
|
3030
|
+
const patchedRows =
|
|
3031
|
+
previous && incrementalChanges
|
|
3032
|
+
? patchUniqueHierarchyRows(
|
|
3033
|
+
hierarchyView,
|
|
3034
|
+
previous.structuralRows,
|
|
3035
|
+
incrementalChanges.changedParentIds,
|
|
3036
|
+
)
|
|
3037
|
+
: null;
|
|
3038
|
+
const structuralRows = patchedRows ?? flattenHierarchyRows(hierarchyView, new Set());
|
|
3039
|
+
// H6 first-open policy: the revealed row opens, its internals start folded —
|
|
3040
|
+
// the reveal root's DIRECT children are what "reveal" means; a 60-bone rig
|
|
3041
|
+
// dumped whole is not. See `applyRevealExpansionDefaults`.
|
|
3042
|
+
// Order is the policy: shape rule first, then "an instance opens closed",
|
|
3043
|
+
// then reveal (which must win — revealing an instance's internals that left
|
|
3044
|
+
// the instance itself folded would look like the action did nothing). All
|
|
3045
|
+
// three are DEFAULTS; the author's session/persisted fold still wins over
|
|
3046
|
+
// every one of them.
|
|
3047
|
+
const defaultExpansion = applyRevealExpansionDefaults(
|
|
3048
|
+
applyComponentRootExpansionDefaults(
|
|
3049
|
+
defaultExpansionByNode(structuralRows, adapter),
|
|
3050
|
+
structuralRows,
|
|
3051
|
+
(id) => marked.isComponentRoot(id),
|
|
3052
|
+
),
|
|
3053
|
+
internals,
|
|
3054
|
+
);
|
|
3055
|
+
return {
|
|
3056
|
+
adapter,
|
|
3057
|
+
contentVersion: deferredContentVersion,
|
|
3058
|
+
objectMapEpoch,
|
|
3059
|
+
marked,
|
|
3060
|
+
internals,
|
|
3061
|
+
hierarchyView,
|
|
3062
|
+
structuralRows,
|
|
3063
|
+
defaultExpansion,
|
|
3064
|
+
index: indexRowPaths(structuralRows),
|
|
3065
|
+
};
|
|
3066
|
+
});
|
|
3067
|
+
const { marked, internals, hierarchyView, structuralRows, defaultExpansion } = structure;
|
|
3068
|
+
|
|
3069
|
+
// The fold state a row is ACTUALLY rendered with: the session's own choice
|
|
3070
|
+
// first, then this project's persisted preference, then the structural
|
|
3071
|
+
// default, then open. Every consumer below asks this question by node id —
|
|
3072
|
+
// including the selection-reveal guard, which is why it is not a
|
|
3073
|
+
// row-shaped helper.
|
|
3074
|
+
const expandedById = (id: string): boolean =>
|
|
3075
|
+
sessionExpansionRef.current.get(id) ??
|
|
3076
|
+
hierarchyExpansionPreference(projectIdentity, id) ??
|
|
3077
|
+
defaultExpansion.get(id) ??
|
|
3078
|
+
true;
|
|
3079
|
+
|
|
3080
|
+
// Selection reveal behaves like an ordinary tree expansion for the rest of
|
|
3081
|
+
// this editor session. It is applied only when selection changes, so a user
|
|
3082
|
+
// can explicitly collapse the revealed branch without the current
|
|
3083
|
+
// selection immediately forcing it open again.
|
|
3084
|
+
const lastExpansion = lastSelectionExpansionRef.current;
|
|
3085
|
+
if (lastExpansion?.adapter !== adapter || lastExpansion.selection !== selectionSignature) {
|
|
3086
|
+
for (const id of selectedAncestorNodeIds(structure.index, selectedIds)) {
|
|
3087
|
+
// Only a REAL change counts. Re-asserting a fold that is already open is
|
|
3088
|
+
// what selecting an already-visible node does, and letting that bump the
|
|
3089
|
+
// generation would make the browse memo miss on every single click.
|
|
3090
|
+
//
|
|
3091
|
+
// "Already open" is the EFFECTIVE state, not just the session map's own
|
|
3092
|
+
// entry. Reading only the session map made every first selection of a
|
|
3093
|
+
// branch bump the generation — a node open by default or by the stored
|
|
3094
|
+
// preference has no session entry at all — so the memo below missed on
|
|
3095
|
+
// essentially every click and re-derived the whole browse view for a
|
|
3096
|
+
// fold that never moved.
|
|
3097
|
+
if (expandedById(id)) continue;
|
|
3098
|
+
sessionExpansionRef.current.set(id, true);
|
|
3099
|
+
expansionGenerationRef.current += 1;
|
|
3100
|
+
}
|
|
3101
|
+
// A selection the index cannot place yet has no ancestors to open — a
|
|
3102
|
+
// drop selects the element it made BEFORE the hierarchy's structure has
|
|
3103
|
+
// absorbed the remount, so the reveal above found nothing and, with the
|
|
3104
|
+
// signature recorded here, never tried again: the new tower sat selected
|
|
3105
|
+
// under a collapsed scene root (measured on the build-44 gate,
|
|
3106
|
+
// 2026-09-02). Record the reveal as done only once every selected id is
|
|
3107
|
+
// in the index; until then the next structure retries it.
|
|
3108
|
+
const everySelectedPlaced = [...selectedIds].every((id) =>
|
|
3109
|
+
structure.index.pathKeysByNodeId.has(id),
|
|
3110
|
+
);
|
|
3111
|
+
if (everySelectedPlaced) {
|
|
3112
|
+
lastSelectionExpansionRef.current = { adapter, selection: selectionSignature };
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
const isExpanded = (row: HierarchyNodeRow): boolean => expandedById(row.node.id);
|
|
3117
|
+
|
|
3118
|
+
// Cap browse-view children (D-C1) whenever a non-first-party/composite
|
|
3119
|
+
// session is active (an ingested/multi-world tree may be huge), OR while
|
|
3120
|
+
// playing (a live scene graph can also balloon) — matches the exact
|
|
3121
|
+
// capping policy the former SceneHierarchy (play-only) / IngestHierarchy
|
|
3122
|
+
// (always, unless searching) components each had on their own.
|
|
3123
|
+
const capEnabled = term.length === 0 && (hasAuthoringOverride() || store.playState !== 'stopped');
|
|
3124
|
+
const temporaryRevealed = mergeRevealCounts(
|
|
3125
|
+
revealedRef.current,
|
|
3126
|
+
capEnabled ? selectedRevealCounts(structure.index, selectedIds) : EMPTY_REVEAL_COUNTS,
|
|
3127
|
+
);
|
|
3128
|
+
// THE FOLD, PROJECTED ONTO THE TREE — its own slot, because deriving it
|
|
3129
|
+
// scans every structural row and a selection is not one of its inputs. It
|
|
3130
|
+
// used to sit inside the browse compute below, whose key carries the reveal
|
|
3131
|
+
// a selection opens, so a click that revealed a branch re-scanned all
|
|
3132
|
+
// 20 000 rows to rebuild a set that had not changed.
|
|
3133
|
+
const fold = rowCacheRef.current.collapsed(
|
|
3134
|
+
[structure, expansionGenerationRef.current, projectIdentity, scopeId],
|
|
3135
|
+
() => {
|
|
3136
|
+
const row = scopeId
|
|
3137
|
+
? (structuralRows.find((r) => !r.more && r.node.id === scopeId) ?? null)
|
|
3138
|
+
: null;
|
|
3139
|
+
const paths = new Set(
|
|
3140
|
+
structuralRows
|
|
3141
|
+
.filter((r) => !r.more && r.hasChildren && !isExpanded(r))
|
|
3142
|
+
.map((r) => r.pathKey),
|
|
3143
|
+
);
|
|
3144
|
+
if (row) {
|
|
3145
|
+
paths.delete(row.pathKey);
|
|
3146
|
+
for (const path of ancestorPathKeys(row.pathKey)) paths.delete(path);
|
|
3147
|
+
}
|
|
3148
|
+
return { scopeRow: row, effectiveCollapsedPaths: paths };
|
|
3149
|
+
},
|
|
3150
|
+
);
|
|
3151
|
+
const { scopeRow, effectiveCollapsedPaths } = fold;
|
|
3152
|
+
const browse = rowCacheRef.current.browse(
|
|
3153
|
+
[
|
|
3154
|
+
structure,
|
|
3155
|
+
fold,
|
|
3156
|
+
term,
|
|
3157
|
+
capEnabled,
|
|
3158
|
+
// ONLY when capping is on. `flattenHierarchyRows` ignores `revealed`
|
|
3159
|
+
// entirely otherwise, and the selected row's child INDEX is in this
|
|
3160
|
+
// signature — so carrying it uncapped would move the key on every single
|
|
3161
|
+
// click for a value the walk never reads, which is the cache paying its
|
|
3162
|
+
// cost and buying nothing.
|
|
3163
|
+
capEnabled ? revealSignature(temporaryRevealed) : '',
|
|
3164
|
+
],
|
|
3165
|
+
() => {
|
|
3166
|
+
const browseRows = flattenHierarchyRows(
|
|
3167
|
+
hierarchyView,
|
|
3168
|
+
effectiveCollapsedPaths,
|
|
3169
|
+
temporaryRevealed,
|
|
3170
|
+
capEnabled,
|
|
3171
|
+
);
|
|
3172
|
+
const matchingPaths = term
|
|
3173
|
+
? searchPathKeys(structuralRows, term, internals.isInternal)
|
|
3174
|
+
: null;
|
|
3175
|
+
const sourceRows = matchingPaths
|
|
3176
|
+
? structuralRows.filter((row) => matchingPaths.has(row.pathKey))
|
|
3177
|
+
: browseRows;
|
|
3178
|
+
// Session-local world-hide (D9): drop every row belonging to a hidden world
|
|
3179
|
+
// group (the group row itself stays, dimmed by its own `visible` flag above).
|
|
3180
|
+
const scopedRows = scopeRow
|
|
3181
|
+
? sourceRows
|
|
3182
|
+
.filter((row) => row.pathKey.startsWith(`${scopeRow.pathKey}.`))
|
|
3183
|
+
.map((row) => ({ ...row, depth: Math.max(0, row.depth - scopeRow.depth - 1) }))
|
|
3184
|
+
: sourceRows;
|
|
3185
|
+
const worldFiltered = scopedRows.filter((row) => {
|
|
3186
|
+
// the group row itself always shows
|
|
3187
|
+
if (isCompositeGroupNode(adapter, row.node.id)) return true;
|
|
3188
|
+
let current: string | null = row.node.parentId;
|
|
3189
|
+
while (current) {
|
|
3190
|
+
const badge = compositeGroupBadge(adapter, current);
|
|
3191
|
+
if (badge && isRootHidden(badge.worldId)) return false;
|
|
3192
|
+
current = hierarchyView.node(current)?.parentId ?? null;
|
|
3193
|
+
}
|
|
3194
|
+
return true;
|
|
3195
|
+
});
|
|
3196
|
+
return { rows: term ? worldFiltered : worldFiltered.filter((r) => !r.hiddenByCollapse) };
|
|
3197
|
+
},
|
|
3198
|
+
);
|
|
3199
|
+
const { rows } = browse;
|
|
3200
|
+
// THE EXCLUDE COLUMN IS PRESENT WHEN ANY ROW ANSWERS IT (owner ruling,
|
|
3201
|
+
// 2026-09-19: a restriction column follows what the adapter ANSWERS, never a
|
|
3202
|
+
// chrome knob). Resolved ONCE per panel render and handed down as an ordinary
|
|
3203
|
+
// prop, the way the chrome-region marks above are: asking per row would make
|
|
3204
|
+
// the column appear and disappear down the tree, which is the opposite of
|
|
3205
|
+
// what a column is. Almost every adapter answers `undefined` for every row
|
|
3206
|
+
// and the whole pass is one `undefined` per visible row; `@volter/editor-blender`'s
|
|
3207
|
+
// Outliner answers it for its layer-collection rows.
|
|
3208
|
+
const excludeColumnShown = useMemo(
|
|
3209
|
+
() => rows.some((row) => adapter.inspector?.get(row.node.id, 'exclude') !== undefined),
|
|
3210
|
+
[rows, adapter, adapterVersion, deferredContentVersion],
|
|
3211
|
+
);
|
|
3212
|
+
|
|
3213
|
+
// A filtered-down result set shouldn't leave the viewport
|
|
3214
|
+
// parked at a stale deep scroll offset from a previous (larger) list.
|
|
3215
|
+
useEffect(() => {
|
|
3216
|
+
setScrollTop(0);
|
|
3217
|
+
if (containerRef.current) containerRef.current.scrollTop = 0;
|
|
3218
|
+
}, [term]);
|
|
3219
|
+
|
|
3220
|
+
// Render only the windowed slice of `rows` — everything
|
|
3221
|
+
// above (cap/collapse/search/world-hide filtering) is unchanged; only the
|
|
3222
|
+
// final `.map()` further down is restricted. The drag/reorder + tailDrop
|
|
3223
|
+
// math below index/position against the FULL `rows` array (and read
|
|
3224
|
+
// `containerRef.current.scrollTop` directly), so windowing this slice
|
|
3225
|
+
// alone doesn't disturb them.
|
|
3226
|
+
const viewportPx = containerRef.current?.clientHeight || DEFAULT_VIEWPORT_PX;
|
|
3227
|
+
const rowHeight = treeRowHeight(containerRef.current);
|
|
3228
|
+
// Below the threshold, render every row (no window) — see VIRTUALIZE_MIN_ROWS.
|
|
3229
|
+
const { start, end } =
|
|
3230
|
+
rows.length > VIRTUALIZE_MIN_ROWS
|
|
3231
|
+
? hierarchyRowWindow(rows.length, scrollTop, viewportPx, rowHeight, OVERSCAN)
|
|
3232
|
+
: { start: 0, end: rows.length };
|
|
3233
|
+
const windowRows = rows.slice(start, end);
|
|
3234
|
+
const topSpacerPx = start * rowHeight;
|
|
3235
|
+
const bottomSpacerPx = (rows.length - end) * rowHeight;
|
|
3236
|
+
|
|
3237
|
+
const setAllExpanded = useCallback(
|
|
3238
|
+
(expanded: boolean) => {
|
|
3239
|
+
const updates = structuralRows
|
|
3240
|
+
.filter((row) => row.hasChildren)
|
|
3241
|
+
.map((row) => [row.node.id, expanded] as const);
|
|
3242
|
+
for (const [id, value] of updates) sessionExpansionRef.current.set(id, value);
|
|
3243
|
+
setHierarchyExpansionPreferences(projectIdentity, updates);
|
|
3244
|
+
expansionGenerationRef.current += 1;
|
|
3245
|
+
forceUpdate();
|
|
3246
|
+
},
|
|
3247
|
+
[projectIdentity, structuralRows],
|
|
3248
|
+
);
|
|
3249
|
+
|
|
3250
|
+
// `editor.hierarchy()`'s door (`hierarchy-panel-view.ts`). This hands over the
|
|
3251
|
+
// rows and predicates computed ABOVE, by reference — the door serializes the
|
|
3252
|
+
// panel's own output rather than re-walking the adapter, because a second row
|
|
3253
|
+
// pipeline is free to disagree with this one and a door that can disagree with
|
|
3254
|
+
// the panel cannot diagnose it (see that module's header). Published from an
|
|
3255
|
+
// effect, so what the door reports has been committed to the DOM.
|
|
3256
|
+
const panelSnapshot: HierarchyPanelSnapshot = {
|
|
3257
|
+
rows,
|
|
3258
|
+
window: { start, end },
|
|
3259
|
+
searchTerm: term ? term : null,
|
|
3260
|
+
scopeId,
|
|
3261
|
+
isExpanded,
|
|
3262
|
+
isInternal: (id) => internals.isInternal(id),
|
|
3263
|
+
isComponentRoot: (id) => marked.isComponentRoot(id),
|
|
3264
|
+
foldedInternalCount: (id) => marked.internalChildren?.(id)?.length ?? 0,
|
|
3265
|
+
expandAll: () => setAllExpanded(true),
|
|
3266
|
+
collapseAll: () => setAllExpanded(false),
|
|
3267
|
+
};
|
|
3268
|
+
useEffect(() => {
|
|
3269
|
+
publishHierarchyPanelSnapshot(panelSnapshot);
|
|
3270
|
+
return () => clearHierarchyPanelSnapshot(panelSnapshot);
|
|
3271
|
+
}, [panelSnapshot]);
|
|
3272
|
+
|
|
3273
|
+
// Row event functions must stay stable across a live structural delta or
|
|
3274
|
+
// React.memo has to revisit every visible survivor merely to receive a new
|
|
3275
|
+
// closure. The callbacks read the latest committed panel inputs through the
|
|
3276
|
+
// same render-ref pattern used by the editor's other long-lived handlers.
|
|
3277
|
+
const rowInteractionRef = useRef({
|
|
3278
|
+
adapter,
|
|
3279
|
+
rows,
|
|
3280
|
+
selectedIds,
|
|
3281
|
+
structuralRows,
|
|
3282
|
+
isExpanded,
|
|
3283
|
+
projectIdentity,
|
|
3284
|
+
});
|
|
3285
|
+
rowInteractionRef.current = {
|
|
3286
|
+
adapter,
|
|
3287
|
+
rows,
|
|
3288
|
+
selectedIds,
|
|
3289
|
+
structuralRows,
|
|
3290
|
+
isExpanded,
|
|
3291
|
+
projectIdentity,
|
|
3292
|
+
};
|
|
3293
|
+
|
|
3294
|
+
const toggleOpen = useCallback((row: HierarchyNodeRow, recursive: boolean) => {
|
|
3295
|
+
const current = rowInteractionRef.current;
|
|
3296
|
+
const expanded = !current.isExpanded(row);
|
|
3297
|
+
const updates: Array<readonly [string, boolean]> = [[row.node.id, expanded]];
|
|
3298
|
+
if (recursive) {
|
|
3299
|
+
const prefix = `${row.pathKey}.`;
|
|
3300
|
+
for (const descendant of current.structuralRows) {
|
|
3301
|
+
if (descendant.hasChildren && descendant.pathKey.startsWith(prefix)) {
|
|
3302
|
+
updates.push([descendant.node.id, expanded]);
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
for (const [id, value] of updates) sessionExpansionRef.current.set(id, value);
|
|
3307
|
+
setHierarchyExpansionPreferences(current.projectIdentity, updates);
|
|
3308
|
+
expansionGenerationRef.current += 1;
|
|
3309
|
+
forceUpdate();
|
|
3310
|
+
}, []);
|
|
3311
|
+
|
|
3312
|
+
const resetExpansion = useCallback(() => {
|
|
3313
|
+
sessionExpansionRef.current.clear();
|
|
3314
|
+
for (const id of selectedAncestorNodeIds(structure.index, selectedIds)) {
|
|
3315
|
+
sessionExpansionRef.current.set(id, true);
|
|
3316
|
+
}
|
|
3317
|
+
clearHierarchyExpansionPreferences(projectIdentity);
|
|
3318
|
+
expansionGenerationRef.current += 1;
|
|
3319
|
+
forceUpdate();
|
|
3320
|
+
}, [projectIdentity, selectionSignature, structure]);
|
|
3321
|
+
|
|
3322
|
+
const revealMore = useCallback((parentPathKey: string) => {
|
|
3323
|
+
const m = revealedRef.current;
|
|
3324
|
+
m.set(parentPathKey, (m.get(parentPathKey) ?? CHILD_CAP) + CHILD_CAP);
|
|
3325
|
+
expansionGenerationRef.current += 1;
|
|
3326
|
+
forceUpdate();
|
|
3327
|
+
}, []);
|
|
3328
|
+
|
|
3329
|
+
// H3 — double-click ENTERS the instance: its scope when it has one, its
|
|
3330
|
+
// callsite when it does not (see `enterInstanceRow` for why Isolate, the
|
|
3331
|
+
// design record's stated target, is not reachable from a scene instance row).
|
|
3332
|
+
const handleEnterScope = useCallback(
|
|
3333
|
+
(id: string) => {
|
|
3334
|
+
enterInstanceRow(adapter, id);
|
|
3335
|
+
},
|
|
3336
|
+
[adapter, store],
|
|
3337
|
+
);
|
|
3338
|
+
|
|
3339
|
+
// Viewport picks and external editor-control selection reveal the selected
|
|
3340
|
+
// row, including through a previously collapsed path and a virtualized
|
|
3341
|
+
// list. Do this only when the selection or adapter changes; ordinary store
|
|
3342
|
+
// notifications must not fight a user's manual scroll position.
|
|
3343
|
+
useEffect(() => {
|
|
3344
|
+
if (!selectionSignature) {
|
|
3345
|
+
lastAutoRevealRef.current = null;
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
const last = lastAutoRevealRef.current;
|
|
3349
|
+
if (last?.adapter === adapter && last.selection === selectionSignature) return;
|
|
3350
|
+
lastAutoRevealRef.current = { adapter, selection: selectionSignature };
|
|
3351
|
+
const index = rows.findIndex((row) => selectedIds.has(row.node.id));
|
|
3352
|
+
const container = containerRef.current;
|
|
3353
|
+
if (index < 0 || !container) return;
|
|
3354
|
+
const height = treeRowHeight(container);
|
|
3355
|
+
const top = index * height;
|
|
3356
|
+
const bottom = top + height;
|
|
3357
|
+
if (top < container.scrollTop) container.scrollTop = top;
|
|
3358
|
+
else if (bottom > container.scrollTop + container.clientHeight) {
|
|
3359
|
+
container.scrollTop = Math.max(0, bottom - container.clientHeight);
|
|
3360
|
+
}
|
|
3361
|
+
setScrollTop(container.scrollTop);
|
|
3362
|
+
}, [adapter, rows, selectedIds, selectionSignature]);
|
|
3363
|
+
|
|
3364
|
+
const handleRowClick = useCallback((e: React.MouseEvent, nodeId: string) => {
|
|
3365
|
+
const current = rowInteractionRef.current;
|
|
3366
|
+
if (!current.adapter.selection) return;
|
|
3367
|
+
if (e.metaKey || e.ctrlKey) {
|
|
3368
|
+
const ids = new Set(current.selectedIds);
|
|
3369
|
+
if (ids.has(nodeId)) ids.delete(nodeId);
|
|
3370
|
+
else ids.add(nodeId);
|
|
3371
|
+
setAuthoringSelection(current.adapter, [...ids]);
|
|
3372
|
+
} else if (e.shiftKey && lastClickedRef.current) {
|
|
3373
|
+
const lastIdx = current.rows.findIndex((r) => r.node.id === lastClickedRef.current);
|
|
3374
|
+
const curIdx = current.rows.findIndex((r) => r.node.id === nodeId);
|
|
3375
|
+
if (lastIdx >= 0 && curIdx >= 0) {
|
|
3376
|
+
const start = Math.min(lastIdx, curIdx);
|
|
3377
|
+
const end = Math.max(lastIdx, curIdx);
|
|
3378
|
+
const rangeIds = current.rows
|
|
3379
|
+
.slice(start, end + 1)
|
|
3380
|
+
.filter((r) => !r.more)
|
|
3381
|
+
.map((r) => r.node.id);
|
|
3382
|
+
setAuthoringSelection(current.adapter, rangeIds);
|
|
3383
|
+
}
|
|
3384
|
+
} else {
|
|
3385
|
+
setAuthoringSelection(current.adapter, [nodeId]);
|
|
3386
|
+
}
|
|
3387
|
+
lastClickedRef.current = nodeId;
|
|
3388
|
+
}, []);
|
|
3389
|
+
|
|
3390
|
+
const handleRowDragStart = useCallback((e: React.DragEvent, nodeId: string) => {
|
|
3391
|
+
const selectedIds = rowInteractionRef.current.selectedIds;
|
|
3392
|
+
const ids = selectedIds.has(nodeId) ? [...selectedIds] : [nodeId];
|
|
3393
|
+
setDraggedIds(ids);
|
|
3394
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
3395
|
+
e.dataTransfer.setData(REORDER_MIME, JSON.stringify(ids));
|
|
3396
|
+
e.dataTransfer.setData('text/plain', nodeId);
|
|
3397
|
+
}, []);
|
|
3398
|
+
const handleRowContextMenu = useCallback((e: React.MouseEvent, id: string) => {
|
|
3399
|
+
setContextMenu({ x: e.clientX, y: e.clientY, nodeId: id });
|
|
3400
|
+
}, []);
|
|
3401
|
+
|
|
3402
|
+
const handleDragOver = useCallback(
|
|
3403
|
+
(e: React.DragEvent) => {
|
|
3404
|
+
const isInternal = e.dataTransfer.types.includes(REORDER_MIME);
|
|
3405
|
+
const isAsset = e.dataTransfer.types.includes(ASSET_MIME);
|
|
3406
|
+
if (!isInternal && !isAsset) return;
|
|
3407
|
+
e.preventDefault();
|
|
3408
|
+
e.dataTransfer.dropEffect = isInternal ? 'move' : 'copy';
|
|
3409
|
+
|
|
3410
|
+
// A refusal computed here is REPORTED at drop time (see `handleDrop`), not
|
|
3411
|
+
// on every dragover frame: a hint that re-fires 60×/s is noise, and the
|
|
3412
|
+
// question the author is asking ("why did nothing happen?") is asked by
|
|
3413
|
+
// letting go. Cleared first so a drag that moves off a refusing row keeps
|
|
3414
|
+
// no stale sentence.
|
|
3415
|
+
dropRefusalRef.current = null;
|
|
3416
|
+
|
|
3417
|
+
const rect = containerRef.current!.getBoundingClientRect();
|
|
3418
|
+
const scrollTop = containerRef.current!.scrollTop;
|
|
3419
|
+
const y = e.clientY - rect.top + scrollTop;
|
|
3420
|
+
const rowHeight = treeRowHeight(containerRef.current);
|
|
3421
|
+
const rowIndex = Math.floor(y / rowHeight);
|
|
3422
|
+
|
|
3423
|
+
if (rowIndex >= rows.length) {
|
|
3424
|
+
setDropTarget(null);
|
|
3425
|
+
setTailDrop(true);
|
|
3426
|
+
if (isInternal && draggedIds) {
|
|
3427
|
+
dropRefusalRef.current = hierarchyDropRefusal({
|
|
3428
|
+
hasStructureWritePath: hasStructureWritePathFor(adapter, draggedIds[0]),
|
|
3429
|
+
targetIsInternal: false,
|
|
3430
|
+
});
|
|
3431
|
+
}
|
|
3432
|
+
return;
|
|
3433
|
+
}
|
|
3434
|
+
setTailDrop(false);
|
|
3435
|
+
|
|
3436
|
+
// ABOVE the first row is not a row. `y` is pointer-minus-container-top
|
|
3437
|
+
// plus scroll, so it goes NEGATIVE whenever a drag drifts over the
|
|
3438
|
+
// panel's own chrome — dragging a prefab up out of the asset browser
|
|
3439
|
+
// does it — and `Math.floor` of a negative is a negative index that the
|
|
3440
|
+
// tail guard above cannot catch. `rows[-1]` is undefined, and the read
|
|
3441
|
+
// below threw "Cannot read properties of undefined (reading 'more')"
|
|
3442
|
+
// straight out of a dragover handler (runhuman pass 105). Not a row
|
|
3443
|
+
// means no drop target, the same answer a `more` row gets.
|
|
3444
|
+
const row = rowIndex >= 0 ? rows[rowIndex] : undefined;
|
|
3445
|
+
if (!row || row.more) {
|
|
3446
|
+
setDropTarget(null);
|
|
3447
|
+
return;
|
|
3448
|
+
}
|
|
3449
|
+
// H6 — a revealed internal is not a drop TARGET either. It cannot be
|
|
3450
|
+
// dragged (`rowAffordances.draggable`), and reparenting/asset-dropping
|
|
3451
|
+
// INTO it would ask the adapter to write source for an object that has
|
|
3452
|
+
// none. HIER-COMPONENT-TREE adds the other half: the refusal now carries
|
|
3453
|
+
// the sentence that says so, because a drag that silently does nothing is
|
|
3454
|
+
// indistinguishable from a broken editor.
|
|
3455
|
+
const refusal = hierarchyDropRefusal({
|
|
3456
|
+
// A REORDER drag needs the owning adapter's `structure` provider; an
|
|
3457
|
+
// ASSET drop goes through `assetDrop` instead, so a missing structure
|
|
3458
|
+
// provider is not what stands in ITS way and must not be named as if it
|
|
3459
|
+
// were. Both are refused by an internal target, for the same reason.
|
|
3460
|
+
hasStructureWritePath: isInternal ? hasStructureWritePathFor(adapter, row.node.id) : true,
|
|
3461
|
+
targetIsInternal: internals.isInternal(row.node.id),
|
|
3462
|
+
});
|
|
3463
|
+
if (refusal !== null) {
|
|
3464
|
+
dropRefusalRef.current = refusal;
|
|
3465
|
+
setDropTarget(null);
|
|
3466
|
+
return;
|
|
3467
|
+
}
|
|
3468
|
+
const rowY = y - rowIndex * rowHeight;
|
|
3469
|
+
let zone: DropZone;
|
|
3470
|
+
if (rowY < rowHeight * 0.25) zone = 'before';
|
|
3471
|
+
else if (rowY > rowHeight * 0.75) zone = 'after';
|
|
3472
|
+
else zone = 'child';
|
|
3473
|
+
|
|
3474
|
+
if (isInternal && draggedIds) {
|
|
3475
|
+
const targetId = row.node.id;
|
|
3476
|
+
if (draggedIds.includes(targetId)) return;
|
|
3477
|
+
if (isDescendantOfAny(targetId, draggedIds, adapter)) return;
|
|
3478
|
+
if (!isSameRootDrop(adapter, draggedIds, targetId)) {
|
|
3479
|
+
setDropTarget(null);
|
|
3480
|
+
return;
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
if (isCompositeGroupNode(adapter, row.node.id) && zone !== 'child') {
|
|
3484
|
+
// A composite world-group row has no siblings of its own kind to
|
|
3485
|
+
// reorder against — only "drop into this world" makes sense.
|
|
3486
|
+
zone = 'child';
|
|
3487
|
+
}
|
|
3488
|
+
if (row.node.role === 'document' || row.node.role === 'story') zone = 'child';
|
|
3489
|
+
if (row.node.role === 'folder' || row.node.role === 'boundary') {
|
|
3490
|
+
setDropTarget(null);
|
|
3491
|
+
return;
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
setDropTarget({ nodeId: row.node.id, zone });
|
|
3495
|
+
},
|
|
3496
|
+
[rows, draggedIds, adapter, internals],
|
|
3497
|
+
);
|
|
3498
|
+
|
|
3499
|
+
const handleDragEnd = useCallback(() => {
|
|
3500
|
+
setDropTarget(null);
|
|
3501
|
+
setTailDrop(false);
|
|
3502
|
+
setDraggedIds(null);
|
|
3503
|
+
dropRefusalRef.current = null;
|
|
3504
|
+
}, []);
|
|
3505
|
+
|
|
3506
|
+
const handleDrop = useCallback(
|
|
3507
|
+
(e: React.DragEvent) => {
|
|
3508
|
+
e.preventDefault();
|
|
3509
|
+
e.stopPropagation();
|
|
3510
|
+
const currentDropTarget = dropTarget;
|
|
3511
|
+
const isTail = tailDrop;
|
|
3512
|
+
const refusal = dropRefusalRef.current;
|
|
3513
|
+
setDropTarget(null);
|
|
3514
|
+
setTailDrop(false);
|
|
3515
|
+
setDraggedIds(null);
|
|
3516
|
+
dropRefusalRef.current = null;
|
|
3517
|
+
|
|
3518
|
+
// NO DEAD DRAGS. Where the hover already decided this drop cannot land,
|
|
3519
|
+
// say so through the editor's own inline notice rather than returning in
|
|
3520
|
+
// silence (which is what made reparenting look broken on a translated
|
|
3521
|
+
// port). Never a blocking dialog — repo rule.
|
|
3522
|
+
if (refusal !== null) {
|
|
3523
|
+
showTransientHint(refusal);
|
|
3524
|
+
return;
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
const structure = adapter.structure;
|
|
3528
|
+
|
|
3529
|
+
// Internal reorder/reparent.
|
|
3530
|
+
const reorderData = e.dataTransfer.getData(REORDER_MIME);
|
|
3531
|
+
if (reorderData && structure) {
|
|
3532
|
+
const ids = JSON.parse(reorderData) as string[];
|
|
3533
|
+
if (isTail) {
|
|
3534
|
+
for (const id of ids) reparentAuthoringNode(adapter, id, null);
|
|
3535
|
+
return;
|
|
3536
|
+
}
|
|
3537
|
+
if (!currentDropTarget) return;
|
|
3538
|
+
const targetRow = rows.find((r) => r.node.id === currentDropTarget.nodeId);
|
|
3539
|
+
if (!targetRow) return;
|
|
3540
|
+
const targetId = targetRow.node.id;
|
|
3541
|
+
if (currentDropTarget.zone === 'child') {
|
|
3542
|
+
for (const id of ids) reparentAuthoringNode(adapter, id, targetId);
|
|
3543
|
+
return;
|
|
3544
|
+
}
|
|
3545
|
+
const targetParentId = targetRow.node.parentId;
|
|
3546
|
+
const siblings = siblingIdsOf(adapter, targetParentId).filter((id) => !ids.includes(id));
|
|
3547
|
+
const idx = siblings.indexOf(targetId);
|
|
3548
|
+
const beforeSiblingId =
|
|
3549
|
+
currentDropTarget.zone === 'before'
|
|
3550
|
+
? targetId
|
|
3551
|
+
: idx >= 0
|
|
3552
|
+
? (siblings[idx + 1] ?? null)
|
|
3553
|
+
: null;
|
|
3554
|
+
for (const id of ids) {
|
|
3555
|
+
const currentParentId = adapter.hierarchy.node(id)?.parentId ?? null;
|
|
3556
|
+
if (currentParentId !== targetParentId)
|
|
3557
|
+
reparentAuthoringNode(adapter, id, targetParentId ?? null);
|
|
3558
|
+
reorderAuthoringNode(adapter, id, beforeSiblingId);
|
|
3559
|
+
}
|
|
3560
|
+
return;
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
// External asset drop.
|
|
3564
|
+
const assetData = e.dataTransfer.getData(ASSET_MIME);
|
|
3565
|
+
if (assetData && adapter.assetDrop) {
|
|
3566
|
+
try {
|
|
3567
|
+
const data = JSON.parse(assetData) as {
|
|
3568
|
+
path: string;
|
|
3569
|
+
kind: string;
|
|
3570
|
+
name?: string;
|
|
3571
|
+
component?: import('@volter/editor-project/adapter').AssetDropContext['item'];
|
|
3572
|
+
};
|
|
3573
|
+
if (data.kind === 'scene') return; // scene-file drop: A4/deferred, not this shell's job
|
|
3574
|
+
const context = {
|
|
3575
|
+
item: data.component ?? {
|
|
3576
|
+
kind: 'file' as const,
|
|
3577
|
+
name: data.name ?? data.path.split('/').pop() ?? data.path,
|
|
3578
|
+
},
|
|
3579
|
+
};
|
|
3580
|
+
if (isTail) {
|
|
3581
|
+
if (adapter.assetDrop.accepts('', data.path, context))
|
|
3582
|
+
void dropAuthoringAsset(adapter, '', data.path, context);
|
|
3583
|
+
return;
|
|
3584
|
+
}
|
|
3585
|
+
if (!currentDropTarget) return;
|
|
3586
|
+
const targetRow = rows.find((r) => r.node.id === currentDropTarget.nodeId);
|
|
3587
|
+
if (!targetRow) return;
|
|
3588
|
+
const targetId = targetRow.node.id;
|
|
3589
|
+
if (adapter.assetDrop.accepts(targetId, data.path, context))
|
|
3590
|
+
void dropAuthoringAsset(adapter, targetId, data.path, context);
|
|
3591
|
+
} catch {
|
|
3592
|
+
/* ignore malformed drag payload */
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
},
|
|
3596
|
+
[dropTarget, tailDrop, rows, adapter],
|
|
3597
|
+
);
|
|
3598
|
+
|
|
3599
|
+
const panelName = authoringDestination(adapter) ?? 'Untitled';
|
|
3600
|
+
|
|
3601
|
+
return (
|
|
3602
|
+
<Panel
|
|
3603
|
+
// THE OUTLINER HEADER IS ONE ROW, and that row is the dock group's tab
|
|
3604
|
+
// strip (Content · Hierarchy · Library — structurally Blender's
|
|
3605
|
+
// editor-type selector). The breadcrumb this panel used to head itself
|
|
3606
|
+
// with — the destination path and the seam badge — is a fact about the
|
|
3607
|
+
// datablock, not about the tree, and now rides the Inspector's identity
|
|
3608
|
+
// row (`inspection/model.ts`'s `InspectionDocumentLine`), which is
|
|
3609
|
+
// exactly where Blender's Properties editor carries it.
|
|
3610
|
+
name={null}
|
|
3611
|
+
hideHeader
|
|
3612
|
+
onPointerDown={() => setActiveScope('hierarchy')}
|
|
3613
|
+
onDragOver={(e) => {
|
|
3614
|
+
if (e.dataTransfer.types.includes(ASSET_MIME)) {
|
|
3615
|
+
e.preventDefault();
|
|
3616
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
3617
|
+
}
|
|
3618
|
+
}}
|
|
3619
|
+
// The host owns the view's resizable/collapsible persisted width; this
|
|
3620
|
+
// panel fills whatever its current one gives it.
|
|
3621
|
+
style={{
|
|
3622
|
+
flex: 1,
|
|
3623
|
+
minWidth: 0,
|
|
3624
|
+
minHeight: 0,
|
|
3625
|
+
borderRight: `1px solid ${themeVars.boundary.default}`,
|
|
3626
|
+
}}
|
|
3627
|
+
>
|
|
3628
|
+
{/* The panel no longer PRINTS its destination (it is the Inspector's
|
|
3629
|
+
datablock line now), but the name stays readable as data: the dock
|
|
3630
|
+
tab says "Hierarchy", and which document that tree is of is the one
|
|
3631
|
+
fact a control command still has to be able to ask for. */}
|
|
3632
|
+
<span data-testid="scene-name" data-scene={panelName} style={{ display: 'none' }}>
|
|
3633
|
+
{panelName}
|
|
3634
|
+
</span>
|
|
3635
|
+
<HierarchyControls
|
|
3636
|
+
slot={headerSlot}
|
|
3637
|
+
search={search}
|
|
3638
|
+
setSearch={setSearch}
|
|
3639
|
+
adapter={adapter}
|
|
3640
|
+
createTargetId={createTargetId}
|
|
3641
|
+
onCollapseAll={() => setAllExpanded(false)}
|
|
3642
|
+
onResetExpansion={resetExpansion}
|
|
3643
|
+
onExpandAll={() => setAllExpanded(true)}
|
|
3644
|
+
/>
|
|
3645
|
+
|
|
3646
|
+
{scopeId && (
|
|
3647
|
+
<nav
|
|
3648
|
+
data-testid="selection-scope-breadcrumb"
|
|
3649
|
+
aria-label="Selection scope"
|
|
3650
|
+
style={{
|
|
3651
|
+
display: 'flex',
|
|
3652
|
+
alignItems: 'center',
|
|
3653
|
+
gap: 3,
|
|
3654
|
+
minHeight: 26,
|
|
3655
|
+
padding: `0 ${space[2]}px`,
|
|
3656
|
+
borderBottom: `1px solid ${themeVars.boundary.default}`,
|
|
3657
|
+
overflow: 'hidden',
|
|
3658
|
+
flexShrink: 0,
|
|
3659
|
+
}}
|
|
3660
|
+
>
|
|
3661
|
+
<Button
|
|
3662
|
+
variant="ghost"
|
|
3663
|
+
size="compact"
|
|
3664
|
+
data-testid="selection-scope-root"
|
|
3665
|
+
onClick={() => setSelectionScope(adapter, null)}
|
|
3666
|
+
>
|
|
3667
|
+
Game
|
|
3668
|
+
</Button>
|
|
3669
|
+
{scopeStack.map((id, index) => {
|
|
3670
|
+
const node = adapter.hierarchy.node(id);
|
|
3671
|
+
if (!node) return null;
|
|
3672
|
+
const current = index === scopeStack.length - 1;
|
|
3673
|
+
return (
|
|
3674
|
+
<span key={id} style={{ display: 'flex', alignItems: 'center', minWidth: 0 }}>
|
|
3675
|
+
<span aria-hidden="true" style={{ color: themeVars.content.muted }}>
|
|
3676
|
+
/
|
|
3677
|
+
</span>
|
|
3678
|
+
<Button
|
|
3679
|
+
variant="ghost"
|
|
3680
|
+
size="compact"
|
|
3681
|
+
data-testid="selection-scope-crumb"
|
|
3682
|
+
data-scope-current={current ? 'true' : undefined}
|
|
3683
|
+
aria-current={current ? 'location' : undefined}
|
|
3684
|
+
onClick={() => setSelectionScope(adapter, id)}
|
|
3685
|
+
style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}
|
|
3686
|
+
>
|
|
3687
|
+
{node.label}
|
|
3688
|
+
</Button>
|
|
3689
|
+
</span>
|
|
3690
|
+
);
|
|
3691
|
+
})}
|
|
3692
|
+
</nav>
|
|
3693
|
+
)}
|
|
3694
|
+
|
|
3695
|
+
<div
|
|
3696
|
+
ref={containerRef}
|
|
3697
|
+
data-testid="ingest-hierarchy"
|
|
3698
|
+
// §2.31 P2 amendment: the tree is a text-dense zone — local frost
|
|
3699
|
+
// layer keeps rows legible over open glass (inert elsewhere).
|
|
3700
|
+
// `vgai-tree-body` carries the ZEBRA — Blender stripes the whole body
|
|
3701
|
+
// including the empty rows past the last item, and the rule lives on
|
|
3702
|
+
// the scrolling element so its phase is the content's (see theme.css).
|
|
3703
|
+
// It paints only under `data-vgai-region="outliner"` (see
|
|
3704
|
+
// `outlinerRegion` above), so a look that paints no editor areas gets
|
|
3705
|
+
// an inert element rather than another look's stripe.
|
|
3706
|
+
className="vgai-content-frost vgai-tree-body"
|
|
3707
|
+
data-vgai-region={outlinerRegion}
|
|
3708
|
+
style={{
|
|
3709
|
+
flex: 1,
|
|
3710
|
+
minHeight: 0,
|
|
3711
|
+
overflow: 'auto',
|
|
3712
|
+
position: 'relative',
|
|
3713
|
+
pointerEvents: 'auto',
|
|
3714
|
+
}}
|
|
3715
|
+
onDragOver={handleDragOver}
|
|
3716
|
+
onDrop={handleDrop}
|
|
3717
|
+
onDragEnd={handleDragEnd}
|
|
3718
|
+
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
|
3719
|
+
onDragLeave={(e) => {
|
|
3720
|
+
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
|
|
3721
|
+
setDropTarget(null);
|
|
3722
|
+
setTailDrop(false);
|
|
3723
|
+
}
|
|
3724
|
+
}}
|
|
3725
|
+
>
|
|
3726
|
+
<div data-testid="game-hierarchy-spacer-top" style={{ height: topSpacerPx }} />
|
|
3727
|
+
{windowRows.map((row, windowIndex) =>
|
|
3728
|
+
row.more ? (
|
|
3729
|
+
<MoreRow key={hierarchyRowKey(row)} row={row} onReveal={revealMore} />
|
|
3730
|
+
) : (
|
|
3731
|
+
<Row
|
|
3732
|
+
key={hierarchyRowKey(row)}
|
|
3733
|
+
row={row}
|
|
3734
|
+
// THE STRIPE'S PHASE IS THE ROW'S ABSOLUTE INDEX, never its DOM
|
|
3735
|
+
// position. `.vgai-tree-row:nth-child(even)` counted siblings,
|
|
3736
|
+
// and this list's first sibling is the virtualization SPACER —
|
|
3737
|
+
// so row 0 always painted the alternate fill (Blender's row 0 is
|
|
3738
|
+
// the base one, `outliner.png`: Scene Collection #272727, then
|
|
3739
|
+
// Collection #2a2a2a), and the whole stripe flipped whenever the
|
|
3740
|
+
// scroll window started on an odd row.
|
|
3741
|
+
rowIndex={start + windowIndex}
|
|
3742
|
+
// The indent guides' only non-local inputs. Read off the FULL
|
|
3743
|
+
// list, not the window, so the rule crosses the window's own
|
|
3744
|
+
// edges exactly as it crosses any other row boundary.
|
|
3745
|
+
prevDepth={rows[start + windowIndex - 1]?.depth ?? -1}
|
|
3746
|
+
nextDepth={rows[start + windowIndex + 1]?.depth ?? -1}
|
|
3747
|
+
adapter={adapter}
|
|
3748
|
+
store={store}
|
|
3749
|
+
isSelected={selectedIds.has(row.node.id)}
|
|
3750
|
+
isSelectionActive={selectionAnchorId === row.node.id}
|
|
3751
|
+
isEditing={editingId === row.node.id}
|
|
3752
|
+
isCollapsed={term ? false : effectiveCollapsedPaths.has(row.pathKey)}
|
|
3753
|
+
dropZone={dropTarget && dropTarget.nodeId === row.node.id ? dropTarget.zone : null}
|
|
3754
|
+
isDragged={draggedIds?.includes(row.node.id) ?? false}
|
|
3755
|
+
isInternal={internals.isInternal(row.node.id)}
|
|
3756
|
+
threeRootId={threeViewportRootId}
|
|
3757
|
+
searchTerm={term}
|
|
3758
|
+
lockCache={lockCacheRef.current}
|
|
3759
|
+
warningCache={warningCacheRef.current}
|
|
3760
|
+
editabilityVersion={editabilityVersion}
|
|
3761
|
+
adapterVersion={adapterVersion}
|
|
3762
|
+
onToggleOpen={toggleOpen}
|
|
3763
|
+
onStartEditing={setEditingId}
|
|
3764
|
+
onContextMenu={handleRowContextMenu}
|
|
3765
|
+
onDragStart={handleRowDragStart}
|
|
3766
|
+
onClick={handleRowClick}
|
|
3767
|
+
onEnterScope={handleEnterScope}
|
|
3768
|
+
typeSuffixShown={typeSuffixShown}
|
|
3769
|
+
instanceRuleShown={instanceRuleShown}
|
|
3770
|
+
restrictionColumns={restrictionColumns}
|
|
3771
|
+
excludeColumnShown={excludeColumnShown}
|
|
3772
|
+
remoteSelectionColors={
|
|
3773
|
+
remoteSelectionColors.get(row.node.id) ?? EMPTY_REMOTE_SELECTION_COLORS
|
|
3774
|
+
}
|
|
3775
|
+
/>
|
|
3776
|
+
),
|
|
3777
|
+
)}
|
|
3778
|
+
<div data-testid="game-hierarchy-spacer-bottom" style={{ height: bottomSpacerPx }} />
|
|
3779
|
+
<div style={{ minHeight: 40 }} />
|
|
3780
|
+
{tailDrop && (
|
|
3781
|
+
<div
|
|
3782
|
+
style={{
|
|
3783
|
+
position: 'absolute',
|
|
3784
|
+
left: 0,
|
|
3785
|
+
right: 0,
|
|
3786
|
+
top: rows.length * rowHeight,
|
|
3787
|
+
height: 2,
|
|
3788
|
+
background: themeVars.accent.default,
|
|
3789
|
+
pointerEvents: 'none',
|
|
3790
|
+
zIndex: zIndex.overlayLow,
|
|
3791
|
+
}}
|
|
3792
|
+
/>
|
|
3793
|
+
)}
|
|
3794
|
+
</div>
|
|
3795
|
+
|
|
3796
|
+
{contextMenu &&
|
|
3797
|
+
(() => {
|
|
3798
|
+
const menuNode = hierarchyView.node(contextMenu.nodeId);
|
|
3799
|
+
// A composite group row owns no name/visible/locked path and no
|
|
3800
|
+
// meaningful Create/Duplicate/Delete target.
|
|
3801
|
+
const isGroupNode = menuNode
|
|
3802
|
+
? isCompositeGroupNode(adapter, menuNode.id) ||
|
|
3803
|
+
isCompositeOrganizationNode(adapter, menuNode.id)
|
|
3804
|
+
: false;
|
|
3805
|
+
return (
|
|
3806
|
+
<ContextMenu
|
|
3807
|
+
state={contextMenu}
|
|
3808
|
+
adapter={adapter}
|
|
3809
|
+
store={store}
|
|
3810
|
+
isGroupNode={isGroupNode}
|
|
3811
|
+
isInternal={internals.isInternal(contextMenu.nodeId)}
|
|
3812
|
+
onRename={setEditingId}
|
|
3813
|
+
onClose={() => setContextMenu(null)}
|
|
3814
|
+
/>
|
|
3815
|
+
);
|
|
3816
|
+
})()}
|
|
3817
|
+
</Panel>
|
|
3818
|
+
);
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3821
|
+
/** Every authorable center document uses the same adapter-backed hierarchy. */
|
|
3822
|
+
export function GameHierarchy() {
|
|
3823
|
+
const store = useEditorStore();
|
|
3824
|
+
const [, refreshBinding] = useReducer((value: number) => value + 1, 0);
|
|
3825
|
+
useSyncExternalStore(subscribeWorkspaceDocuments, workspaceDocumentRegistryVersion);
|
|
3826
|
+
// A1: the override slot is module state — subscribe to it, or this panel
|
|
3827
|
+
// keeps rendering the adapter it resolved BEFORE an ingest session installed
|
|
3828
|
+
// its own (`authoring/active-adapter.ts`'s notification comment).
|
|
3829
|
+
useSyncExternalStore(subscribeActiveAuthoring, activeAuthoringVersion);
|
|
3830
|
+
// Shares ONE resolver with the Inspector — the two panels disagreeing about
|
|
3831
|
+
// the active adapter is the bug (`authoring/panel-authoring.ts`).
|
|
3832
|
+
const { adapter } = resolvePanelAuthoring(store);
|
|
3833
|
+
// A composite can replace its focused Boundary child with a live R3F child
|
|
3834
|
+
// in place while retaining both the composite and active-override identity.
|
|
3835
|
+
// The surface's subscription can invalidate rows, but it cannot replace its
|
|
3836
|
+
// own adapter prop. Re-resolve the document binding at this outer boundary
|
|
3837
|
+
// whenever the resolved adapter reports structural change.
|
|
3838
|
+
useEffect(() => adapter.subscribe?.(refreshBinding), [adapter]);
|
|
3839
|
+
return (
|
|
3840
|
+
<GameHierarchySurface key={authoringAdapterKey(adapter)} store={store} adapter={adapter} />
|
|
3841
|
+
);
|
|
3842
|
+
}
|