@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,2400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReactWorldAuthoringAdapter — the editor's {@link AuthoringAdapter} for a react-kind
|
|
3
|
+
* world (T6.2 slice 2, `docs/REACT-WORLD-DESIGN.md` §1.D/§1.E/§3 slice 2).
|
|
4
|
+
*
|
|
5
|
+
* A react world has no GameComponents, no ticking, no mirror (D8) — its authoring
|
|
6
|
+
* entities ARE the `data-oid`-stamped elements of its rendered DOM (the OID
|
|
7
|
+
* instrumentation from the UI visual-edit program, `../ui-source/oid-transform.ts`,
|
|
8
|
+
* whose vite-plugin include this slice widens from the `editable-components` fixture
|
|
9
|
+
* dir to project scope — see `../../vite-plugin-ui-oid.ts`). This adapter derives its
|
|
10
|
+
* hierarchy by walking the world's live DOM root (`WorldInstance.reactRoot()`) for
|
|
11
|
+
* `data-oid`-carrying elements, tree-ordered; there is no cached/mirrored state to
|
|
12
|
+
* fall out of sync — every `hierarchy` call re-walks the live DOM.
|
|
13
|
+
*
|
|
14
|
+
* Writes (style/className/delete) reuse the EXISTING T3.2-slice-3 source-write seam
|
|
15
|
+
* verbatim (`../ui-source/source-write-backend.ts`'s `SourceWriteBackend`, the same
|
|
16
|
+
* `/__ui-source/write` + `/__ui-source/struct` dev-server endpoints
|
|
17
|
+
* `vite-plugin-ui-oid.ts` serves for `UIAuthoringAdapter`/`SourceEditPanel`) — this
|
|
18
|
+
* file does NOT invent a second source-writer. What IS new here (§1.E): edits enter
|
|
19
|
+
* the T3.2 global undo timeline as INVERSE-WRITE entries — the prior literal (read
|
|
20
|
+
* off the live DOM before the write) is captured and written back on undo. Structural
|
|
21
|
+
* ops (D-1, Phase D, spec27 §2) use a DIFFERENT inverse — a per-OID one is unsound for
|
|
22
|
+
* them (OIDs are content-signature keyed; `reorder`/`delete` reassign occurrence
|
|
23
|
+
* indices; `delete` has no inverse payload at all) — a checksum-guarded WHOLE-FILE
|
|
24
|
+
* snapshot restore instead (`pushStructUndo`/`restoreStructSnapshot`,
|
|
25
|
+
* `SourceWriteBackend.restoreSource`), so every structural op (including `delete`) now
|
|
26
|
+
* pushes exactly one undo entry.
|
|
27
|
+
*
|
|
28
|
+
* Persistence mirrors `UIAuthoringAdapter`'s de-stubbed pattern exactly: writes are
|
|
29
|
+
* immediate (server-side, on commit), so `save()` is an honest no-op; `destination`
|
|
30
|
+
* reports whether a source-write backend even exists in this session (absent in a
|
|
31
|
+
* hosted/no-dev-server build — selection/inspection still work, writes report
|
|
32
|
+
* unavailable via a loud console warning instead of silently no-op'ing).
|
|
33
|
+
*/
|
|
34
|
+
import type {
|
|
35
|
+
AuthoringAdapter,
|
|
36
|
+
AuthoringCapabilities,
|
|
37
|
+
BoxEditProvider,
|
|
38
|
+
ColorSampleProvider,
|
|
39
|
+
DOMRectLike,
|
|
40
|
+
EditorNode,
|
|
41
|
+
HierarchyProvider,
|
|
42
|
+
InspectorProvider,
|
|
43
|
+
PersistenceProvider,
|
|
44
|
+
PickProvider,
|
|
45
|
+
PropertyDescriptor,
|
|
46
|
+
RectProvider,
|
|
47
|
+
SelectionProvider,
|
|
48
|
+
StoriesProvider,
|
|
49
|
+
StoryRef,
|
|
50
|
+
StructureProvider,
|
|
51
|
+
TextProvider,
|
|
52
|
+
} from '@engine/adapter';
|
|
53
|
+
import type { UIStory } from '@vgai/scene-ui/registry';
|
|
54
|
+
import type { UIComponentRegistry } from '@vgai/scene-ui/SceneUI';
|
|
55
|
+
import type { EditorStore } from '../editor-store';
|
|
56
|
+
import {
|
|
57
|
+
browserOrInlineResolver,
|
|
58
|
+
type ComputedStyleResolver,
|
|
59
|
+
type DesignToken,
|
|
60
|
+
type EmptyCandidate,
|
|
61
|
+
findEmptyContainers,
|
|
62
|
+
getComponentProps,
|
|
63
|
+
getComputedStyleValue,
|
|
64
|
+
getDesignTokens,
|
|
65
|
+
getMatchedCssRules,
|
|
66
|
+
getReactComponentName,
|
|
67
|
+
type MatchableElement,
|
|
68
|
+
} from '../ui-source/inspect';
|
|
69
|
+
import type { OidEntry } from '../ui-source/oid-transform';
|
|
70
|
+
import type { SourceWriteBackend } from '../ui-source/source-write-backend';
|
|
71
|
+
import { type CssRuleTarget, pickCssRuleTarget } from '../ui-source/writer';
|
|
72
|
+
import { getStoryOverlay, setStoryOverlay } from './story-overlay';
|
|
73
|
+
import { notifyUndoableOp, registerUndoAdapter } from './undo-timeline';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The minimal structural shape this adapter needs from a live DOM element —
|
|
77
|
+
* deliberately NOT `HTMLElement` so it stays testable with a plain-object fixture
|
|
78
|
+
* headlessly (this repo's vitest environment is `node`, no jsdom — see
|
|
79
|
+
* `docs/REACT-WORLD-DESIGN.md`'s slice-2 dispatch: "use the repo's DOM-stub +
|
|
80
|
+
* fixture patterns"). A real `HTMLElement` satisfies this structurally: `tagName`
|
|
81
|
+
* (uppercase, per the DOM spec — lower-cased for labels/kind below), `children`
|
|
82
|
+
* (an `HTMLCollection`, `Array.from`-able), and `getAttribute` reading the REAL
|
|
83
|
+
* `data-oid="…"` attribute `transformSource` stamped into the JSX (a genuine DOM
|
|
84
|
+
* attribute at runtime, not a mirror).
|
|
85
|
+
*/
|
|
86
|
+
export interface OidElementLike {
|
|
87
|
+
readonly tagName: string;
|
|
88
|
+
readonly children: ArrayLike<OidElementLike>;
|
|
89
|
+
getAttribute(name: string): string | null;
|
|
90
|
+
/**
|
|
91
|
+
* `unknown` rather than a structural record — a real `CSSStyleDeclaration` has
|
|
92
|
+
* NO string index signature (TS models it as a fixed set of named properties
|
|
93
|
+
* plus methods), so it can't structurally satisfy `Record<string, unknown>`.
|
|
94
|
+
* Read through {@link styleProp} below, which handles both shapes.
|
|
95
|
+
*/
|
|
96
|
+
readonly style?: unknown;
|
|
97
|
+
/**
|
|
98
|
+
* D12 (B4) — the element's live viewport rect, for `pickable.pick`'s
|
|
99
|
+
* geometric hit-test (see that provider's doc comment for why NOT
|
|
100
|
+
* `elementFromPoint`). Optional so existing plain-object test fixtures
|
|
101
|
+
* (which never provide one) keep type-checking unchanged — a node with no
|
|
102
|
+
* `getBoundingClientRect` simply never wins a pick (its rect is treated as
|
|
103
|
+
* absent, never a fabricated 0×0 that could win a tie).
|
|
104
|
+
*/
|
|
105
|
+
getBoundingClientRect?(): {
|
|
106
|
+
left: number;
|
|
107
|
+
top: number;
|
|
108
|
+
right: number;
|
|
109
|
+
bottom: number;
|
|
110
|
+
width: number;
|
|
111
|
+
height: number;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* T0 (spec 27 §2) — the element's rendered text, read for `TextProvider.get`'s
|
|
115
|
+
* "has child elements" gate below. Optional so pre-existing `OidElementLike`
|
|
116
|
+
* fixtures (none of which set it) keep satisfying the interface unchanged.
|
|
117
|
+
*/
|
|
118
|
+
readonly textContent?: string | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Read one style property off an `OidElementLike.style` of either shape (a real
|
|
122
|
+
* `CSSStyleDeclaration` or a plain-object test fixture). */
|
|
123
|
+
export function styleProp(style: unknown, prop: string): unknown {
|
|
124
|
+
return style ? (style as Record<string, unknown>)[prop] : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Parse a real `CSSStyleDeclaration`'s length-property VALUE (always a
|
|
129
|
+
* unit-suffixed STRING, e.g. `"16px"` — CSSOM never hands back a bare
|
|
130
|
+
* number) into the plain number a `type: 'number'` {@link PropertyDescriptor}
|
|
131
|
+
* needs to render/edit correctly (`Inspector.tsx`'s generic renderer does
|
|
132
|
+
* `typeof v === 'number' ? … : 0` — without this parse, EVERY numeric style
|
|
133
|
+
* field would silently display `0` against a real browser DOM, even though
|
|
134
|
+
* the property test fixtures (`react-world-authoring-adapter.test.ts`'s
|
|
135
|
+
* plain-object `style: {}`) never caught it, because a hand-built fixture
|
|
136
|
+
* can hold a bare JS number directly). Strips a trailing CSS unit
|
|
137
|
+
* (`px`/`em`/`rem`/`%`/`vh`/`vw`, mirroring `writer.ts`'s own `LITERAL_RE`
|
|
138
|
+
* unit set); a value this repo's fixtures already hand over AS a number
|
|
139
|
+
* (or `undefined`/unset) passes through unchanged.
|
|
140
|
+
*/
|
|
141
|
+
export function numericStyleValue(raw: unknown): number | undefined {
|
|
142
|
+
if (typeof raw === 'number') return raw;
|
|
143
|
+
if (typeof raw !== 'string' || raw === '') return undefined;
|
|
144
|
+
const parsed = Number.parseFloat(raw);
|
|
145
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const RGB_RE = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Normalize a CSS color VALUE into `#rrggbb` for a `type: 'color'`
|
|
152
|
+
* {@link PropertyDescriptor} (`<input type="color">` only accepts exactly
|
|
153
|
+
* that shape — an unparseable value makes the browser silently coerce the
|
|
154
|
+
* input to black). A real `CSSStyleDeclaration` always serializes an inline
|
|
155
|
+
* color property as `rgb(r, g, b)`/`rgba(r, g, b, a)` — EVEN WHEN the author
|
|
156
|
+
* wrote a hex literal in JSX (`el.style.color = '#3ddc65'` reads back as
|
|
157
|
+
* `"rgb(61, 220, 101)"`, verified empirically against a real Chromium page,
|
|
158
|
+
* not assumed) — never hex. Without this normalization every color-typed
|
|
159
|
+
* style field would show black regardless of its real value against a real
|
|
160
|
+
* browser DOM (the plain-object test fixtures never caught this: a
|
|
161
|
+
* hand-built fixture's `style` can hold the author's hex string directly,
|
|
162
|
+
* which real CSSOM never does). An already-hex value (a fixture, or a
|
|
163
|
+
* property this repo's CSSOM happens to serialize as hex) passes through
|
|
164
|
+
* unchanged; a value matching neither shape returns `undefined` (the
|
|
165
|
+
* generic inspector's own '#ffffff' fallback) rather than handing the input
|
|
166
|
+
* an invalid string.
|
|
167
|
+
*/
|
|
168
|
+
export function cssColorToHex(raw: unknown): string | undefined {
|
|
169
|
+
if (typeof raw !== 'string' || raw === '') return undefined;
|
|
170
|
+
if (raw.startsWith('#')) return raw;
|
|
171
|
+
const m = RGB_RE.exec(raw);
|
|
172
|
+
if (!m) return undefined;
|
|
173
|
+
const toHex = (n: string) => Number.parseInt(n, 10).toString(16).padStart(2, '0');
|
|
174
|
+
return `#${toHex(m[1]!)}${toHex(m[2]!)}${toHex(m[3]!)}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** One resolved `BoxEditProvider` patch-key mapping (spec 27 §4, B1). */
|
|
178
|
+
export interface BoxEditPropMapping {
|
|
179
|
+
/** The CSS style prop to write (already the target, e.g. `x` → `left`). */
|
|
180
|
+
prop: string;
|
|
181
|
+
/** Live-preview CSS VALUE for a raw patch number — always px-suffixed for
|
|
182
|
+
* spatial/spacing props, the `rotate(<deg>deg)` transform string for `rotate`. */
|
|
183
|
+
cssValue: (v: number) => string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* `BoxEditProvider` patch-key → CSS-prop mapping (spec 27 B1), shared by BOTH
|
|
188
|
+
* authoring adapters' `boxEdit.apply/end` (`react-dom-authoring-adapter.ts`
|
|
189
|
+
* imports this rather than redefining it — same DRY reuse as
|
|
190
|
+
* {@link cssColorToHex}/{@link numericStyleValue}/{@link styleProp} below).
|
|
191
|
+
* `width`/`height`/`margin*`/`padding*` map 1:1 to their same-named style
|
|
192
|
+
* prop. `x`/`y` map to `left`/`top` ONLY when the node is
|
|
193
|
+
* absolutely/fixed-positioned (there is no `left`/`top` to move on a
|
|
194
|
+
* static/relative node) — `isPositioned` is a caller-resolved boolean (each
|
|
195
|
+
* adapter reads its own computed-style resolver) — a mismatched key returns
|
|
196
|
+
* `null` to mean "drop this key"; the CALLER does its own loud
|
|
197
|
+
* `console.warn` so the adapter's own name appears in the message. `rotate`
|
|
198
|
+
* (deg) maps to the `transform` prop as a `rotate(<deg>deg)` string.
|
|
199
|
+
*/
|
|
200
|
+
export function mapBoxEditPatchKey(key: string, isPositioned: boolean): BoxEditPropMapping | null {
|
|
201
|
+
switch (key) {
|
|
202
|
+
case 'width':
|
|
203
|
+
case 'height':
|
|
204
|
+
case 'marginTop':
|
|
205
|
+
case 'marginRight':
|
|
206
|
+
case 'marginBottom':
|
|
207
|
+
case 'marginLeft':
|
|
208
|
+
case 'paddingTop':
|
|
209
|
+
case 'paddingRight':
|
|
210
|
+
case 'paddingBottom':
|
|
211
|
+
case 'paddingLeft':
|
|
212
|
+
return { prop: key, cssValue: (v) => `${v}px` };
|
|
213
|
+
case 'x':
|
|
214
|
+
return isPositioned ? { prop: 'left', cssValue: (v) => `${v}px` } : null;
|
|
215
|
+
case 'y':
|
|
216
|
+
return isPositioned ? { prop: 'top', cssValue: (v) => `${v}px` } : null;
|
|
217
|
+
case 'rotate':
|
|
218
|
+
return { prop: 'transform', cssValue: (v) => `rotate(${v}deg)` };
|
|
219
|
+
default:
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const OID_ATTR = 'data-oid';
|
|
225
|
+
|
|
226
|
+
/** One OID-tagged DOM element resolved into the adapter's internal tree. */
|
|
227
|
+
interface OidNode {
|
|
228
|
+
/** Disambiguated entity id — see {@link walkOidTree}'s doc comment. */
|
|
229
|
+
id: string;
|
|
230
|
+
/** The raw OID (may repeat across sibling `OidNode`s — see below). */
|
|
231
|
+
oid: string;
|
|
232
|
+
tag: string;
|
|
233
|
+
el: OidElementLike;
|
|
234
|
+
parentId: string | null;
|
|
235
|
+
childIds: string[];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface OidTree {
|
|
239
|
+
/** Every OID-tagged node, keyed by its (disambiguated) entity id. */
|
|
240
|
+
nodes: Map<string, OidNode>;
|
|
241
|
+
/** Top-level entity ids (no OID-tagged ancestor) in DOM/tree order. */
|
|
242
|
+
rootIds: string[];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Walk a react world's live DOM root for `data-oid`-carrying elements, tree-ordered
|
|
247
|
+
* (§1.D: "React entities = the OID tree; the live DOM carries it").
|
|
248
|
+
*
|
|
249
|
+
* OID → entity id disambiguation: the OID is a SOURCE-content signature
|
|
250
|
+
* (`component:tag:nthOccurrence` — see `oid-transform.ts`), not a per-DOM-instance
|
|
251
|
+
* id — a list rendered from ONE JSX line (`items.map(...)`) stamps the SAME `data-
|
|
252
|
+
* oid` on every rendered item. Since an `EditorNode.id` must be unique per
|
|
253
|
+
* SELECTABLE entity, repeats are disambiguated by DOM-order OCCURRENCE within this
|
|
254
|
+
* walk: the first element carrying a given oid keeps `id === oid`; the Nth repeat
|
|
255
|
+
* (0-based) gets `id === "${oid}#${n}"`. This is deliberately NOT persisted/hashed
|
|
256
|
+
* across renders beyond "same relative position, same id" — stable enough for
|
|
257
|
+
* selection/inspection within a session, honestly component-global (not
|
|
258
|
+
* per-instance) for writes, exactly like every other OID-keyed edit in this
|
|
259
|
+
* program (a style/delete write on ANY repeat edits the ONE shared JSX source).
|
|
260
|
+
*
|
|
261
|
+
* An element with no `data-oid` (e.g. DOM emitted by an un-instrumented library,
|
|
262
|
+
* or a plain intrinsic a Fragment wrapped) is transparently skipped — its own
|
|
263
|
+
* children attach to the nearest OID-tagged ANCESTOR, not to it, so a gap in
|
|
264
|
+
* instrumentation never breaks the tree into disconnected pieces.
|
|
265
|
+
*/
|
|
266
|
+
export function walkOidTree(root: OidElementLike): OidTree {
|
|
267
|
+
const nodes = new Map<string, OidNode>();
|
|
268
|
+
const rootIds: string[] = [];
|
|
269
|
+
const occurrence = new Map<string, number>();
|
|
270
|
+
|
|
271
|
+
function visit(el: OidElementLike, parentId: string | null): void {
|
|
272
|
+
const oid = el.getAttribute(OID_ATTR);
|
|
273
|
+
let effectiveParent = parentId;
|
|
274
|
+
if (oid) {
|
|
275
|
+
const n = occurrence.get(oid) ?? 0;
|
|
276
|
+
occurrence.set(oid, n + 1);
|
|
277
|
+
const id = n === 0 ? oid : `${oid}#${n}`;
|
|
278
|
+
const node: OidNode = {
|
|
279
|
+
id,
|
|
280
|
+
oid,
|
|
281
|
+
tag: el.tagName.toLowerCase(),
|
|
282
|
+
el,
|
|
283
|
+
parentId,
|
|
284
|
+
childIds: [],
|
|
285
|
+
};
|
|
286
|
+
nodes.set(id, node);
|
|
287
|
+
if (parentId) nodes.get(parentId)?.childIds.push(id);
|
|
288
|
+
else rootIds.push(id);
|
|
289
|
+
effectiveParent = id;
|
|
290
|
+
}
|
|
291
|
+
for (const child of Array.from(el.children)) visit(child, effectiveParent);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const child of Array.from(root.children)) visit(child, null);
|
|
295
|
+
return { nodes, rootIds };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Cap 6 (React visual-edit parity): the RICH GROUPED inspector model — the figma-style
|
|
300
|
+
* panels (Layout / Position / Spacing / Type / Fill / Stroke / Effects / Transform), each
|
|
301
|
+
* property tagged with a `group` the generic inspector renders as a titled sub-section
|
|
302
|
+
* (`PropertyDescriptor.group`). Every property here is covered by the writer's broadened
|
|
303
|
+
* `ARB_MAP`/`ENUM_UTILITIES` class routing (or inline style), so a set writes real source.
|
|
304
|
+
* (Bespoke widgets — HSV picker, scrub inputs, gradient/multi-shadow editors — are the
|
|
305
|
+
* one descoped part of Cap 6; the generic color/number/enum/string inputs render each of
|
|
306
|
+
* these functionally.)
|
|
307
|
+
*/
|
|
308
|
+
const STYLE_PROPERTIES: ReadonlyArray<{
|
|
309
|
+
prop: string;
|
|
310
|
+
label: string;
|
|
311
|
+
type: PropertyDescriptor['type'];
|
|
312
|
+
group: string;
|
|
313
|
+
options?: string[];
|
|
314
|
+
}> = [
|
|
315
|
+
// -- Layout --
|
|
316
|
+
{
|
|
317
|
+
prop: 'display',
|
|
318
|
+
label: 'Display',
|
|
319
|
+
type: 'enum',
|
|
320
|
+
group: 'Layout',
|
|
321
|
+
options: ['block', 'flex', 'grid', 'inline', 'inline-block', 'inline-flex', 'none'],
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
prop: 'flexDirection',
|
|
325
|
+
label: 'Direction',
|
|
326
|
+
type: 'enum',
|
|
327
|
+
group: 'Layout',
|
|
328
|
+
options: ['row', 'column', 'row-reverse', 'column-reverse'],
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
prop: 'flexWrap',
|
|
332
|
+
label: 'Wrap',
|
|
333
|
+
type: 'enum',
|
|
334
|
+
group: 'Layout',
|
|
335
|
+
options: ['nowrap', 'wrap', 'wrap-reverse'],
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
prop: 'justifyContent',
|
|
339
|
+
label: 'Justify',
|
|
340
|
+
type: 'enum',
|
|
341
|
+
group: 'Layout',
|
|
342
|
+
options: ['flex-start', 'center', 'flex-end', 'space-between', 'space-around', 'space-evenly'],
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
prop: 'alignItems',
|
|
346
|
+
label: 'Align',
|
|
347
|
+
type: 'enum',
|
|
348
|
+
group: 'Layout',
|
|
349
|
+
options: ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'],
|
|
350
|
+
},
|
|
351
|
+
{ prop: 'gap', label: 'Gap', type: 'number', group: 'Layout' },
|
|
352
|
+
{
|
|
353
|
+
prop: 'overflow',
|
|
354
|
+
label: 'Overflow',
|
|
355
|
+
type: 'enum',
|
|
356
|
+
group: 'Layout',
|
|
357
|
+
options: ['visible', 'hidden', 'scroll', 'auto'],
|
|
358
|
+
},
|
|
359
|
+
{ prop: 'width', label: 'Width', type: 'number', group: 'Layout' },
|
|
360
|
+
{ prop: 'height', label: 'Height', type: 'number', group: 'Layout' },
|
|
361
|
+
{ prop: 'minWidth', label: 'Min W', type: 'number', group: 'Layout' },
|
|
362
|
+
{ prop: 'minHeight', label: 'Min H', type: 'number', group: 'Layout' },
|
|
363
|
+
{ prop: 'maxWidth', label: 'Max W', type: 'number', group: 'Layout' },
|
|
364
|
+
{ prop: 'maxHeight', label: 'Max H', type: 'number', group: 'Layout' },
|
|
365
|
+
// -- Position --
|
|
366
|
+
{
|
|
367
|
+
prop: 'position',
|
|
368
|
+
label: 'Position',
|
|
369
|
+
type: 'enum',
|
|
370
|
+
group: 'Position',
|
|
371
|
+
options: ['static', 'relative', 'absolute', 'fixed', 'sticky'],
|
|
372
|
+
},
|
|
373
|
+
{ prop: 'top', label: 'Top', type: 'number', group: 'Position' },
|
|
374
|
+
{ prop: 'right', label: 'Right', type: 'number', group: 'Position' },
|
|
375
|
+
{ prop: 'bottom', label: 'Bottom', type: 'number', group: 'Position' },
|
|
376
|
+
{ prop: 'left', label: 'Left', type: 'number', group: 'Position' },
|
|
377
|
+
{ prop: 'zIndex', label: 'Z Index', type: 'number', group: 'Position' },
|
|
378
|
+
{ prop: 'flexGrow', label: 'Grow', type: 'number', group: 'Position' },
|
|
379
|
+
{ prop: 'flexShrink', label: 'Shrink', type: 'number', group: 'Position' },
|
|
380
|
+
// -- Spacing (per-side) --
|
|
381
|
+
{ prop: 'marginTop', label: 'Margin T', type: 'number', group: 'Spacing' },
|
|
382
|
+
{ prop: 'marginRight', label: 'Margin R', type: 'number', group: 'Spacing' },
|
|
383
|
+
{ prop: 'marginBottom', label: 'Margin B', type: 'number', group: 'Spacing' },
|
|
384
|
+
{ prop: 'marginLeft', label: 'Margin L', type: 'number', group: 'Spacing' },
|
|
385
|
+
{ prop: 'paddingTop', label: 'Padding T', type: 'number', group: 'Spacing' },
|
|
386
|
+
{ prop: 'paddingRight', label: 'Padding R', type: 'number', group: 'Spacing' },
|
|
387
|
+
{ prop: 'paddingBottom', label: 'Padding B', type: 'number', group: 'Spacing' },
|
|
388
|
+
{ prop: 'paddingLeft', label: 'Padding L', type: 'number', group: 'Spacing' },
|
|
389
|
+
// -- Type --
|
|
390
|
+
{ prop: 'color', label: 'Color', type: 'color', group: 'Type' },
|
|
391
|
+
{ prop: 'fontSize', label: 'Size', type: 'number', group: 'Type' },
|
|
392
|
+
{ prop: 'fontWeight', label: 'Weight', type: 'string', group: 'Type' },
|
|
393
|
+
{ prop: 'lineHeight', label: 'Line H', type: 'string', group: 'Type' },
|
|
394
|
+
{ prop: 'letterSpacing', label: 'Spacing', type: 'string', group: 'Type' },
|
|
395
|
+
{
|
|
396
|
+
prop: 'textAlign',
|
|
397
|
+
label: 'Align',
|
|
398
|
+
type: 'enum',
|
|
399
|
+
group: 'Type',
|
|
400
|
+
options: ['left', 'center', 'right', 'justify'],
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
prop: 'textTransform',
|
|
404
|
+
label: 'Transform',
|
|
405
|
+
type: 'enum',
|
|
406
|
+
group: 'Type',
|
|
407
|
+
options: ['none', 'uppercase', 'lowercase', 'capitalize'],
|
|
408
|
+
},
|
|
409
|
+
{ prop: 'fontStyle', label: 'Style', type: 'enum', group: 'Type', options: ['normal', 'italic'] },
|
|
410
|
+
{ prop: 'fontFamily', label: 'Font', type: 'string', group: 'Type' },
|
|
411
|
+
{
|
|
412
|
+
prop: 'textDecoration',
|
|
413
|
+
label: 'Decoration',
|
|
414
|
+
type: 'enum',
|
|
415
|
+
group: 'Type',
|
|
416
|
+
options: ['none', 'underline', 'line-through', 'overline'],
|
|
417
|
+
},
|
|
418
|
+
// -- Fill --
|
|
419
|
+
{ prop: 'backgroundColor', label: 'Background', type: 'color', group: 'Fill' },
|
|
420
|
+
// Cap 6 (§5 gap-fill): computed style never round-trips the `background`
|
|
421
|
+
// shorthand — `backgroundImage` is the prop that actually reads back
|
|
422
|
+
// (see `inspector.get`'s computed-style path below), so the gradient
|
|
423
|
+
// widget for a STYLE property must key off it, not `background`.
|
|
424
|
+
{ prop: 'backgroundImage', label: 'Gradient', type: 'string', group: 'Fill' },
|
|
425
|
+
// -- Stroke --
|
|
426
|
+
{ prop: 'borderColor', label: 'Border Color', type: 'color', group: 'Stroke' },
|
|
427
|
+
{ prop: 'borderWidth', label: 'Border Width', type: 'number', group: 'Stroke' },
|
|
428
|
+
{
|
|
429
|
+
prop: 'borderStyle',
|
|
430
|
+
label: 'Border Style',
|
|
431
|
+
type: 'enum',
|
|
432
|
+
group: 'Stroke',
|
|
433
|
+
options: ['none', 'solid', 'dashed', 'dotted', 'double'],
|
|
434
|
+
},
|
|
435
|
+
{ prop: 'borderRadius', label: 'Radius', type: 'number', group: 'Stroke' },
|
|
436
|
+
// -- Effects --
|
|
437
|
+
{ prop: 'opacity', label: 'Opacity', type: 'number', group: 'Effects' },
|
|
438
|
+
{ prop: 'boxShadow', label: 'Box Shadow', type: 'string', group: 'Effects' },
|
|
439
|
+
{
|
|
440
|
+
prop: 'mixBlendMode',
|
|
441
|
+
label: 'Blend',
|
|
442
|
+
type: 'enum',
|
|
443
|
+
group: 'Effects',
|
|
444
|
+
options: ['normal', 'multiply', 'screen', 'overlay'],
|
|
445
|
+
},
|
|
446
|
+
{ prop: 'filter', label: 'Filter', type: 'string', group: 'Effects' },
|
|
447
|
+
{ prop: 'backdropFilter', label: 'Backdrop', type: 'string', group: 'Effects' },
|
|
448
|
+
{ prop: 'textShadow', label: 'Text Shadow', type: 'string', group: 'Effects' },
|
|
449
|
+
{
|
|
450
|
+
prop: 'cursor',
|
|
451
|
+
label: 'Cursor',
|
|
452
|
+
type: 'enum',
|
|
453
|
+
group: 'Effects',
|
|
454
|
+
options: ['auto', 'default', 'pointer', 'text', 'move', 'grab', 'not-allowed'],
|
|
455
|
+
},
|
|
456
|
+
// -- Transform --
|
|
457
|
+
{ prop: 'transform', label: 'Transform', type: 'string', group: 'Transform' },
|
|
458
|
+
];
|
|
459
|
+
const STYLE_PATH_PREFIX = 'style.';
|
|
460
|
+
/** Cap 4: inspector path prefix for a component's editable props (`prop.<name>`). */
|
|
461
|
+
const PROP_PATH_PREFIX = 'prop.';
|
|
462
|
+
/** Cap 7: inspector path prefix for the document's design tokens (`token.<name>`). */
|
|
463
|
+
const TOKEN_PATH_PREFIX = 'token.';
|
|
464
|
+
/** D3.R1 (spec 27 §2/§5 U4 precedent, reopen fix) — the `dynamicPaths` key for
|
|
465
|
+
* Cap 3's text edit (`editText`), so a dynamic-body refusal is recorded and
|
|
466
|
+
* surfaced through the SAME session-scoped after-touch mechanism the
|
|
467
|
+
* style/prop U4 widgets use — not a real inspector path (text has no
|
|
468
|
+
* `properties()` descriptor), just a stable key for `` `${id}|${TEXT_PATH}` ``. */
|
|
469
|
+
const TEXT_PATH = 'text';
|
|
470
|
+
/** B3 — catalog node id prefix (`catalog:<registryKey>`) and the one
|
|
471
|
+
* `entry (live preview)` node id, collision-proof against OID-derived ids
|
|
472
|
+
* (`o<counter36><hash36>`, never containing `:`) and composite ids
|
|
473
|
+
* (`world:<id>`, only ever seen by the COMPOSITE, never by this adapter). */
|
|
474
|
+
const CATALOG_ID_PREFIX = 'catalog:';
|
|
475
|
+
const CATALOG_ENTRY_ID = 'catalog-entry';
|
|
476
|
+
/** B3 — inspector path prefix for a catalog node's active-story props
|
|
477
|
+
* (`storyProp.<name>`) — deliberately distinct from {@link PROP_PATH_PREFIX}
|
|
478
|
+
* (a DOM node's live fiber props): a catalog node has no call site to read a
|
|
479
|
+
* fiber from (see `inspector.properties`'s catalog branch doc comment). */
|
|
480
|
+
const STORY_PROP_PATH_PREFIX = 'storyProp.';
|
|
481
|
+
|
|
482
|
+
/** `prop -> declared type`, so `inspector.get()` knows when to run a style
|
|
483
|
+
* value through {@link numericStyleValue} instead of handing it back raw
|
|
484
|
+
* (a real CSSOM length value is always a unit-suffixed string). */
|
|
485
|
+
const STYLE_PROPERTY_TYPE: ReadonlyMap<string, PropertyDescriptor['type']> = new Map(
|
|
486
|
+
STYLE_PROPERTIES.map(({ prop, type }) => [prop, type]),
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* U2 (spec 27 §5 C2) — a per-side/per-corner CSS LONGHAND → the SHORTHAND that
|
|
491
|
+
* governs it in the longhand's absence. Used by `inspector.remove` (below): when
|
|
492
|
+
* a uniform border/radius edit removes a stale longhand override, the removed
|
|
493
|
+
* longhand's optimistic echo is set to the shorthand's current (just-committed)
|
|
494
|
+
* value — so an `inspector.get(longhand)` right after removal reports the value
|
|
495
|
+
* the corner actually renders at (the shorthand), not a stale override or an
|
|
496
|
+
* empty computed read, and it stays consistent once HMR clears the echo (the
|
|
497
|
+
* longhand is gone from source, so the shorthand cascades to it). Purely the
|
|
498
|
+
* longhands the C2 combo rows can write — the shorthand set itself is uniform.
|
|
499
|
+
*/
|
|
500
|
+
const LONGHAND_TO_SHORTHAND: Readonly<Record<string, string>> = {
|
|
501
|
+
borderTopLeftRadius: 'borderRadius',
|
|
502
|
+
borderTopRightRadius: 'borderRadius',
|
|
503
|
+
borderBottomLeftRadius: 'borderRadius',
|
|
504
|
+
borderBottomRightRadius: 'borderRadius',
|
|
505
|
+
borderTopWidth: 'borderWidth',
|
|
506
|
+
borderRightWidth: 'borderWidth',
|
|
507
|
+
borderBottomWidth: 'borderWidth',
|
|
508
|
+
borderLeftWidth: 'borderWidth',
|
|
509
|
+
borderTopStyle: 'borderStyle',
|
|
510
|
+
borderRightStyle: 'borderStyle',
|
|
511
|
+
borderBottomStyle: 'borderStyle',
|
|
512
|
+
borderLeftStyle: 'borderStyle',
|
|
513
|
+
borderTopColor: 'borderColor',
|
|
514
|
+
borderRightColor: 'borderColor',
|
|
515
|
+
borderBottomColor: 'borderColor',
|
|
516
|
+
borderLeftColor: 'borderColor',
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* D3.e (spec 27 §2 T0 leftover, §6 D3 "Insert-child submenu") — the kinds
|
|
521
|
+
* `structure.create`'s `wrapperTag` genuinely inserts. `insertChildElement`
|
|
522
|
+
* (`ui-source/writer.ts:1036`) writes `<tag />` VERBATIM for whatever tag
|
|
523
|
+
* string it is given — it has no allow-list of its own, and no void/non-void
|
|
524
|
+
* element distinction (every insert is self-closing, syntactically valid
|
|
525
|
+
* JSX for every one of these real HTML element names). So this is a CURATED
|
|
526
|
+
* subset, not a writer-enforced ceiling — the same "small sensible palette,
|
|
527
|
+
* not the writer's full permissiveness" stance `ui-authoring-adapter.ts`'s
|
|
528
|
+
* own `CREATABLE_KINDS` doc comment takes (there for UINode kinds; here for
|
|
529
|
+
* literal HTML tag names, since a react-world node's "kind" IS its tag).
|
|
530
|
+
*/
|
|
531
|
+
const CREATABLE_KINDS: ReadonlyArray<{ kind: string; label: string }> = [
|
|
532
|
+
{ kind: 'div', label: 'Container' },
|
|
533
|
+
{ kind: 'span', label: 'Text' },
|
|
534
|
+
{ kind: 'p', label: 'Paragraph' },
|
|
535
|
+
{ kind: 'button', label: 'Button' },
|
|
536
|
+
{ kind: 'a', label: 'Link' },
|
|
537
|
+
{ kind: 'img', label: 'Image' },
|
|
538
|
+
{ kind: 'ul', label: 'List' },
|
|
539
|
+
{ kind: 'li', label: 'List Item' },
|
|
540
|
+
];
|
|
541
|
+
|
|
542
|
+
interface PendingUndo {
|
|
543
|
+
undo: () => void;
|
|
544
|
+
redo: () => void;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export interface ReactWorldAuthoringOptions {
|
|
548
|
+
/** T3.2 slice-3 write seam. Absent ⇒ no dev-server backend in this session (a
|
|
549
|
+
* hosted/browser build) — selection/inspection still work; writes report
|
|
550
|
+
* unavailable (see the class doc comment). */
|
|
551
|
+
writeBackend?: SourceWriteBackend | undefined;
|
|
552
|
+
/** Cap 1 (React visual-edit parity): resolves an element to its COMPUTED style so
|
|
553
|
+
* `inspector.get` reflects class- and CSS-file-styled properties, not just inline
|
|
554
|
+
* style. Defaults to `window.getComputedStyle` in a browser, or the element's inline
|
|
555
|
+
* `.style` under vitest's `node` env (so headless fixtures work). Injectable for
|
|
556
|
+
* headless tests that want to drive the real computed-value path. */
|
|
557
|
+
computedStyle?: ComputedStyleResolver | undefined;
|
|
558
|
+
/** Cap 2 (React visual-edit parity): resolves an element to the first-party CSS rules
|
|
559
|
+
* that match it (source file + selector + declared properties), so a style edit whose
|
|
560
|
+
* property lives in a CSS FILE routes to that file instead of inline/class. Defaults to
|
|
561
|
+
* `inspect.ts`'s `getMatchedCssRules` against the live document. Injectable for headless
|
|
562
|
+
* tests. */
|
|
563
|
+
matchedCssRules?: ((el: unknown) => CssRuleTarget[]) | undefined;
|
|
564
|
+
/** Cap 7 (React visual-edit parity): the document's design tokens (`:root` custom
|
|
565
|
+
* properties). Defaults to `inspect.ts`'s `getDesignTokens` against the live `:root`
|
|
566
|
+
* (empty under vitest's `node` env). Injectable for headless tests. */
|
|
567
|
+
designTokens?: (() => DesignToken[]) | undefined;
|
|
568
|
+
/**
|
|
569
|
+
* D4 — ordered stories sources (Q2 decoupling: the provider takes stories
|
|
570
|
+
* as a constructor input, a project's `src/ui/registry.ts` is merely the
|
|
571
|
+
* DEFAULT source — an editor-side overlay, `../authoring/story-overlay.ts`,
|
|
572
|
+
* plugs into the same parameter). Later sources win over earlier ones,
|
|
573
|
+
* per story id — see {@link ReactWorldAuthoringAdapter.stories}. Absent/
|
|
574
|
+
* empty ⇒ `storiesFor` returns `[]` for every node (today's B1 behavior,
|
|
575
|
+
* no registry case).
|
|
576
|
+
*/
|
|
577
|
+
storySources?: ReadonlyArray<Record<string, UIStory>> | undefined;
|
|
578
|
+
/**
|
|
579
|
+
* D4 — invoked whenever `stories.apply` changes the active story
|
|
580
|
+
* (including clearing it to `null`). The caller (`design-time-layers.ts`'s
|
|
581
|
+
* `mountReactLayer`) re-renders the layer root against the story's `data`/
|
|
582
|
+
* `props` — this adapter never touches the DOM/React tree itself, it only
|
|
583
|
+
* tracks WHICH story is active and reports the change.
|
|
584
|
+
*/
|
|
585
|
+
onStoryApplied?: ((story: (UIStory & { id: string }) | null) => void) | undefined;
|
|
586
|
+
/**
|
|
587
|
+
* B3 — the react world's registered components (a project's
|
|
588
|
+
* `src/ui/registry.ts` `components`, `SceneUI.tsx`'s flat
|
|
589
|
+
* `Record<string, ComponentType>`, NO metadata). Absent/EMPTY ⇒ this
|
|
590
|
+
* adapter's hierarchy is the plain DOM walk, byte-identical to pre-B3
|
|
591
|
+
* (§0's gate — `play-mode.ts`'s `installMultiWorldAuthoring` never passes
|
|
592
|
+
* this option, so PLAY mode's react-world hierarchy is untouched by B3's
|
|
593
|
+
* construction). Non-empty ⇒ `hierarchy.roots()` becomes the CATALOG: one
|
|
594
|
+
* `kind:'component'` node per registered key, plus one `'entry (live
|
|
595
|
+
* preview)'` node nesting the ordinary DOM-walk roots one level down (see
|
|
596
|
+
* the class doc comment's storybook-model rationale and
|
|
597
|
+
* `docs/unified-world-editor/26-unified-editor-spec.md` §3 B3).
|
|
598
|
+
*/
|
|
599
|
+
catalogComponents?: UIComponentRegistry | undefined;
|
|
600
|
+
/**
|
|
601
|
+
* B3 — invoked whenever `stories.isolate` changes the isolated view
|
|
602
|
+
* (including clearing it to `null`, restoring the entry). The caller
|
|
603
|
+
* (`design-time-layers.ts`'s `mountReactLayer`) re-renders the layer root
|
|
604
|
+
* against the isolated component + story (or back to the entry) — this
|
|
605
|
+
* adapter never touches the DOM/React tree itself, it only tracks WHICH
|
|
606
|
+
* catalog node is isolated and reports the change.
|
|
607
|
+
*/
|
|
608
|
+
onIsolate?:
|
|
609
|
+
| ((v: { componentKey: string; story: (UIStory & { id: string }) | null } | null) => void)
|
|
610
|
+
| undefined;
|
|
611
|
+
/**
|
|
612
|
+
* B3 — this world's manifest id. Needed ONLY to key a catalog node's
|
|
613
|
+
* writable story-prop edits into the B2 story-overlay
|
|
614
|
+
* (`../authoring/story-overlay.ts`'s per-worldId slot) — every OTHER
|
|
615
|
+
* option/method in this adapter is worldId-agnostic. Absent ⇒ catalog
|
|
616
|
+
* inspector prop writes report unavailable (there is no world to key the
|
|
617
|
+
* overlay by) rather than silently no-op'ing — see `writeStoryProp`.
|
|
618
|
+
*/
|
|
619
|
+
worldId?: string | undefined;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export class ReactWorldAuthoringAdapter implements AuthoringAdapter {
|
|
623
|
+
readonly capabilities: AuthoringCapabilities;
|
|
624
|
+
private readonly writeBackend: SourceWriteBackend | undefined;
|
|
625
|
+
private readonly computedStyle: ComputedStyleResolver;
|
|
626
|
+
private readonly matchedCssRules: (el: unknown) => CssRuleTarget[];
|
|
627
|
+
private readonly designTokens: () => DesignToken[];
|
|
628
|
+
/** OID → {component, tag, …} labels, fetched once (if a backend exists) from the
|
|
629
|
+
* SAME `/__ui-source/index` the OID store already serves — see `index()` on
|
|
630
|
+
* `SourceWriteBackend`. Absent/unresolved ⇒ nodes label from the DOM tag alone
|
|
631
|
+
* (honest degradation, not a second index). */
|
|
632
|
+
private oidIndex: Map<string, OidEntry> = new Map();
|
|
633
|
+
private dirty = false;
|
|
634
|
+
/**
|
|
635
|
+
* A4 (spec 27 §3) — the optimistic value echo. `inspector.set` writes the just-committed
|
|
636
|
+
* value here BEFORE its async source-write returns; `inspector.get` reads THROUGH it (a
|
|
637
|
+
* hit wins over the live-DOM walk), so an edited field shows the NEW value within one
|
|
638
|
+
* frame of commit instead of snapping back to the old value until the source-write + Vite
|
|
639
|
+
* HMR re-render land (that timing WAS the desync bug). Keyed by OID-signature entity id
|
|
640
|
+
* (which survives the react remount — see {@link walkOidTree}) → full inspector path →
|
|
641
|
+
* value. Cleared per-entry the instant a write is refused/coerced-away (so it can't go
|
|
642
|
+
* stale showing a value the source never took), and wholesale once HMR actually lands
|
|
643
|
+
* ({@link reconcileEchoAfterReload}) so the now-updated live DOM is authoritative again.
|
|
644
|
+
*/
|
|
645
|
+
private readonly valueEcho = new Map<string, Map<string, unknown>>();
|
|
646
|
+
/**
|
|
647
|
+
* U4 (spec 27 §5 "Widgets: dynamic-expression read-only indicator") — the
|
|
648
|
+
* smallest honest version of the indicator: no new backend literality
|
|
649
|
+
* PROBE (that's real new `SourceWriteBackend` surface, deferred to Phase-E
|
|
650
|
+
* scale). Instead this surfaces the write path's EXISTING refusal signal —
|
|
651
|
+
* `writeStyleEntry`/`writePropEdit` already set `res.dynamic` when the
|
|
652
|
+
* backend refuses because the target is a dynamic `{expression}`, clearing
|
|
653
|
+
* the optimistic echo and warning. Once THAT has happened for a given
|
|
654
|
+
* `id|path`, `properties()` marks its descriptor `readonly: true` on every
|
|
655
|
+
* subsequent call, so the widget disables itself (`KindRowProps.disabled`)
|
|
656
|
+
* instead of silently re-offering an edit the source will refuse again.
|
|
657
|
+
* This is PREDICTIVE-AFTER-TOUCH, not predictive-BEFORE-touch (the field is
|
|
658
|
+
* still editable — and will visibly refuse once — the very first time);
|
|
659
|
+
* a pre-touch indicator needs the backend probe noted above. Session-scoped
|
|
660
|
+
* (never persisted) — cleared for an id on `dispose()`-adjacent resets only
|
|
661
|
+
* via normal adapter lifetime, same scope as `valueEcho`.
|
|
662
|
+
*/
|
|
663
|
+
private readonly dynamicPaths = new Set<string>();
|
|
664
|
+
/**
|
|
665
|
+
* D4 (spec27 §6 D4, layer-tree "lock" toggle) — SESSION-LOCAL node ids the
|
|
666
|
+
* layer tree/overlay has marked locked. Deliberately NOT persisted/written
|
|
667
|
+
* to source: unlike the first-party threejs adapter's `locked` (a real
|
|
668
|
+
* `SceneEntity.locked` field consumed by the viewport's raycaster), a react
|
|
669
|
+
* component has no equivalent schema field, and authoring one with no
|
|
670
|
+
* runtime reader would violate this repo's "no described field without a
|
|
671
|
+
* consumer" rule (CLAUDE.md; the SAME reason `ui.ts` dropped its dead
|
|
672
|
+
* `locked` field). Mirrors `world-session-state.ts`'s per-world pick-lock.
|
|
673
|
+
* D4.R1 — now READ by `pickable.pick` below (a locked node is skipped by
|
|
674
|
+
* canvas click/marquee pick, matching the first-party threejs raycast's
|
|
675
|
+
* own locked-skip in `viewport-raycast.ts`) and by
|
|
676
|
+
* `WorldSelectionOverlay.collectMarqueeCandidates` (via
|
|
677
|
+
* `inspector.get(id, 'locked')`, which reads this Set — see the
|
|
678
|
+
* `inspector.get`/`set` `'locked'` case below) for the marquee pool.
|
|
679
|
+
* Deliberately NOT consulted by `hierarchy`/`selection.set` — a locked
|
|
680
|
+
* node stays selectable/unlockable from the layer tree, mirroring the
|
|
681
|
+
* first-party adapter's own "locked blocks the raycast, not the
|
|
682
|
+
* hierarchy" behavior. Cleared only by adapter disposal (new Set per
|
|
683
|
+
* adapter instance/mount).
|
|
684
|
+
*/
|
|
685
|
+
private readonly lockedIds = new Set<string>();
|
|
686
|
+
/**
|
|
687
|
+
* D3.R4 (reopen fix), widened by D3.R5 — a COUNTER (not a boolean) of source writes
|
|
688
|
+
* whose own pre-HMR `notifyIngestEdit()` carries no fresh DOM shape, still awaiting
|
|
689
|
+
* their post-HMR "reload landed" reconcile. Incremented by every SUCCESSFUL write on
|
|
690
|
+
* a path with no `valueEcho` of its own: `structOp`, `removeElement`,
|
|
691
|
+
* `restoreStructSnapshot` (the struct-undo/redo inverse — success only, never on a
|
|
692
|
+
* refused/sha-mismatched restore, which writes nothing and has no reload to await),
|
|
693
|
+
* `editText`'s success path, and `applyTextWrite` (the text-undo/redo inverse —
|
|
694
|
+
* again success only). D3.R4 originally covered only `structOp`/`removeElement`; the
|
|
695
|
+
* D3.R5 reopen found the SAME staleness, unflagged, at the struct-undo/redo closure
|
|
696
|
+
* (every `structOp`/`removeElement` undo routes through `restoreStructSnapshot`) and
|
|
697
|
+
* at a successful text write (`editText`/`applyTextWrite` flip `hasText`, itself a
|
|
698
|
+
* `findEmptyContainers` hint-eligibility criterion — text populates no `valueEcho`,
|
|
699
|
+
* so neither existing reconcile branch fired for it).
|
|
700
|
+
*
|
|
701
|
+
* A structural/text op's own `notifyIngestEdit()` (right after the write response
|
|
702
|
+
* lands) fires BEFORE the HMR remount that actually changes the DOM — so a memoized
|
|
703
|
+
* hover-render cache keyed on that notify's `storeVersion`
|
|
704
|
+
* (`WorldSelectionOverlay`'s `emptyHintsCacheRef`) pins the PRE-HMR tree shape.
|
|
705
|
+
* `reconcileEchoAfterReload` is the "HMR actually landed" signal; it used to no-op
|
|
706
|
+
* whenever `valueEcho` was empty — true for every one of the sites above, since only
|
|
707
|
+
* A4's value-echo path (style/prop) ever populates it.
|
|
708
|
+
*
|
|
709
|
+
* COUNTER, not boolean (the D3.R5 shape): a boolean cleared unconditionally on the
|
|
710
|
+
* FIRST reconcile after it was set, so two rapid writes each of whose OWN HMR fires a
|
|
711
|
+
* separate `vite:afterUpdate` would reconcile once and then no-op on the second
|
|
712
|
+
* reload-landed signal — the second write's hint delta going stale forever (a
|
|
713
|
+
* residual the boolean shape left undocumented). Each successful qualifying write
|
|
714
|
+
* increments this counter; `reconcileEchoAfterReload` notifies and decrements by
|
|
715
|
+
* exactly one whenever it is above zero (in addition to notifying whenever `valueEcho`
|
|
716
|
+
* is non-empty), so N pending writes need N reconciles to fully drain — matching N
|
|
717
|
+
* real `vite:afterUpdate` events in production.
|
|
718
|
+
*/
|
|
719
|
+
private pendingSourceReconcile = 0;
|
|
720
|
+
/** A4 — unsubscribes this adapter's `vite:afterUpdate` reconcile hook (see the constructor);
|
|
721
|
+
* `undefined` when there is no HMR context (hosted build / vitest `node` env). */
|
|
722
|
+
private readonly disposeReloadSignal: (() => void) | undefined;
|
|
723
|
+
private readonly undoStack: PendingUndo[] = [];
|
|
724
|
+
private readonly redoStack: PendingUndo[] = [];
|
|
725
|
+
private readonly storySources: ReadonlyArray<Record<string, UIStory>>;
|
|
726
|
+
private readonly onStoryApplied: ((story: (UIStory & { id: string }) | null) => void) | undefined;
|
|
727
|
+
/** D4 — the currently-applied story id for this world (world-level in B2,
|
|
728
|
+
* not per-node — see {@link stories}'s doc comment); `null` = no story
|
|
729
|
+
* applied (the layer mounts against empty data, today's B1 behavior). */
|
|
730
|
+
private activeStoryId: string | null = null;
|
|
731
|
+
/** B3 — the registered components catalog (see
|
|
732
|
+
* {@link ReactWorldAuthoringOptions.catalogComponents}'s doc comment).
|
|
733
|
+
* Empty ⇒ {@link catalogOn} is `false` ⇒ pure DOM-walk hierarchy. */
|
|
734
|
+
private readonly catalogComponents: UIComponentRegistry;
|
|
735
|
+
private readonly onIsolate:
|
|
736
|
+
| ((v: { componentKey: string; story: (UIStory & { id: string }) | null } | null) => void)
|
|
737
|
+
| undefined;
|
|
738
|
+
private readonly worldId: string | undefined;
|
|
739
|
+
/** B3 — the currently-isolated catalog node, or `null` (the entry preview
|
|
740
|
+
* is rendered). See {@link stories}'s `isolate`. */
|
|
741
|
+
private isolated: { nodeId: string; componentKey: string; storyId: string | undefined } | null =
|
|
742
|
+
null;
|
|
743
|
+
/**
|
|
744
|
+
* T0 (spec 27 §4, B1) — the currently-open `boxEdit` begin/apply×N/end gesture (a
|
|
745
|
+
* drag), or `null` between gestures. `touched` maps the RESOLVED CSS prop (already
|
|
746
|
+
* patch-key-mapped, e.g. `x` → `left`) to the FINAL value `end` should commit — a
|
|
747
|
+
* `Map` so the last `apply` in the gesture wins, matching an in-flight drag's most
|
|
748
|
+
* recent pointer position. `priorInline` captures each touched prop's ORIGINAL inline
|
|
749
|
+
* value, LAZILY on the prop's first `apply` in this gesture (i.e. BEFORE `apply`
|
|
750
|
+
* mutates the live style) — `apply` writes the live DOM directly for zero-latency
|
|
751
|
+
* preview, which would otherwise corrupt `writeStyleEntry`'s own prior-value capture
|
|
752
|
+
* (it reads `n.el.style` fresh, and by `end` time that already holds the LAST applied
|
|
753
|
+
* preview value, not the true pre-gesture one) — this is why `writeStyleEntry` takes
|
|
754
|
+
* an explicit override rather than re-deriving `prev` itself for a box-edit commit.
|
|
755
|
+
*/
|
|
756
|
+
private boxEditSession: {
|
|
757
|
+
id: string;
|
|
758
|
+
touched: Map<string, string | number>;
|
|
759
|
+
priorInline: Map<string, string>;
|
|
760
|
+
} | null = null;
|
|
761
|
+
|
|
762
|
+
constructor(
|
|
763
|
+
private readonly root: OidElementLike,
|
|
764
|
+
private readonly store: EditorStore,
|
|
765
|
+
opts: ReactWorldAuthoringOptions = {},
|
|
766
|
+
) {
|
|
767
|
+
this.writeBackend = opts.writeBackend;
|
|
768
|
+
this.computedStyle = opts.computedStyle ?? browserOrInlineResolver;
|
|
769
|
+
this.matchedCssRules =
|
|
770
|
+
opts.matchedCssRules ??
|
|
771
|
+
((el) => getMatchedCssRules(el as MatchableElement) as CssRuleTarget[]);
|
|
772
|
+
this.designTokens = opts.designTokens ?? (() => getDesignTokens());
|
|
773
|
+
this.storySources = opts.storySources ?? [];
|
|
774
|
+
this.onStoryApplied = opts.onStoryApplied;
|
|
775
|
+
this.catalogComponents = opts.catalogComponents ?? {};
|
|
776
|
+
this.onIsolate = opts.onIsolate;
|
|
777
|
+
this.worldId = opts.worldId;
|
|
778
|
+
this.capabilities = {
|
|
779
|
+
transform: false, // no 3D gizmo — DOM has no Object3D pose (§1.F)
|
|
780
|
+
material: false,
|
|
781
|
+
inspectorFields: true,
|
|
782
|
+
create: true, // Cap 5: insert-child / duplicate / wrap are real source ops
|
|
783
|
+
delete: true,
|
|
784
|
+
reparent: true, // Cap 5: real source reparent (drag-to-reparent)
|
|
785
|
+
persist: true,
|
|
786
|
+
};
|
|
787
|
+
// T3.2 slice 2: this adapter IS the undo/redo target the editor-global
|
|
788
|
+
// timeline dispatches to for entries it records (`pushUndo`). The caller
|
|
789
|
+
// (`play-mode.ts`'s `exitMultiWorldAuthoring`) unregisters it on teardown,
|
|
790
|
+
// mirroring `IngestAuthoringAdapter`.
|
|
791
|
+
registerUndoAdapter(this);
|
|
792
|
+
// A4 (spec 27 §3) — re-sync the optimistic echo once HMR has actually re-rendered the
|
|
793
|
+
// react tree. Vite fires `vite:afterUpdate` after it applies an HMR update — here, the
|
|
794
|
+
// dev-server source-write this adapter triggered (→ file watcher → react-refresh
|
|
795
|
+
// re-render) — so that event IS the "reload landed" signal (the adapter otherwise never
|
|
796
|
+
// learns about reloads; play-mode.ts owns the component-class HMR handler, not this
|
|
797
|
+
// per-world adapter). Absent under a hosted/no-dev-server build and under vitest's `node`
|
|
798
|
+
// env (no `import.meta.hot`), where the unit test drives `reconcileEchoAfterReload()`
|
|
799
|
+
// directly to simulate a landed reload.
|
|
800
|
+
const hot = import.meta.hot;
|
|
801
|
+
if (hot) {
|
|
802
|
+
const onAfterUpdate = (): void => this.reconcileEchoAfterReload();
|
|
803
|
+
hot.on('vite:afterUpdate', onAfterUpdate);
|
|
804
|
+
this.disposeReloadSignal = () => hot.off('vite:afterUpdate', onAfterUpdate);
|
|
805
|
+
}
|
|
806
|
+
if (this.writeBackend?.index) {
|
|
807
|
+
this.writeBackend
|
|
808
|
+
.index()
|
|
809
|
+
.then((idx) => {
|
|
810
|
+
this.oidIndex = new Map(Object.entries(idx));
|
|
811
|
+
this.store.notifyIngestEdit();
|
|
812
|
+
})
|
|
813
|
+
.catch(() => {
|
|
814
|
+
// Honest degradation: labels stay tag-only if the index can't be fetched.
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
private snapshot(): OidTree {
|
|
820
|
+
return walkOidTree(this.root);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// --- A4: optimistic value echo (see {@link valueEcho}) ---
|
|
824
|
+
|
|
825
|
+
/** Record the value just committed by `inspector.set`, so `inspector.get(id, path)`
|
|
826
|
+
* returns it immediately (before the async source-write + HMR land). */
|
|
827
|
+
private setEcho(id: string, path: string, value: unknown): void {
|
|
828
|
+
let byPath = this.valueEcho.get(id);
|
|
829
|
+
if (!byPath) {
|
|
830
|
+
byPath = new Map();
|
|
831
|
+
this.valueEcho.set(id, byPath);
|
|
832
|
+
}
|
|
833
|
+
byPath.set(path, value);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** Drop one echoed entry — used the instant a write is refused/no-op'd, so the field
|
|
837
|
+
* reverts to the live-DOM (unchanged) value rather than sticking on a value the source
|
|
838
|
+
* never took (the "stale in the other direction" guard for rejected writes). */
|
|
839
|
+
private clearEcho(id: string, path: string): void {
|
|
840
|
+
const byPath = this.valueEcho.get(id);
|
|
841
|
+
if (!byPath) return;
|
|
842
|
+
byPath.delete(path);
|
|
843
|
+
if (byPath.size === 0) this.valueEcho.delete(id);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** U4 — record that `id`'s `path` was refused as a dynamic expression, so the
|
|
847
|
+
* NEXT `properties()` call marks its descriptor `readonly`. */
|
|
848
|
+
private markDynamic(id: string, path: string): void {
|
|
849
|
+
this.dynamicPaths.add(`${id}|${path}`);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** Read-through for `inspector.get`: `{hit:true}` when this id+path was optimistically
|
|
853
|
+
* echoed and not yet reconciled; `{hit:false}` otherwise (fall back to the live DOM). A
|
|
854
|
+
* distinct `hit` flag (not a sentinel value) so a legitimately-`undefined` echoed value
|
|
855
|
+
* still wins over the live-DOM walk. */
|
|
856
|
+
private readEcho(id: string, path: string): { hit: boolean; value: unknown } {
|
|
857
|
+
const byPath = this.valueEcho.get(id);
|
|
858
|
+
if (byPath?.has(path)) return { hit: true, value: byPath.get(path) };
|
|
859
|
+
return { hit: false, value: undefined };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* A4 — the "HMR reload landed" reconcile (fired by the constructor's `vite:afterUpdate`
|
|
864
|
+
* hook, or called directly to simulate a landed reload). The echo held the freshly-set
|
|
865
|
+
* values while the async source-write + HMR re-render were in flight; once HMR has
|
|
866
|
+
* re-rendered the react tree the LIVE DOM is authoritative again — including any
|
|
867
|
+
* server-side value coercion — so drop the whole echo and notify, and every open inspector
|
|
868
|
+
* re-reads the now-updated DOM. (An unrelated module's `afterUpdate` that fires before THIS
|
|
869
|
+
* edit's HMR is a benign race: the field momentarily re-reads the old DOM, then this
|
|
870
|
+
* edit's own `afterUpdate` reconciles it — the echo self-heals on the next tick.) Selection
|
|
871
|
+
* is untouched: it lives as OID-signature ids in the store, re-resolved by `walkOidTree`
|
|
872
|
+
* against the remounted tree, never as a cached element reference.
|
|
873
|
+
*
|
|
874
|
+
* D3.R4 (reopen fix), widened by D3.R5 — ALSO the "landed" signal for every pending
|
|
875
|
+
* source write counted by {@link pendingSourceReconcile} (see its doc comment for the
|
|
876
|
+
* full site list and the counter-vs-boolean rationale): each of those writes' own
|
|
877
|
+
* `notifyIngestEdit()` fires before the HMR remount lands, so it never carries fresh
|
|
878
|
+
* DOM shape on its own; this reconcile is what does, once the remount has actually
|
|
879
|
+
* happened. Decrements the counter by exactly one per call (never resets it to zero)
|
|
880
|
+
* so N pending writes drain over N reconciles, each one notifying — not just the
|
|
881
|
+
* first.
|
|
882
|
+
*/
|
|
883
|
+
reconcileEchoAfterReload(): void {
|
|
884
|
+
if (this.valueEcho.size === 0 && this.pendingSourceReconcile === 0) return;
|
|
885
|
+
this.valueEcho.clear();
|
|
886
|
+
if (this.pendingSourceReconcile > 0) this.pendingSourceReconcile--;
|
|
887
|
+
this.store.notifyIngestEdit();
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** A4 — release the `vite:afterUpdate` reconcile subscription. Idempotent; safe when no
|
|
891
|
+
* HMR context was present (the disposer is `undefined`). */
|
|
892
|
+
disposeReactWorldAdapter(): void {
|
|
893
|
+
this.disposeReloadSignal?.();
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
private toEditorNode(n: OidNode): EditorNode {
|
|
897
|
+
// Component label priority (Cap 1): the OID source index (`component:tag`), then the
|
|
898
|
+
// LIVE React fiber's component name (`getReactComponentName` — resolves for a
|
|
899
|
+
// component's root element even before/without the index fetch), then the DOM tag.
|
|
900
|
+
const entry = this.oidIndex.get(n.oid);
|
|
901
|
+
const component = entry?.component ?? getReactComponentName(n.el);
|
|
902
|
+
const label = component ? `${component}:${n.tag}` : n.tag;
|
|
903
|
+
const node: EditorNode = {
|
|
904
|
+
id: n.id,
|
|
905
|
+
label,
|
|
906
|
+
kind: n.tag,
|
|
907
|
+
parentId: n.parentId,
|
|
908
|
+
childIds: n.childIds,
|
|
909
|
+
flags: { runtimeOnly: true },
|
|
910
|
+
};
|
|
911
|
+
// B3 — a former DOM ROOT (`parentId === null`) nests one level under the
|
|
912
|
+
// catalog's entry-preview node when the catalog is on (§0's reconciliation:
|
|
913
|
+
// GameHierarchy's sibling/drag math keys off `parentId`, so a root that
|
|
914
|
+
// kept `parentId: null` while `roots()` stopped returning it would corrupt
|
|
915
|
+
// top-level DOM drag-reorder). Only ROOTS are remapped — a non-root node's
|
|
916
|
+
// `parentId` already points at its real DOM parent, untouched.
|
|
917
|
+
if (this.catalogOn && node.parentId === null) return { ...node, parentId: CATALOG_ENTRY_ID };
|
|
918
|
+
return node;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** B3 gate (§0/§1): the catalog hierarchy is ON only when constructed with a
|
|
922
|
+
* NON-EMPTY `catalogComponents` — absent/empty keeps today's plain DOM-walk
|
|
923
|
+
* hierarchy byte-identical (play mode never passes this option at all). */
|
|
924
|
+
private get catalogOn(): boolean {
|
|
925
|
+
return Object.keys(this.catalogComponents).length > 0;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** B3 — one `kind:'component'` node per registered key. `object3D`/
|
|
929
|
+
* `idForObject3D` stay `null` for these ids (no DOM/Object3D backing). */
|
|
930
|
+
private catalogNode(key: string): EditorNode {
|
|
931
|
+
return {
|
|
932
|
+
id: `${CATALOG_ID_PREFIX}${key}`,
|
|
933
|
+
label: key,
|
|
934
|
+
kind: 'component',
|
|
935
|
+
parentId: null,
|
|
936
|
+
childIds: [],
|
|
937
|
+
flags: { runtimeOnly: true },
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** B3 — the one `'entry (live preview)'` node; its children are the
|
|
942
|
+
* DOM-walk's OWN root ids (unchanged, still resolved by `hierarchy.node()`
|
|
943
|
+
* through the ordinary DOM path — see {@link toEditorNode}'s remap). */
|
|
944
|
+
private entryNode(rootIds: string[]): EditorNode {
|
|
945
|
+
return {
|
|
946
|
+
id: CATALOG_ENTRY_ID,
|
|
947
|
+
label: 'entry (live preview)',
|
|
948
|
+
kind: 'group',
|
|
949
|
+
parentId: null,
|
|
950
|
+
childIds: rootIds,
|
|
951
|
+
flags: { runtimeOnly: true },
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
readonly hierarchy: HierarchyProvider = {
|
|
956
|
+
roots: () => {
|
|
957
|
+
const { nodes, rootIds } = this.snapshot();
|
|
958
|
+
if (!this.catalogOn) return rootIds.map((id) => this.toEditorNode(nodes.get(id)!));
|
|
959
|
+
return [
|
|
960
|
+
...Object.keys(this.catalogComponents).map((key) => this.catalogNode(key)),
|
|
961
|
+
this.entryNode(rootIds),
|
|
962
|
+
];
|
|
963
|
+
},
|
|
964
|
+
node: (id) => {
|
|
965
|
+
if (this.catalogOn) {
|
|
966
|
+
if (id === CATALOG_ENTRY_ID) return this.entryNode(this.snapshot().rootIds);
|
|
967
|
+
if (id.startsWith(CATALOG_ID_PREFIX)) {
|
|
968
|
+
const key = id.slice(CATALOG_ID_PREFIX.length);
|
|
969
|
+
return key in this.catalogComponents ? this.catalogNode(key) : null;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
const n = this.snapshot().nodes.get(id);
|
|
973
|
+
return n ? this.toEditorNode(n) : null;
|
|
974
|
+
},
|
|
975
|
+
object3D: () => null, // react entities are DOM, not Object3D — no 3D gizmo binding
|
|
976
|
+
idForObject3D: () => null,
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
readonly selection: SelectionProvider = {
|
|
980
|
+
get: () => [...this.store.selectedEntityIds],
|
|
981
|
+
set: (ids) => this.store.selectMultiple(ids),
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* D12 (B4) — GEOMETRIC rect hit-test over the live OID tree, NOT
|
|
986
|
+
* `document.elementFromPoint` (which SKIPS a `pointer-events:none` wrapper —
|
|
987
|
+
* this layer's resting CSS state at design time, `design-time-layers.ts`'s
|
|
988
|
+
* `applySessionStyle`). Reuses `ui-editor/react-store.ts`'s established
|
|
989
|
+
* `hitTestAttr` ranking rule (smallest-area element containing the point
|
|
990
|
+
* wins — the deepest/most-specific node; ties broken by later tree-walk
|
|
991
|
+
* order — the topmost) over each OID node's own `getBoundingClientRect()`,
|
|
992
|
+
* inlined here rather than sharing that function directly since this walks
|
|
993
|
+
* `OidNode`s already resolved by `walkOidTree`, not a live `querySelectorAll`
|
|
994
|
+
* over a DOM attribute string.
|
|
995
|
+
*
|
|
996
|
+
* The winning id is the SAME disambiguated id `hierarchy`/`selection`
|
|
997
|
+
* already use (this IS `walkOidTree`'s own id space) — a hit routes
|
|
998
|
+
* straight into `composite.selection.set([id])` with no translation. A
|
|
999
|
+
* catalog node (`catalog:<key>`) has no DOM element behind it at all, so it
|
|
1000
|
+
* can never be a candidate here — only ordinary OID nodes are walked
|
|
1001
|
+
* (correct: nothing on screen corresponds to a bare catalog entry).
|
|
1002
|
+
*/
|
|
1003
|
+
readonly pickable: PickProvider = {
|
|
1004
|
+
pick: (clientX, clientY) => {
|
|
1005
|
+
const { nodes } = this.snapshot();
|
|
1006
|
+
let bestId: string | null = null;
|
|
1007
|
+
let bestArea = Number.POSITIVE_INFINITY;
|
|
1008
|
+
let bestOrder = -1;
|
|
1009
|
+
let order = -1;
|
|
1010
|
+
for (const node of nodes.values()) {
|
|
1011
|
+
order++;
|
|
1012
|
+
// D4.R1 — a locked node is SKIPPED, not returned: exactly the
|
|
1013
|
+
// `viewport-raycast.ts` first-party semantics ("skip locked
|
|
1014
|
+
// entities in viewport selection", falling through to whatever
|
|
1015
|
+
// unlocked node is behind/around it). See `lockedIds`'s doc
|
|
1016
|
+
// comment for why this Set, not a real inspector-backed field.
|
|
1017
|
+
if (this.lockedIds.has(node.id)) continue;
|
|
1018
|
+
const rect = node.el.getBoundingClientRect?.();
|
|
1019
|
+
if (!rect) continue; // no live rect (test fixture, or unmounted) — never a candidate
|
|
1020
|
+
if (
|
|
1021
|
+
clientX < rect.left ||
|
|
1022
|
+
clientX > rect.right ||
|
|
1023
|
+
clientY < rect.top ||
|
|
1024
|
+
clientY > rect.bottom
|
|
1025
|
+
) {
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
const area = rect.width * rect.height;
|
|
1029
|
+
// smallest-area (deepest) wins; ties broken by later tree-order (topmost)
|
|
1030
|
+
if (area < bestArea || (area === bestArea && order > bestOrder)) {
|
|
1031
|
+
bestId = node.id;
|
|
1032
|
+
bestArea = area;
|
|
1033
|
+
bestOrder = order;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
return bestId;
|
|
1037
|
+
},
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
/** Host rect to subtract for {@link rects}' HOST-RELATIVE geometry — `this.root`
|
|
1041
|
+
* IS the world's own mounted DOM layer (`world.reactRoot()` / the ingest
|
|
1042
|
+
* sibling's `layer`, see the constructor call sites), i.e. exactly the
|
|
1043
|
+
* `position:absolute; inset:0` per-world surface the overlay (Phase A3) is
|
|
1044
|
+
* itself hosted over. No new constructor option is needed — reusing the
|
|
1045
|
+
* proven `react-store.ts` `nodeRect` pattern (element rect minus stage/host
|
|
1046
|
+
* rect) against the field this adapter already holds. */
|
|
1047
|
+
private hostRect(): { left: number; top: number } {
|
|
1048
|
+
const r = this.root.getBoundingClientRect?.();
|
|
1049
|
+
return { left: r?.left ?? 0, top: r?.top ?? 0 };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
private toHostRelative(r: {
|
|
1053
|
+
left: number;
|
|
1054
|
+
top: number;
|
|
1055
|
+
width: number;
|
|
1056
|
+
height: number;
|
|
1057
|
+
}): DOMRectLike {
|
|
1058
|
+
const host = this.hostRect();
|
|
1059
|
+
return { x: r.left - host.left, y: r.top - host.top, width: r.width, height: r.height };
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* T0 (spec 27 §2) — per-node screen geometry for the DOM visual editor's
|
|
1064
|
+
* overlay/snap/measure math. `rect`/`contextRects` return HOST-RELATIVE
|
|
1065
|
+
* coordinates (see {@link hostRect}'s doc comment) — the overlay this feeds
|
|
1066
|
+
* (Phase A3, `WorldSelectionOverlay`) is itself mounted as a
|
|
1067
|
+
* `position:absolute; inset:0` layer over the same per-world DOM host, so a
|
|
1068
|
+
* host-relative rect is exactly what it can draw against with no further
|
|
1069
|
+
* translation (matching the established `ui-editor/react-store.ts`
|
|
1070
|
+
* `nodeRect` pattern this reuses).
|
|
1071
|
+
*/
|
|
1072
|
+
readonly rects: RectProvider = {
|
|
1073
|
+
rect: (id) => {
|
|
1074
|
+
const n = this.snapshot().nodes.get(id);
|
|
1075
|
+
const r = n?.el.getBoundingClientRect?.();
|
|
1076
|
+
return r ? this.toHostRelative(r) : null;
|
|
1077
|
+
},
|
|
1078
|
+
contextRects: (id) => {
|
|
1079
|
+
const { nodes } = this.snapshot();
|
|
1080
|
+
const n = nodes.get(id);
|
|
1081
|
+
if (!n) return {};
|
|
1082
|
+
const parentNode = n.parentId ? nodes.get(n.parentId) : undefined;
|
|
1083
|
+
const parentRect = parentNode?.el.getBoundingClientRect?.();
|
|
1084
|
+
const siblingIds = (parentNode ? parentNode.childIds : this.snapshot().rootIds).filter(
|
|
1085
|
+
(sid) => sid !== id,
|
|
1086
|
+
);
|
|
1087
|
+
const siblings = siblingIds
|
|
1088
|
+
.map((sid) => nodes.get(sid)?.el.getBoundingClientRect?.())
|
|
1089
|
+
.filter((r): r is NonNullable<typeof r> => r != null)
|
|
1090
|
+
.map((r) => this.toHostRelative(r));
|
|
1091
|
+
// Padding box (CSS box model, inside the border) — border widths read off
|
|
1092
|
+
// the same computed-style resolver the inspector uses; absent/unparsable
|
|
1093
|
+
// border widths degrade to 0 (padding box === border box), never thrown.
|
|
1094
|
+
const el = n.el;
|
|
1095
|
+
const rect = el.getBoundingClientRect?.();
|
|
1096
|
+
let paddingBox: DOMRectLike | undefined;
|
|
1097
|
+
if (rect) {
|
|
1098
|
+
const bt =
|
|
1099
|
+
numericStyleValue(getComputedStyleValue(el, 'borderTopWidth', this.computedStyle)) ?? 0;
|
|
1100
|
+
const br =
|
|
1101
|
+
numericStyleValue(getComputedStyleValue(el, 'borderRightWidth', this.computedStyle)) ?? 0;
|
|
1102
|
+
const bb =
|
|
1103
|
+
numericStyleValue(getComputedStyleValue(el, 'borderBottomWidth', this.computedStyle)) ??
|
|
1104
|
+
0;
|
|
1105
|
+
const bl =
|
|
1106
|
+
numericStyleValue(getComputedStyleValue(el, 'borderLeftWidth', this.computedStyle)) ?? 0;
|
|
1107
|
+
paddingBox = this.toHostRelative({
|
|
1108
|
+
left: rect.left + bl,
|
|
1109
|
+
top: rect.top + bt,
|
|
1110
|
+
width: Math.max(0, rect.width - bl - br),
|
|
1111
|
+
height: Math.max(0, rect.height - bt - bb),
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
return {
|
|
1115
|
+
...(parentRect ? { parent: this.toHostRelative(parentRect) } : {}),
|
|
1116
|
+
...(siblings.length ? { siblings } : {}),
|
|
1117
|
+
...(paddingBox ? { paddingBox } : {}),
|
|
1118
|
+
};
|
|
1119
|
+
},
|
|
1120
|
+
// D3.c (spec 27 §6) — every currently-empty OID container: no visible
|
|
1121
|
+
// (OID or non-OID) child ELEMENT, no text, feeding the pure
|
|
1122
|
+
// `findEmptyContainers` math (`ui-source/inspect.ts:463`) unchanged. Rule
|
|
1123
|
+
// zero stays intact — the DOM READ happens HERE, in the adapter; the
|
|
1124
|
+
// overlay/shell only ever sees the already-filtered `{id, rect,
|
|
1125
|
+
// displayName}` result.
|
|
1126
|
+
emptyContainers: () => {
|
|
1127
|
+
const { nodes } = this.snapshot();
|
|
1128
|
+
const candidates: EmptyCandidate[] = [];
|
|
1129
|
+
for (const n of nodes.values()) {
|
|
1130
|
+
const r = n.el.getBoundingClientRect?.();
|
|
1131
|
+
if (!r) continue;
|
|
1132
|
+
candidates.push({
|
|
1133
|
+
oid: n.id,
|
|
1134
|
+
rect: this.toHostRelative(r),
|
|
1135
|
+
displayName: n.tag,
|
|
1136
|
+
hasVisibleChildren: n.el.children.length > 0,
|
|
1137
|
+
hasText: (n.el.textContent ?? '').trim().length > 0,
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
return findEmptyContainers(candidates).map((c) => ({
|
|
1141
|
+
id: c.oid,
|
|
1142
|
+
rect: c.rect,
|
|
1143
|
+
displayName: c.displayName,
|
|
1144
|
+
}));
|
|
1145
|
+
},
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* D3.d (spec 27 §6) — the eyedropper FALLBACK color-sample path: pick the
|
|
1150
|
+
* topmost OID node at the point (reusing this adapter's OWN `pickable.pick`,
|
|
1151
|
+
* never `elementFromPoint`), then walk its ancestor chain collecting each
|
|
1152
|
+
* node's RAW (un-normalized) computed `background-color` — hit-element
|
|
1153
|
+
* first, matching `effectiveColorFromChain`'s expected order. Raw, not
|
|
1154
|
+
* `cssColorToHex`-normalized, so a `transparent`/`rgba(0,0,0,0)` background
|
|
1155
|
+
* is recognizable as such by `isTransparentBackground` (the hex form would
|
|
1156
|
+
* lose that signal — see the contract's own doc comment on
|
|
1157
|
+
* `ColorSampleProvider`).
|
|
1158
|
+
*/
|
|
1159
|
+
readonly colorSample: ColorSampleProvider = {
|
|
1160
|
+
backgroundChainAt: (clientX, clientY) => {
|
|
1161
|
+
const hitId = this.pickable.pick(clientX, clientY);
|
|
1162
|
+
if (!hitId) return null;
|
|
1163
|
+
const { nodes } = this.snapshot();
|
|
1164
|
+
const chain: string[] = [];
|
|
1165
|
+
let cur: OidNode | undefined = nodes.get(hitId);
|
|
1166
|
+
while (cur) {
|
|
1167
|
+
chain.push(getComputedStyleValue(cur.el, 'backgroundColor', this.computedStyle));
|
|
1168
|
+
cur = cur.parentId ? nodes.get(cur.parentId) : undefined;
|
|
1169
|
+
}
|
|
1170
|
+
return chain;
|
|
1171
|
+
},
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* T0 (spec 27 §4, B1) — spatial drag-resize/move/spacing → source write, for
|
|
1176
|
+
* non-Object3D (DOM) nodes. `apply` is LIVE PREVIEW ONLY: it mutates the live
|
|
1177
|
+
* element's inline style directly, with ZERO backend traffic (acceptance:311 —
|
|
1178
|
+
* a resize drag must not spam the dev server with a write per frame). `end`
|
|
1179
|
+
* commits every touched prop ONCE through the existing {@link writeStyleEntry}
|
|
1180
|
+
* write pipeline (CSS-file routing + append-aware undo preserved unchanged),
|
|
1181
|
+
* composed into exactly ONE undo entry per gesture (acceptance:310) even when
|
|
1182
|
+
* the gesture touched multiple props (e.g. a corner-resize writes both `width`
|
|
1183
|
+
* and `height`). See {@link boxEditSession}'s doc comment for the
|
|
1184
|
+
* `priorInline` capture this depends on for a correct undo inverse.
|
|
1185
|
+
*/
|
|
1186
|
+
readonly boxEdit: BoxEditProvider = {
|
|
1187
|
+
begin: (id) => {
|
|
1188
|
+
// A stale, never-`end`ed session (caller bug) is simply replaced — its
|
|
1189
|
+
// preview mutations are already live on the DOM either way.
|
|
1190
|
+
this.boxEditSession = { id, touched: new Map(), priorInline: new Map() };
|
|
1191
|
+
},
|
|
1192
|
+
apply: (id, patch) => {
|
|
1193
|
+
const session = this.boxEditSession;
|
|
1194
|
+
if (!session || session.id !== id) return; // no open gesture for this id
|
|
1195
|
+
const n = this.snapshot().nodes.get(id);
|
|
1196
|
+
if (!n) return; // unresolved id — no-op (per contract)
|
|
1197
|
+
const pos = getComputedStyleValue(n.el, 'position', this.computedStyle);
|
|
1198
|
+
const isPositioned = pos === 'absolute' || pos === 'fixed';
|
|
1199
|
+
for (const [key, v] of Object.entries(patch)) {
|
|
1200
|
+
const mapped = mapBoxEditPatchKey(key, isPositioned);
|
|
1201
|
+
if (!mapped) {
|
|
1202
|
+
console.warn(
|
|
1203
|
+
`[ReactWorldAuthoringAdapter] boxEdit: dropping patch key "${key}" for "${id}" — ` +
|
|
1204
|
+
(key === 'x' || key === 'y'
|
|
1205
|
+
? `node is not absolutely/fixed positioned (computed position: "${pos}"), ` +
|
|
1206
|
+
'no left/top to move'
|
|
1207
|
+
: 'unrecognized box-edit patch key'),
|
|
1208
|
+
);
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
// Lazily snapshot the TRUE pre-gesture inline value the first time THIS
|
|
1212
|
+
// prop is touched in this gesture — before mutating it — see
|
|
1213
|
+
// `boxEditSession`'s doc comment for why this can't be re-derived later.
|
|
1214
|
+
if (!session.priorInline.has(mapped.prop)) {
|
|
1215
|
+
const prevRaw = styleProp(n.el.style, mapped.prop);
|
|
1216
|
+
session.priorInline.set(mapped.prop, prevRaw == null ? '' : String(prevRaw));
|
|
1217
|
+
}
|
|
1218
|
+
const cssValue = mapped.cssValue(v);
|
|
1219
|
+
if (n.el.style) (n.el.style as Record<string, unknown>)[mapped.prop] = cssValue;
|
|
1220
|
+
// Commit numerics as plain numbers (matching every other numeric style
|
|
1221
|
+
// write in this adapter — see `writeStyle`'s callers), transform as a
|
|
1222
|
+
// string — NOT the px-suffixed preview string, which is preview-only.
|
|
1223
|
+
session.touched.set(mapped.prop, mapped.prop === 'transform' ? cssValue : v);
|
|
1224
|
+
}
|
|
1225
|
+
},
|
|
1226
|
+
end: (id) => {
|
|
1227
|
+
const session = this.boxEditSession;
|
|
1228
|
+
this.boxEditSession = null;
|
|
1229
|
+
if (!session || session.id !== id || session.touched.size === 0) return;
|
|
1230
|
+
void this.commitBoxEdit(id, session.touched, session.priorInline);
|
|
1231
|
+
},
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* T0 (spec 27 §4, B1) — commit every prop touched by one `boxEdit` gesture, each
|
|
1236
|
+
* through {@link writeStyleEntry} (so CSS-file routing / append-aware undo are
|
|
1237
|
+
* unchanged), then compose all resulting entries into exactly ONE undo entry
|
|
1238
|
+
* (acceptance:310) whose inverse runs in REVERSE order and whose redo runs in
|
|
1239
|
+
* gesture order — mirroring how a multi-statement edit undoes as one unit
|
|
1240
|
+
* elsewhere in this adapter.
|
|
1241
|
+
*/
|
|
1242
|
+
private async commitBoxEdit(
|
|
1243
|
+
id: string,
|
|
1244
|
+
touched: Map<string, string | number>,
|
|
1245
|
+
priorInline: Map<string, string>,
|
|
1246
|
+
): Promise<void> {
|
|
1247
|
+
// A4 — echo every touched prop BEFORE any write, so the inspector field shows
|
|
1248
|
+
// the dragged value at once (same discipline as `inspector.set`). The echo holds
|
|
1249
|
+
// the BARE numeric (a `type:'number'` descriptor's `Inspector` field needs an
|
|
1250
|
+
// actual number — see `inspector.get`'s `numericStyleValue` note), NOT the
|
|
1251
|
+
// px-suffixed source form D1 writes below.
|
|
1252
|
+
for (const [prop, value] of touched) {
|
|
1253
|
+
this.setEcho(id, `${STYLE_PATH_PREFIX}${prop}`, value);
|
|
1254
|
+
}
|
|
1255
|
+
const entries: PendingUndo[] = [];
|
|
1256
|
+
for (const [prop, value] of touched) {
|
|
1257
|
+
// D1 (spec 27 §4 B2/B3 reload-safety) — a numeric length-prop value must
|
|
1258
|
+
// persist to JSX SOURCE in a form React honors on REMOUNT. React 19 DROPS a
|
|
1259
|
+
// bare UNITLESS numeric STRING (the writer quotes `String(152)` → `width:
|
|
1260
|
+
// '152'`) on reload — the element collapses to content size (verified in real
|
|
1261
|
+
// Chromium + React 19) — but HONORS a quoted CSS length (`width: '152px'`).
|
|
1262
|
+
// `boxEdit.apply` stores length props as bare NUMBERS and CSS-string props
|
|
1263
|
+
// (`transform` → `'rotate(90deg)'`) as strings, so a numeric value here is
|
|
1264
|
+
// EXACTLY the set of length props (width/height/left/top/margin*/padding*)
|
|
1265
|
+
// needing a `px` unit — suffix only those, leaving `transform` untouched.
|
|
1266
|
+
// (The live-preview `el.style` path already applied a px-suffixed string via
|
|
1267
|
+
// `mapBoxEditPatchKey.cssValue`; only the persisted-source form was unitless.)
|
|
1268
|
+
// Localized to this box-edit commit — the color/`inspector.set` write path is
|
|
1269
|
+
// deliberately NOT changed.
|
|
1270
|
+
const writeValue = typeof value === 'number' ? `${value}px` : value;
|
|
1271
|
+
const entry = await this.writeStyleEntry(id, prop, writeValue, priorInline.get(prop));
|
|
1272
|
+
if (entry) entries.push(entry);
|
|
1273
|
+
}
|
|
1274
|
+
if (entries.length === 0) return;
|
|
1275
|
+
this.pushUndo({
|
|
1276
|
+
undo: () => {
|
|
1277
|
+
for (let i = entries.length - 1; i >= 0; i--) entries[i]!.undo();
|
|
1278
|
+
},
|
|
1279
|
+
redo: () => {
|
|
1280
|
+
for (const entry of entries) entry.redo();
|
|
1281
|
+
},
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* T0 (spec 27 §2) — brings the react adapter's existing off-contract `editText`
|
|
1287
|
+
* (below) onto the contract. `get` is a best-effort CLIENT-side read: the live
|
|
1288
|
+
* DOM can only rule out "has child elements" (`el.children.length > 0`), not a
|
|
1289
|
+
* dynamic `{expression}` body — that guard is source-side and already enforced
|
|
1290
|
+
* at write time (`editText`'s `res.dynamic`, surfaced as a loud console warning
|
|
1291
|
+
* on refusal). `set` fires the existing undo-tracked write path unchanged.
|
|
1292
|
+
*/
|
|
1293
|
+
readonly text: TextProvider = {
|
|
1294
|
+
get: (id) => {
|
|
1295
|
+
// D3.R1 — a node already refused once as a dynamic-body text edit
|
|
1296
|
+
// (`markDynamic(id, TEXT_PATH)`, set from `editText`'s `res.dynamic`
|
|
1297
|
+
// refusal below) reports null from here on this session, mirroring
|
|
1298
|
+
// U4's style/prop after-touch marker — so a SECOND double-click reports
|
|
1299
|
+
// the refusal immediately instead of re-opening the textarea only to
|
|
1300
|
+
// have the source refuse it again.
|
|
1301
|
+
if (this.dynamicPaths.has(`${id}|${TEXT_PATH}`)) return null;
|
|
1302
|
+
const n = this.snapshot().nodes.get(id);
|
|
1303
|
+
if (!n || n.el.children.length > 0) return null;
|
|
1304
|
+
const raw = n.el.textContent;
|
|
1305
|
+
if (raw == null) return null;
|
|
1306
|
+
const trimmed = raw.trim();
|
|
1307
|
+
return trimmed ? trimmed : null;
|
|
1308
|
+
},
|
|
1309
|
+
set: (id, text) => {
|
|
1310
|
+
void this.editText(id, text);
|
|
1311
|
+
},
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1314
|
+
readonly inspector: InspectorProvider = {
|
|
1315
|
+
properties: (id: string): PropertyDescriptor[] => {
|
|
1316
|
+
// B3 — a catalog node (`catalog:<key>`) has no DOM element/fiber behind
|
|
1317
|
+
// it at all (`object3D`/`idForObject3D` are null for these ids too) —
|
|
1318
|
+
// style/Cap-4-props/tokens would be garbage for it. Its ONE inspector
|
|
1319
|
+
// surface is the active isolation story's props (see
|
|
1320
|
+
// `storyPropDescriptors`'s doc comment) — deliberately NOT routed
|
|
1321
|
+
// through `getComponentProps`/`writePropEdit` (Cap 4), which reads a
|
|
1322
|
+
// real call-site fiber that doesn't exist for a catalog entry.
|
|
1323
|
+
if (this.isCatalogId(id)) return this.storyPropDescriptors(id);
|
|
1324
|
+
const style = STYLE_PROPERTIES.map(({ prop, label, type, group, options }) => ({
|
|
1325
|
+
path: `${STYLE_PATH_PREFIX}${prop}`,
|
|
1326
|
+
label,
|
|
1327
|
+
type,
|
|
1328
|
+
group,
|
|
1329
|
+
...(options ? { options } : {}),
|
|
1330
|
+
}));
|
|
1331
|
+
// Cap 4 (React visual-edit parity): append a "Props" section for this node's component
|
|
1332
|
+
// call site — its editable primitive props, read live off the React fiber
|
|
1333
|
+
// (`getComponentProps`). A write is guarded server-side (a dynamic prop is refused),
|
|
1334
|
+
// so surfacing every primitive prop here is safe. Cap 7 appends a read-only "Tokens"
|
|
1335
|
+
// section — the document's design tokens (`:root` custom properties).
|
|
1336
|
+
const all = [...style, ...this.propDescriptors(id), ...this.tokenDescriptors()];
|
|
1337
|
+
// U4 — a path already refused once as a dynamic expression (`markDynamic`,
|
|
1338
|
+
// set from `writeStyleEntry`/`writePropEdit`'s `res.dynamic` refusal) reports
|
|
1339
|
+
// `readonly: true` from here on, so the widget disables itself instead of
|
|
1340
|
+
// re-offering an edit the source will refuse again. See `dynamicPaths`'s
|
|
1341
|
+
// doc comment for why this is after-touch, not a pre-touch predictor.
|
|
1342
|
+
if (this.dynamicPaths.size === 0) return all;
|
|
1343
|
+
return all.map((p) =>
|
|
1344
|
+
this.dynamicPaths.has(`${id}|${p.path}`) ? { ...p, readonly: true } : p,
|
|
1345
|
+
);
|
|
1346
|
+
},
|
|
1347
|
+
get: (id, path) => {
|
|
1348
|
+
// A4 — read THROUGH the optimistic echo: a value just set (style/prop) wins over the
|
|
1349
|
+
// live-DOM walk until the source-write + HMR land, so the field never snaps back.
|
|
1350
|
+
const echoed = this.readEcho(id, path);
|
|
1351
|
+
if (echoed.hit) return echoed.value;
|
|
1352
|
+
if (this.isCatalogId(id)) {
|
|
1353
|
+
if (!path.startsWith(STORY_PROP_PATH_PREFIX)) return undefined;
|
|
1354
|
+
return this.activeIsolationStoryProps(id)?.[path.slice(STORY_PROP_PATH_PREFIX.length)];
|
|
1355
|
+
}
|
|
1356
|
+
// D4 (spec27 §6 D4, layer-tree visibility/lock) — the two reserved
|
|
1357
|
+
// paths GameHierarchy's row reads generically off ANY adapter's
|
|
1358
|
+
// `inspector`. `locked` is session-local (see `lockedIds`'s doc
|
|
1359
|
+
// comment — no source-backed equivalent). `visible` IS source-backed:
|
|
1360
|
+
// sugar for the `style.visibility` prop (read THROUGH that same prop's
|
|
1361
|
+
// own optimistic echo, so the eye icon never snap-backs while the
|
|
1362
|
+
// async write is in flight — same A4 discipline every other style
|
|
1363
|
+
// path gets).
|
|
1364
|
+
if (path === 'locked') return this.lockedIds.has(id);
|
|
1365
|
+
if (path === 'visible') {
|
|
1366
|
+
const echoedStyle = this.readEcho(id, `${STYLE_PATH_PREFIX}visibility`);
|
|
1367
|
+
if (echoedStyle.hit) return echoedStyle.value !== 'hidden';
|
|
1368
|
+
const n = this.snapshot().nodes.get(id);
|
|
1369
|
+
if (!n) return undefined;
|
|
1370
|
+
const raw = getComputedStyleValue(n.el, 'visibility', this.computedStyle);
|
|
1371
|
+
return raw !== 'hidden';
|
|
1372
|
+
}
|
|
1373
|
+
if (path.startsWith(TOKEN_PATH_PREFIX)) {
|
|
1374
|
+
const name = path.slice(TOKEN_PATH_PREFIX.length);
|
|
1375
|
+
return this.designTokens().find((t) => t.name === name)?.value;
|
|
1376
|
+
}
|
|
1377
|
+
if (path.startsWith(PROP_PATH_PREFIX)) {
|
|
1378
|
+
const n = this.snapshot().nodes.get(id);
|
|
1379
|
+
const cp = n ? getComponentProps(n.el) : null;
|
|
1380
|
+
return cp?.props[path.slice(PROP_PATH_PREFIX.length)];
|
|
1381
|
+
}
|
|
1382
|
+
if (!path.startsWith(STYLE_PATH_PREFIX)) return undefined;
|
|
1383
|
+
const prop = path.slice(STYLE_PATH_PREFIX.length);
|
|
1384
|
+
const n = this.snapshot().nodes.get(id);
|
|
1385
|
+
if (!n) return undefined;
|
|
1386
|
+
// Cap 1 (React visual-edit parity): read the COMPUTED value via the resolver, so a
|
|
1387
|
+
// property styled through a className utility (F5's class routing) OR a CSS file
|
|
1388
|
+
// resolves too — fixing the "class-styled props show blank" read-back gap. Under
|
|
1389
|
+
// vitest's `node` env the default resolver falls back to the element's inline
|
|
1390
|
+
// `.style`, so headless fixtures keep working unchanged.
|
|
1391
|
+
const raw = getComputedStyleValue(n.el, prop, this.computedStyle);
|
|
1392
|
+
const declaredType = STYLE_PROPERTY_TYPE.get(prop);
|
|
1393
|
+
// A `type: 'number'` descriptor (fontSize/width/height/padding/margin/
|
|
1394
|
+
// borderRadius/gap) needs an actual number for the generic numeric
|
|
1395
|
+
// input to render/edit correctly — a REAL `CSSStyleDeclaration` always
|
|
1396
|
+
// hands these back as a unit-suffixed STRING (e.g. `"16px"`, never a
|
|
1397
|
+
// bare `16`), which `Inspector.tsx`'s `typeof v === 'number'` check
|
|
1398
|
+
// would otherwise silently read as `0` (this repo's own
|
|
1399
|
+
// `react-world-authoring-adapter.test.ts` fixtures never caught this —
|
|
1400
|
+
// a hand-built plain-object `style: {}` can hold a bare JS number
|
|
1401
|
+
// directly, which a real DOM element's `style` never does). A
|
|
1402
|
+
// `type: 'color'` descriptor needs `#rrggbb` for the same reason —
|
|
1403
|
+
// see `cssColorToHex`'s doc comment.
|
|
1404
|
+
if (declaredType === 'number') return numericStyleValue(raw);
|
|
1405
|
+
if (declaredType === 'color') return cssColorToHex(raw);
|
|
1406
|
+
return raw || undefined; // an unset computed value reads '' — surface as blank
|
|
1407
|
+
},
|
|
1408
|
+
set: (id, path, value) => {
|
|
1409
|
+
if (this.isCatalogId(id)) {
|
|
1410
|
+
if (path.startsWith(STORY_PROP_PATH_PREFIX)) {
|
|
1411
|
+
this.writeStoryProp(id, path.slice(STORY_PROP_PATH_PREFIX.length), value);
|
|
1412
|
+
}
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
// D4 — see the matching `get` branch's doc comment.
|
|
1416
|
+
if (path === 'locked') {
|
|
1417
|
+
if (value) this.lockedIds.add(id);
|
|
1418
|
+
else this.lockedIds.delete(id);
|
|
1419
|
+
this.store.notifyIngestEdit();
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
if (path === 'visible') {
|
|
1423
|
+
const cssValue = value ? 'visible' : 'hidden';
|
|
1424
|
+
this.setEcho(id, `${STYLE_PATH_PREFIX}visibility`, cssValue);
|
|
1425
|
+
void this.writeStyle(id, 'visibility', cssValue);
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
if (path.startsWith(PROP_PATH_PREFIX)) {
|
|
1429
|
+
// A4 — echo BEFORE the async write so `inspector.get` shows the new value at once.
|
|
1430
|
+
this.setEcho(id, path, value);
|
|
1431
|
+
void this.writePropEdit(id, path.slice(PROP_PATH_PREFIX.length), String(value));
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
if (!path.startsWith(STYLE_PATH_PREFIX)) return;
|
|
1435
|
+
const prop = path.slice(STYLE_PATH_PREFIX.length);
|
|
1436
|
+
// A4 — echo BEFORE the async write (the desync fix): the field reflects the commit
|
|
1437
|
+
// within one frame; the write path clears this echo if the write is refused, and HMR
|
|
1438
|
+
// clears it once the re-render lands.
|
|
1439
|
+
this.setEcho(id, path, value);
|
|
1440
|
+
void this.writeStyle(id, prop, value as string | number);
|
|
1441
|
+
},
|
|
1442
|
+
remove: (id, path) => {
|
|
1443
|
+
// U2 — remove a stale CSS longhand override so its shorthand actually
|
|
1444
|
+
// wins (a uniform border/radius edit clears the per-side/per-corner
|
|
1445
|
+
// longhands a prior non-uniform edit wrote; without this the longhand
|
|
1446
|
+
// silently overrides the shorthand on reload — the D1 defect). Style
|
|
1447
|
+
// paths only; a `prop.`/`token.`/catalog path has no removable-override
|
|
1448
|
+
// meaning here (no-op). `removeStyle` is itself a source no-op when the
|
|
1449
|
+
// longhand isn't authored, so calling this unconditionally for all four
|
|
1450
|
+
// corners / twelve side-props is safe and never pollutes clean source.
|
|
1451
|
+
if (this.isCatalogId(id)) return;
|
|
1452
|
+
if (!path.startsWith(STYLE_PATH_PREFIX)) return;
|
|
1453
|
+
const prop = path.slice(STYLE_PATH_PREFIX.length);
|
|
1454
|
+
// Point the removed longhand's echo at the value it actually renders at
|
|
1455
|
+
// once the override is gone — the shorthand's current (just-committed)
|
|
1456
|
+
// value — so `inspector.get(longhand)` reports the resolved corner value,
|
|
1457
|
+
// not a stale override or an empty computed read, and stays consistent
|
|
1458
|
+
// after HMR clears the echo (the shorthand then cascades to it).
|
|
1459
|
+
const shorthand = LONGHAND_TO_SHORTHAND[prop];
|
|
1460
|
+
if (shorthand)
|
|
1461
|
+
this.setEcho(id, path, this.inspector.get(id, `${STYLE_PATH_PREFIX}${shorthand}`));
|
|
1462
|
+
else this.clearEcho(id, path);
|
|
1463
|
+
void this.removeStyleProp(id, prop);
|
|
1464
|
+
},
|
|
1465
|
+
};
|
|
1466
|
+
|
|
1467
|
+
/** B3 — true for a catalog node id (`catalog:<key>`), never for the entry
|
|
1468
|
+
* node or an ordinary DOM node id. */
|
|
1469
|
+
private isCatalogId(id: string): boolean {
|
|
1470
|
+
return id.startsWith(CATALOG_ID_PREFIX);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** B3 — the active isolation story's `props` for catalog node `id`, or
|
|
1474
|
+
* `null` when `id` isn't the CURRENTLY isolated node (no active story to
|
|
1475
|
+
* show props for — v1's inspector surface only has meaning once isolated,
|
|
1476
|
+
* see the class doc comment on `isolate`). An isolation with no story
|
|
1477
|
+
* picked (`storyId` undefined) or an unresolvable story both read as `{}`
|
|
1478
|
+
* (no props), not `null` — `null` means "not this node". */
|
|
1479
|
+
private activeIsolationStoryProps(id: string): Record<string, unknown> | null {
|
|
1480
|
+
if (!this.isolated || this.isolated.nodeId !== id) return null;
|
|
1481
|
+
const storyId = this.isolated.storyId;
|
|
1482
|
+
const story = storyId ? this.mergedStories().get(storyId) : undefined;
|
|
1483
|
+
return story?.props ?? {};
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/** B3 — PropertyDescriptors for the active isolation story's props (see
|
|
1487
|
+
* {@link activeIsolationStoryProps}), grouped under "Story Props". Every
|
|
1488
|
+
* code-authored prop the component receives is otherwise read-only (spec:
|
|
1489
|
+
* "there is nowhere to persist an edit to") — these ARE writable because
|
|
1490
|
+
* they resolve through the B2 story-overlay (`writeStoryProp`), a
|
|
1491
|
+
* session-scoped seam, not the component's JSX call site. */
|
|
1492
|
+
private storyPropDescriptors(id: string): PropertyDescriptor[] {
|
|
1493
|
+
const props = this.activeIsolationStoryProps(id);
|
|
1494
|
+
if (!props) return [];
|
|
1495
|
+
return Object.keys(props).map((name) => {
|
|
1496
|
+
// Derive the descriptor type from the story value so the generic
|
|
1497
|
+
// inspector renders the right widget: a boolean story prop (e.g. a
|
|
1498
|
+
// modal's `open: true`) as a checkbox, a number as a number input, a
|
|
1499
|
+
// nested object/array as JSON — NOT a text input that would stringify
|
|
1500
|
+
// every edit (typing "false" into a text field writes the truthy string
|
|
1501
|
+
// `'false'`, so a bool could never be toggled off from the UI).
|
|
1502
|
+
const value = props[name];
|
|
1503
|
+
const type: PropertyDescriptor['type'] =
|
|
1504
|
+
typeof value === 'boolean'
|
|
1505
|
+
? 'boolean'
|
|
1506
|
+
: typeof value === 'number'
|
|
1507
|
+
? 'number'
|
|
1508
|
+
: value !== null && typeof value === 'object'
|
|
1509
|
+
? 'json'
|
|
1510
|
+
: 'string';
|
|
1511
|
+
return {
|
|
1512
|
+
path: `${STORY_PROP_PATH_PREFIX}${name}`,
|
|
1513
|
+
label: name,
|
|
1514
|
+
type,
|
|
1515
|
+
group: 'Story Props',
|
|
1516
|
+
};
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* B3 — write a catalog node's active-isolation-story prop through the B2
|
|
1522
|
+
* story-overlay (`../authoring/story-overlay.ts`'s `setStoryOverlay`) —
|
|
1523
|
+
* the one write seam that doesn't require a JSX call site (a catalog
|
|
1524
|
+
* component has none — see `inspector.properties`'s catalog branch doc
|
|
1525
|
+
* comment). Clones the active story, patches ONE prop, writes the overlay
|
|
1526
|
+
* for THIS world (keyed by the required `worldId` constructor option — no
|
|
1527
|
+
* world id ⇒ nowhere to key the overlay, a loud warning, no silent no-op),
|
|
1528
|
+
* then re-invokes `stories.isolate` with the SAME node/story so the layer
|
|
1529
|
+
* re-renders against the patched props immediately (mirrors `stories.apply`'s
|
|
1530
|
+
* "re-render in place" contract — never a remount).
|
|
1531
|
+
*/
|
|
1532
|
+
private writeStoryProp(nodeId: string, propName: string, value: unknown): void {
|
|
1533
|
+
if (!this.isolated || this.isolated.nodeId !== nodeId) {
|
|
1534
|
+
console.warn(
|
|
1535
|
+
`[ReactWorldAuthoringAdapter] cannot write story prop "${propName}" on "${nodeId}": ` +
|
|
1536
|
+
'not currently isolated.',
|
|
1537
|
+
);
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
if (!this.worldId) {
|
|
1541
|
+
console.warn(
|
|
1542
|
+
`[ReactWorldAuthoringAdapter] cannot write story prop "${propName}" on "${nodeId}": ` +
|
|
1543
|
+
'no world id configured for the story overlay (session-only writes unavailable).',
|
|
1544
|
+
);
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
const { storyId } = this.isolated;
|
|
1548
|
+
if (!storyId) {
|
|
1549
|
+
console.warn(
|
|
1550
|
+
`[ReactWorldAuthoringAdapter] cannot write story prop "${propName}" on "${nodeId}": ` +
|
|
1551
|
+
'no story is currently applied to isolate against.',
|
|
1552
|
+
);
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
const base = this.mergedStories().get(storyId);
|
|
1556
|
+
const patched: UIStory = {
|
|
1557
|
+
label: base?.label ?? storyId,
|
|
1558
|
+
data: base?.data ?? {},
|
|
1559
|
+
props: { ...(base?.props ?? {}), [propName]: value },
|
|
1560
|
+
};
|
|
1561
|
+
setStoryOverlay(this.worldId, { ...(getStoryOverlay(this.worldId) ?? {}), [storyId]: patched });
|
|
1562
|
+
this.doIsolate(nodeId, storyId); // re-render against the patched story
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/** Cap 4: PropertyDescriptors for a node's editable component props (read off the fiber). */
|
|
1566
|
+
private propDescriptors(id: string): PropertyDescriptor[] {
|
|
1567
|
+
const n = this.snapshot().nodes.get(id);
|
|
1568
|
+
const cp = n ? getComponentProps(n.el) : null;
|
|
1569
|
+
if (!cp) return [];
|
|
1570
|
+
return Object.keys(cp.props).map((name) => ({
|
|
1571
|
+
path: `${PROP_PATH_PREFIX}${name}`,
|
|
1572
|
+
label: name,
|
|
1573
|
+
type: 'string' as const,
|
|
1574
|
+
group: 'Props',
|
|
1575
|
+
}));
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/** Cap 7: read-only PropertyDescriptors for the document's design tokens (`:root` custom
|
|
1579
|
+
* properties, incl. Tailwind v4 @theme/oklch), shown under a "Tokens" section. */
|
|
1580
|
+
private tokenDescriptors(): PropertyDescriptor[] {
|
|
1581
|
+
return this.designTokens().map((t) => ({
|
|
1582
|
+
path: `${TOKEN_PATH_PREFIX}${t.name}`,
|
|
1583
|
+
label: t.name,
|
|
1584
|
+
type: 'string' as const,
|
|
1585
|
+
group: 'Tokens',
|
|
1586
|
+
readonly: true,
|
|
1587
|
+
}));
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* All configured story sources merged, later sources winning per id (see
|
|
1592
|
+
* {@link ReactWorldAuthoringOptions.storySources}'s doc comment). B3 — the
|
|
1593
|
+
* story-overlay slot (`../authoring/story-overlay.ts`) is ALSO re-read
|
|
1594
|
+
* fresh here (not only whatever snapshot `design-time-layers.ts` baked
|
|
1595
|
+
* into `storySources` at mount time) so a catalog node's story-prop write
|
|
1596
|
+
* (`writeStoryProp`, which calls `setStoryOverlay`) is visible on the very
|
|
1597
|
+
* next call — still LAST, so it wins on a collision, same precedence the
|
|
1598
|
+
* constructor's `storySources` array already documents. A no-op when
|
|
1599
|
+
* `worldId` wasn't configured (no overlay to key).
|
|
1600
|
+
*/
|
|
1601
|
+
private mergedStories(): Map<string, UIStory> {
|
|
1602
|
+
const out = new Map<string, UIStory>();
|
|
1603
|
+
for (const source of this.storySources) {
|
|
1604
|
+
for (const [id, story] of Object.entries(source)) out.set(id, story);
|
|
1605
|
+
}
|
|
1606
|
+
if (this.worldId) {
|
|
1607
|
+
const overlay = getStoryOverlay(this.worldId);
|
|
1608
|
+
if (overlay) for (const [id, story] of Object.entries(overlay)) out.set(id, story);
|
|
1609
|
+
}
|
|
1610
|
+
return out;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/**
|
|
1614
|
+
* D4 — storybook stories (`docs/unified-world-editor/26-unified-editor-spec.md`
|
|
1615
|
+
* §3 B2). B2 scope is WORLD-LEVEL: `storiesFor`/`active` ignore `nodeId`
|
|
1616
|
+
* entirely (every node in this world reports the SAME merged story list —
|
|
1617
|
+
* B3 is what differentiates a per-component catalog via `isolate`, which
|
|
1618
|
+
* this slice deliberately OMITS). `apply` is the one write path: it
|
|
1619
|
+
* validates the id (a loud console warning on an unknown id — never a
|
|
1620
|
+
* silent no-op that leaves the picker looking like nothing happened),
|
|
1621
|
+
* updates `activeStoryId`, invokes `onStoryApplied` (the design-time
|
|
1622
|
+
* layer's re-render hook), and notifies the store so the inspector's
|
|
1623
|
+
* dropdown re-reads `active()` immediately.
|
|
1624
|
+
*/
|
|
1625
|
+
readonly stories: StoriesProvider = {
|
|
1626
|
+
storiesFor: (_nodeId): StoryRef[] =>
|
|
1627
|
+
[...this.mergedStories().entries()].map(([id, story]) => ({ id, label: story.label })),
|
|
1628
|
+
active: (_nodeId) => this.activeStoryId,
|
|
1629
|
+
apply: (_nodeId, storyId) => {
|
|
1630
|
+
if (storyId === null) {
|
|
1631
|
+
this.activeStoryId = null;
|
|
1632
|
+
this.onStoryApplied?.(null);
|
|
1633
|
+
this.store.notifyIngestEdit();
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
const story = this.mergedStories().get(storyId);
|
|
1637
|
+
if (!story) {
|
|
1638
|
+
console.warn(
|
|
1639
|
+
`[ReactWorldAuthoringAdapter] stories.apply: unknown story id "${storyId}" — ignoring.`,
|
|
1640
|
+
);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
this.activeStoryId = storyId;
|
|
1644
|
+
this.onStoryApplied?.({ id: storyId, ...story });
|
|
1645
|
+
this.store.notifyIngestEdit();
|
|
1646
|
+
},
|
|
1647
|
+
// B3 — per-component catalog isolation (see `doIsolate`'s doc comment).
|
|
1648
|
+
isolate: (nodeId, storyId) => this.doIsolate(nodeId, storyId),
|
|
1649
|
+
};
|
|
1650
|
+
|
|
1651
|
+
/** B3 — the currently-isolated catalog node id, or `null` (the entry
|
|
1652
|
+
* preview is rendered). Read by `design-time-layers.ts`'s store
|
|
1653
|
+
* subscription (auto-exit when the selection no longer contains the
|
|
1654
|
+
* isolated node) and by `Inspector.tsx`'s catalog Isolate-toggle state
|
|
1655
|
+
* (same "ask the owning child adapter" discipline as `worldGroupChildAdapter`). */
|
|
1656
|
+
get isolatedNodeId(): string | null {
|
|
1657
|
+
return this.isolated?.nodeId ?? null;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/**
|
|
1661
|
+
* B3 — `stories.isolate(nodeId, storyId?)`: render ONLY a catalog
|
|
1662
|
+
* component against a story (the storybook canvas), or restore the entry
|
|
1663
|
+
* preview.
|
|
1664
|
+
* - `nodeId === null` → clear the isolation, invoke `onIsolate(null)`
|
|
1665
|
+
* (the layer's re-render hook — see `design-time-layers.ts`), notify.
|
|
1666
|
+
* - a non-catalog id, or a catalog id whose key isn't actually registered
|
|
1667
|
+
* → a loud `console.warn`, no-op (never a silent no-op that leaves the
|
|
1668
|
+
* UI looking like nothing happened — house style, `stories.apply`'s
|
|
1669
|
+
* unknown-id case above).
|
|
1670
|
+
* - a valid catalog id → resolve the story from the merged sources (`{}`
|
|
1671
|
+
* props when no story/an unresolvable story id was given — isolating
|
|
1672
|
+
* with NO story is a valid "just show the component" state), invoke
|
|
1673
|
+
* `onIsolate`, notify the store so any open inspector re-reads state.
|
|
1674
|
+
*/
|
|
1675
|
+
private doIsolate(nodeId: string | null, storyId?: string): void {
|
|
1676
|
+
if (nodeId === null) {
|
|
1677
|
+
this.isolated = null;
|
|
1678
|
+
this.onIsolate?.(null);
|
|
1679
|
+
this.store.notifyIngestEdit();
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
if (!this.isCatalogId(nodeId)) {
|
|
1683
|
+
console.warn(
|
|
1684
|
+
`[ReactWorldAuthoringAdapter] stories.isolate: "${nodeId}" is not a catalog component ` +
|
|
1685
|
+
'node — ignoring.',
|
|
1686
|
+
);
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
const key = nodeId.slice(CATALOG_ID_PREFIX.length);
|
|
1690
|
+
if (!(key in this.catalogComponents)) {
|
|
1691
|
+
console.warn(
|
|
1692
|
+
`[ReactWorldAuthoringAdapter] stories.isolate: unknown catalog component "${key}" — ` +
|
|
1693
|
+
'ignoring.',
|
|
1694
|
+
);
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
const story = storyId ? this.mergedStories().get(storyId) : undefined;
|
|
1698
|
+
this.isolated = { nodeId, componentKey: key, storyId };
|
|
1699
|
+
this.onIsolate?.({ componentKey: key, story: story ? { id: storyId!, ...story } : null });
|
|
1700
|
+
this.store.notifyIngestEdit();
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
readonly structure: StructureProvider = {
|
|
1704
|
+
// Cap 5 (React visual-edit parity): real source structural ops. D-1 (Phase D,
|
|
1705
|
+
// spec27 §2): EVERY structural op — including `create`/`duplicate`, whose new
|
|
1706
|
+
// element has no OID until the next transform/HMR — IS pushed to the undo
|
|
1707
|
+
// timeline, via a checksum-guarded WHOLE-FILE snapshot inverse (`pushStructUndo`)
|
|
1708
|
+
// rather than a per-OID one. That sidesteps the OID problem entirely: undo/redo
|
|
1709
|
+
// never needs to address the new/moved element by OID, it just restores the
|
|
1710
|
+
// file's prior/next full text. (Previously irreversible in this slice — see
|
|
1711
|
+
// `pushStructUndo`'s doc comment for the full rationale and the delete case.)
|
|
1712
|
+
create: (kind, parentId) => {
|
|
1713
|
+
void this.structOp(parentId ?? '', 'create', { wrapperTag: kind });
|
|
1714
|
+
return '';
|
|
1715
|
+
},
|
|
1716
|
+
// Returns `removeElement`'s own `Promise<void>` (not `void this....`) so
|
|
1717
|
+
// `deleteSelection` (`editor-hotkeys.ts`) can `await` each id's write
|
|
1718
|
+
// before firing the next — see the CORRECTNESS INVARIANT comment below
|
|
1719
|
+
// for why that serialization (plus deletion ORDER) is what makes the
|
|
1720
|
+
// per-id fallback loop sound for an adapter WITHOUT `removeMany` (any
|
|
1721
|
+
// adapter override lacking `structure.removeMany` — `deleteSelection`
|
|
1722
|
+
// always prefers a batched `removeMany` when present, see below).
|
|
1723
|
+
remove: (id) => this.removeElement(id),
|
|
1724
|
+
// delete-order-residual fix (bug-panel follow-up to the multi-delete
|
|
1725
|
+
// corruption fix, 50f90a6d) — SOUND batched delete, ONE undo entry for
|
|
1726
|
+
// the whole selection. D4.R2 originally investigated and REJECTED
|
|
1727
|
+
// `removeMany` here as unsound because the OBVIOUS implementation is N
|
|
1728
|
+
// separate `structOp`-style calls, each targeting its OID's `{file,
|
|
1729
|
+
// line, col}` from the SERVER's `OidStore.index`
|
|
1730
|
+
// (`vite-plugin-ui-oid.ts`'s `handleStruct`) — exactly the per-id
|
|
1731
|
+
// `remove` loop's own stale-offset hazard (see the CORRECTNESS INVARIANT
|
|
1732
|
+
// comment below), just without the ordering discipline that loop needs
|
|
1733
|
+
// to stay sound. This implementation is NOT that: `removeManyElements`
|
|
1734
|
+
// posts every id's raw oid in ONE request to `/__ui-source/struct-many`
|
|
1735
|
+
// (`handleStructMany`, `vite-plugin-ui-oid.ts`), which resolves every
|
|
1736
|
+
// oid's offset against a SINGLE shared `readFileSync` snapshot (never a
|
|
1737
|
+
// per-id re-read, so no write-to-write staleness is even possible) and
|
|
1738
|
+
// applies them highest-offset-first in one pass, one write. That is also
|
|
1739
|
+
// why this is the fix for the DOM-reordered-vs-source residual the
|
|
1740
|
+
// per-id fallback below still carries: this batch never consults
|
|
1741
|
+
// `collectAllNodeIds`/hierarchy-walk order at all, so it is correct
|
|
1742
|
+
// regardless of whether the live DOM's child order matches the .tsx
|
|
1743
|
+
// source order. (Explicitly NOT a live re-transform/re-scan per id
|
|
1744
|
+
// either — see `deleteElements`'s doc comment in `writer.ts` for why
|
|
1745
|
+
// that would reopen a DIFFERENT unsoundness: occurrence-index-based oid
|
|
1746
|
+
// identity churns when same-tag siblings are removed mid-batch.)
|
|
1747
|
+
// Returns `removeManyElements`'s own `Promise<void>` (not `void
|
|
1748
|
+
// this....`) so `deleteSelection` can `await` the whole batch's write
|
|
1749
|
+
// landing before it returns, same reason `remove` above does.
|
|
1750
|
+
removeMany: (ids) => this.removeManyElements(ids),
|
|
1751
|
+
//
|
|
1752
|
+
// CORRECTNESS INVARIANT for the per-id `remove` loop (bug-panel reopen,
|
|
1753
|
+
// post-D4.R2) — `deleteSelection` (`editor-hotkeys.ts`) falls back to
|
|
1754
|
+
// this loop only when `removeMany` is ABSENT (a different adapter
|
|
1755
|
+
// override); for THIS adapter `removeMany` above is always preferred, so
|
|
1756
|
+
// this loop is dead code for react-world today, kept sound and
|
|
1757
|
+
// documented for any future override without a batched delete. It is
|
|
1758
|
+
// safe ONLY under TWO conditions `deleteSelection` enforces together —
|
|
1759
|
+
// neither held before the 50f90a6d fix (an unsorted, unawaited loop was
|
|
1760
|
+
// a real source-corruption hazard, reachable by an ordinary top-first
|
|
1761
|
+
// marquee/multi-select delete) — AND both assume the caller's walk order
|
|
1762
|
+
// (`collectAllNodeIds`, DOM order for this adapter) matches true SOURCE
|
|
1763
|
+
// order (the DOM-reordered-vs-source residual this file's `removeMany`
|
|
1764
|
+
// fixes for THIS adapter specifically):
|
|
1765
|
+
// 1. REVERSE (walk-)DOCUMENT ORDER — delete the bottom-most element
|
|
1766
|
+
// first, per the caller's own hierarchy walk. Every element's
|
|
1767
|
+
// `{line, col}` in `OidStore.index` is a snapshot from the LAST
|
|
1768
|
+
// real parse and is never refreshed between same-file writes (only
|
|
1769
|
+
// by a real Vite `transform()` re-run — the async HMR round trip
|
|
1770
|
+
// `pendingSourceReconcile` already tracks). Deleting bottom-up
|
|
1771
|
+
// means every write only ever removes source that sits BELOW every
|
|
1772
|
+
// id still queued — nothing ABOVE a queued id's own offset ever
|
|
1773
|
+
// shifts, so that offset stays valid no matter how many of its
|
|
1774
|
+
// later-walked siblings/descendants have already been removed. A
|
|
1775
|
+
// descendant is later in DFS pre-order than its ancestor, so this
|
|
1776
|
+
// same reverse-pre-order rule also deletes a selected descendant
|
|
1777
|
+
// before a selected ancestor — required, since deleting the
|
|
1778
|
+
// ancestor first would remove the descendant's own source out from
|
|
1779
|
+
// under it before its turn. This is sound ONLY when walk order ==
|
|
1780
|
+
// source order (true for ordinary JSX; false when a component's
|
|
1781
|
+
// live DOM child order is a runtime permutation of its JSX source,
|
|
1782
|
+
// e.g. `{[...els].reverse()}` over an array of DISTINCT element
|
|
1783
|
+
// values — see `test/delete-selection-dom-order-residual.test.ts`).
|
|
1784
|
+
// 2. SERIALIZED WRITES — `deleteSelection` `await`s each `remove(id)`
|
|
1785
|
+
// (this method returns `removeElement`'s promise instead of firing
|
|
1786
|
+
// it `void`) before starting the next. `writeStruct` is a real
|
|
1787
|
+
// network POST in production (`source-write-backend.ts`); two
|
|
1788
|
+
// in-flight, un-awaited requests for the SAME file can each read it
|
|
1789
|
+
// BEFORE either has written back, so whichever write lands second
|
|
1790
|
+
// silently clobbers (loses) the first — a lost-update race
|
|
1791
|
+
// independent of ordering. Awaiting each id in turn guarantees
|
|
1792
|
+
// request N only starts once request N-1's write has landed.
|
|
1793
|
+
// Together, every write in the sequence reads a file that already
|
|
1794
|
+
// reflects every prior delete in the sequence, and targets an offset
|
|
1795
|
+
// still valid against that file — the "safe" claim this comment used to
|
|
1796
|
+
// make unconditionally, which was FALSE for the raw (unsorted, top-first)
|
|
1797
|
+
// selection order `deleteSelection` used to iterate in
|
|
1798
|
+
// (`test/react-world-struct-undo.test.ts`'s own "offset-staleness
|
|
1799
|
+
// hazard" case, reachable through the real `deleteSelection` path, not
|
|
1800
|
+
// just a hand-picked unsafe direct-adapter call).
|
|
1801
|
+
duplicate: (id) => {
|
|
1802
|
+
void this.structOp(id, 'duplicate');
|
|
1803
|
+
return id;
|
|
1804
|
+
},
|
|
1805
|
+
reparent: (id, newParentId) => {
|
|
1806
|
+
if (!newParentId) return;
|
|
1807
|
+
const parentOid = this.oidOf(newParentId);
|
|
1808
|
+
if (parentOid) void this.structOp(id, 'reparent', { parentOid });
|
|
1809
|
+
},
|
|
1810
|
+
// T0 (spec 27 §2): bring the pre-existing off-contract `reorder(id, beforeId,
|
|
1811
|
+
// parentId)` method (below) onto the contract. D2.b (spec 27 §6) fix: a
|
|
1812
|
+
// `null` `beforeSiblingId` means "move to the end of id's OWN current
|
|
1813
|
+
// parent" (this contract method's own doc comment) — that needs a REAL
|
|
1814
|
+
// `parentOid` to target (`reorder`'s own `parentStart != null` end-of-list
|
|
1815
|
+
// branch), so resolve `id`'s CURRENT parent from the live snapshot for
|
|
1816
|
+
// that case. A non-null `beforeSiblingId` already fully determines the
|
|
1817
|
+
// target position via `targetOid` alone, so `parentId` stays `null` there
|
|
1818
|
+
// (matching this method's pre-D2 behavior — no other caller of this
|
|
1819
|
+
// contract method existed before D2.b's canvas drag-to-reorder, which is
|
|
1820
|
+
// the first to actually exercise the null/"move to end" case).
|
|
1821
|
+
reorder: (id, beforeSiblingId) => {
|
|
1822
|
+
const parentId =
|
|
1823
|
+
beforeSiblingId === null ? (this.snapshot().nodes.get(id)?.parentId ?? null) : null;
|
|
1824
|
+
this.reorder(id, beforeSiblingId, parentId);
|
|
1825
|
+
},
|
|
1826
|
+
// D3.e (spec 27 §2 T0 leftover) — the generic HTML kinds `create`
|
|
1827
|
+
// genuinely supports (see `CREATABLE_KINDS`'s doc comment below).
|
|
1828
|
+
// `parentId` is ignored: every OID node accepts any of these as a plain
|
|
1829
|
+
// child, mirroring `ui-authoring-adapter.ts`'s identically-parentId-
|
|
1830
|
+
// agnostic `creatableKinds`.
|
|
1831
|
+
creatableKinds: () => [...CREATABLE_KINDS],
|
|
1832
|
+
// D3.a (spec 27 §6) — bring the pre-existing off-contract `wrap`/`unwrap`
|
|
1833
|
+
// methods (below) onto the contract so the canvas context menu can reach
|
|
1834
|
+
// them through the adapter interface alone (rule zero).
|
|
1835
|
+
wrap: (id, wrapperTag) => this.wrap(id, wrapperTag),
|
|
1836
|
+
unwrap: (id) => this.unwrap(id),
|
|
1837
|
+
};
|
|
1838
|
+
|
|
1839
|
+
/** Wrap the element in a new container (Cap 5). */
|
|
1840
|
+
wrap(id: string, wrapperTag = 'div'): void {
|
|
1841
|
+
void this.structOp(id, 'wrap', { wrapperTag });
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/** Replace the element with its children (Cap 5). */
|
|
1845
|
+
unwrap(id: string): void {
|
|
1846
|
+
void this.structOp(id, 'unwrap');
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/** Reorder the element before a sibling (`beforeId`), or to the end of `parentId` when
|
|
1850
|
+
* `beforeId` is null (Cap 5, layer-tree drag-to-reorder). */
|
|
1851
|
+
reorder(id: string, beforeId: string | null, parentId: string | null): void {
|
|
1852
|
+
const opts: { targetOid?: string; parentOid?: string } = {};
|
|
1853
|
+
const targetOid = beforeId ? this.oidOf(beforeId) : undefined;
|
|
1854
|
+
const parentOid = parentId ? this.oidOf(parentId) : undefined;
|
|
1855
|
+
if (targetOid) opts.targetOid = targetOid;
|
|
1856
|
+
if (parentOid) opts.parentOid = parentOid;
|
|
1857
|
+
void this.structOp(id, 'reorder', opts);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/** The underlying (component-global) OID for an entity id (strips the `#n` repeat tag). */
|
|
1861
|
+
private oidOf(id: string): string | undefined {
|
|
1862
|
+
return this.snapshot().nodes.get(id)?.oid;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
/** Run a structural op through the write backend (loud degradation without one). */
|
|
1866
|
+
private async structOp(
|
|
1867
|
+
id: string,
|
|
1868
|
+
op: string,
|
|
1869
|
+
opts?: { targetOid?: string; parentOid?: string; wrapperTag?: string },
|
|
1870
|
+
): Promise<void> {
|
|
1871
|
+
const oid = this.oidOf(id);
|
|
1872
|
+
if (!oid) return;
|
|
1873
|
+
if (!this.writeBackend) {
|
|
1874
|
+
console.warn(
|
|
1875
|
+
`[ReactWorldAuthoringAdapter] cannot "${op}" on "${id}": no source-write backend in ` +
|
|
1876
|
+
'this session (hosted/no dev server).',
|
|
1877
|
+
);
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
const res = await this.writeBackend.writeStruct(oid, op, opts);
|
|
1881
|
+
if (!res.changed) {
|
|
1882
|
+
console.warn(
|
|
1883
|
+
`[ReactWorldAuthoringAdapter] struct "${op}" refused/no-op for oid "${oid}": ${res.error ?? 'no change'}`,
|
|
1884
|
+
);
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
this.dirty = true;
|
|
1888
|
+
// D-1 (Phase D, spec27 §2): push the whole-file-snapshot undo/redo entry —
|
|
1889
|
+
// see `pushStructUndo`'s doc comment.
|
|
1890
|
+
this.pushStructUndo(op, res);
|
|
1891
|
+
// D3.R4 (reopen fix) — this notify's `storeVersion` still reflects the PRE-HMR DOM (see
|
|
1892
|
+
// `pendingSourceReconcile`'s doc comment); queue a pending reconcile so the upcoming
|
|
1893
|
+
// `vite:afterUpdate` (or a test's direct `reconcileEchoAfterReload()` call) notifies AGAIN
|
|
1894
|
+
// once the remount has actually happened.
|
|
1895
|
+
this.pendingSourceReconcile++;
|
|
1896
|
+
this.store.notifyIngestEdit();
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
/**
|
|
1900
|
+
* D-1 (Phase D, spec27 §2 "known limitation to schedule, not hide"): push the
|
|
1901
|
+
* checksum-guarded whole-file-snapshot undo/redo entry for a successful structural
|
|
1902
|
+
* write — shared by {@link structOp} (create/duplicate/wrap/unwrap/reorder/reparent)
|
|
1903
|
+
* and {@link removeElement} (delete). A naive per-OID inverse is UNSOUND for a
|
|
1904
|
+
* structural op: OIDs are `file:component:tag:nthOccurrence` content signatures,
|
|
1905
|
+
* `reorder`/`delete` reassign occurrence indices out from under any OID captured
|
|
1906
|
+
* before the op, and `delete` has no inverse payload of its own (there is nothing
|
|
1907
|
+
* left to re-target). Instead the inverse is the WHOLE touched file's prior/next
|
|
1908
|
+
* text (`res.prevSource`/`res.newSource`, captured server-side by `handleStruct` —
|
|
1909
|
+
* `vite-plugin-ui-oid.ts`) replayed through a checksum-guarded restore
|
|
1910
|
+
* (`SourceWriteBackend.restoreSource`) — a plain overwrite the server refuses,
|
|
1911
|
+
* WITHOUT writing, if the file's current sha256 doesn't match the hash the caller
|
|
1912
|
+
* expects (an out-of-band change since, e.g. a hand-edit). Degrades to "not
|
|
1913
|
+
* undoable" (console.warn, no entry pushed) when the backend lacks `restoreSource`
|
|
1914
|
+
* or the write response is missing a snapshot field (an old/mocked backend) — an
|
|
1915
|
+
* honest limitation, never a crash.
|
|
1916
|
+
*/
|
|
1917
|
+
private pushStructUndo(
|
|
1918
|
+
op: string,
|
|
1919
|
+
res: {
|
|
1920
|
+
file?: string;
|
|
1921
|
+
prevSource?: string;
|
|
1922
|
+
newSource?: string;
|
|
1923
|
+
prevSha?: string;
|
|
1924
|
+
newSha?: string;
|
|
1925
|
+
},
|
|
1926
|
+
): void {
|
|
1927
|
+
const { file, prevSource, newSource, prevSha, newSha } = res;
|
|
1928
|
+
if (
|
|
1929
|
+
!this.writeBackend?.restoreSource ||
|
|
1930
|
+
file === undefined ||
|
|
1931
|
+
prevSource === undefined ||
|
|
1932
|
+
newSource === undefined ||
|
|
1933
|
+
prevSha === undefined ||
|
|
1934
|
+
newSha === undefined
|
|
1935
|
+
) {
|
|
1936
|
+
console.warn(
|
|
1937
|
+
`[ReactWorldAuthoringAdapter] struct "${op}" is not undoable: the write backend ` +
|
|
1938
|
+
'does not support whole-file restore (missing restoreSource or a snapshot field).',
|
|
1939
|
+
);
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
this.pushUndo({
|
|
1943
|
+
undo: () => void this.restoreStructSnapshot(file, prevSource, newSha),
|
|
1944
|
+
redo: () => void this.restoreStructSnapshot(file, newSource, prevSha),
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
/**
|
|
1949
|
+
* D-1 — the structural undo/redo inverse: POST the checksum-guarded whole-file
|
|
1950
|
+
* restore. A refusal (`res.restored === false` — the file's CURRENT sha256 didn't
|
|
1951
|
+
* match `ifMatchSha`, e.g. it changed out-of-band since this entry was recorded) is
|
|
1952
|
+
* a LOUD no-op: `restoreSource` never writes on refusal, and this still notifies the
|
|
1953
|
+
* store (same pattern as the D2 dynamic-write-refusal notify, `writeStyleEntry`/
|
|
1954
|
+
* `writePropEdit`) so the UI ticks and can render the refusal instead of silently
|
|
1955
|
+
* doing nothing.
|
|
1956
|
+
*
|
|
1957
|
+
* D3.R5 (reopen fix) — every `structOp`/`removeElement` undo/redo closure routes
|
|
1958
|
+
* here (see `pushStructUndo`), so a SUCCESSFUL restore is itself a structural DOM
|
|
1959
|
+
* change with the exact same pre-HMR-notify timing gap `structOp`/`removeElement`
|
|
1960
|
+
* have (see `pendingSourceReconcile`'s doc comment) — queue a pending reconcile so
|
|
1961
|
+
* hint-cache consumers reconcile once the restore's own remount actually lands. NOT
|
|
1962
|
+
* queued on a refused restore: `restoreSource` never writes when refused, so there is
|
|
1963
|
+
* no reload to await.
|
|
1964
|
+
*/
|
|
1965
|
+
private async restoreStructSnapshot(
|
|
1966
|
+
file: string,
|
|
1967
|
+
source: string,
|
|
1968
|
+
ifMatchSha: string,
|
|
1969
|
+
): Promise<void> {
|
|
1970
|
+
if (!this.writeBackend?.restoreSource) return;
|
|
1971
|
+
const res = await this.writeBackend.restoreSource(file, source, ifMatchSha);
|
|
1972
|
+
if (!res.restored) {
|
|
1973
|
+
console.warn(
|
|
1974
|
+
`[ReactWorldAuthoringAdapter] structural undo/redo refused for "${file}": ` +
|
|
1975
|
+
`${res.error ?? 'the file changed since this entry was recorded'}`,
|
|
1976
|
+
);
|
|
1977
|
+
} else {
|
|
1978
|
+
this.pendingSourceReconcile++;
|
|
1979
|
+
}
|
|
1980
|
+
this.store.notifyIngestEdit();
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
/**
|
|
1984
|
+
* T0 (spec 27 §4, B1) — the write body extracted from {@link writeStyle}, RETURNING
|
|
1985
|
+
* the resulting {@link PendingUndo} entry instead of pushing it, so a caller that
|
|
1986
|
+
* writes MULTIPLE props for one logical gesture (`boxEdit`'s {@link commitBoxEdit})
|
|
1987
|
+
* can compose them into a single undo entry itself. `writeStyle` (below) is now a
|
|
1988
|
+
* one-line wrapper: identical behavior for every existing single-prop caller
|
|
1989
|
+
* (`inspector.set`). `priorInlineOverride`, when given, is used as the captured
|
|
1990
|
+
* PRE-gesture inline value instead of re-reading `n.el.style` — needed because
|
|
1991
|
+
* `boxEdit.apply` already mutated the live inline style for live preview before
|
|
1992
|
+
* `end` gets here, so a fresh DOM read would see the LAST previewed value, not the
|
|
1993
|
+
* true original (see {@link boxEditSession}'s doc comment). Unused by the plain CSS
|
|
1994
|
+
* cascade branch below (`pickCssRuleTarget`'s own `prevValue` comes from the matched
|
|
1995
|
+
* rule's text, not inline style, and is unaffected either way).
|
|
1996
|
+
*/
|
|
1997
|
+
private async writeStyleEntry(
|
|
1998
|
+
id: string,
|
|
1999
|
+
prop: string,
|
|
2000
|
+
value: string | number,
|
|
2001
|
+
priorInlineOverride?: string,
|
|
2002
|
+
): Promise<PendingUndo | null> {
|
|
2003
|
+
// A4 — the echo key `inspector.set` populated for this write; cleared on any failure
|
|
2004
|
+
// return below so a refused/unbacked write can't leave the field stuck on a value the
|
|
2005
|
+
// source never took.
|
|
2006
|
+
const echoPath = `${STYLE_PATH_PREFIX}${prop}`;
|
|
2007
|
+
const n = this.snapshot().nodes.get(id);
|
|
2008
|
+
if (!n) {
|
|
2009
|
+
this.clearEcho(id, echoPath);
|
|
2010
|
+
return null;
|
|
2011
|
+
}
|
|
2012
|
+
if (!this.writeBackend) {
|
|
2013
|
+
this.clearEcho(id, echoPath);
|
|
2014
|
+
console.warn(
|
|
2015
|
+
`[ReactWorldAuthoringAdapter] cannot write "${prop}" on "${id}": no source-write ` +
|
|
2016
|
+
'backend in this session (hosted/no dev server) — selection/inspection still work.',
|
|
2017
|
+
);
|
|
2018
|
+
return null;
|
|
2019
|
+
}
|
|
2020
|
+
// Cap 2 (React visual-edit parity): if a first-party CSS RULE declares this property,
|
|
2021
|
+
// edit that CSS FILE (cascade-correct: the last matched rule wins) instead of writing
|
|
2022
|
+
// inline/class. A `generated: true` response means the selector isn't in source
|
|
2023
|
+
// (Tailwind/styled-components) — fall through to the inline/class path below.
|
|
2024
|
+
if (this.writeBackend.writeCss) {
|
|
2025
|
+
const cssTarget = pickCssRuleTarget(this.matchedCssRules(n.el), prop);
|
|
2026
|
+
if (cssTarget) {
|
|
2027
|
+
const { rule, prevValue } = cssTarget;
|
|
2028
|
+
const res = await this.writeBackend.writeCss(
|
|
2029
|
+
rule.sourceFile,
|
|
2030
|
+
rule.selectorText,
|
|
2031
|
+
prop,
|
|
2032
|
+
String(value),
|
|
2033
|
+
);
|
|
2034
|
+
if (res.changed) {
|
|
2035
|
+
this.dirty = true;
|
|
2036
|
+
this.store.notifyIngestEdit();
|
|
2037
|
+
return {
|
|
2038
|
+
undo: () => void this.applyCssWrite(rule, prop, prevValue),
|
|
2039
|
+
redo: () => void this.applyCssWrite(rule, prop, String(value)),
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
if (!res.generated) {
|
|
2043
|
+
this.clearEcho(id, echoPath);
|
|
2044
|
+
console.warn(
|
|
2045
|
+
`[ReactWorldAuthoringAdapter] CSS write refused/no-op for selector ` +
|
|
2046
|
+
`"${rule.selectorText}" prop "${prop}": ${res.error ?? 'no change'}`,
|
|
2047
|
+
);
|
|
2048
|
+
return null;
|
|
2049
|
+
}
|
|
2050
|
+
// res.generated ⇒ selector is generated CSS — fall through to inline/class routing.
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
// Capture the prior literal from the LIVE DOM before writing — the inverse
|
|
2054
|
+
// write for undo (§1.E: "store the prior literal, write it back on undo") —
|
|
2055
|
+
// unless the caller already captured the TRUE pre-gesture value itself.
|
|
2056
|
+
const prevRaw =
|
|
2057
|
+
priorInlineOverride !== undefined ? priorInlineOverride : styleProp(n.el.style, prop);
|
|
2058
|
+
const prev: string | number = typeof prevRaw === 'number' ? prevRaw : String(prevRaw ?? '');
|
|
2059
|
+
const res = await this.writeBackend.writeStyle(n.oid, prop, String(value));
|
|
2060
|
+
if (!res.changed) {
|
|
2061
|
+
this.clearEcho(id, echoPath);
|
|
2062
|
+
if (res.dynamic) this.markDynamic(id, echoPath);
|
|
2063
|
+
// D2 — notify so the refusal actually RE-RENDERS: the cleared echo (field
|
|
2064
|
+
// snaps back off the stale value) and, for a dynamic refusal, the now
|
|
2065
|
+
// `readonly: true` descriptor only reach the UI on a store tick. Without
|
|
2066
|
+
// this the widget keeps showing the refused value, enabled, until some
|
|
2067
|
+
// unrelated event happens to re-render.
|
|
2068
|
+
this.store.notifyIngestEdit();
|
|
2069
|
+
console.warn(
|
|
2070
|
+
`[ReactWorldAuthoringAdapter] style write refused/no-op for oid "${n.oid}" ` +
|
|
2071
|
+
`prop "${prop}": ${res.dynamic ? 'value is a dynamic expression (guarded)' : (res.error ?? 'no change')}`,
|
|
2072
|
+
);
|
|
2073
|
+
return null;
|
|
2074
|
+
}
|
|
2075
|
+
this.dirty = true;
|
|
2076
|
+
// D-A4 (wave 13, docs/WAVE13-JSX-SPREAD-UNDO-DESIGN.md): if this write took the
|
|
2077
|
+
// writer's APPEND branch (no prior literal for `prop`, e.g. spread-derived
|
|
2078
|
+
// `style={{ ...vars }}`), undo must REMOVE the appended prop rather than
|
|
2079
|
+
// replay the write with `prev` — replaying would find the NOW-appended
|
|
2080
|
+
// literal and REPLACE it with a hardcoded runtime value, baking a literal
|
|
2081
|
+
// into a spot the source never had one. Redo is a plain write either way (a
|
|
2082
|
+
// re-append is just a write).
|
|
2083
|
+
const appended = res.appended === true;
|
|
2084
|
+
// B1-parity live preview for single-prop inspector writes. The `boxEdit`
|
|
2085
|
+
// gesture patches `n.el.style` live during the drag (see `boxEdit.apply`),
|
|
2086
|
+
// but a plain single-prop write (a color / any inspector field) only wrote
|
|
2087
|
+
// SOURCE — so the live element didn't repaint until HMR/reload, which never
|
|
2088
|
+
// lands in-harness (27a-overlay.spec.ts:238: color edit's element stayed the
|
|
2089
|
+
// old color forever). Optimistically apply the committed value to the live
|
|
2090
|
+
// inline style here — mirroring `react-dom-authoring-adapter`'s own
|
|
2091
|
+
// `applyStyleToElement` — so the edit is visible immediately; HMR then
|
|
2092
|
+
// converges on the same value from source and the A4 echo re-syncs. Skipped
|
|
2093
|
+
// for the box-edit caller (`priorInlineOverride` set), which already applied
|
|
2094
|
+
// its own live preview and whose committed `value` can differ from the inline
|
|
2095
|
+
// CSS (e.g. a unitless length vs the `px` string it painted). INLINE branch
|
|
2096
|
+
// only — the CSS-cascade branch above returns early; patching inline there
|
|
2097
|
+
// would shadow the rule and stick past later edits.
|
|
2098
|
+
if (priorInlineOverride === undefined && n.el.style) {
|
|
2099
|
+
(n.el.style as Record<string, unknown>)[prop] = String(value);
|
|
2100
|
+
}
|
|
2101
|
+
this.store.notifyIngestEdit();
|
|
2102
|
+
return {
|
|
2103
|
+
undo: appended
|
|
2104
|
+
? () => void this.applyStyleRemove(n.oid, prop)
|
|
2105
|
+
: () => void this.applyStyleWrite(n.oid, prop, prev),
|
|
2106
|
+
redo: () => void this.applyStyleWrite(n.oid, prop, value),
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
private async writeStyle(id: string, prop: string, value: string | number): Promise<void> {
|
|
2111
|
+
const entry = await this.writeStyleEntry(id, prop, value);
|
|
2112
|
+
if (entry) this.pushUndo(entry);
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
/**
|
|
2116
|
+
* U2 (spec 27 §5 C2) — surgically REMOVE a style property's source override
|
|
2117
|
+
* (`inspector.remove`'s style path). Captures the prior source literal first
|
|
2118
|
+
* so the removal is undoable (undo re-writes it, redo re-removes); a source
|
|
2119
|
+
* no-op (`changed: false`, e.g. the prop wasn't authored) pushes NO undo
|
|
2120
|
+
* entry and touches nothing — which is what makes it safe to call for every
|
|
2121
|
+
* corner/side unconditionally on a uniform edit. Also drops the live inline
|
|
2122
|
+
* override so the element re-cascades to the shorthand immediately (mirrors
|
|
2123
|
+
* `writeStyleEntry`'s inline live-preview, inverse direction).
|
|
2124
|
+
*/
|
|
2125
|
+
private async removeStyleProp(id: string, prop: string): Promise<void> {
|
|
2126
|
+
const n = this.snapshot().nodes.get(id);
|
|
2127
|
+
if (!n || !this.writeBackend) return;
|
|
2128
|
+
const prevRaw = styleProp(n.el.style, prop);
|
|
2129
|
+
const prev: string | number = typeof prevRaw === 'number' ? prevRaw : String(prevRaw ?? '');
|
|
2130
|
+
const res = await this.writeBackend.removeStyle(n.oid, prop);
|
|
2131
|
+
if (!res.changed) return; // longhand wasn't authored — nothing removed, no undo
|
|
2132
|
+
this.dirty = true;
|
|
2133
|
+
if (n.el.style) delete (n.el.style as Record<string, unknown>)[prop];
|
|
2134
|
+
this.store.notifyIngestEdit();
|
|
2135
|
+
this.pushUndo({
|
|
2136
|
+
undo: () => void this.applyStyleWrite(n.oid, prop, prev),
|
|
2137
|
+
redo: () => void this.applyStyleRemove(n.oid, prop),
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
private async applyStyleWrite(oid: string, prop: string, value: string | number): Promise<void> {
|
|
2142
|
+
if (!this.writeBackend) return;
|
|
2143
|
+
await this.writeBackend.writeStyle(oid, prop, String(value));
|
|
2144
|
+
this.store.notifyIngestEdit();
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
/** D-A4: the append-aware undo target — removes a prop that had no prior source
|
|
2148
|
+
* literal, instead of writing a captured runtime value back into it. */
|
|
2149
|
+
private async applyStyleRemove(oid: string, prop: string): Promise<void> {
|
|
2150
|
+
if (!this.writeBackend) return;
|
|
2151
|
+
await this.writeBackend.removeStyle(oid, prop);
|
|
2152
|
+
this.store.notifyIngestEdit();
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
/** Re-apply a CSS-file write (used by the undo/redo inverse for a Cap 2 CSS edit). */
|
|
2156
|
+
private async applyCssWrite(rule: CssRuleTarget, prop: string, value: string): Promise<void> {
|
|
2157
|
+
if (!this.writeBackend?.writeCss) return;
|
|
2158
|
+
await this.writeBackend.writeCss(rule.sourceFile, rule.selectorText, prop, value);
|
|
2159
|
+
this.store.notifyIngestEdit();
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
/**
|
|
2163
|
+
* Cap 3 (React visual-edit parity): replace a leaf element's pure-text content in source
|
|
2164
|
+
* (double-click-to-edit). Refused (guarded) when the body has an expression or child
|
|
2165
|
+
* elements. Undoable: the prior text (returned by the backend) is written back on undo.
|
|
2166
|
+
*/
|
|
2167
|
+
async editText(id: string, newText: string): Promise<void> {
|
|
2168
|
+
const n = this.snapshot().nodes.get(id);
|
|
2169
|
+
if (!n) return;
|
|
2170
|
+
if (!this.writeBackend?.writeText) {
|
|
2171
|
+
console.warn(
|
|
2172
|
+
`[ReactWorldAuthoringAdapter] cannot edit text on "${id}": no source-write backend ` +
|
|
2173
|
+
'with text support in this session (hosted/no dev server).',
|
|
2174
|
+
);
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
const res = await this.writeBackend.writeText(n.oid, newText);
|
|
2178
|
+
if (!res.changed) {
|
|
2179
|
+
// D3.R1 (reopen fix) — a dynamic-body refusal marks this id readonly for
|
|
2180
|
+
// text edits (U4's session-scoped `dynamicPaths`, read by `text.get`
|
|
2181
|
+
// above) and — mirroring D2's style/prop precedent — notifies the store
|
|
2182
|
+
// UNCONDITIONALLY so the refusal (and the overlay's refused indicator,
|
|
2183
|
+
// which polls `text.get` off this same notify) renders at refusal time
|
|
2184
|
+
// instead of silently vanishing until an unrelated event ticks the store.
|
|
2185
|
+
if (res.dynamic) this.markDynamic(id, TEXT_PATH);
|
|
2186
|
+
this.store.notifyIngestEdit();
|
|
2187
|
+
console.warn(
|
|
2188
|
+
`[ReactWorldAuthoringAdapter] text edit refused/no-op for oid "${n.oid}": ` +
|
|
2189
|
+
`${res.dynamic ? 'body has an expression/children (guarded)' : (res.error ?? 'no change')}`,
|
|
2190
|
+
);
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
this.dirty = true;
|
|
2194
|
+
const prev = res.prevText ?? '';
|
|
2195
|
+
this.pushUndo({
|
|
2196
|
+
undo: () => void this.applyTextWrite(n.oid, prev),
|
|
2197
|
+
redo: () => void this.applyTextWrite(n.oid, newText),
|
|
2198
|
+
});
|
|
2199
|
+
// D3.R5 (reopen fix) — a successful text write flips `hasText`, itself a
|
|
2200
|
+
// `findEmptyContainers` hint-eligibility criterion (`ui-source/inspect.ts`), yet
|
|
2201
|
+
// populates no `valueEcho` (echo is only ever set by the inspector style/prop
|
|
2202
|
+
// paths) — same pre-HMR-notify timing gap as a structural op (see
|
|
2203
|
+
// `pendingSourceReconcile`'s doc comment): queue a pending reconcile.
|
|
2204
|
+
this.pendingSourceReconcile++;
|
|
2205
|
+
this.store.notifyIngestEdit();
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
private async applyTextWrite(oid: string, text: string): Promise<void> {
|
|
2209
|
+
if (!this.writeBackend?.writeText) return;
|
|
2210
|
+
const res = await this.writeBackend.writeText(oid, text);
|
|
2211
|
+
// D3.R5 (reopen fix) — the text-undo/redo inverse is itself a text write with the
|
|
2212
|
+
// same `hasText` reconcile need as `editText`'s success path above; queued on
|
|
2213
|
+
// success only — a refused/no-op write never touches the counter.
|
|
2214
|
+
if (res.changed) this.pendingSourceReconcile++;
|
|
2215
|
+
this.store.notifyIngestEdit();
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
/**
|
|
2219
|
+
* Cap 4 (React visual-edit parity): write a component prop at its CALL SITE (the
|
|
2220
|
+
* `<Component …>` tag), resolved from the live fiber (`getComponentProps` → callSiteOid).
|
|
2221
|
+
* Refused (guarded) when the prop is a dynamic expression. Undoable via the prior fiber
|
|
2222
|
+
* value.
|
|
2223
|
+
*/
|
|
2224
|
+
private async writePropEdit(id: string, prop: string, value: string): Promise<void> {
|
|
2225
|
+
// A4 — the echo key `inspector.set` populated for this prop write; cleared on any
|
|
2226
|
+
// failure return so a refused write can't leave the field stuck.
|
|
2227
|
+
const echoPath = `${PROP_PATH_PREFIX}${prop}`;
|
|
2228
|
+
const n = this.snapshot().nodes.get(id);
|
|
2229
|
+
if (!n) {
|
|
2230
|
+
this.clearEcho(id, echoPath);
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
if (!this.writeBackend?.writeProp) {
|
|
2234
|
+
this.clearEcho(id, echoPath);
|
|
2235
|
+
console.warn(
|
|
2236
|
+
`[ReactWorldAuthoringAdapter] cannot write prop "${prop}" on "${id}": no source-write ` +
|
|
2237
|
+
'backend with prop support in this session (hosted/no dev server).',
|
|
2238
|
+
);
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
const cp = getComponentProps(n.el);
|
|
2242
|
+
if (!cp) {
|
|
2243
|
+
this.clearEcho(id, echoPath);
|
|
2244
|
+
console.warn(
|
|
2245
|
+
`[ReactWorldAuthoringAdapter] "${id}" is not a component call site with props — ` +
|
|
2246
|
+
`cannot write prop "${prop}".`,
|
|
2247
|
+
);
|
|
2248
|
+
return;
|
|
2249
|
+
}
|
|
2250
|
+
const prev = cp.props[prop] ?? '';
|
|
2251
|
+
const res = await this.writeBackend.writeProp(cp.callSiteOid, prop, value);
|
|
2252
|
+
if (!res.changed) {
|
|
2253
|
+
this.clearEcho(id, echoPath);
|
|
2254
|
+
if (res.dynamic) this.markDynamic(id, echoPath);
|
|
2255
|
+
// D2 — notify so the cleared echo + (dynamic) new `readonly: true`
|
|
2256
|
+
// descriptor render at refusal time, not on the next unrelated event.
|
|
2257
|
+
this.store.notifyIngestEdit();
|
|
2258
|
+
console.warn(
|
|
2259
|
+
`[ReactWorldAuthoringAdapter] prop write refused/no-op for oid "${cp.callSiteOid}" ` +
|
|
2260
|
+
`prop "${prop}": ${res.dynamic ? 'value is a dynamic expression (guarded)' : (res.error ?? 'no change')}`,
|
|
2261
|
+
);
|
|
2262
|
+
return;
|
|
2263
|
+
}
|
|
2264
|
+
this.dirty = true;
|
|
2265
|
+
this.pushUndo({
|
|
2266
|
+
undo: () => void this.applyPropWrite(cp.callSiteOid, prop, prev),
|
|
2267
|
+
redo: () => void this.applyPropWrite(cp.callSiteOid, prop, value),
|
|
2268
|
+
});
|
|
2269
|
+
this.store.notifyIngestEdit();
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
private async applyPropWrite(callSiteOid: string, prop: string, value: string): Promise<void> {
|
|
2273
|
+
if (!this.writeBackend?.writeProp) return;
|
|
2274
|
+
await this.writeBackend.writeProp(callSiteOid, prop, value);
|
|
2275
|
+
this.store.notifyIngestEdit();
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
private async removeElement(id: string): Promise<void> {
|
|
2279
|
+
const n = this.snapshot().nodes.get(id);
|
|
2280
|
+
if (!n) return;
|
|
2281
|
+
if (!this.writeBackend) {
|
|
2282
|
+
console.warn(
|
|
2283
|
+
`[ReactWorldAuthoringAdapter] cannot delete "${id}": no source-write backend in ` +
|
|
2284
|
+
'this session (hosted/no dev server).',
|
|
2285
|
+
);
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
const res = await this.writeBackend.writeStruct(n.oid, 'delete');
|
|
2289
|
+
if (!res.changed) {
|
|
2290
|
+
console.warn(
|
|
2291
|
+
`[ReactWorldAuthoringAdapter] delete refused/no-op for oid "${n.oid}": ${res.error ?? 'no change'}`,
|
|
2292
|
+
);
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
this.dirty = true;
|
|
2296
|
+
// D-1 (Phase D, spec27 §2): delete IS undoable now, via the SAME checksum-guarded
|
|
2297
|
+
// whole-file-snapshot inverse `structOp` uses (`pushStructUndo`) — it sidesteps
|
|
2298
|
+
// "no element-insertion primitive" entirely by never re-inserting the deleted
|
|
2299
|
+
// element from an OID; it replays the whole file's PRIOR text instead.
|
|
2300
|
+
this.pushStructUndo('delete', res);
|
|
2301
|
+
// D3.R4 (reopen fix) — same pre-HMR-notify timing gap `structOp` has (see
|
|
2302
|
+
// `pendingSourceReconcile`'s doc comment); `delete` is one of the two triggering
|
|
2303
|
+
// scenarios the reviewer's contrast probe reproduced.
|
|
2304
|
+
this.pendingSourceReconcile++;
|
|
2305
|
+
this.store.notifyIngestEdit();
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
/**
|
|
2309
|
+
* delete-order-residual fix — the `structure.removeMany` backing. Resolves every
|
|
2310
|
+
* `id` to its raw (non-disambiguated) oid, dedupes (a repeated-OID `.map` list's
|
|
2311
|
+
* `#n` siblings share ONE raw oid — deleting it once is correct, per
|
|
2312
|
+
* `walkOidTree`'s own disambiguation comment), and posts them ALL in ONE
|
|
2313
|
+
* `writeStructMany` call — see that method's doc comment on `SourceWriteBackend`
|
|
2314
|
+
* (`source-write-backend.ts`) and `handleStructMany`'s (`vite-plugin-ui-oid.ts`)
|
|
2315
|
+
* for the soundness argument (one shared file snapshot, highest-offset-first,
|
|
2316
|
+
* caller-order-independent). Degrades to a loud no-op — never a silent partial
|
|
2317
|
+
* delete — when this session's backend hasn't implemented `writeStructMany` (a
|
|
2318
|
+
* hosted/no-dev-server session, or a legacy test mock): `deleteSelection`
|
|
2319
|
+
* (`editor-hotkeys.ts`) only reaches this method because `structure.removeMany`
|
|
2320
|
+
* is present at all, so there is no further per-id fallback to drop into here.
|
|
2321
|
+
*/
|
|
2322
|
+
private async removeManyElements(ids: readonly string[]): Promise<void> {
|
|
2323
|
+
const snapshot = this.snapshot();
|
|
2324
|
+
const oids = [
|
|
2325
|
+
...new Set(
|
|
2326
|
+
ids.map((id) => snapshot.nodes.get(id)?.oid).filter((oid): oid is string => !!oid),
|
|
2327
|
+
),
|
|
2328
|
+
];
|
|
2329
|
+
if (oids.length === 0) return;
|
|
2330
|
+
if (!this.writeBackend?.writeStructMany) {
|
|
2331
|
+
console.warn(
|
|
2332
|
+
'[ReactWorldAuthoringAdapter] cannot batch-delete: no source-write backend in this ' +
|
|
2333
|
+
'session supports writeStructMany (hosted/no dev server, or a legacy mock).',
|
|
2334
|
+
);
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
const res = await this.writeBackend.writeStructMany(oids, 'delete');
|
|
2338
|
+
if (!res.changed) {
|
|
2339
|
+
console.warn(
|
|
2340
|
+
`[ReactWorldAuthoringAdapter] batch delete refused/no-op for oids [${oids.join(', ')}]: ` +
|
|
2341
|
+
`${res.error ?? 'no change'}`,
|
|
2342
|
+
);
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
this.dirty = true;
|
|
2346
|
+
// D-1 (Phase D, spec27 §2): same checksum-guarded whole-file-snapshot inverse
|
|
2347
|
+
// `removeElement`/`structOp` use (`pushStructUndo`) — ONE entry for the whole
|
|
2348
|
+
// batch (a bonus of batching, not the point of this fix).
|
|
2349
|
+
this.pushStructUndo('delete-many', res);
|
|
2350
|
+
this.pendingSourceReconcile++;
|
|
2351
|
+
this.store.notifyIngestEdit();
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
private pushUndo(entry: PendingUndo): void {
|
|
2355
|
+
this.undoStack.push(entry);
|
|
2356
|
+
this.redoStack.length = 0;
|
|
2357
|
+
notifyUndoableOp(this); // T3.2 slice 2 — tag this op on the global timeline
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
undo(): boolean {
|
|
2361
|
+
const e = this.undoStack.pop();
|
|
2362
|
+
if (!e) return false;
|
|
2363
|
+
e.undo();
|
|
2364
|
+
this.redoStack.push(e);
|
|
2365
|
+
return true;
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
redo(): boolean {
|
|
2369
|
+
const e = this.redoStack.pop();
|
|
2370
|
+
if (!e) return false;
|
|
2371
|
+
e.redo();
|
|
2372
|
+
this.undoStack.push(e);
|
|
2373
|
+
return true;
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
subscribe(listener: () => void): () => void {
|
|
2377
|
+
return this.store.subscribe(listener);
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
// A class GETTER, not a field initializer — see `ui-authoring-adapter.ts`/
|
|
2381
|
+
// `vgai-scene-authoring-adapter.ts` for why (field initializers run before the
|
|
2382
|
+
// constructor body assigns `this.writeBackend`).
|
|
2383
|
+
get persistence(): PersistenceProvider {
|
|
2384
|
+
const backend = this.writeBackend;
|
|
2385
|
+
return {
|
|
2386
|
+
isDirty: () => this.dirty,
|
|
2387
|
+
save: async () => {
|
|
2388
|
+
// Immediate-write architecture (same as UIAuthoringAdapter/
|
|
2389
|
+
// SourceWriteBackend's doc comment) — every edit already landed on disk
|
|
2390
|
+
// the instant it was made; nothing is pending to flush.
|
|
2391
|
+
},
|
|
2392
|
+
serialize: () => ({
|
|
2393
|
+
note: 'react world edits write directly to JSX source — nothing is serialized here',
|
|
2394
|
+
}),
|
|
2395
|
+
destination: backend
|
|
2396
|
+
? 'component source (JSX, via /__ui-source — writes are immediate; nothing pending to flush)'
|
|
2397
|
+
: 'component source (JSX) — no source-write backend in this session (hosted/no dev server)',
|
|
2398
|
+
};
|
|
2399
|
+
}
|
|
2400
|
+
}
|