@vgai/editor 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +35 -0
- package/dist/a/b/c/test-nested.json +1 -0
- package/dist/assets/CCDIKSolver-Dz-ysxZF.js +1 -0
- package/dist/assets/SelectionBox-BXsv_6aH.js +1 -0
- package/dist/assets/browser-project-scripts-DOru4L1x.js +2 -0
- package/dist/assets/browserAll-CEkWYlFk.js +1 -0
- package/dist/assets/collision-world-DniMB0qq.glb +0 -0
- package/dist/assets/create-runtime-DYIWr3va.js +1 -0
- package/dist/assets/data-asset-Bb9sIM7i.js +5 -0
- package/dist/assets/esbuild-BHljloGq.wasm +0 -0
- package/dist/assets/fake-three-BMTQ9rl_.js +1 -0
- package/dist/assets/game-8SDZPPgH.js +1 -0
- package/dist/assets/game-BI111o5f.js +10 -0
- package/dist/assets/game-CevorxyV.js +1 -0
- package/dist/assets/game-Di1H0gFj.js +1 -0
- package/dist/assets/game-Di7XlywD.js +1 -0
- package/dist/assets/game-DjnCaw8R.js +1 -0
- package/dist/assets/game-Q9A0aXSV.js +1 -0
- package/dist/assets/game-pt_1lQLv.js +1 -0
- package/dist/assets/game-source-CvidN7AB.js +1 -0
- package/dist/assets/game-xsOho_wg.js +83 -0
- package/dist/assets/index-Bp5cecD6.js +12 -0
- package/dist/assets/index-CPVV6pjw.js +1 -0
- package/dist/assets/index-CpOnYyeD.js +213 -0
- package/dist/assets/index-Cs8xwv3e.js +1 -0
- package/dist/assets/index-DL7RcQ0n.js +27 -0
- package/dist/assets/index-Dr8H_6Rl.js +1 -0
- package/dist/assets/index-_bCaipUk.js +13650 -0
- package/dist/assets/ingest-siblings-Dz8eEqrV.js +1 -0
- package/dist/assets/mount-manifest-Bhk0ohsl.js +1 -0
- package/dist/assets/recast-navigation.wasm-compat-CvP4UGK6.js +1 -0
- package/dist/assets/scene-query-BivAHhJu.js +1 -0
- package/dist/assets/stats.module--VATS4Kh.js +1 -0
- package/dist/assets/webworkerAll-BLrH0F6F.js +1 -0
- package/dist/assets/xstate-animation-binding-1NGLmw6y.js +1 -0
- package/dist/assets/xstate.esm-BgUD33nl.js +3 -0
- package/dist/index.html +37 -0
- package/dist/ingest/racing-game/UPSTREAM.md +68 -0
- package/dist/ingest/racing-game/a/Inter-Black.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Black.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-BlackItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-BlackItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Bold.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Bold.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-BoldItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-BoldItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraBold.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraBold.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraBoldItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraBoldItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraLight.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraLight.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraLightItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-ExtraLightItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Italic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Italic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Light.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Light.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-LightItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-LightItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Medium.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Medium.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-MediumItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-MediumItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Regular.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Regular.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-SemiBold.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-SemiBold.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-SemiBoldItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-SemiBoldItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-Thin.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-Thin.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-ThinItalic.woff +0 -0
- package/dist/ingest/racing-game/a/Inter-ThinItalic.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-italic.var.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter-roman.var.woff2 +0 -0
- package/dist/ingest/racing-game/a/Inter.var.woff2 +0 -0
- package/dist/ingest/racing-game/a/index.css +1 -0
- package/dist/ingest/racing-game/images/bronze.png +0 -0
- package/dist/ingest/racing-game/images/gold.png +0 -0
- package/dist/ingest/racing-game/images/silver.png +0 -0
- package/dist/ingest/racing-game/index.html +15 -0
- package/dist/ingest/racing-game/models/chassis-draco.glb +0 -0
- package/dist/ingest/racing-game/models/track-draco.glb +0 -0
- package/dist/ingest/racing-game/models/wheel-draco.glb +0 -0
- package/dist/ingest/racing-game/rg-entry.js +1 -0
- package/dist/ingest/racing-game/sounds/accelerate.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/boost.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/crash.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/engine.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/honk.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/tire-brake.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/train.mp3 +0 -0
- package/dist/ingest/racing-game/sounds/water.mp3 +0 -0
- package/dist/ingest/racing-game/textures/cursor.svg +3 -0
- package/dist/ingest/racing-game/textures/dikhololo_night_1k.hdr +0 -0
- package/dist/ingest/racing-game/textures/heightmap_1024.png +0 -0
- package/dist/ingest/racing-game/textures/mask.svg +11 -0
- package/dist/ingest/racing-game/textures/no_sound.png +0 -0
- package/dist/ingest/racing-game/three-r139.module.js +50617 -0
- package/dist/ingest/racing-game/vendor.js +179 -0
- package/dist/ingest/racing-game/vgai.game.json +20 -0
- package/dist/ingest/simcity/UPSTREAM.md +65 -0
- package/dist/ingest/simcity/favicon.ico +0 -0
- package/dist/ingest/simcity/fonts/Bebas-Regular.otf +0 -0
- package/dist/ingest/simcity/fonts/Digitalt.otf +0 -0
- package/dist/ingest/simcity/fonts/Stanberry.ttf +0 -0
- package/dist/ingest/simcity/icons/bulldozer-color.png +0 -0
- package/dist/ingest/simcity/icons/bulldozer.png +0 -0
- package/dist/ingest/simcity/icons/calendar.png +0 -0
- package/dist/ingest/simcity/icons/factory-color.png +0 -0
- package/dist/ingest/simcity/icons/factory.png +0 -0
- package/dist/ingest/simcity/icons/house-color.png +0 -0
- package/dist/ingest/simcity/icons/house.png +0 -0
- package/dist/ingest/simcity/icons/job.png +0 -0
- package/dist/ingest/simcity/icons/office.png +0 -0
- package/dist/ingest/simcity/icons/pause-color.png +0 -0
- package/dist/ingest/simcity/icons/pause.png +0 -0
- package/dist/ingest/simcity/icons/person.png +0 -0
- package/dist/ingest/simcity/icons/play-color.png +0 -0
- package/dist/ingest/simcity/icons/play.png +0 -0
- package/dist/ingest/simcity/icons/power-color.png +0 -0
- package/dist/ingest/simcity/icons/power-line-color.png +0 -0
- package/dist/ingest/simcity/icons/power.png +0 -0
- package/dist/ingest/simcity/icons/road-color.png +0 -0
- package/dist/ingest/simcity/icons/road.png +0 -0
- package/dist/ingest/simcity/icons/select-color.png +0 -0
- package/dist/ingest/simcity/icons/select.png +0 -0
- package/dist/ingest/simcity/icons/store-color.png +0 -0
- package/dist/ingest/simcity/index.html +94 -0
- package/dist/ingest/simcity/index.js +58 -0
- package/dist/ingest/simcity/main.css +233 -0
- package/dist/ingest/simcity/models/armored-truck.glb +0 -0
- package/dist/ingest/simcity/models/building-block-4floor-corner.glb +0 -0
- package/dist/ingest/simcity/models/building-block-4floor-short.glb +0 -0
- package/dist/ingest/simcity/models/building-block-5floor.glb +0 -0
- package/dist/ingest/simcity/models/building-burger-joint.glb +0 -0
- package/dist/ingest/simcity/models/building-cafe.glb +0 -0
- package/dist/ingest/simcity/models/building-casino.glb +0 -0
- package/dist/ingest/simcity/models/building-cinema.glb +0 -0
- package/dist/ingest/simcity/models/building-house-block-big.glb +0 -0
- package/dist/ingest/simcity/models/building-house-family-large.glb +0 -0
- package/dist/ingest/simcity/models/building-house-family-small.glb +0 -0
- package/dist/ingest/simcity/models/building-office-balcony.glb +0 -0
- package/dist/ingest/simcity/models/building-office-big.glb +0 -0
- package/dist/ingest/simcity/models/building-office-pyramid.glb +0 -0
- package/dist/ingest/simcity/models/building-office-tall.glb +0 -0
- package/dist/ingest/simcity/models/building-office.glb +0 -0
- package/dist/ingest/simcity/models/building-restaurant.glb +0 -0
- package/dist/ingest/simcity/models/building-skyscraper.glb +0 -0
- package/dist/ingest/simcity/models/car-ambulance-pickup.glb +0 -0
- package/dist/ingest/simcity/models/car-baywatch.glb +0 -0
- package/dist/ingest/simcity/models/car-hippie-van.glb +0 -0
- package/dist/ingest/simcity/models/car-passenger-race.glb +0 -0
- package/dist/ingest/simcity/models/car-passenger.glb +0 -0
- package/dist/ingest/simcity/models/car-police.glb +0 -0
- package/dist/ingest/simcity/models/car-taxi.glb +0 -0
- package/dist/ingest/simcity/models/car-tow-truck.glb +0 -0
- package/dist/ingest/simcity/models/car-truck-dump.glb +0 -0
- package/dist/ingest/simcity/models/car-veteran.glb +0 -0
- package/dist/ingest/simcity/models/construction-small.glb +0 -0
- package/dist/ingest/simcity/models/data-center.glb +0 -0
- package/dist/ingest/simcity/models/industry-factory-old.glb +0 -0
- package/dist/ingest/simcity/models/industry-factory.glb +0 -0
- package/dist/ingest/simcity/models/industry-refinery.glb +0 -0
- package/dist/ingest/simcity/models/industry-warehouse.glb +0 -0
- package/dist/ingest/simcity/models/power_line_pole_modified.glb +0 -0
- package/dist/ingest/simcity/models/tile-plain_grass.glb +0 -0
- package/dist/ingest/simcity/models/tile-road-curve.glb +0 -0
- package/dist/ingest/simcity/models/tile-road-end.glb +0 -0
- package/dist/ingest/simcity/models/tile-road-intersection-t.glb +0 -0
- package/dist/ingest/simcity/models/tile-road-intersection.glb +0 -0
- package/dist/ingest/simcity/models/tile-road-straight.glb +0 -0
- package/dist/ingest/simcity/models/truck.glb +0 -0
- package/dist/ingest/simcity/statusIcons/no-power.png +0 -0
- package/dist/ingest/simcity/statusIcons/no-road-access.png +0 -0
- package/dist/ingest/simcity/textures/base.png +0 -0
- package/dist/ingest/simcity/textures/grid.png +0 -0
- package/dist/ingest/simcity/textures/specular.png +0 -0
- package/dist/ingest/simcity/three-r155.module.js +51466 -0
- package/dist/ingest/simcity/vgai.game.json +20 -0
- package/dist/ingest/tanks/UPSTREAM.md +69 -0
- package/dist/ingest/tanks/index.html +214 -0
- package/dist/ingest/tanks/models/pixelTank.fbx +0 -0
- package/dist/ingest/tanks/tanks-entry.js +92 -0
- package/dist/ingest/tanks/three-r170.module.js +54571 -0
- package/dist/ingest/tanks/vgai.game.json +20 -0
- package/dist/inputmaps/default.inputmap.json +41 -0
- package/dist/jsm/libs/draco/gltf/draco_decoder.js +33 -0
- package/dist/jsm/libs/draco/gltf/draco_decoder.wasm +0 -0
- package/dist/jsm/libs/draco/gltf/draco_wasm_wrapper.js +116 -0
- package/dist/scenes/untitled.vscn.json +29 -0
- package/dist/textures/waternormals.jpg +0 -0
- package/dist-server/packaged.mjs +18155 -0
- package/package.json +36 -0
- package/server/asset-library-routes.ts +833 -0
- package/server/canonical-path.ts +50 -0
- package/server/cloud-asset-catalog.ts +101 -0
- package/server/dev.ts +350 -0
- package/server/editor-server.ts +1537 -0
- package/server/editor-sse.ts +73 -0
- package/server/game-globals-shadow.ts +58 -0
- package/server/local-asset-catalog.ts +163 -0
- package/server/packaged.ts +571 -0
- package/server/prod.ts +75 -0
- package/server/project-optimize-deps-entries.ts +149 -0
- package/server/project-view.ts +65 -0
- package/server/server-utils.ts +430 -0
- package/server/session-registry.ts +93 -0
- package/src/EditorContext.tsx +87 -0
- package/src/achieved-tier.ts +126 -0
- package/src/action-registry.ts +268 -0
- package/src/adapter-resolver.ts +1858 -0
- package/src/asset-events.ts +100 -0
- package/src/authoring/active-adapter.ts +98 -0
- package/src/authoring/active-systems.ts +50 -0
- package/src/authoring/boundary-authoring-adapter.ts +178 -0
- package/src/authoring/composite-authoring-adapter.ts +891 -0
- package/src/authoring/design-time-layers.ts +776 -0
- package/src/authoring/edit-mode-authoring.ts +643 -0
- package/src/authoring/embed-only-adapter.ts +57 -0
- package/src/authoring/ephemeral-persistence.ts +26 -0
- package/src/authoring/eyedropper-session.ts +69 -0
- package/src/authoring/first-party-hierarchy-menu.ts +74 -0
- package/src/authoring/first-party-inspector/AddSectionMenu.tsx +118 -0
- package/src/authoring/first-party-inspector/EnvironmentSection.tsx +550 -0
- package/src/authoring/first-party-inspector/HeaderSection.tsx +329 -0
- package/src/authoring/first-party-inspector/is-first-party-selection.ts +44 -0
- package/src/authoring/first-party-inspector/register.tsx +108 -0
- package/src/authoring/ingest-authoring-adapter.ts +475 -0
- package/src/authoring/ingest-capture.ts +94 -0
- package/src/authoring/ingest-game-adapter.ts +291 -0
- package/src/authoring/ingest-iframe-adapter.ts +38 -0
- package/src/authoring/ingest-iframe-reachable-adapter.ts +401 -0
- package/src/authoring/ingest-overlay.ts +193 -0
- package/src/authoring/layered-pick.ts +87 -0
- package/src/authoring/mount-failure-report.ts +112 -0
- package/src/authoring/overlay-report.ts +52 -0
- package/src/authoring/project-scenes.ts +111 -0
- package/src/authoring/react-dom-authoring-adapter.ts +795 -0
- package/src/authoring/react-inspector-section.tsx +1041 -0
- package/src/authoring/react-world-authoring-adapter.ts +2400 -0
- package/src/authoring/root-activation.ts +61 -0
- package/src/authoring/scene-ui-layer.ts +485 -0
- package/src/authoring/story-overlay.ts +48 -0
- package/src/authoring/ui-authoring-adapter.ts +325 -0
- package/src/authoring/ui-hierarchy-menu.ts +40 -0
- package/src/authoring/ui-inspector-sections.tsx +156 -0
- package/src/authoring/undo-timeline.ts +145 -0
- package/src/authoring/vgai-scene-authoring-adapter.ts +746 -0
- package/src/authoring/viewport-pick-context.ts +31 -0
- package/src/authoring/viewport-raycast.ts +80 -0
- package/src/authoring/world-overlay.ts +76 -0
- package/src/authoring/world-pan-state.ts +115 -0
- package/src/authoring/world-session-state.ts +102 -0
- package/src/authoring/world2d-authoring-adapter.ts +313 -0
- package/src/browser-external-watch.ts +98 -0
- package/src/browser-play-entry.ts +112 -0
- package/src/browser-project-scripts.ts +112 -0
- package/src/browser-transpile.ts +246 -0
- package/src/browser-watch-reload.ts +100 -0
- package/src/command-listener.ts +326 -0
- package/src/components/AlignToolbar.tsx +189 -0
- package/src/components/AppRoot.tsx +73 -0
- package/src/components/AssetBrowser.tsx +502 -0
- package/src/components/BottomPanel.tsx +326 -0
- package/src/components/BuildPanel.tsx +455 -0
- package/src/components/CameraInfo.tsx +56 -0
- package/src/components/CommandPalette.tsx +265 -0
- package/src/components/ConsolePanel.tsx +272 -0
- package/src/components/DataPanel.tsx +1268 -0
- package/src/components/DefaultEditorLayout.tsx +509 -0
- package/src/components/FolderTree.tsx +179 -0
- package/src/components/GameHierarchy.tsx +1349 -0
- package/src/components/Inspector.tsx +671 -0
- package/src/components/InspectorToolSection.tsx +74 -0
- package/src/components/MountFailureBanner.tsx +110 -0
- package/src/components/NetworkSection.tsx +109 -0
- package/src/components/OnlineAssetBrowser.tsx +448 -0
- package/src/components/OverlayReportBanner.tsx +122 -0
- package/src/components/PlayBar.tsx +356 -0
- package/src/components/PrefabSaveDialog.tsx +201 -0
- package/src/components/ProjectHeader.tsx +174 -0
- package/src/components/ProjectScreen.tsx +1004 -0
- package/src/components/RightPanel.tsx +166 -0
- package/src/components/RootActivateDialog.tsx +105 -0
- package/src/components/SaveDialog.tsx +197 -0
- package/src/components/SaveStatus.tsx +69 -0
- package/src/components/StatsOverlay.tsx +35 -0
- package/src/components/ToolHost.tsx +80 -0
- package/src/components/Toolbar.tsx +307 -0
- package/src/components/ViewportOverlay.tsx +234 -0
- package/src/components/ViewportPanel.tsx +369 -0
- package/src/components/WorldContextMenu.tsx +257 -0
- package/src/components/WorldSelectionOverlay.tsx +2003 -0
- package/src/components/WorldTextEditor.tsx +86 -0
- package/src/components/asset-viewers/AssetViewer.tsx +38 -0
- package/src/components/asset-viewers/AudioViewer.tsx +149 -0
- package/src/components/asset-viewers/GenericJsonViewer.tsx +77 -0
- package/src/components/asset-viewers/ImageViewer.tsx +65 -0
- package/src/components/asset-viewers/InputMapViewer.tsx +206 -0
- package/src/components/asset-viewers/ModelViewer.tsx +180 -0
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +236 -0
- package/src/components/data-csv.ts +301 -0
- package/src/components/entity-name-commit.ts +18 -0
- package/src/components/inspector-property-grouping.ts +65 -0
- package/src/components/inspector-widgets/AlignmentGrid.tsx +188 -0
- package/src/components/inspector-widgets/BorderEditor.tsx +305 -0
- package/src/components/inspector-widgets/ColorPicker.tsx +548 -0
- package/src/components/inspector-widgets/FilterEditor.tsx +108 -0
- package/src/components/inspector-widgets/FontPicker.tsx +196 -0
- package/src/components/inspector-widgets/GradientEditor.tsx +277 -0
- package/src/components/inspector-widgets/ScrubbableInput.tsx +152 -0
- package/src/components/inspector-widgets/ShadowEditor.tsx +332 -0
- package/src/components/inspector-widgets/color-utils.ts +202 -0
- package/src/components/inspector-widgets/gallery-main.tsx +167 -0
- package/src/components/inspector-widgets/index.ts +22 -0
- package/src/components/inspector-widgets/shared.tsx +135 -0
- package/src/components/inspectors/AnimationSection.tsx +143 -0
- package/src/components/inspectors/AudioSection.tsx +215 -0
- package/src/components/inspectors/BoneAttachmentSection.tsx +82 -0
- package/src/components/inspectors/CameraSection.tsx +105 -0
- package/src/components/inspectors/ComponentsSection.tsx +358 -0
- package/src/components/inspectors/JointsSection.tsx +255 -0
- package/src/components/inspectors/LightSection.tsx +116 -0
- package/src/components/inspectors/MaterialSection.tsx +1189 -0
- package/src/components/inspectors/MeshSection.tsx +99 -0
- package/src/components/inspectors/NavigationSection.tsx +58 -0
- package/src/components/inspectors/ParticleSection.tsx +681 -0
- package/src/components/inspectors/PhysicsSection.tsx +326 -0
- package/src/components/inspectors/PostProcessingSection.tsx +1290 -0
- package/src/components/inspectors/RenderingSection.tsx +109 -0
- package/src/components/inspectors/ShadowSection.tsx +84 -0
- package/src/components/inspectors/SplineSection.tsx +191 -0
- package/src/components/inspectors/TransformSection.tsx +191 -0
- package/src/components/inspectors/json-schema-fields.ts +281 -0
- package/src/components/inspectors/multi-edit-utils.ts +49 -0
- package/src/components/inspectors/zod-fields.ts +184 -0
- package/src/components/primitives/ColorInput.tsx +109 -0
- package/src/components/primitives/JsonInput.tsx +98 -0
- package/src/components/primitives/NumberInput.tsx +164 -0
- package/src/components/primitives/Panel.tsx +68 -0
- package/src/components/primitives/SectionHeader.tsx +45 -0
- package/src/components/primitives/Tooltip.tsx +82 -0
- package/src/components/primitives/Vec3Input.tsx +62 -0
- package/src/components/world-overlay-gestures.ts +1438 -0
- package/src/doctor/data-checks.ts +285 -0
- package/src/doctor/report.ts +386 -0
- package/src/editor-api.ts +700 -0
- package/src/editor-console.ts +194 -0
- package/src/editor-hotkeys.ts +441 -0
- package/src/editor-layers.ts +2 -0
- package/src/editor-mode.ts +28 -0
- package/src/editor-store.ts +2225 -0
- package/src/editor-viewport.ts +1924 -0
- package/src/entity-factory.ts +221 -0
- package/src/game-globals-prelude.ts +27 -0
- package/src/gated-globals.ts +131 -0
- package/src/generated/setup-by-example.generated.ts +53 -0
- package/src/gizmo-registry.ts +608 -0
- package/src/handle-system.ts +221 -0
- package/src/hierarchy-menu-registry.ts +70 -0
- package/src/hierarchy-rows.ts +73 -0
- package/src/hosted-example.ts +160 -0
- package/src/hotkeys.ts +142 -0
- package/src/ingest/discovery-react.ts +132 -0
- package/src/ingest/discovery.ts +205 -0
- package/src/ingest/discovery2d.ts +204 -0
- package/src/ingest/esm-entry-resolution.ts +102 -0
- package/src/ingest/games/bloom-composer/game.js +99 -0
- package/src/ingest/games/bloom-composer/vgai.game.json +21 -0
- package/src/ingest/games/embed-bundle/embed.html +10 -0
- package/src/ingest/games/embed-bundle/vgai.game.json +21 -0
- package/src/ingest/games/games-fps/assets/collision-world.glb +0 -0
- package/src/ingest/games/games-fps/game.js +479 -0
- package/src/ingest/games/games-fps/vgai.game.json +24 -0
- package/src/ingest/games/iframe-reachable/game-source.js +58 -0
- package/src/ingest/games/iframe-reachable/vgai.game.json +21 -0
- package/src/ingest/games/interactive-cubes/game.js +139 -0
- package/src/ingest/games/interactive-cubes/vgai.game.json +21 -0
- package/src/ingest/games/mismatched-three/fake-three.js +38 -0
- package/src/ingest/games/mismatched-three/game.js +10 -0
- package/src/ingest/games/mismatched-three/vgai.game.json +22 -0
- package/src/ingest/games/own-three-bundled/game.js +43 -0
- package/src/ingest/games/own-three-bundled/vgai.game.json +21 -0
- package/src/ingest/games/pointerlock-maze/game.js +288 -0
- package/src/ingest/games/pointerlock-maze/vgai.game.json +22 -0
- package/src/ingest/games-2d/_fixtures/first-party-scene.scn2d.json +124 -0
- package/src/ingest/games-2d/bubbo-bubbo/vgai.game.json +25 -0
- package/src/ingest/games-2d/bunnymark/game.ts +88 -0
- package/src/ingest/games-2d/bunnymark/vgai.game.json +21 -0
- package/src/ingest/games-2d/flappy/game.ts +148 -0
- package/src/ingest/games-2d/flappy/vgai.game.json +21 -0
- package/src/ingest/games-2d/flappy-pixi/vgai.game.json +25 -0
- package/src/ingest/games-2d/puzzling-potions/vgai.game.json +25 -0
- package/src/ingest/games-2d/tilemap-camera/game.ts +115 -0
- package/src/ingest/games-2d/tilemap-camera/vgai.game.json +21 -0
- package/src/ingest/games-react/react-rpg/entry.tsx +185 -0
- package/src/ingest/games-react/react-rpg/shims.d.ts +26 -0
- package/src/ingest/games-react/react-rpg/src/ui/game-state.tsx +129 -0
- package/src/ingest/games-react/react-rpg/vgai.game.json +21 -0
- package/src/ingest/host-namespace-registry.ts +196 -0
- package/src/ingest/types.ts +127 -0
- package/src/ingest-hierarchy-rows.ts +207 -0
- package/src/ingest-mode.ts +2092 -0
- package/src/ingest-siblings.ts +466 -0
- package/src/initial-scene.ts +194 -0
- package/src/inspector-section-registry.ts +117 -0
- package/src/interactive-edit-scope.ts +34 -0
- package/src/main.tsx +23 -0
- package/src/manifest-project.ts +43 -0
- package/src/model-thumbnail.ts +156 -0
- package/src/module-mode.ts +513 -0
- package/src/navmesh-handler.ts +94 -0
- package/src/packaged-runtime.ts +49 -0
- package/src/play-mode.ts +1148 -0
- package/src/playground/main.ts +206 -0
- package/src/project-manager.ts +173 -0
- package/src/scene-diff.ts +112 -0
- package/src/scene-serializer.ts +144 -0
- package/src/scene-sync.ts +587 -0
- package/src/scene-ui-mode/active-scene-ui.ts +58 -0
- package/src/scene-ui-mode/scene-ui-palette-panel.tsx +152 -0
- package/src/scene-ui-mode/scene-ui-timeline-tab.tsx +29 -0
- package/src/scene-ui-mode/ui-prefab-dialog-host.tsx +74 -0
- package/src/scene-ui-mode/ui-prefab-hierarchy-menu.ts +49 -0
- package/src/scene-ui-mode/ui-prefab-io.ts +76 -0
- package/src/script-loader.ts +143 -0
- package/src/storage/fsa-storage.ts +55 -0
- package/src/storage/handle-storage.ts +150 -0
- package/src/storage/handle-store.ts +116 -0
- package/src/storage/http-storage.ts +137 -0
- package/src/storage/index.ts +147 -0
- package/src/storage/mem-storage.ts +135 -0
- package/src/storage/opfs-storage.ts +25 -0
- package/src/storage/paths.ts +26 -0
- package/src/storage/seed.ts +106 -0
- package/src/storage/types.ts +110 -0
- package/src/tool-loader.ts +291 -0
- package/src/ui-editor/inspector.tsx +563 -0
- package/src/ui-editor/node-ops.ts +266 -0
- package/src/ui-editor/overlay.tsx +380 -0
- package/src/ui-editor/palette.tsx +114 -0
- package/src/ui-editor/react-store.ts +78 -0
- package/src/ui-editor/timeline-panel.tsx +443 -0
- package/src/ui-editor/ui-edit-store.ts +552 -0
- package/src/ui-editor/ui-prefab.ts +95 -0
- package/src/ui-source/inspect.ts +544 -0
- package/src/ui-source/oid-transform.ts +133 -0
- package/src/ui-source/source-write-backend.ts +203 -0
- package/src/ui-source/tailwind-map.ts +174 -0
- package/src/ui-source/writer.ts +1175 -0
- package/src/widgets/index.ts +73 -0
- package/template/.claude/skills/editor/SKILL.md +252 -0
- package/template/CLAUDE.md +512 -0
- package/template/LICENSE +30 -0
- package/template/emit-schemas.ts +42 -0
- package/template/index.html +17 -0
- package/template/package.json +53 -0
- package/template/project.json +9 -0
- package/template/public/audio/ambient-arena.wav +0 -0
- package/template/public/audio/ambient-music.wav +0 -0
- package/template/public/audio/ambient-nature.wav +0 -0
- package/template/public/audio/ambient-wind.wav +0 -0
- package/template/public/inputmaps/default.inputmap.json +41 -0
- package/template/public/jsm/libs/draco/gltf/draco_decoder.js +33 -0
- package/template/public/jsm/libs/draco/gltf/draco_decoder.wasm +0 -0
- package/template/public/jsm/libs/draco/gltf/draco_encoder.js +33 -0
- package/template/public/jsm/libs/draco/gltf/draco_wasm_wrapper.js +116 -0
- package/template/public/scenes/default.vscn.json +43 -0
- package/template/public/scenes/untitled.vscn.json +29 -0
- package/template/public/textures/waternormals.jpg +0 -0
- package/template/server/colyseus-setup.ts +85 -0
- package/template/server/main.ts +34 -0
- package/template/server/rooms/arena-room.ts +420 -0
- package/template/server/rooms/game-room.ts +162 -0
- package/template/server/rooms.ts +15 -0
- package/template/server/tsconfig.json +19 -0
- package/template/src/data/README.md +40 -0
- package/template/src/data/assets.ts +31 -0
- package/template/src/data/tuning.data.json +5 -0
- package/template/src/data/tuning.schema.json +21 -0
- package/template/src/data/tuning.schema.ts +34 -0
- package/template/src/data/tuning.ts +24 -0
- package/template/src/main.ts +157 -0
- package/template/src/scripts/components/data-spinner.ts +45 -0
- package/template/src/scripts/components/scene-camera.ts +40 -0
- package/template/src/scripts/main.ts +94 -0
- package/template/src/scripts/registry.ts +46 -0
- package/template/src/tools/README.md +14 -0
- package/template/src/tools/example.tool.tsx +91 -0
- package/template/src/tools/spin.tool.tsx +101 -0
- package/template/src/ui/Button.tsx +27 -0
- package/template/src/ui/DefaultHud.tsx +40 -0
- package/template/src/ui/KeyHint.tsx +25 -0
- package/template/src/ui/Panel.tsx +52 -0
- package/template/src/ui/RebindPanel.tsx +129 -0
- package/template/src/ui/Slider.tsx +28 -0
- package/template/src/ui/StatusDot.tsx +22 -0
- package/template/src/ui/Toggle.tsx +19 -0
- package/template/src/ui/game-state.tsx +37 -0
- package/template/src/ui/index.ts +7 -0
- package/template/src/ui/mount-default-hud.tsx +19 -0
- package/template/src/ui/mount-ui.ts +15 -0
- package/template/tsconfig.json +36 -0
- package/template/validate-asset-content.ts +78 -0
- package/template/validate-manifest.ts +55 -0
- package/template/validate-scenes.ts +211 -0
- package/template/vgai.game.json +21 -0
- package/template/vite.config.ts +128 -0
- package/vite-plugin-example-assets.ts +60 -0
- package/vite-plugin-game-globals.ts +46 -0
- package/vite-plugin-game-static.ts +71 -0
- package/vite-plugin-project-game-static.ts +84 -0
- package/vite-plugin-react-game-static.ts +98 -0
- package/vite-plugin-react-world-provider.ts +81 -0
- package/vite-plugin-react-world-runtime.ts +76 -0
- package/vite-plugin-ui-oid.ts +561 -0
|
@@ -0,0 +1,1349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GameHierarchy — the ONE hierarchy panel (A2,
|
|
3
|
+
* docs/unified-world-editor/26-unified-editor-spec.md §2 A2). Replaces the
|
|
4
|
+
* former pair of per-format hierarchy panels (first-party single-document,
|
|
5
|
+
* ingested-game) — a single contract-only shell driven ENTIRELY through the
|
|
6
|
+
* active {@link AuthoringAdapter}: `hierarchy`/`selection`/`inspector`/
|
|
7
|
+
* `structure`/`assetDrop`. Every row affordance is gated on the PRESENCE of a
|
|
8
|
+
* provider or a reserved inspector path, never on which concrete adapter is
|
|
9
|
+
* active.
|
|
10
|
+
*
|
|
11
|
+
* Rule zero (spec §0): this file imports ONLY from `@engine/adapter` types
|
|
12
|
+
* and editor-shell modules — no private first-party entity type, no direct
|
|
13
|
+
* 3D-library value import, no first-party document-format string literal.
|
|
14
|
+
* First-party-only affordances not yet contract-expressible (prefab
|
|
15
|
+
* unpack/open/save-as) live behind `hierarchy-menu-registry.ts`'s escape
|
|
16
|
+
* hatch, registered elsewhere (`authoring/first-party-hierarchy-menu.ts`).
|
|
17
|
+
*
|
|
18
|
+
* Shape handling (the manager's reconciliation constraint): the ACTIVE
|
|
19
|
+
* adapter is either a bare per-scene adapter (single-world edit, an ingested
|
|
20
|
+
* game, …) — `adapter.hierarchy.roots()` returns real entity roots directly —
|
|
21
|
+
* or `CompositeAuthoringAdapter` (2+ worlds) — roots are synthetic
|
|
22
|
+
* `world:<id>` GROUP nodes (`flags.runtimeOnly`), each world's own roots
|
|
23
|
+
* nested beneath. Both shapes are walked by the SAME generic row engine
|
|
24
|
+
* (`flattenIngestRows` — the format-neutral `EditorNode`-typed row
|
|
25
|
+
* flattener, not the legacy first-party-typed sibling module); a group node
|
|
26
|
+
* only ever gets a small set of cosmetic extras (kind/zOrder badge, a
|
|
27
|
+
* session-local eye, `flags.inactiveRoot` dimming) layered on TOP of the
|
|
28
|
+
* same row component.
|
|
29
|
+
*
|
|
30
|
+
* Zero-regression testids (landmine a): every row carries BOTH the
|
|
31
|
+
* `data-entity-name`/`data-testid="scene-name"` family (`EditorPage.getEntityNames`/
|
|
32
|
+
* 09-editor-interaction.spec.ts) and the full `ingest-*` family (`ingest-row`,
|
|
33
|
+
* `ingest-row-caret`, `ingest-row-visibility`, `ingest-search`,
|
|
34
|
+
* `ingest-hierarchy`, `data-ingest-name`, `ingest-context-menu`) so the 13
|
|
35
|
+
* existing ingest-family e2e specs keep passing unchanged.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { AuthoringAdapter } from '@engine/adapter';
|
|
39
|
+
import {
|
|
40
|
+
faCaretDown,
|
|
41
|
+
faCaretRight,
|
|
42
|
+
faCrosshairs,
|
|
43
|
+
faEye,
|
|
44
|
+
faEyeSlash,
|
|
45
|
+
faHandPointer,
|
|
46
|
+
faLock,
|
|
47
|
+
faLockOpen,
|
|
48
|
+
faPlus,
|
|
49
|
+
} from '@fortawesome/free-solid-svg-icons';
|
|
50
|
+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
51
|
+
import { useCallback, useEffect, useReducer, useRef, useState, useSyncExternalStore } from 'react';
|
|
52
|
+
import { getActiveAuthoring, hasAuthoringOverride } from '../authoring/active-adapter';
|
|
53
|
+
import { CompositeAuthoringAdapter } from '../authoring/composite-authoring-adapter';
|
|
54
|
+
import { requestRootActivation } from '../authoring/root-activation';
|
|
55
|
+
import {
|
|
56
|
+
isWorldHidden,
|
|
57
|
+
isWorldInteractive,
|
|
58
|
+
isWorldPickLocked,
|
|
59
|
+
toggleWorldHidden,
|
|
60
|
+
toggleWorldInteractive,
|
|
61
|
+
toggleWorldPickLock,
|
|
62
|
+
} from '../authoring/world-session-state';
|
|
63
|
+
import { useEditorStore } from '../EditorContext';
|
|
64
|
+
import type { EditorStore } from '../editor-store';
|
|
65
|
+
import { getHierarchyMenuItems } from '../hierarchy-menu-registry';
|
|
66
|
+
import { CHILD_CAP } from '../hierarchy-rows';
|
|
67
|
+
import { setActiveScope } from '../hotkeys';
|
|
68
|
+
import {
|
|
69
|
+
flattenIngestRows,
|
|
70
|
+
type IngestRow,
|
|
71
|
+
ingestRowKey,
|
|
72
|
+
ingestRowWindow,
|
|
73
|
+
} from '../ingest-hierarchy-rows';
|
|
74
|
+
import { OverlayReportBanner } from './OverlayReportBanner';
|
|
75
|
+
import { Panel } from './primitives/Panel';
|
|
76
|
+
|
|
77
|
+
const ASSET_MIME = 'application/x-editor-asset';
|
|
78
|
+
const REORDER_MIME = 'application/x-hierarchy-reorder';
|
|
79
|
+
const ROW_HEIGHT = 24;
|
|
80
|
+
const INDENT = 14;
|
|
81
|
+
|
|
82
|
+
// Wave-18-ported scroll-window sizing (docs/WAVE18-INGEST-HIERARCHY-VIRTUALIZATION-DESIGN.md),
|
|
83
|
+
// adapted from `IngestHierarchy`'s pre-A2 windowing: `ROW_HEIGHT` above (24)
|
|
84
|
+
// is THIS panel's row height, not IngestHierarchy's old 22 — every rendered
|
|
85
|
+
// row (Row/MoreRow) is already fixed at that height, so the spacer-div trick
|
|
86
|
+
// only keeps the scrollbar honest as long as this constant matches. OVERSCAN
|
|
87
|
+
// keeps a small buffer of already-rendered rows on each side of the visible
|
|
88
|
+
// band so a fast scroll/keyboard-nav doesn't flash empty space before the
|
|
89
|
+
// next paint catches up. DEFAULT_VIEWPORT_PX is used whenever the container
|
|
90
|
+
// hasn't reported a real (nonzero) `clientHeight` yet — jsdom never performs
|
|
91
|
+
// real layout, so unit tests never measure anything unless they explicitly
|
|
92
|
+
// mock it; this generous fallback also keeps every pre-existing small-tree
|
|
93
|
+
// render byte-identical without any test needing to.
|
|
94
|
+
const OVERSCAN = 8;
|
|
95
|
+
const DEFAULT_VIEWPORT_PX = 2400;
|
|
96
|
+
// Only scroll-window ABOVE this row count. Small/medium trees (every real
|
|
97
|
+
// multi-world browse tree — world-group rows + CHILD_CAP-bounded children)
|
|
98
|
+
// render in full, immune to a transient/small `clientHeight` measurement
|
|
99
|
+
// windowing out later world-group rows (which broke 19-multi-world/22-tri-world
|
|
100
|
+
// when virtualization first landed). Windowing only kicks in for pathologically
|
|
101
|
+
// large lists (e.g. an ingested game's uncapped SEARCH result — react-rpg's
|
|
102
|
+
// dungeon is ~1900 rows), which is exactly the case wave-18 targeted.
|
|
103
|
+
const VIRTUALIZE_MIN_ROWS = 300;
|
|
104
|
+
|
|
105
|
+
const KIND_GLYPH: Record<string, string> = {
|
|
106
|
+
mesh: '◧',
|
|
107
|
+
light: '☀',
|
|
108
|
+
camera: '▣',
|
|
109
|
+
group: '▾',
|
|
110
|
+
object: '•',
|
|
111
|
+
// B3 — a react world's catalog component node (`kind:'component'`, the
|
|
112
|
+
// registry entry, not the entry-preview's DOM subtree). A neutral glyph
|
|
113
|
+
// (NOT react-branded) — this shell only ever knows the format-neutral
|
|
114
|
+
// `EditorNode.kind` string, never react internals.
|
|
115
|
+
component: '❖',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
type DropZone = 'before' | 'child' | 'after';
|
|
119
|
+
interface DropTarget {
|
|
120
|
+
nodeId: string;
|
|
121
|
+
zone: DropZone;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Walk UP from `targetId` via `parentId` — true if any ancestor (or itself)
|
|
125
|
+
* is in `dragIds` (prevents dropping a subtree into its own descendant). */
|
|
126
|
+
function isDescendantOfAny(
|
|
127
|
+
targetId: string,
|
|
128
|
+
dragIds: string[],
|
|
129
|
+
adapter: AuthoringAdapter,
|
|
130
|
+
): boolean {
|
|
131
|
+
let current: string | null = targetId;
|
|
132
|
+
let guard = 0;
|
|
133
|
+
while (current && guard++ < 1000) {
|
|
134
|
+
if (dragIds.includes(current)) return true;
|
|
135
|
+
current = adapter.hierarchy.node(current)?.parentId ?? null;
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The ids of `parentId`'s children, in order — `null` parentId means "the
|
|
141
|
+
* adapter's own roots" (NOT necessarily the document root of a composite —
|
|
142
|
+
* callers pass the group-node id for "this world's roots"). */
|
|
143
|
+
function siblingIdsOf(adapter: AuthoringAdapter, parentId: string | null): string[] {
|
|
144
|
+
if (parentId === null) return adapter.hierarchy.roots().map((n) => n.id);
|
|
145
|
+
return adapter.hierarchy.node(parentId)?.childIds ?? [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `CompositeAuthoringAdapter`'s zOrder badge for a `world:<id>` group row —
|
|
150
|
+
* `null` when the active adapter isn't a composite (bare shape) or the id
|
|
151
|
+
* isn't one of its synthetic group nodes.
|
|
152
|
+
*
|
|
153
|
+
* IMPORTANT: a composite GROUP node is NOT the same as `flags.runtimeOnly`.
|
|
154
|
+
* Six different adapters (ingest / react-dom / react-world / world2d /
|
|
155
|
+
* embed-only, plus the composite's own group nodes) all set
|
|
156
|
+
* `flags.runtimeOnly: true` on their nodes — so `runtimeOnly` means "not an
|
|
157
|
+
* authored first-party entity", NOT "a composite world wrapper". Only the
|
|
158
|
+
* composite manufactures the synthetic `world:<id>` group rows this panel
|
|
159
|
+
* treats specially (session-local eye per D9, kind/zOrder badge, non-draggable);
|
|
160
|
+
* an ingested game's own rows are ordinary rows whose eye/lock/rename route
|
|
161
|
+
* through the contract like any other. Keying "is a group node" off
|
|
162
|
+
* `runtimeOnly` (an earlier bug) wrongly treated every ingest/react row as a
|
|
163
|
+
* world wrapper, regressing the ingest eye toggle to a session-local shell
|
|
164
|
+
* hide instead of the adapter's `inspector.set(id,'visible',…)`.
|
|
165
|
+
*/
|
|
166
|
+
function compositeGroupBadge(
|
|
167
|
+
adapter: AuthoringAdapter,
|
|
168
|
+
nodeId: string,
|
|
169
|
+
): { zOrder: number; worldId: string } | null {
|
|
170
|
+
if (!(adapter instanceof CompositeAuthoringAdapter)) return null;
|
|
171
|
+
for (const child of adapter.childAdapters()) {
|
|
172
|
+
if (adapter.groupNodeId(child.worldId) === nodeId) {
|
|
173
|
+
// A4 (D8) — prefer the LIVE manifest zOrder (editable on the game root,
|
|
174
|
+
// `composite.inspector.get(groupId, 'zOrder')`) over the array-index
|
|
175
|
+
// fallback `child.zOrder`, so editing zOrder through the generic
|
|
176
|
+
// inspector is reflected on this badge immediately (the write path
|
|
177
|
+
// calls `store.notifyIngestEdit()`, which re-renders this panel).
|
|
178
|
+
// Falls back to the array index when there's no manifest surface
|
|
179
|
+
// (play-mode composites, or any adapter that doesn't report a
|
|
180
|
+
// `zOrder` inspector property for its own group-node id) — unchanged
|
|
181
|
+
// from before A4.
|
|
182
|
+
const manifestZOrder = adapter.inspector?.get(nodeId, 'zOrder');
|
|
183
|
+
return {
|
|
184
|
+
zOrder: typeof manifestZOrder === 'number' ? manifestZOrder : child.zOrder,
|
|
185
|
+
// B1 — the RAW manifest world id (`CompositeChild.worldId`), so
|
|
186
|
+
// callers can key session-local world state (`world-session-state.ts`
|
|
187
|
+
// — eye/interactive/pick-lock) consistently with `design-time-layers.ts`,
|
|
188
|
+
// which reads the SAME id off `composite.childAdapters()` directly.
|
|
189
|
+
worldId: child.worldId,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** True only for a composite's synthetic `world:<id>` group row (see
|
|
197
|
+
* {@link compositeGroupBadge}) — never for an ordinary (even runtime-only)
|
|
198
|
+
* ingest/react/world2d node. */
|
|
199
|
+
function isCompositeGroupNode(adapter: AuthoringAdapter, nodeId: string): boolean {
|
|
200
|
+
return compositeGroupBadge(adapter, nodeId) !== null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A4 (D8) — true only for the composite's synthetic game-root row. Asks the
|
|
205
|
+
* adapter for its reserved id (`CompositeAuthoringAdapter.gameRootId`) rather
|
|
206
|
+
* than hardcoding the string here — same "ask, don't sniff" discipline as
|
|
207
|
+
* `groupNodeId()`/`compositeGroupBadge` above (Rule zero: this file never
|
|
208
|
+
* decodes an adapter-minted id itself).
|
|
209
|
+
*/
|
|
210
|
+
function isGameRootNode(adapter: AuthoringAdapter, nodeId: string): boolean {
|
|
211
|
+
return adapter instanceof CompositeAuthoringAdapter && adapter.gameRootId === nodeId;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A4 (D8) — every root-group MEMBER id (radio row — a data-file root), keyed
|
|
216
|
+
* by node id, built fresh from `adapter.rootGroups.groups()` each render.
|
|
217
|
+
* This is the ONLY way `GameHierarchy` learns which rows are members/which
|
|
218
|
+
* is active — never by decoding the opaque id string itself (Rule zero:
|
|
219
|
+
* this file's ids stay format-neutral, no first-party document vocabulary).
|
|
220
|
+
*/
|
|
221
|
+
function buildRootMembers(
|
|
222
|
+
adapter: AuthoringAdapter,
|
|
223
|
+
): Map<string, { groupId: string; active: boolean }> {
|
|
224
|
+
const map = new Map<string, { groupId: string; active: boolean }>();
|
|
225
|
+
for (const group of adapter.rootGroups?.groups() ?? []) {
|
|
226
|
+
for (const id of group.memberRootIds) {
|
|
227
|
+
map.set(id, { groupId: group.id, active: group.activeRootId === id });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return map;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- Context menu ---
|
|
234
|
+
|
|
235
|
+
interface ContextMenuState {
|
|
236
|
+
x: number;
|
|
237
|
+
y: number;
|
|
238
|
+
nodeId: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function ContextMenu({
|
|
242
|
+
state,
|
|
243
|
+
adapter,
|
|
244
|
+
store,
|
|
245
|
+
isGroupNode,
|
|
246
|
+
onRename,
|
|
247
|
+
onClose,
|
|
248
|
+
}: {
|
|
249
|
+
state: ContextMenuState;
|
|
250
|
+
adapter: AuthoringAdapter;
|
|
251
|
+
store: EditorStore;
|
|
252
|
+
isGroupNode: boolean;
|
|
253
|
+
onRename: (id: string) => void;
|
|
254
|
+
onClose: () => void;
|
|
255
|
+
}) {
|
|
256
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
257
|
+
const [createOpen, setCreateOpen] = useState(false);
|
|
258
|
+
// Same at-most-once-per-open guard as CreateMenu: `onClose()` on select is
|
|
259
|
+
// async, so a double-dispatched click/Enter on a create item could otherwise
|
|
260
|
+
// double-invoke `create` before the menu unmounts. Reset on each open.
|
|
261
|
+
const createGuardRef = useRef(false);
|
|
262
|
+
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
if (createOpen) createGuardRef.current = false;
|
|
265
|
+
}, [createOpen]);
|
|
266
|
+
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
const handler = (e: MouseEvent) => {
|
|
269
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
|
|
270
|
+
};
|
|
271
|
+
const keyHandler = (e: KeyboardEvent) => {
|
|
272
|
+
if (e.key === 'Escape') onClose();
|
|
273
|
+
};
|
|
274
|
+
document.addEventListener('mousedown', handler);
|
|
275
|
+
document.addEventListener('keydown', keyHandler);
|
|
276
|
+
return () => {
|
|
277
|
+
document.removeEventListener('mousedown', handler);
|
|
278
|
+
document.removeEventListener('keydown', keyHandler);
|
|
279
|
+
};
|
|
280
|
+
}, [onClose]);
|
|
281
|
+
|
|
282
|
+
const node = adapter.hierarchy.node(state.nodeId);
|
|
283
|
+
if (!node) return null;
|
|
284
|
+
|
|
285
|
+
const creatableKinds = adapter.structure?.creatableKinds?.(state.nodeId) ?? [];
|
|
286
|
+
const hasStructure = !!adapter.structure;
|
|
287
|
+
const nameKnown = adapter.inspector?.get(state.nodeId, 'name') !== undefined;
|
|
288
|
+
const lockedKnown = adapter.inspector?.get(state.nodeId, 'locked') !== undefined;
|
|
289
|
+
const locked = adapter.inspector?.get(state.nodeId, 'locked') === true;
|
|
290
|
+
const visibleKnown = adapter.inspector !== undefined;
|
|
291
|
+
const visible = adapter.inspector?.get(state.nodeId, 'visible') !== false;
|
|
292
|
+
|
|
293
|
+
const items: { label: string; action: () => void; color?: string }[] = [];
|
|
294
|
+
|
|
295
|
+
if (!isGroupNode) {
|
|
296
|
+
if (nameKnown) {
|
|
297
|
+
items.push({
|
|
298
|
+
label: 'Rename',
|
|
299
|
+
action: () => {
|
|
300
|
+
onRename(state.nodeId);
|
|
301
|
+
onClose();
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
if (hasStructure) {
|
|
306
|
+
items.push({
|
|
307
|
+
label: 'Duplicate',
|
|
308
|
+
action: () => {
|
|
309
|
+
adapter.structure!.duplicate(state.nodeId);
|
|
310
|
+
onClose();
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (visibleKnown) {
|
|
315
|
+
items.push({
|
|
316
|
+
label: visible ? 'Hide' : 'Show',
|
|
317
|
+
action: () => {
|
|
318
|
+
adapter.inspector!.set(state.nodeId, 'visible', !visible);
|
|
319
|
+
store.notifyIngestEdit();
|
|
320
|
+
onClose();
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
if (lockedKnown) {
|
|
325
|
+
items.push({
|
|
326
|
+
label: locked ? 'Unlock' : 'Lock',
|
|
327
|
+
action: () => {
|
|
328
|
+
adapter.inspector!.set(state.nodeId, 'locked', !locked);
|
|
329
|
+
store.notifyIngestEdit();
|
|
330
|
+
onClose();
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
// First-party escape-hatch extras (prefab unpack/open/save-as, …) —
|
|
335
|
+
// permanent adapter-module contribution seam (see hierarchy-menu-registry.ts).
|
|
336
|
+
for (const extra of getHierarchyMenuItems({ nodeId: state.nodeId, store })) {
|
|
337
|
+
items.push({
|
|
338
|
+
label: extra.label,
|
|
339
|
+
...(extra.color !== undefined ? { color: extra.color } : {}),
|
|
340
|
+
action: () => {
|
|
341
|
+
extra.action();
|
|
342
|
+
onClose();
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
if (hasStructure) {
|
|
347
|
+
items.push({
|
|
348
|
+
label: 'Delete',
|
|
349
|
+
color: '#ff6b6b',
|
|
350
|
+
action: () => {
|
|
351
|
+
adapter.structure!.remove(state.nodeId);
|
|
352
|
+
onClose();
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<div
|
|
360
|
+
ref={menuRef}
|
|
361
|
+
data-testid="ingest-context-menu"
|
|
362
|
+
style={{
|
|
363
|
+
position: 'fixed',
|
|
364
|
+
left: state.x,
|
|
365
|
+
top: state.y,
|
|
366
|
+
background: '#2c2c2c',
|
|
367
|
+
border: '1px solid #444',
|
|
368
|
+
borderRadius: 4,
|
|
369
|
+
padding: '4px 0',
|
|
370
|
+
zIndex: 1000,
|
|
371
|
+
minWidth: 150,
|
|
372
|
+
maxWidth: 260,
|
|
373
|
+
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
|
374
|
+
fontSize: 12,
|
|
375
|
+
color: '#c5c8ce',
|
|
376
|
+
}}
|
|
377
|
+
>
|
|
378
|
+
{/* A4 (D8): also suppressed for a root-group MEMBER row, same as an
|
|
379
|
+
existing composite group row — `isGroupNode` covers both (see the
|
|
380
|
+
caller's computation below). Create▸ on a member id would silently
|
|
381
|
+
land at the scene root instead of doing anything meaningful. */}
|
|
382
|
+
{!isGroupNode && creatableKinds.length > 0 && (
|
|
383
|
+
<div>
|
|
384
|
+
<div
|
|
385
|
+
role="menuitem"
|
|
386
|
+
tabIndex={0}
|
|
387
|
+
onClick={() => setCreateOpen((v) => !v)}
|
|
388
|
+
onKeyDown={(e) => {
|
|
389
|
+
if (e.key === 'Enter') setCreateOpen((v) => !v);
|
|
390
|
+
}}
|
|
391
|
+
style={{ padding: '5px 12px', cursor: 'pointer' }}
|
|
392
|
+
>
|
|
393
|
+
Create ▸
|
|
394
|
+
</div>
|
|
395
|
+
{createOpen &&
|
|
396
|
+
creatableKinds.map((k) => (
|
|
397
|
+
<div
|
|
398
|
+
key={k.kind}
|
|
399
|
+
role="menuitem"
|
|
400
|
+
tabIndex={0}
|
|
401
|
+
onClick={() => {
|
|
402
|
+
if (createGuardRef.current) return;
|
|
403
|
+
createGuardRef.current = true;
|
|
404
|
+
adapter.structure!.create(k.kind, state.nodeId);
|
|
405
|
+
onClose();
|
|
406
|
+
}}
|
|
407
|
+
onKeyDown={(e) => {
|
|
408
|
+
if (e.key === 'Enter') {
|
|
409
|
+
if (createGuardRef.current) return;
|
|
410
|
+
createGuardRef.current = true;
|
|
411
|
+
adapter.structure!.create(k.kind, state.nodeId);
|
|
412
|
+
onClose();
|
|
413
|
+
}
|
|
414
|
+
}}
|
|
415
|
+
style={{ padding: '5px 12px 5px 24px', cursor: 'pointer' }}
|
|
416
|
+
onMouseEnter={(e) => {
|
|
417
|
+
(e.currentTarget as HTMLDivElement).style.background = '#3a3a3a';
|
|
418
|
+
}}
|
|
419
|
+
onMouseLeave={(e) => {
|
|
420
|
+
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
|
|
421
|
+
}}
|
|
422
|
+
>
|
|
423
|
+
{k.label}
|
|
424
|
+
</div>
|
|
425
|
+
))}
|
|
426
|
+
</div>
|
|
427
|
+
)}
|
|
428
|
+
{items.map((item) => (
|
|
429
|
+
<div
|
|
430
|
+
key={item.label}
|
|
431
|
+
role="menuitem"
|
|
432
|
+
tabIndex={0}
|
|
433
|
+
onClick={item.action}
|
|
434
|
+
onKeyDown={(e) => {
|
|
435
|
+
if (e.key === 'Enter') item.action();
|
|
436
|
+
}}
|
|
437
|
+
style={{ padding: '5px 12px', cursor: 'pointer', color: item.color ?? '#c5c8ce' }}
|
|
438
|
+
onMouseEnter={(e) => {
|
|
439
|
+
(e.currentTarget as HTMLDivElement).style.background = '#3a3a3a';
|
|
440
|
+
}}
|
|
441
|
+
onMouseLeave={(e) => {
|
|
442
|
+
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
|
|
443
|
+
}}
|
|
444
|
+
>
|
|
445
|
+
{item.label}
|
|
446
|
+
</div>
|
|
447
|
+
))}
|
|
448
|
+
{/* Bounded-authoring honest degrade (landmine f): the ACTIVE adapter has
|
|
449
|
+
no `structure` provider at all (an ingested/foreign game owns its
|
|
450
|
+
own scene graph) — Rename/Show-Hide/Lock above still work (they're
|
|
451
|
+
gated independently, on `inspector`), but Create/Duplicate/Delete/
|
|
452
|
+
Reparent are not, so say so plainly instead of hiding the menu. */}
|
|
453
|
+
{!hasStructure && (
|
|
454
|
+
<div style={{ padding: '8px 12px', color: '#c9a227', maxWidth: 240, lineHeight: 1.4 }}>
|
|
455
|
+
<strong>Create / Delete / Reparent — not available.</strong> This game owns its own scene
|
|
456
|
+
graph, so structural authoring isn't reapplicable. You can edit transform / material /
|
|
457
|
+
visibility, or capture a subtree into the first-party editable format.
|
|
458
|
+
</div>
|
|
459
|
+
)}
|
|
460
|
+
</div>
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// --- Add / Create menu (shared by the toolbar "+" and reused create list) ---
|
|
465
|
+
|
|
466
|
+
function CreateMenu({ adapter, parentId }: { adapter: AuthoringAdapter; parentId: string | null }) {
|
|
467
|
+
const [open, setOpen] = useState(false);
|
|
468
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
469
|
+
// At-most-once-per-open guard. `setOpen(false)` on select is async — the menu
|
|
470
|
+
// item stays mounted until the next render, so under CI input jank a single
|
|
471
|
+
// menu-open can receive two `click` dispatches on the same item before it
|
|
472
|
+
// unmounts, double-invoking `create` (the "expected 3, saw 8" overshoot in
|
|
473
|
+
// 29-game-hierarchy). React state can't gate this because both clicks land in
|
|
474
|
+
// the same commit; a ref reset on each open bounds it to one create per open.
|
|
475
|
+
const creatingRef = useRef(false);
|
|
476
|
+
const kinds = adapter.structure?.creatableKinds?.(parentId) ?? [];
|
|
477
|
+
|
|
478
|
+
useEffect(() => {
|
|
479
|
+
if (!open) return;
|
|
480
|
+
creatingRef.current = false; // fresh open → allow exactly one create
|
|
481
|
+
const handler = (e: MouseEvent) => {
|
|
482
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
483
|
+
};
|
|
484
|
+
const keyHandler = (e: KeyboardEvent) => {
|
|
485
|
+
if (e.key === 'Escape') setOpen(false);
|
|
486
|
+
};
|
|
487
|
+
document.addEventListener('mousedown', handler);
|
|
488
|
+
document.addEventListener('keydown', keyHandler);
|
|
489
|
+
return () => {
|
|
490
|
+
document.removeEventListener('mousedown', handler);
|
|
491
|
+
document.removeEventListener('keydown', keyHandler);
|
|
492
|
+
};
|
|
493
|
+
}, [open]);
|
|
494
|
+
|
|
495
|
+
if (kinds.length === 0) return null;
|
|
496
|
+
|
|
497
|
+
return (
|
|
498
|
+
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
|
|
499
|
+
<span
|
|
500
|
+
role="button"
|
|
501
|
+
title="Add Entity"
|
|
502
|
+
data-testid="hierarchy-add-menu"
|
|
503
|
+
onClick={() => setOpen(!open)}
|
|
504
|
+
style={{
|
|
505
|
+
cursor: 'pointer',
|
|
506
|
+
fontSize: 13,
|
|
507
|
+
padding: '1px 4px',
|
|
508
|
+
borderRadius: 3,
|
|
509
|
+
background: open ? '#3a3a3a' : 'transparent',
|
|
510
|
+
userSelect: 'none',
|
|
511
|
+
color: '#c5c8ce',
|
|
512
|
+
}}
|
|
513
|
+
>
|
|
514
|
+
<FontAwesomeIcon icon={faPlus} style={{ fontSize: 10 }} />{' '}
|
|
515
|
+
<FontAwesomeIcon icon={faCaretDown} style={{ fontSize: 9 }} />
|
|
516
|
+
</span>
|
|
517
|
+
{open && (
|
|
518
|
+
<div
|
|
519
|
+
style={{
|
|
520
|
+
position: 'absolute',
|
|
521
|
+
top: '100%',
|
|
522
|
+
right: 0,
|
|
523
|
+
marginTop: 4,
|
|
524
|
+
background: '#2c2c2c',
|
|
525
|
+
border: '1px solid #444',
|
|
526
|
+
borderRadius: 4,
|
|
527
|
+
padding: '4px 0',
|
|
528
|
+
zIndex: 1000,
|
|
529
|
+
minWidth: 130,
|
|
530
|
+
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
|
531
|
+
}}
|
|
532
|
+
>
|
|
533
|
+
{kinds.map((k) => (
|
|
534
|
+
<div
|
|
535
|
+
key={k.kind}
|
|
536
|
+
data-testid={`hierarchy-add-${k.kind}`}
|
|
537
|
+
onClick={() => {
|
|
538
|
+
if (creatingRef.current) return; // ignore a double-dispatched click
|
|
539
|
+
creatingRef.current = true;
|
|
540
|
+
adapter.structure!.create(k.kind, parentId ?? undefined);
|
|
541
|
+
setOpen(false);
|
|
542
|
+
}}
|
|
543
|
+
style={{ padding: '5px 12px', cursor: 'pointer', fontSize: 12, color: '#c5c8ce' }}
|
|
544
|
+
onMouseEnter={(e) => {
|
|
545
|
+
(e.currentTarget as HTMLDivElement).style.background = '#3a3a3a';
|
|
546
|
+
}}
|
|
547
|
+
onMouseLeave={(e) => {
|
|
548
|
+
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
|
|
549
|
+
}}
|
|
550
|
+
>
|
|
551
|
+
{k.label}
|
|
552
|
+
</div>
|
|
553
|
+
))}
|
|
554
|
+
</div>
|
|
555
|
+
)}
|
|
556
|
+
</div>
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// --- Row ---
|
|
561
|
+
|
|
562
|
+
function MoreRow({ row, onReveal }: { row: IngestRow; onReveal: (parentPathKey: string) => void }) {
|
|
563
|
+
const hidden = row.more!.hidden;
|
|
564
|
+
return (
|
|
565
|
+
<button
|
|
566
|
+
type="button"
|
|
567
|
+
data-testid="ingest-row-more"
|
|
568
|
+
onClick={() => onReveal(row.more!.parentPathKey)}
|
|
569
|
+
style={{
|
|
570
|
+
display: 'flex',
|
|
571
|
+
alignItems: 'center',
|
|
572
|
+
width: '100%',
|
|
573
|
+
height: ROW_HEIGHT,
|
|
574
|
+
paddingLeft: row.depth * INDENT + INDENT,
|
|
575
|
+
border: 'none',
|
|
576
|
+
background: 'transparent',
|
|
577
|
+
color: '#7d828c',
|
|
578
|
+
fontSize: 12,
|
|
579
|
+
fontStyle: 'italic',
|
|
580
|
+
textAlign: 'left',
|
|
581
|
+
cursor: 'pointer',
|
|
582
|
+
}}
|
|
583
|
+
>
|
|
584
|
+
… {hidden} more
|
|
585
|
+
</button>
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* D4 (spec27 §6 D4, layer-tree "search filters + HIGHLIGHTS") — split
|
|
591
|
+
* `label` into `<mark>`-wrapped/plain segments around every case-insensitive
|
|
592
|
+
* occurrence of `term`, or the plain label unchanged when `term` is empty
|
|
593
|
+
* (the pre-D4 render, byte-identical). Pure text math — no adapter/DOM
|
|
594
|
+
* access — factored out purely so `Row` itself stays simple.
|
|
595
|
+
*/
|
|
596
|
+
function highlightSegments(label: string, term: string): React.ReactNode {
|
|
597
|
+
if (!term) return label;
|
|
598
|
+
const lower = label.toLowerCase();
|
|
599
|
+
const needle = term.toLowerCase();
|
|
600
|
+
const parts: React.ReactNode[] = [];
|
|
601
|
+
let cursor = 0;
|
|
602
|
+
let idx = lower.indexOf(needle, cursor);
|
|
603
|
+
if (idx < 0) return label;
|
|
604
|
+
let key = 0;
|
|
605
|
+
while (idx >= 0) {
|
|
606
|
+
if (idx > cursor) parts.push(label.slice(cursor, idx));
|
|
607
|
+
parts.push(
|
|
608
|
+
<mark
|
|
609
|
+
key={key++}
|
|
610
|
+
data-testid="ingest-row-search-highlight"
|
|
611
|
+
style={{ background: '#e0a030', color: '#1a1a1a', borderRadius: 2 }}
|
|
612
|
+
>
|
|
613
|
+
{label.slice(idx, idx + needle.length)}
|
|
614
|
+
</mark>,
|
|
615
|
+
);
|
|
616
|
+
cursor = idx + needle.length;
|
|
617
|
+
idx = lower.indexOf(needle, cursor);
|
|
618
|
+
}
|
|
619
|
+
if (cursor < label.length) parts.push(label.slice(cursor));
|
|
620
|
+
return parts;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function Row({
|
|
624
|
+
row,
|
|
625
|
+
adapter,
|
|
626
|
+
store,
|
|
627
|
+
isSelected,
|
|
628
|
+
isEditing,
|
|
629
|
+
isCollapsed,
|
|
630
|
+
dropZone,
|
|
631
|
+
isDragged,
|
|
632
|
+
rootMember,
|
|
633
|
+
searchTerm,
|
|
634
|
+
onToggleOpen,
|
|
635
|
+
onStartEditing,
|
|
636
|
+
onContextMenu,
|
|
637
|
+
onDragStart,
|
|
638
|
+
onClick,
|
|
639
|
+
onActivateRoot,
|
|
640
|
+
}: {
|
|
641
|
+
row: IngestRow;
|
|
642
|
+
adapter: AuthoringAdapter;
|
|
643
|
+
store: EditorStore;
|
|
644
|
+
isSelected: boolean;
|
|
645
|
+
isEditing: boolean;
|
|
646
|
+
isCollapsed: boolean;
|
|
647
|
+
dropZone: DropZone | null;
|
|
648
|
+
isDragged: boolean;
|
|
649
|
+
/** A4 (D8) — set when this row is a root-group member (a scene-file
|
|
650
|
+
* root); `active` is whether it's the currently-loaded one. */
|
|
651
|
+
rootMember: { groupId: string; active: boolean } | undefined;
|
|
652
|
+
/** D4 — the trimmed, lowercased-by-the-caller search term (empty string
|
|
653
|
+
* when the search box is empty), for {@link highlightSegments}. */
|
|
654
|
+
searchTerm: string;
|
|
655
|
+
onToggleOpen: (pathKey: string) => void;
|
|
656
|
+
onStartEditing: (id: string) => void;
|
|
657
|
+
onContextMenu: (e: React.MouseEvent, id: string) => void;
|
|
658
|
+
onDragStart: (e: React.DragEvent, id: string) => void;
|
|
659
|
+
onClick: (e: React.MouseEvent, id: string) => void;
|
|
660
|
+
onActivateRoot: (groupId: string, rootId: string) => void;
|
|
661
|
+
}) {
|
|
662
|
+
const { node, depth, hasChildren, pathKey } = row;
|
|
663
|
+
// A composite `world:<id>` group row (session-local eye per D9, zOrder badge,
|
|
664
|
+
// non-draggable) — NOT merely a runtime-only node (see `isCompositeGroupNode`).
|
|
665
|
+
const badge = compositeGroupBadge(adapter, node.id);
|
|
666
|
+
const isGroupNode = badge !== null;
|
|
667
|
+
// A4 (D8) — the synthetic game root, and a root-group member (scene-file
|
|
668
|
+
// root) row. Neither owns a real name/visible/locked path, isn't
|
|
669
|
+
// draggable, and carries no `data-entity-name` (landmine: every project
|
|
670
|
+
// ships 2+ scene-file members — they must not pollute `getEntityNames()`).
|
|
671
|
+
const isGameRootRow = isGameRootNode(adapter, node.id);
|
|
672
|
+
const isMemberRow = rootMember !== undefined;
|
|
673
|
+
const isStructural = isGroupNode || isGameRootRow || isMemberRow;
|
|
674
|
+
// Reserved-path reads, resolved ONCE per row and only for non-structural
|
|
675
|
+
// rows: an adapter that doesn't support a reserved path returns
|
|
676
|
+
// `undefined` (its affordance simply doesn't render). Cheap for every
|
|
677
|
+
// stock adapter — the ingest/react adapters early-return `undefined` for a
|
|
678
|
+
// non-style path before touching their tree; the first-party adapter
|
|
679
|
+
// reads a plain descriptor.
|
|
680
|
+
const lockedValue = isStructural ? undefined : adapter.inspector?.get(node.id, 'locked');
|
|
681
|
+
const nameValue = isStructural ? undefined : adapter.inspector?.get(node.id, 'name');
|
|
682
|
+
const visibleKnown = !isStructural && adapter.inspector !== undefined;
|
|
683
|
+
// B1 (D9) — a group row's eye/interactive/pick-lock read the LIFTED,
|
|
684
|
+
// module-level session store (`world-session-state.ts`), keyed by the
|
|
685
|
+
// RAW manifest world id `badge.worldId` — not the group-node id itself.
|
|
686
|
+
const worldHidden = badge ? isWorldHidden(badge.worldId) : false;
|
|
687
|
+
const worldInteractive = badge ? isWorldInteractive(badge.worldId) : false;
|
|
688
|
+
const worldPickLocked = badge ? isWorldPickLocked(badge.worldId) : false;
|
|
689
|
+
const visible = isGroupNode ? !worldHidden : adapter.inspector?.get(node.id, 'visible') !== false;
|
|
690
|
+
const lockedKnown = lockedValue !== undefined;
|
|
691
|
+
const locked = lockedValue === true;
|
|
692
|
+
const nameKnown = nameValue !== undefined;
|
|
693
|
+
|
|
694
|
+
const bg =
|
|
695
|
+
dropZone === 'child' ? 'rgba(87, 158, 255, 0.25)' : isSelected ? '#2a4a7a' : 'transparent';
|
|
696
|
+
|
|
697
|
+
return (
|
|
698
|
+
<div
|
|
699
|
+
data-entity-name={isMemberRow || isGameRootRow ? undefined : node.label}
|
|
700
|
+
data-testid="ingest-row"
|
|
701
|
+
data-ingest-name={node.label}
|
|
702
|
+
draggable={!isStructural}
|
|
703
|
+
onDragStart={(e) => onDragStart(e, node.id)}
|
|
704
|
+
onClick={(e) => onClick(e, node.id)}
|
|
705
|
+
onDoubleClick={() => {
|
|
706
|
+
if (rootMember && !rootMember.active) {
|
|
707
|
+
onActivateRoot(rootMember.groupId, node.id);
|
|
708
|
+
} else if (nameKnown) {
|
|
709
|
+
onStartEditing(node.id);
|
|
710
|
+
}
|
|
711
|
+
}}
|
|
712
|
+
onContextMenu={(e) => {
|
|
713
|
+
e.preventDefault();
|
|
714
|
+
onContextMenu(e, node.id);
|
|
715
|
+
}}
|
|
716
|
+
style={{
|
|
717
|
+
position: 'relative',
|
|
718
|
+
display: 'flex',
|
|
719
|
+
alignItems: 'center',
|
|
720
|
+
height: ROW_HEIGHT,
|
|
721
|
+
paddingLeft: depth * INDENT,
|
|
722
|
+
paddingRight: 4,
|
|
723
|
+
gap: 4,
|
|
724
|
+
cursor: 'pointer',
|
|
725
|
+
background: bg,
|
|
726
|
+
outline: dropZone === 'child' ? '1px dashed #579EFF' : 'none',
|
|
727
|
+
whiteSpace: 'nowrap',
|
|
728
|
+
overflow: 'hidden',
|
|
729
|
+
opacity:
|
|
730
|
+
isDragged || (!isStructural && !visible) || (rootMember && !rootMember.active) ? 0.45 : 1,
|
|
731
|
+
fontSize: 13,
|
|
732
|
+
color: '#c5c8ce',
|
|
733
|
+
userSelect: 'none',
|
|
734
|
+
}}
|
|
735
|
+
>
|
|
736
|
+
{dropZone === 'before' && (
|
|
737
|
+
<div
|
|
738
|
+
style={{
|
|
739
|
+
position: 'absolute',
|
|
740
|
+
top: 0,
|
|
741
|
+
left: depth * INDENT,
|
|
742
|
+
right: 0,
|
|
743
|
+
height: 2,
|
|
744
|
+
background: '#579EFF',
|
|
745
|
+
pointerEvents: 'none',
|
|
746
|
+
zIndex: 10,
|
|
747
|
+
}}
|
|
748
|
+
/>
|
|
749
|
+
)}
|
|
750
|
+
{dropZone === 'after' && (
|
|
751
|
+
<div
|
|
752
|
+
style={{
|
|
753
|
+
position: 'absolute',
|
|
754
|
+
bottom: 0,
|
|
755
|
+
left: depth * INDENT,
|
|
756
|
+
right: 0,
|
|
757
|
+
height: 2,
|
|
758
|
+
background: '#579EFF',
|
|
759
|
+
pointerEvents: 'none',
|
|
760
|
+
zIndex: 10,
|
|
761
|
+
}}
|
|
762
|
+
/>
|
|
763
|
+
)}
|
|
764
|
+
|
|
765
|
+
<span
|
|
766
|
+
data-testid="ingest-row-caret"
|
|
767
|
+
onClick={(e) => {
|
|
768
|
+
e.stopPropagation();
|
|
769
|
+
onToggleOpen(pathKey);
|
|
770
|
+
}}
|
|
771
|
+
style={{
|
|
772
|
+
width: 12,
|
|
773
|
+
fontSize: 8,
|
|
774
|
+
textAlign: 'center',
|
|
775
|
+
flexShrink: 0,
|
|
776
|
+
cursor: hasChildren ? 'pointer' : 'default',
|
|
777
|
+
color: '#8c8c8c',
|
|
778
|
+
}}
|
|
779
|
+
>
|
|
780
|
+
{hasChildren ? <FontAwesomeIcon icon={isCollapsed ? faCaretRight : faCaretDown} /> : ''}
|
|
781
|
+
</span>
|
|
782
|
+
|
|
783
|
+
{rootMember && (
|
|
784
|
+
<span
|
|
785
|
+
data-testid="root-radio"
|
|
786
|
+
data-root-active={rootMember.active ? 'true' : 'false'}
|
|
787
|
+
title={rootMember.active ? 'Active root' : 'Inactive root — double-click to activate'}
|
|
788
|
+
style={{ fontSize: 9, opacity: 0.8, flexShrink: 0 }}
|
|
789
|
+
>
|
|
790
|
+
{rootMember.active ? '●' : '○'}
|
|
791
|
+
</span>
|
|
792
|
+
)}
|
|
793
|
+
|
|
794
|
+
<span style={{ fontSize: 10, opacity: 0.6, flexShrink: 0 }}>
|
|
795
|
+
{KIND_GLYPH[node.kind] ?? '•'}
|
|
796
|
+
</span>
|
|
797
|
+
|
|
798
|
+
{isEditing ? (
|
|
799
|
+
<input
|
|
800
|
+
autoFocus
|
|
801
|
+
defaultValue={node.label}
|
|
802
|
+
onBlur={(e) => {
|
|
803
|
+
adapter.inspector!.set(node.id, 'name', e.currentTarget.value);
|
|
804
|
+
store.notifyIngestEdit();
|
|
805
|
+
onStartEditing('');
|
|
806
|
+
}}
|
|
807
|
+
onKeyDown={(e) => {
|
|
808
|
+
if (e.key === 'Enter') {
|
|
809
|
+
adapter.inspector!.set(node.id, 'name', e.currentTarget.value);
|
|
810
|
+
store.notifyIngestEdit();
|
|
811
|
+
onStartEditing('');
|
|
812
|
+
}
|
|
813
|
+
if (e.key === 'Escape') onStartEditing('');
|
|
814
|
+
}}
|
|
815
|
+
onClick={(e) => e.stopPropagation()}
|
|
816
|
+
style={{
|
|
817
|
+
flex: 1,
|
|
818
|
+
minWidth: 0,
|
|
819
|
+
background: '#1a1a1a',
|
|
820
|
+
border: '1px solid #579EFF',
|
|
821
|
+
borderRadius: 2,
|
|
822
|
+
color: '#c5c8ce',
|
|
823
|
+
fontSize: 13,
|
|
824
|
+
padding: '0 4px',
|
|
825
|
+
outline: 'none',
|
|
826
|
+
}}
|
|
827
|
+
/>
|
|
828
|
+
) : (
|
|
829
|
+
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
830
|
+
{highlightSegments(node.label, searchTerm)}
|
|
831
|
+
{badge && (
|
|
832
|
+
<span style={{ marginLeft: 6, fontSize: 10, opacity: 0.6 }}>z{badge.zOrder}</span>
|
|
833
|
+
)}
|
|
834
|
+
</span>
|
|
835
|
+
)}
|
|
836
|
+
|
|
837
|
+
{lockedKnown && (
|
|
838
|
+
<span
|
|
839
|
+
data-testid="ingest-row-lock"
|
|
840
|
+
title="Toggle Lock"
|
|
841
|
+
onClick={(e) => {
|
|
842
|
+
e.stopPropagation();
|
|
843
|
+
adapter.inspector!.set(node.id, 'locked', !locked);
|
|
844
|
+
store.notifyIngestEdit();
|
|
845
|
+
}}
|
|
846
|
+
style={{ fontSize: 9, cursor: 'pointer', flexShrink: 0, opacity: locked ? 0.8 : 0.35 }}
|
|
847
|
+
>
|
|
848
|
+
<FontAwesomeIcon icon={locked ? faLock : faLockOpen} />
|
|
849
|
+
</span>
|
|
850
|
+
)}
|
|
851
|
+
|
|
852
|
+
{(visibleKnown || isGroupNode) && (
|
|
853
|
+
<button
|
|
854
|
+
type="button"
|
|
855
|
+
data-testid="ingest-row-visibility"
|
|
856
|
+
title={visible ? 'Hide' : 'Show'}
|
|
857
|
+
onClick={(e) => {
|
|
858
|
+
e.stopPropagation();
|
|
859
|
+
if (badge) {
|
|
860
|
+
toggleWorldHidden(badge.worldId);
|
|
861
|
+
store.notifyIngestEdit();
|
|
862
|
+
} else {
|
|
863
|
+
adapter.inspector!.set(node.id, 'visible', !visible);
|
|
864
|
+
store.notifyIngestEdit();
|
|
865
|
+
}
|
|
866
|
+
}}
|
|
867
|
+
style={{
|
|
868
|
+
width: 20,
|
|
869
|
+
height: 20,
|
|
870
|
+
border: 'none',
|
|
871
|
+
background: 'transparent',
|
|
872
|
+
color: visible ? '#9aa0a6' : '#e0a030',
|
|
873
|
+
cursor: 'pointer',
|
|
874
|
+
fontSize: 11,
|
|
875
|
+
flexShrink: 0,
|
|
876
|
+
}}
|
|
877
|
+
>
|
|
878
|
+
<FontAwesomeIcon icon={visible ? faEye : faEyeSlash} />
|
|
879
|
+
</button>
|
|
880
|
+
)}
|
|
881
|
+
|
|
882
|
+
{/* B1 (D9) — session-local, per-world design-time toggles: `interactive`
|
|
883
|
+
* forwards real pointer events to the world's layer (design-time
|
|
884
|
+
* hover/press testing, `design-time-layers.ts`); `pickLock` is stored
|
|
885
|
+
* for B4's layered viewport picking to consume. Group rows only —
|
|
886
|
+
* neither concept applies to an ordinary entity row. */}
|
|
887
|
+
{badge && (
|
|
888
|
+
<span
|
|
889
|
+
data-testid="world-interactive-toggle"
|
|
890
|
+
title={
|
|
891
|
+
worldInteractive
|
|
892
|
+
? 'Interactive (layer forwards pointer events)'
|
|
893
|
+
: 'Not interactive (layer is pointer-events:none)'
|
|
894
|
+
}
|
|
895
|
+
onClick={(e) => {
|
|
896
|
+
e.stopPropagation();
|
|
897
|
+
toggleWorldInteractive(badge.worldId);
|
|
898
|
+
store.notifyIngestEdit();
|
|
899
|
+
}}
|
|
900
|
+
style={{
|
|
901
|
+
fontSize: 9,
|
|
902
|
+
cursor: 'pointer',
|
|
903
|
+
flexShrink: 0,
|
|
904
|
+
opacity: worldInteractive ? 0.9 : 0.35,
|
|
905
|
+
}}
|
|
906
|
+
>
|
|
907
|
+
<FontAwesomeIcon icon={faHandPointer} />
|
|
908
|
+
</span>
|
|
909
|
+
)}
|
|
910
|
+
{badge && (
|
|
911
|
+
<span
|
|
912
|
+
data-testid="world-picklock-toggle"
|
|
913
|
+
title={
|
|
914
|
+
worldPickLocked ? 'Pick-locked (skipped by topmost-layer picking)' : 'Not pick-locked'
|
|
915
|
+
}
|
|
916
|
+
onClick={(e) => {
|
|
917
|
+
e.stopPropagation();
|
|
918
|
+
toggleWorldPickLock(badge.worldId);
|
|
919
|
+
store.notifyIngestEdit();
|
|
920
|
+
}}
|
|
921
|
+
style={{
|
|
922
|
+
fontSize: 9,
|
|
923
|
+
cursor: 'pointer',
|
|
924
|
+
flexShrink: 0,
|
|
925
|
+
opacity: worldPickLocked ? 0.9 : 0.35,
|
|
926
|
+
}}
|
|
927
|
+
>
|
|
928
|
+
<FontAwesomeIcon icon={faCrosshairs} />
|
|
929
|
+
</span>
|
|
930
|
+
)}
|
|
931
|
+
</div>
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// --- Main panel ---
|
|
936
|
+
|
|
937
|
+
export function GameHierarchy() {
|
|
938
|
+
const store = useEditorStore();
|
|
939
|
+
useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
940
|
+
const adapter = getActiveAuthoring(store);
|
|
941
|
+
|
|
942
|
+
const [search, setSearch] = useState('');
|
|
943
|
+
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
944
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
945
|
+
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
|
946
|
+
const [tailDrop, setTailDrop] = useState(false);
|
|
947
|
+
const [draggedIds, setDraggedIds] = useState<string[] | null>(null);
|
|
948
|
+
// D9 — world-group visibility (eye) is SESSION-LOCAL shell state, never
|
|
949
|
+
// routed through `inspector`/serialized. B1 LIFTED this out of a local
|
|
950
|
+
// `useState` (which the design-time layer stack could never see) into the
|
|
951
|
+
// module-level `world-session-state.ts` singleton, so `design-time-layers.ts`
|
|
952
|
+
// can actually hide/show a world's DOM layer, not just dim its hierarchy
|
|
953
|
+
// row — see that module's header comment.
|
|
954
|
+
// Wave-18-ported scroll-window state — the live `scrollTop`, updated by
|
|
955
|
+
// `onScroll` below; `clientHeight` is read fresh from `containerRef` every
|
|
956
|
+
// render rather than cached in state (see the constants' doc comment).
|
|
957
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
958
|
+
|
|
959
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
960
|
+
const collapsedKeysRef = useRef(new Set<string>());
|
|
961
|
+
const revealedRef = useRef(new Map<string, number>());
|
|
962
|
+
const lastClickedRef = useRef<string | null>(null);
|
|
963
|
+
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
|
964
|
+
|
|
965
|
+
const term = search.trim().toLowerCase();
|
|
966
|
+
// Cap browse-view children (D-C1) whenever a non-first-party/composite
|
|
967
|
+
// session is active (an ingested/multi-world tree may be huge), OR while
|
|
968
|
+
// playing (a live scene graph can also balloon) — matches the exact
|
|
969
|
+
// capping policy the former SceneHierarchy (play-only) / IngestHierarchy
|
|
970
|
+
// (always, unless searching) components each had on their own.
|
|
971
|
+
const capEnabled = term.length === 0 && (hasAuthoringOverride() || store.playState !== 'stopped');
|
|
972
|
+
|
|
973
|
+
const allRows = flattenIngestRows(
|
|
974
|
+
adapter.hierarchy,
|
|
975
|
+
collapsedKeysRef.current,
|
|
976
|
+
revealedRef.current,
|
|
977
|
+
capEnabled,
|
|
978
|
+
);
|
|
979
|
+
// Session-local world-hide (D9): drop every row belonging to a hidden world
|
|
980
|
+
// group (the group row itself stays, dimmed by its own `visible` flag above).
|
|
981
|
+
const worldFiltered = allRows.filter((row) => {
|
|
982
|
+
if (isCompositeGroupNode(adapter, row.node.id)) return true; // the group row itself always shows
|
|
983
|
+
let current: string | null = row.node.parentId;
|
|
984
|
+
while (current) {
|
|
985
|
+
const badge = compositeGroupBadge(adapter, current);
|
|
986
|
+
if (badge && isWorldHidden(badge.worldId)) return false;
|
|
987
|
+
current = adapter.hierarchy.node(current)?.parentId ?? null;
|
|
988
|
+
}
|
|
989
|
+
return true;
|
|
990
|
+
});
|
|
991
|
+
const rows = term
|
|
992
|
+
? worldFiltered.filter((r) => r.node.label.toLowerCase().includes(term))
|
|
993
|
+
: worldFiltered.filter((r) => !r.hiddenByCollapse);
|
|
994
|
+
|
|
995
|
+
// Wave-18-ported: a filtered-down result set shouldn't leave the viewport
|
|
996
|
+
// parked at a stale deep scroll offset from a previous (larger) list.
|
|
997
|
+
useEffect(() => {
|
|
998
|
+
setScrollTop(0);
|
|
999
|
+
if (containerRef.current) containerRef.current.scrollTop = 0;
|
|
1000
|
+
}, [term]);
|
|
1001
|
+
|
|
1002
|
+
// Wave-18-ported: render only the windowed slice of `rows` — everything
|
|
1003
|
+
// above (cap/collapse/search/world-hide filtering) is unchanged; only the
|
|
1004
|
+
// final `.map()` further down is restricted. The drag/reorder + tailDrop
|
|
1005
|
+
// math below index/position against the FULL `rows` array (and read
|
|
1006
|
+
// `containerRef.current.scrollTop` directly), so windowing this slice
|
|
1007
|
+
// alone doesn't disturb them.
|
|
1008
|
+
const viewportPx = containerRef.current?.clientHeight || DEFAULT_VIEWPORT_PX;
|
|
1009
|
+
// Below the threshold, render every row (no window) — see VIRTUALIZE_MIN_ROWS.
|
|
1010
|
+
const { start, end } =
|
|
1011
|
+
rows.length > VIRTUALIZE_MIN_ROWS
|
|
1012
|
+
? ingestRowWindow(rows.length, scrollTop, viewportPx, ROW_HEIGHT, OVERSCAN)
|
|
1013
|
+
: { start: 0, end: rows.length };
|
|
1014
|
+
const windowRows = rows.slice(start, end);
|
|
1015
|
+
const topSpacerPx = start * ROW_HEIGHT;
|
|
1016
|
+
const bottomSpacerPx = (rows.length - end) * ROW_HEIGHT;
|
|
1017
|
+
|
|
1018
|
+
const toggleOpen = useCallback((pathKey: string) => {
|
|
1019
|
+
const set = collapsedKeysRef.current;
|
|
1020
|
+
if (set.has(pathKey)) set.delete(pathKey);
|
|
1021
|
+
else set.add(pathKey);
|
|
1022
|
+
forceUpdate();
|
|
1023
|
+
}, []);
|
|
1024
|
+
|
|
1025
|
+
const revealMore = useCallback((parentPathKey: string) => {
|
|
1026
|
+
const m = revealedRef.current;
|
|
1027
|
+
m.set(parentPathKey, (m.get(parentPathKey) ?? CHILD_CAP) + CHILD_CAP);
|
|
1028
|
+
forceUpdate();
|
|
1029
|
+
}, []);
|
|
1030
|
+
|
|
1031
|
+
// A4 (D8) — every root-group member, rebuilt each render straight from
|
|
1032
|
+
// `rootGroups.groups()` (never from the id string — see `buildRootMembers`'s
|
|
1033
|
+
// doc comment). `handleActivateRoot` is the ONE choke point a double-click
|
|
1034
|
+
// on an inactive member row goes through, shared with the asset-browser's
|
|
1035
|
+
// scene double-click (`DefaultEditorLayout.tsx`'s `handleOpenScene`) via
|
|
1036
|
+
// `requestRootActivation` so the dirty-gate/confirmation UX can't drift
|
|
1037
|
+
// between the two call sites.
|
|
1038
|
+
const rootMembers = buildRootMembers(adapter);
|
|
1039
|
+
const handleActivateRoot = useCallback(
|
|
1040
|
+
(groupId: string, rootId: string) => {
|
|
1041
|
+
requestRootActivation(adapter, groupId, rootId);
|
|
1042
|
+
},
|
|
1043
|
+
[adapter],
|
|
1044
|
+
);
|
|
1045
|
+
|
|
1046
|
+
const selectedIds = new Set(adapter.selection?.get() ?? []);
|
|
1047
|
+
|
|
1048
|
+
const handleRowClick = useCallback(
|
|
1049
|
+
(e: React.MouseEvent, nodeId: string) => {
|
|
1050
|
+
if (!adapter.selection) return;
|
|
1051
|
+
if (e.metaKey || e.ctrlKey) {
|
|
1052
|
+
const ids = new Set(selectedIds);
|
|
1053
|
+
if (ids.has(nodeId)) ids.delete(nodeId);
|
|
1054
|
+
else ids.add(nodeId);
|
|
1055
|
+
adapter.selection.set([...ids]);
|
|
1056
|
+
} else if (e.shiftKey && lastClickedRef.current) {
|
|
1057
|
+
const lastIdx = rows.findIndex((r) => r.node.id === lastClickedRef.current);
|
|
1058
|
+
const curIdx = rows.findIndex((r) => r.node.id === nodeId);
|
|
1059
|
+
if (lastIdx >= 0 && curIdx >= 0) {
|
|
1060
|
+
const start = Math.min(lastIdx, curIdx);
|
|
1061
|
+
const end = Math.max(lastIdx, curIdx);
|
|
1062
|
+
const rangeIds = rows
|
|
1063
|
+
.slice(start, end + 1)
|
|
1064
|
+
.filter((r) => !r.more)
|
|
1065
|
+
.map((r) => r.node.id);
|
|
1066
|
+
adapter.selection.set(rangeIds);
|
|
1067
|
+
}
|
|
1068
|
+
} else {
|
|
1069
|
+
adapter.selection.set([nodeId]);
|
|
1070
|
+
}
|
|
1071
|
+
lastClickedRef.current = nodeId;
|
|
1072
|
+
},
|
|
1073
|
+
[adapter, rows, selectedIds],
|
|
1074
|
+
);
|
|
1075
|
+
|
|
1076
|
+
const handleRowDragStart = useCallback(
|
|
1077
|
+
(e: React.DragEvent, nodeId: string) => {
|
|
1078
|
+
const ids = selectedIds.has(nodeId) ? [...selectedIds] : [nodeId];
|
|
1079
|
+
setDraggedIds(ids);
|
|
1080
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
1081
|
+
e.dataTransfer.setData(REORDER_MIME, JSON.stringify(ids));
|
|
1082
|
+
e.dataTransfer.setData('text/plain', nodeId);
|
|
1083
|
+
},
|
|
1084
|
+
[selectedIds],
|
|
1085
|
+
);
|
|
1086
|
+
|
|
1087
|
+
const handleDragOver = useCallback(
|
|
1088
|
+
(e: React.DragEvent) => {
|
|
1089
|
+
const isInternal = e.dataTransfer.types.includes(REORDER_MIME);
|
|
1090
|
+
const isAsset = e.dataTransfer.types.includes(ASSET_MIME);
|
|
1091
|
+
if (!isInternal && !isAsset) return;
|
|
1092
|
+
e.preventDefault();
|
|
1093
|
+
e.dataTransfer.dropEffect = isInternal ? 'move' : 'copy';
|
|
1094
|
+
|
|
1095
|
+
const rect = containerRef.current!.getBoundingClientRect();
|
|
1096
|
+
const scrollTop = containerRef.current!.scrollTop;
|
|
1097
|
+
const y = e.clientY - rect.top + scrollTop;
|
|
1098
|
+
const rowIndex = Math.floor(y / ROW_HEIGHT);
|
|
1099
|
+
|
|
1100
|
+
if (rowIndex >= rows.length) {
|
|
1101
|
+
setDropTarget(null);
|
|
1102
|
+
setTailDrop(true);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
setTailDrop(false);
|
|
1106
|
+
|
|
1107
|
+
const row = rows[rowIndex]!;
|
|
1108
|
+
if (row.more) {
|
|
1109
|
+
setDropTarget(null);
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
const rowY = y - rowIndex * ROW_HEIGHT;
|
|
1113
|
+
let zone: DropZone;
|
|
1114
|
+
if (rowY < ROW_HEIGHT * 0.25) zone = 'before';
|
|
1115
|
+
else if (rowY > ROW_HEIGHT * 0.75) zone = 'after';
|
|
1116
|
+
else zone = 'child';
|
|
1117
|
+
|
|
1118
|
+
if (isInternal && draggedIds) {
|
|
1119
|
+
const targetId = row.node.id;
|
|
1120
|
+
if (draggedIds.includes(targetId)) return;
|
|
1121
|
+
if (isDescendantOfAny(targetId, draggedIds, adapter)) return;
|
|
1122
|
+
}
|
|
1123
|
+
if (isCompositeGroupNode(adapter, row.node.id) && zone !== 'child') {
|
|
1124
|
+
// A composite world-group row has no siblings of its own kind to
|
|
1125
|
+
// reorder against — only "drop into this world" makes sense.
|
|
1126
|
+
zone = 'child';
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
setDropTarget({ nodeId: row.node.id, zone });
|
|
1130
|
+
},
|
|
1131
|
+
[rows, draggedIds, adapter],
|
|
1132
|
+
);
|
|
1133
|
+
|
|
1134
|
+
const handleDragEnd = useCallback(() => {
|
|
1135
|
+
setDropTarget(null);
|
|
1136
|
+
setTailDrop(false);
|
|
1137
|
+
setDraggedIds(null);
|
|
1138
|
+
}, []);
|
|
1139
|
+
|
|
1140
|
+
const handleDrop = useCallback(
|
|
1141
|
+
(e: React.DragEvent) => {
|
|
1142
|
+
e.preventDefault();
|
|
1143
|
+
e.stopPropagation();
|
|
1144
|
+
const currentDropTarget = dropTarget;
|
|
1145
|
+
const isTail = tailDrop;
|
|
1146
|
+
setDropTarget(null);
|
|
1147
|
+
setTailDrop(false);
|
|
1148
|
+
setDraggedIds(null);
|
|
1149
|
+
|
|
1150
|
+
const structure = adapter.structure;
|
|
1151
|
+
|
|
1152
|
+
// Internal reorder/reparent.
|
|
1153
|
+
const reorderData = e.dataTransfer.getData(REORDER_MIME);
|
|
1154
|
+
if (reorderData && structure) {
|
|
1155
|
+
const ids = JSON.parse(reorderData) as string[];
|
|
1156
|
+
if (isTail) {
|
|
1157
|
+
for (const id of ids) structure.reparent(id, null);
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
if (!currentDropTarget) return;
|
|
1161
|
+
const targetRow = rows.find((r) => r.node.id === currentDropTarget.nodeId);
|
|
1162
|
+
if (!targetRow) return;
|
|
1163
|
+
const targetId = targetRow.node.id;
|
|
1164
|
+
if (currentDropTarget.zone === 'child') {
|
|
1165
|
+
for (const id of ids) structure.reparent(id, targetId);
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
const targetParentId = targetRow.node.parentId;
|
|
1169
|
+
const siblings = siblingIdsOf(adapter, targetParentId).filter((id) => !ids.includes(id));
|
|
1170
|
+
const idx = siblings.indexOf(targetId);
|
|
1171
|
+
const beforeSiblingId =
|
|
1172
|
+
currentDropTarget.zone === 'before'
|
|
1173
|
+
? targetId
|
|
1174
|
+
: idx >= 0
|
|
1175
|
+
? (siblings[idx + 1] ?? null)
|
|
1176
|
+
: null;
|
|
1177
|
+
for (const id of ids) {
|
|
1178
|
+
const currentParentId = adapter.hierarchy.node(id)?.parentId ?? null;
|
|
1179
|
+
if (currentParentId !== targetParentId) structure.reparent(id, targetParentId ?? null);
|
|
1180
|
+
structure.reorder?.(id, beforeSiblingId);
|
|
1181
|
+
}
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// External asset drop.
|
|
1186
|
+
const assetData = e.dataTransfer.getData(ASSET_MIME);
|
|
1187
|
+
if (assetData && adapter.assetDrop) {
|
|
1188
|
+
try {
|
|
1189
|
+
const data = JSON.parse(assetData) as { path: string; kind: string };
|
|
1190
|
+
if (data.kind === 'scene') return; // scene-file drop: A4/deferred, not this shell's job
|
|
1191
|
+
if (isTail) {
|
|
1192
|
+
if (adapter.assetDrop.accepts('', data.path)) adapter.assetDrop.drop('', data.path);
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
if (!currentDropTarget) return;
|
|
1196
|
+
const targetRow = rows.find((r) => r.node.id === currentDropTarget.nodeId);
|
|
1197
|
+
if (!targetRow) return;
|
|
1198
|
+
const targetId = targetRow.node.id;
|
|
1199
|
+
if (adapter.assetDrop.accepts(targetId, data.path))
|
|
1200
|
+
adapter.assetDrop.drop(targetId, data.path);
|
|
1201
|
+
} catch {
|
|
1202
|
+
/* ignore malformed drag payload */
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
},
|
|
1206
|
+
[dropTarget, tailDrop, rows, adapter],
|
|
1207
|
+
);
|
|
1208
|
+
|
|
1209
|
+
const panelName = adapter.persistence?.destination ?? 'Untitled';
|
|
1210
|
+
|
|
1211
|
+
return (
|
|
1212
|
+
<Panel
|
|
1213
|
+
name={
|
|
1214
|
+
<span data-testid="scene-name" data-scene={panelName}>
|
|
1215
|
+
{panelName}
|
|
1216
|
+
</span>
|
|
1217
|
+
}
|
|
1218
|
+
onPointerDown={() => setActiveScope('hierarchy')}
|
|
1219
|
+
onDragOver={(e) => {
|
|
1220
|
+
if (e.dataTransfer.types.includes(ASSET_MIME)) {
|
|
1221
|
+
e.preventDefault();
|
|
1222
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
1223
|
+
}
|
|
1224
|
+
}}
|
|
1225
|
+
style={{ width: 240, borderRight: '1px solid #333' }}
|
|
1226
|
+
>
|
|
1227
|
+
<OverlayReportBanner />
|
|
1228
|
+
<div
|
|
1229
|
+
style={{
|
|
1230
|
+
padding: '4px 8px',
|
|
1231
|
+
borderBottom: '1px solid #333',
|
|
1232
|
+
display: 'flex',
|
|
1233
|
+
alignItems: 'center',
|
|
1234
|
+
gap: 4,
|
|
1235
|
+
flexShrink: 0,
|
|
1236
|
+
}}
|
|
1237
|
+
>
|
|
1238
|
+
<input
|
|
1239
|
+
type="text"
|
|
1240
|
+
data-testid="ingest-search"
|
|
1241
|
+
placeholder="Search..."
|
|
1242
|
+
value={search}
|
|
1243
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
1244
|
+
style={{
|
|
1245
|
+
flex: 1,
|
|
1246
|
+
minWidth: 0,
|
|
1247
|
+
background: '#1e1e1e',
|
|
1248
|
+
border: '1px solid #333',
|
|
1249
|
+
borderRadius: 3,
|
|
1250
|
+
color: '#c5c8ce',
|
|
1251
|
+
fontSize: 11,
|
|
1252
|
+
padding: '3px 6px',
|
|
1253
|
+
outline: 'none',
|
|
1254
|
+
}}
|
|
1255
|
+
/>
|
|
1256
|
+
<CreateMenu adapter={adapter} parentId={null} />
|
|
1257
|
+
</div>
|
|
1258
|
+
|
|
1259
|
+
<div
|
|
1260
|
+
ref={containerRef}
|
|
1261
|
+
data-testid="ingest-hierarchy"
|
|
1262
|
+
style={{
|
|
1263
|
+
flex: 1,
|
|
1264
|
+
minHeight: 0,
|
|
1265
|
+
overflow: 'auto',
|
|
1266
|
+
position: 'relative',
|
|
1267
|
+
pointerEvents: 'auto',
|
|
1268
|
+
}}
|
|
1269
|
+
onDragOver={handleDragOver}
|
|
1270
|
+
onDrop={handleDrop}
|
|
1271
|
+
onDragEnd={handleDragEnd}
|
|
1272
|
+
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
|
1273
|
+
onDragLeave={(e) => {
|
|
1274
|
+
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
|
|
1275
|
+
setDropTarget(null);
|
|
1276
|
+
setTailDrop(false);
|
|
1277
|
+
}
|
|
1278
|
+
}}
|
|
1279
|
+
>
|
|
1280
|
+
<div data-testid="game-hierarchy-spacer-top" style={{ height: topSpacerPx }} />
|
|
1281
|
+
{windowRows.map((row) =>
|
|
1282
|
+
row.more ? (
|
|
1283
|
+
<MoreRow key={ingestRowKey(row)} row={row} onReveal={revealMore} />
|
|
1284
|
+
) : (
|
|
1285
|
+
<Row
|
|
1286
|
+
key={ingestRowKey(row)}
|
|
1287
|
+
row={row}
|
|
1288
|
+
adapter={adapter}
|
|
1289
|
+
store={store}
|
|
1290
|
+
isSelected={selectedIds.has(row.node.id)}
|
|
1291
|
+
isEditing={editingId === row.node.id}
|
|
1292
|
+
isCollapsed={collapsedKeysRef.current.has(row.pathKey)}
|
|
1293
|
+
dropZone={dropTarget && dropTarget.nodeId === row.node.id ? dropTarget.zone : null}
|
|
1294
|
+
isDragged={draggedIds?.includes(row.node.id) ?? false}
|
|
1295
|
+
rootMember={rootMembers.get(row.node.id)}
|
|
1296
|
+
searchTerm={term}
|
|
1297
|
+
onToggleOpen={toggleOpen}
|
|
1298
|
+
onStartEditing={setEditingId}
|
|
1299
|
+
onContextMenu={(e, id) => setContextMenu({ x: e.clientX, y: e.clientY, nodeId: id })}
|
|
1300
|
+
onDragStart={handleRowDragStart}
|
|
1301
|
+
onClick={handleRowClick}
|
|
1302
|
+
onActivateRoot={handleActivateRoot}
|
|
1303
|
+
/>
|
|
1304
|
+
),
|
|
1305
|
+
)}
|
|
1306
|
+
<div data-testid="game-hierarchy-spacer-bottom" style={{ height: bottomSpacerPx }} />
|
|
1307
|
+
<div style={{ minHeight: 40 }} />
|
|
1308
|
+
{tailDrop && (
|
|
1309
|
+
<div
|
|
1310
|
+
style={{
|
|
1311
|
+
position: 'absolute',
|
|
1312
|
+
left: 0,
|
|
1313
|
+
right: 0,
|
|
1314
|
+
top: rows.length * ROW_HEIGHT,
|
|
1315
|
+
height: 2,
|
|
1316
|
+
background: '#579EFF',
|
|
1317
|
+
pointerEvents: 'none',
|
|
1318
|
+
zIndex: 10,
|
|
1319
|
+
}}
|
|
1320
|
+
/>
|
|
1321
|
+
)}
|
|
1322
|
+
</div>
|
|
1323
|
+
|
|
1324
|
+
{contextMenu &&
|
|
1325
|
+
(() => {
|
|
1326
|
+
const menuNode = adapter.hierarchy.node(contextMenu.nodeId);
|
|
1327
|
+
// A4 (D8): a root-group member or the game root gets the same
|
|
1328
|
+
// "no structural affordances" treatment as an existing composite
|
|
1329
|
+
// group row — none of the three own a name/visible/locked path or
|
|
1330
|
+
// a meaningful Create/Duplicate/Delete target.
|
|
1331
|
+
const isGroupNode = menuNode
|
|
1332
|
+
? isCompositeGroupNode(adapter, menuNode.id) ||
|
|
1333
|
+
isGameRootNode(adapter, menuNode.id) ||
|
|
1334
|
+
rootMembers.has(menuNode.id)
|
|
1335
|
+
: false;
|
|
1336
|
+
return (
|
|
1337
|
+
<ContextMenu
|
|
1338
|
+
state={contextMenu}
|
|
1339
|
+
adapter={adapter}
|
|
1340
|
+
store={store}
|
|
1341
|
+
isGroupNode={isGroupNode}
|
|
1342
|
+
onRename={setEditingId}
|
|
1343
|
+
onClose={() => setContextMenu(null)}
|
|
1344
|
+
/>
|
|
1345
|
+
);
|
|
1346
|
+
})()}
|
|
1347
|
+
</Panel>
|
|
1348
|
+
);
|
|
1349
|
+
}
|