@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,1438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* world-overlay-gestures — spec 27 §4 B2/B3 pure gesture math
|
|
3
|
+
* (docs/unified-world-editor/27-visual-react-editing.md), factored out of
|
|
4
|
+
* `WorldSelectionOverlay.tsx` so the handle/spacing-band geometry and the
|
|
5
|
+
* per-gesture patch math are directly unit-testable without mounting React
|
|
6
|
+
* (same "exported pure fn" convention `WorldSelectionOverlay.tsx` already
|
|
7
|
+
* uses for `resolveClickSelection`/`resolveEmptySpaceSelection`).
|
|
8
|
+
*
|
|
9
|
+
* DRIFT FLAG (per this task's executor brief): the spec cites an external
|
|
10
|
+
* `visual-edit/overlay.tsx` (`RESIZE_HANDLES`, rotate cursors, `spacingZone`)
|
|
11
|
+
* that does not exist in this repo. The handle table and the padding/margin
|
|
12
|
+
* band geometry below are derived FRESH from the spec text (§4 B2/B3, §5's
|
|
13
|
+
* "lose nothing" rows :459-467) plus the in-repo SE-handle precedent
|
|
14
|
+
* `ui-editor/overlay.tsx` (`GRID = 8` snap, `Handle` ~10×10 white/accent box,
|
|
15
|
+
* `startResize`, the dashed gap-measure label pattern) — noted again in this
|
|
16
|
+
* repo's PR message per the brief's instruction.
|
|
17
|
+
*
|
|
18
|
+
* Rule zero (spec §0): pure geometry/math only — no `UIEditStore`/
|
|
19
|
+
* `SceneEntity`/`THREE.`/`elementFromPoint`. The two adapter-routing helpers
|
|
20
|
+
* (`boxEditForId`/`structureCapableForId`) talk ONLY to the `AuthoringAdapter`
|
|
21
|
+
* contract, cloning `WorldSelectionOverlay.tsx`'s own `rectForId` composite→
|
|
22
|
+
* owner-child routing pattern (`rects`/`boxEdit`/`structure` are deliberately
|
|
23
|
+
* NOT merged onto `CompositeAuthoringAdapter` — only `inspector`/`hierarchy`
|
|
24
|
+
* are).
|
|
25
|
+
*/
|
|
26
|
+
import type {
|
|
27
|
+
AuthoringAdapter,
|
|
28
|
+
BoxEditProvider,
|
|
29
|
+
ColorSampleProvider,
|
|
30
|
+
DOMRectLike,
|
|
31
|
+
StructureProvider,
|
|
32
|
+
TextProvider,
|
|
33
|
+
} from '@engine/adapter';
|
|
34
|
+
import { CompositeAuthoringAdapter } from '../authoring/composite-authoring-adapter';
|
|
35
|
+
import { numericStyleValue } from '../authoring/react-world-authoring-adapter';
|
|
36
|
+
import { isWorldHidden } from '../authoring/world-session-state';
|
|
37
|
+
import { computeSnapTargets } from '../ui-source/inspect';
|
|
38
|
+
|
|
39
|
+
/** 8px snap grid — same constant `ui-editor/overlay.tsx`'s `GRID` uses (K3). */
|
|
40
|
+
export const GRID = 8;
|
|
41
|
+
|
|
42
|
+
/** Minimum resized dimension (px) — mirrors `ui-editor/overlay.tsx`'s own
|
|
43
|
+
* `Math.max(8, snap(...))` resize clamp. */
|
|
44
|
+
export const MIN_SIZE = 8;
|
|
45
|
+
|
|
46
|
+
export type HandlePos = 'nw' | 'n' | 'ne' | 'w' | 'e' | 'sw' | 's' | 'se';
|
|
47
|
+
|
|
48
|
+
/** The 8 resize handles (spec:317, §5 :465 "8 resize handles"), each with the
|
|
49
|
+
* cursor a figma-grade editor uses for that axis: corner handles get the
|
|
50
|
+
* diagonal (nwse/nesw) cursor, edge handles the straight (ns/ew) cursor. */
|
|
51
|
+
export const RESIZE_HANDLES: ReadonlyArray<{ pos: HandlePos; cursor: string }> = [
|
|
52
|
+
{ pos: 'nw', cursor: 'nwse-resize' },
|
|
53
|
+
{ pos: 'n', cursor: 'ns-resize' },
|
|
54
|
+
{ pos: 'ne', cursor: 'nesw-resize' },
|
|
55
|
+
{ pos: 'w', cursor: 'ew-resize' },
|
|
56
|
+
{ pos: 'e', cursor: 'ew-resize' },
|
|
57
|
+
{ pos: 'sw', cursor: 'nesw-resize' },
|
|
58
|
+
{ pos: 's', cursor: 'ns-resize' },
|
|
59
|
+
{ pos: 'se', cursor: 'nwse-resize' },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
export interface RectLike {
|
|
63
|
+
x: number;
|
|
64
|
+
y: number;
|
|
65
|
+
width: number;
|
|
66
|
+
height: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Where a handle sits on `rect` — the handle's CENTER point (the caller
|
|
70
|
+
* offsets by half the handle's rendered size to place its box). */
|
|
71
|
+
export function handlePosition(rect: RectLike, pos: HandlePos): { x: number; y: number } {
|
|
72
|
+
const midX = rect.x + rect.width / 2;
|
|
73
|
+
const midY = rect.y + rect.height / 2;
|
|
74
|
+
switch (pos) {
|
|
75
|
+
case 'nw':
|
|
76
|
+
return { x: rect.x, y: rect.y };
|
|
77
|
+
case 'n':
|
|
78
|
+
return { x: midX, y: rect.y };
|
|
79
|
+
case 'ne':
|
|
80
|
+
return { x: rect.x + rect.width, y: rect.y };
|
|
81
|
+
case 'w':
|
|
82
|
+
return { x: rect.x, y: midY };
|
|
83
|
+
case 'e':
|
|
84
|
+
return { x: rect.x + rect.width, y: midY };
|
|
85
|
+
case 'sw':
|
|
86
|
+
return { x: rect.x, y: rect.y + rect.height };
|
|
87
|
+
case 's':
|
|
88
|
+
return { x: midX, y: rect.y + rect.height };
|
|
89
|
+
case 'se':
|
|
90
|
+
return { x: rect.x + rect.width, y: rect.y + rect.height };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `v => e.altKey ? Math.round(v) : Math.round(v/GRID)*GRID` (spec:319 "8px
|
|
95
|
+
* grid snap, Alt = free") — shared by resize/move gesture math below. */
|
|
96
|
+
export function snapValue(v: number, free: boolean): number {
|
|
97
|
+
return free ? Math.round(v) : Math.round(v / GRID) * GRID;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Per-handle resize patch math (spec:321-322): east-side handles grow width
|
|
102
|
+
* by `+dx`; west-side handles shrink width by `dx` AND (only for an
|
|
103
|
+
* absolutely/fixed-positioned node) move `x` by `dx` too (there is no `left`
|
|
104
|
+
* to adjust on a static/relative node — non-absolute nodes resize SIZE only,
|
|
105
|
+
* spec:322). South/north are the `height`/`y` analogue on the vertical axis.
|
|
106
|
+
* A corner handle (e.g. `se`) touches both axes at once. Width/height are
|
|
107
|
+
* clamped to {@link MIN_SIZE}.
|
|
108
|
+
*
|
|
109
|
+
* D1 (spec §6) — `context`, when supplied and `free` is false, additionally
|
|
110
|
+
* edge-snaps the DRAGGED edge (only — the anchored opposite edge never moves,
|
|
111
|
+
* unlike the move gesture where either edge may align) to the owner's own
|
|
112
|
+
* `contextRects(id)` padding-box/sibling edges, within `threshold`, taking
|
|
113
|
+
* priority over the plain 8px grid snap already applied above — same
|
|
114
|
+
* "edge-align wins over grid" precedence {@link computeMovePatch} uses. The
|
|
115
|
+
* VISUAL guide this same decision drives is the separate, pure
|
|
116
|
+
* {@link computeResizeSnapGuides} (this function's own return shape — a
|
|
117
|
+
* style-prop patch — stays exactly what the 8 pre-D1 tests above expect).
|
|
118
|
+
*/
|
|
119
|
+
/** EAST-handle width math (grid-snap, then optional edge-snap of the moving
|
|
120
|
+
* RIGHT edge) — factored out of {@link computeResizePatch} purely to keep
|
|
121
|
+
* ITS cognitive complexity down; behavior unchanged from the pre-D1 inline
|
|
122
|
+
* version. Also returns the matched snap candidate (`null` if none), so
|
|
123
|
+
* {@link computeResizeSnapGuides} can reuse this SAME computation for its
|
|
124
|
+
* guide geometry instead of re-deriving it (no drift between the patch the
|
|
125
|
+
* gesture commits and the guide it shows while committing it). */
|
|
126
|
+
function resizeEastWidth(
|
|
127
|
+
dx: number,
|
|
128
|
+
orig: RectLike,
|
|
129
|
+
free: boolean,
|
|
130
|
+
xCandidates: readonly SnapEdgeCandidate[],
|
|
131
|
+
threshold: number,
|
|
132
|
+
): { value: number; match: SnapEdgeCandidate | null } {
|
|
133
|
+
let width = Math.max(MIN_SIZE, snapValue(orig.width + dx, free));
|
|
134
|
+
let match: SnapEdgeCandidate | null = null;
|
|
135
|
+
if (xCandidates.length) {
|
|
136
|
+
match = edgeSnapPoint(orig.x + width, xCandidates, threshold);
|
|
137
|
+
if (match) width = Math.max(MIN_SIZE, match.edge - orig.x);
|
|
138
|
+
}
|
|
139
|
+
return { value: width, match };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** WEST-handle width math: the WEST handle drags the left edge while the
|
|
143
|
+
* EAST edge stays anchored. Snap+clamp the width first, then derive x FROM
|
|
144
|
+
* the clamped width so the east edge is pinned: `x = eastEdge - width`.
|
|
145
|
+
* Deriving x independently (`orig.x + dx`) breaks under the MIN clamp —
|
|
146
|
+
* D3: over-dragging past the east edge pinned width at MIN but let x keep
|
|
147
|
+
* following the cursor, so the anchored east edge teleported and the
|
|
148
|
+
* resize became a move. This formula also preserves the
|
|
149
|
+
* `x + width == eastEdge` invariant for a non-multiple-of-8 drag, which
|
|
150
|
+
* the old independent per-axis snap could violate. Factored out of
|
|
151
|
+
* {@link computeResizePatch} for the same complexity/reuse reasons as
|
|
152
|
+
* {@link resizeEastWidth}. */
|
|
153
|
+
function resizeWestWidth(
|
|
154
|
+
dx: number,
|
|
155
|
+
orig: RectLike,
|
|
156
|
+
free: boolean,
|
|
157
|
+
xCandidates: readonly SnapEdgeCandidate[],
|
|
158
|
+
threshold: number,
|
|
159
|
+
): { value: number; eastEdge: number; match: SnapEdgeCandidate | null } {
|
|
160
|
+
const eastEdge = orig.x + orig.width;
|
|
161
|
+
let width = Math.max(MIN_SIZE, snapValue(orig.width - dx, free));
|
|
162
|
+
let match: SnapEdgeCandidate | null = null;
|
|
163
|
+
if (xCandidates.length) {
|
|
164
|
+
match = edgeSnapPoint(eastEdge - width, xCandidates, threshold);
|
|
165
|
+
if (match) width = Math.max(MIN_SIZE, eastEdge - match.edge);
|
|
166
|
+
}
|
|
167
|
+
return { value: width, eastEdge, match };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** SOUTH-handle height math — the vertical analogue of
|
|
171
|
+
* {@link resizeEastWidth}. */
|
|
172
|
+
function resizeSouthHeight(
|
|
173
|
+
dy: number,
|
|
174
|
+
orig: RectLike,
|
|
175
|
+
free: boolean,
|
|
176
|
+
yCandidates: readonly SnapEdgeCandidate[],
|
|
177
|
+
threshold: number,
|
|
178
|
+
): { value: number; match: SnapEdgeCandidate | null } {
|
|
179
|
+
let height = Math.max(MIN_SIZE, snapValue(orig.height + dy, free));
|
|
180
|
+
let match: SnapEdgeCandidate | null = null;
|
|
181
|
+
if (yCandidates.length) {
|
|
182
|
+
match = edgeSnapPoint(orig.y + height, yCandidates, threshold);
|
|
183
|
+
if (match) height = Math.max(MIN_SIZE, match.edge - orig.y);
|
|
184
|
+
}
|
|
185
|
+
return { value: height, match };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** NORTH-handle height math — the vertical analogue of
|
|
189
|
+
* {@link resizeWestWidth} (the SOUTH edge is the anchor). */
|
|
190
|
+
function resizeNorthHeight(
|
|
191
|
+
dy: number,
|
|
192
|
+
orig: RectLike,
|
|
193
|
+
free: boolean,
|
|
194
|
+
yCandidates: readonly SnapEdgeCandidate[],
|
|
195
|
+
threshold: number,
|
|
196
|
+
): { value: number; southEdge: number; match: SnapEdgeCandidate | null } {
|
|
197
|
+
const southEdge = orig.y + orig.height;
|
|
198
|
+
let height = Math.max(MIN_SIZE, snapValue(orig.height - dy, free));
|
|
199
|
+
let match: SnapEdgeCandidate | null = null;
|
|
200
|
+
if (yCandidates.length) {
|
|
201
|
+
match = edgeSnapPoint(southEdge - height, yCandidates, threshold);
|
|
202
|
+
if (match) height = Math.max(MIN_SIZE, southEdge - match.edge);
|
|
203
|
+
}
|
|
204
|
+
return { value: height, southEdge, match };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The x-axis half of {@link computeResizePatch} — `width` (+`x` if
|
|
208
|
+
* `isPositioned` and the WEST handle moved) for whichever of `pos`'s x-side
|
|
209
|
+
* (east/west/neither) applies. Factored out purely to keep
|
|
210
|
+
* `computeResizePatch` itself under the complexity ceiling. */
|
|
211
|
+
function resizeXPatch(
|
|
212
|
+
pos: HandlePos,
|
|
213
|
+
dx: number,
|
|
214
|
+
orig: RectLike,
|
|
215
|
+
isPositioned: boolean,
|
|
216
|
+
free: boolean,
|
|
217
|
+
xCandidates: readonly SnapEdgeCandidate[],
|
|
218
|
+
threshold: number,
|
|
219
|
+
): Record<string, number> {
|
|
220
|
+
if (pos === 'e' || pos === 'ne' || pos === 'se') {
|
|
221
|
+
return { width: resizeEastWidth(dx, orig, free, xCandidates, threshold).value };
|
|
222
|
+
}
|
|
223
|
+
if (pos === 'w' || pos === 'nw' || pos === 'sw') {
|
|
224
|
+
const { value: width, eastEdge } = resizeWestWidth(dx, orig, free, xCandidates, threshold);
|
|
225
|
+
return isPositioned ? { width, x: eastEdge - width } : { width };
|
|
226
|
+
}
|
|
227
|
+
return {};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The y-axis analogue of {@link resizeXPatch} (south/north). */
|
|
231
|
+
function resizeYPatch(
|
|
232
|
+
pos: HandlePos,
|
|
233
|
+
dy: number,
|
|
234
|
+
orig: RectLike,
|
|
235
|
+
isPositioned: boolean,
|
|
236
|
+
free: boolean,
|
|
237
|
+
yCandidates: readonly SnapEdgeCandidate[],
|
|
238
|
+
threshold: number,
|
|
239
|
+
): Record<string, number> {
|
|
240
|
+
if (pos === 's' || pos === 'sw' || pos === 'se') {
|
|
241
|
+
return { height: resizeSouthHeight(dy, orig, free, yCandidates, threshold).value };
|
|
242
|
+
}
|
|
243
|
+
if (pos === 'n' || pos === 'nw' || pos === 'ne') {
|
|
244
|
+
const { value: height, southEdge } = resizeNorthHeight(dy, orig, free, yCandidates, threshold);
|
|
245
|
+
return isPositioned ? { height, y: southEdge - height } : { height };
|
|
246
|
+
}
|
|
247
|
+
return {};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function computeResizePatch(
|
|
251
|
+
pos: HandlePos,
|
|
252
|
+
dx: number,
|
|
253
|
+
dy: number,
|
|
254
|
+
orig: RectLike,
|
|
255
|
+
isPositioned: boolean,
|
|
256
|
+
free: boolean,
|
|
257
|
+
context?: MoveSnapContext,
|
|
258
|
+
threshold = EDGE_SNAP_THRESHOLD_PX,
|
|
259
|
+
): Record<string, number> {
|
|
260
|
+
const targets = !free && context ? snapTargetsForContext(context) : null;
|
|
261
|
+
const xCandidates = targets ? xEdgeCandidates(targets) : [];
|
|
262
|
+
const yCandidates = targets ? yEdgeCandidates(targets) : [];
|
|
263
|
+
return {
|
|
264
|
+
...resizeXPatch(pos, dx, orig, isPositioned, free, xCandidates, threshold),
|
|
265
|
+
...resizeYPatch(pos, dy, orig, isPositioned, free, yCandidates, threshold),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Snap-target context for a move gesture — the owner's own
|
|
270
|
+
* `rects.contextRects(id)` shape (padding box + sibling rects), reduced to
|
|
271
|
+
* just the two fields the edge-snap below consumes. Reused, unchanged, by
|
|
272
|
+
* the D1 resize-snap path below (same shape, same source — a single
|
|
273
|
+
* `contextRectsForId` call at gesture start covers both). */
|
|
274
|
+
export interface MoveSnapContext {
|
|
275
|
+
paddingBox?: DOMRectLike;
|
|
276
|
+
siblings?: readonly DOMRectLike[];
|
|
277
|
+
/** D1 (spec §6) — the immediate parent's own rect, unused by the
|
|
278
|
+
* edge-snap math above but carried through so a single
|
|
279
|
+
* `contextRectsForId(adapter, id)` call also feeds the D1.c
|
|
280
|
+
* parent-container outline (`WorldSelectionOverlay.tsx`), without a
|
|
281
|
+
* narrower return type silently dropping the field the underlying
|
|
282
|
+
* `RectProvider.contextRects` already returns. */
|
|
283
|
+
parent?: DOMRectLike;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** ±4px — same threshold both the B2 move/resize edge-snap MATH and the D1
|
|
287
|
+
* snap-guide VISIBILITY below use, so a guide is shown if and only if the
|
|
288
|
+
* gesture it describes actually engaged (spec:321 "within ±4px prefer
|
|
289
|
+
* edge-alignment ... over the grid"; spec §6 D1 "snapping engages within
|
|
290
|
+
* threshold"). */
|
|
291
|
+
export const EDGE_SNAP_THRESHOLD_PX = 4;
|
|
292
|
+
|
|
293
|
+
/** One candidate target edge for edge-snapping: its coordinate (x for a
|
|
294
|
+
* vertical/left-right edge, y for a horizontal/top-bottom edge) paired with
|
|
295
|
+
* the SOURCE rect it came from (the parent padding box or a sibling) — the
|
|
296
|
+
* source rect is what a D1 snap guide spans across (see
|
|
297
|
+
* {@link computeMoveSnapGuides}/{@link computeResizeSnapGuides}). */
|
|
298
|
+
interface SnapEdgeCandidate {
|
|
299
|
+
edge: number;
|
|
300
|
+
rect: RectLike;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** `context`'s padding-box + sibling rects, reduced through
|
|
304
|
+
* `inspect.ts:computeSnapTargets` (drops zero-area siblings) — the shared
|
|
305
|
+
* first step both the x/y candidate builders below start from. */
|
|
306
|
+
function snapTargetsForContext(context: MoveSnapContext): {
|
|
307
|
+
paddingBox: RectLike;
|
|
308
|
+
siblingRects: RectLike[];
|
|
309
|
+
} {
|
|
310
|
+
const zeroPad = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
311
|
+
return computeSnapTargets(
|
|
312
|
+
context.paddingBox ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
313
|
+
zeroPad,
|
|
314
|
+
context.siblings ?? [],
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The x-axis (left/right) snap candidates — the parent padding box's own
|
|
319
|
+
* left/right edges plus every sibling's left/right edges. */
|
|
320
|
+
function xEdgeCandidates(targets: {
|
|
321
|
+
paddingBox: RectLike;
|
|
322
|
+
siblingRects: RectLike[];
|
|
323
|
+
}): SnapEdgeCandidate[] {
|
|
324
|
+
const out: SnapEdgeCandidate[] = [
|
|
325
|
+
{ edge: targets.paddingBox.x, rect: targets.paddingBox },
|
|
326
|
+
{ edge: targets.paddingBox.x + targets.paddingBox.width, rect: targets.paddingBox },
|
|
327
|
+
];
|
|
328
|
+
for (const r of targets.siblingRects) {
|
|
329
|
+
out.push({ edge: r.x, rect: r });
|
|
330
|
+
out.push({ edge: r.x + r.width, rect: r });
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The y-axis (top/bottom) analogue of {@link xEdgeCandidates}. */
|
|
336
|
+
function yEdgeCandidates(targets: {
|
|
337
|
+
paddingBox: RectLike;
|
|
338
|
+
siblingRects: RectLike[];
|
|
339
|
+
}): SnapEdgeCandidate[] {
|
|
340
|
+
const out: SnapEdgeCandidate[] = [
|
|
341
|
+
{ edge: targets.paddingBox.y, rect: targets.paddingBox },
|
|
342
|
+
{ edge: targets.paddingBox.y + targets.paddingBox.height, rect: targets.paddingBox },
|
|
343
|
+
];
|
|
344
|
+
for (const r of targets.siblingRects) {
|
|
345
|
+
out.push({ edge: r.y, rect: r });
|
|
346
|
+
out.push({ edge: r.y + r.height, rect: r });
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Snap `value` (the candidate LEFT/TOP edge of a `size`-wide/tall box) to the
|
|
352
|
+
* nearest of `candidates` within `threshold` px, preferring alignment of
|
|
353
|
+
* either the box's leading OR trailing edge to a target edge (spec:321
|
|
354
|
+
* "within ±4px prefer edge-alignment ... over the grid") — the MOVE-gesture
|
|
355
|
+
* case, where either edge of the moving box may be the one that aligns.
|
|
356
|
+
* Returns `value` unchanged (and `match: null`) when nothing is within
|
|
357
|
+
* threshold. */
|
|
358
|
+
function edgeSnapBox(
|
|
359
|
+
value: number,
|
|
360
|
+
size: number,
|
|
361
|
+
candidates: readonly SnapEdgeCandidate[],
|
|
362
|
+
threshold: number,
|
|
363
|
+
): { value: number; match: SnapEdgeCandidate | null } {
|
|
364
|
+
let best = value;
|
|
365
|
+
let bestDelta = threshold;
|
|
366
|
+
let match: SnapEdgeCandidate | null = null;
|
|
367
|
+
for (const c of candidates) {
|
|
368
|
+
const dLeading = Math.abs(value - c.edge);
|
|
369
|
+
if (dLeading <= bestDelta) {
|
|
370
|
+
bestDelta = dLeading;
|
|
371
|
+
best = c.edge;
|
|
372
|
+
match = c;
|
|
373
|
+
}
|
|
374
|
+
const dTrailing = Math.abs(value + size - c.edge);
|
|
375
|
+
if (dTrailing <= bestDelta) {
|
|
376
|
+
bestDelta = dTrailing;
|
|
377
|
+
best = c.edge - size;
|
|
378
|
+
match = c;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return { value: best, match };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Snap a SINGLE moving edge coordinate (e.g. a resize handle's dragged
|
|
385
|
+
* border) to the nearest of `candidates` within `threshold` — the RESIZE-
|
|
386
|
+
* gesture case, where only the one edge under the handle can align (the
|
|
387
|
+
* opposite edge is the anchor and never moves). `null` when nothing is
|
|
388
|
+
* within threshold. */
|
|
389
|
+
function edgeSnapPoint(
|
|
390
|
+
value: number,
|
|
391
|
+
candidates: readonly SnapEdgeCandidate[],
|
|
392
|
+
threshold: number,
|
|
393
|
+
): SnapEdgeCandidate | null {
|
|
394
|
+
let best: SnapEdgeCandidate | null = null;
|
|
395
|
+
let bestDelta = threshold;
|
|
396
|
+
for (const c of candidates) {
|
|
397
|
+
const d = Math.abs(value - c.edge);
|
|
398
|
+
if (d <= bestDelta) {
|
|
399
|
+
bestDelta = d;
|
|
400
|
+
best = c;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return best;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Move-gesture patch (spec:322 "move a positioned element writes left/top"):
|
|
408
|
+
* grid-snaps `orig.x + dx`/`orig.y + dy` (Alt = free, matching resize), then —
|
|
409
|
+
* unless Alt is held — prefers ±4px edge-alignment to the owner's own
|
|
410
|
+
* `contextRects(id)` padding-box/sibling edges over the grid (spec:321's snap
|
|
411
|
+
* MATH, `inspect.ts:computeSnapTargets` fed by `rects.contextRects`). The
|
|
412
|
+
* VISUAL guide rendering this same snap decision drives is
|
|
413
|
+
* {@link computeMoveSnapGuides} (D1, spec §6) — a separate pure function
|
|
414
|
+
* (not this one's return shape) so this function's existing `{x,y}` contract
|
|
415
|
+
* stays byte-for-byte stable for every caller/test that predates D1.
|
|
416
|
+
*/
|
|
417
|
+
export function computeMovePatch(
|
|
418
|
+
dx: number,
|
|
419
|
+
dy: number,
|
|
420
|
+
orig: RectLike,
|
|
421
|
+
free: boolean,
|
|
422
|
+
context?: MoveSnapContext,
|
|
423
|
+
): { x: number; y: number } {
|
|
424
|
+
let x = snapValue(orig.x + dx, free);
|
|
425
|
+
let y = snapValue(orig.y + dy, free);
|
|
426
|
+
if (!free && context) {
|
|
427
|
+
const targets = snapTargetsForContext(context);
|
|
428
|
+
x = edgeSnapBox(x, orig.width, xEdgeCandidates(targets), EDGE_SNAP_THRESHOLD_PX).value;
|
|
429
|
+
y = edgeSnapBox(y, orig.height, yEdgeCandidates(targets), EDGE_SNAP_THRESHOLD_PX).value;
|
|
430
|
+
}
|
|
431
|
+
return { x, y };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// --- D1 — snap/alignment guides (spec §6 D1, §5 :461 "snap/alignment
|
|
435
|
+
// guides") ------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
/** A single active D1 alignment guide: a straight line at coordinate `at`
|
|
438
|
+
* along the snapped axis (an x-coordinate for a `'vertical'` guide, a
|
|
439
|
+
* y-coordinate for a `'horizontal'` one — the guide LINE's own orientation,
|
|
440
|
+
* matching {@link computeMeasureLines}'s `orientation` convention), spanning
|
|
441
|
+
* `start`..`end` on the PERPENDICULAR axis (the union of the moving/
|
|
442
|
+
* resizing element's own extent and the target rect's extent, so the guide
|
|
443
|
+
* visibly touches both). Rendered ONLY while the underlying gesture's own
|
|
444
|
+
* edge-snap actually engaged (spec:465 "guides appear only within the snap
|
|
445
|
+
* threshold; disappear when not snapping") — {@link computeMoveSnapGuides}/
|
|
446
|
+
* {@link computeResizeSnapGuides} return `[]` whenever nothing snapped. */
|
|
447
|
+
export interface SnapGuide {
|
|
448
|
+
orientation: 'vertical' | 'horizontal';
|
|
449
|
+
at: number;
|
|
450
|
+
start: number;
|
|
451
|
+
end: number;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Build the 0, 1, or 2 {@link SnapGuide}s for a matched x/y edge-snap pair —
|
|
455
|
+
* shared tail of {@link computeMoveSnapGuides}/{@link computeResizeSnapGuides}
|
|
456
|
+
* (both resolve their own x/y matches differently — move via
|
|
457
|
+
* {@link edgeSnapBox}, resize via {@link edgeSnapPoint} on a single dragged
|
|
458
|
+
* edge — but converge on the same "matched candidate -> guide spanning the
|
|
459
|
+
* moved rect ∪ target rect" geometry once a match exists). */
|
|
460
|
+
function guidesFromMatches(
|
|
461
|
+
movedRect: RectLike,
|
|
462
|
+
xMatch: SnapEdgeCandidate | null,
|
|
463
|
+
yMatch: SnapEdgeCandidate | null,
|
|
464
|
+
): SnapGuide[] {
|
|
465
|
+
const guides: SnapGuide[] = [];
|
|
466
|
+
if (xMatch) {
|
|
467
|
+
guides.push({
|
|
468
|
+
orientation: 'vertical',
|
|
469
|
+
at: xMatch.edge,
|
|
470
|
+
start: Math.min(movedRect.y, xMatch.rect.y),
|
|
471
|
+
end: Math.max(movedRect.y + movedRect.height, xMatch.rect.y + xMatch.rect.height),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
if (yMatch) {
|
|
475
|
+
guides.push({
|
|
476
|
+
orientation: 'horizontal',
|
|
477
|
+
at: yMatch.edge,
|
|
478
|
+
start: Math.min(movedRect.x, yMatch.rect.x),
|
|
479
|
+
end: Math.max(movedRect.x + movedRect.width, yMatch.rect.x + yMatch.rect.width),
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
return guides;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* D1 (spec §6) — the alignment guides a LIVE move gesture's own edge-snap
|
|
487
|
+
* (the same math {@link computeMovePatch} applies to the committed patch)
|
|
488
|
+
* currently engages, or `[]` when Alt is held, there's no context, or
|
|
489
|
+
* nothing is within `threshold`. Deliberately a SEPARATE pure function from
|
|
490
|
+
* `computeMovePatch` (not a richer return shape on it) so that function's
|
|
491
|
+
* `{x,y}` contract — asserted by name in tests that predate D1 — never
|
|
492
|
+
* changes shape.
|
|
493
|
+
*/
|
|
494
|
+
export function computeMoveSnapGuides(
|
|
495
|
+
dx: number,
|
|
496
|
+
dy: number,
|
|
497
|
+
orig: RectLike,
|
|
498
|
+
free: boolean,
|
|
499
|
+
context: MoveSnapContext | undefined,
|
|
500
|
+
threshold = EDGE_SNAP_THRESHOLD_PX,
|
|
501
|
+
): SnapGuide[] {
|
|
502
|
+
if (free || !context) return [];
|
|
503
|
+
const x = snapValue(orig.x + dx, false);
|
|
504
|
+
const y = snapValue(orig.y + dy, false);
|
|
505
|
+
const targets = snapTargetsForContext(context);
|
|
506
|
+
const xMatch = edgeSnapBox(x, orig.width, xEdgeCandidates(targets), threshold).match;
|
|
507
|
+
const yMatch = edgeSnapBox(y, orig.height, yEdgeCandidates(targets), threshold).match;
|
|
508
|
+
const movedRect: RectLike = { x, y, width: orig.width, height: orig.height };
|
|
509
|
+
return guidesFromMatches(movedRect, xMatch, yMatch);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* D1 (spec §6) — the alignment guide(s) a LIVE resize gesture's own
|
|
514
|
+
* per-handle edge-snap (the same math {@link computeResizePatch} applies)
|
|
515
|
+
* currently engages. Only the axis/axes the handle actually drags produce a
|
|
516
|
+
* candidate match (an `'e'` handle only ever checks x; a corner checks
|
|
517
|
+
* both) — mirrors `computeResizePatch`'s own `east`/`west`/`south`/`north`
|
|
518
|
+
* gating. `[]` under the same bypass conditions `computeMoveSnapGuides` uses.
|
|
519
|
+
*/
|
|
520
|
+
export function computeResizeSnapGuides(
|
|
521
|
+
pos: HandlePos,
|
|
522
|
+
dx: number,
|
|
523
|
+
dy: number,
|
|
524
|
+
orig: RectLike,
|
|
525
|
+
free: boolean,
|
|
526
|
+
context: MoveSnapContext | undefined,
|
|
527
|
+
threshold = EDGE_SNAP_THRESHOLD_PX,
|
|
528
|
+
): SnapGuide[] {
|
|
529
|
+
if (free || !context) return [];
|
|
530
|
+
const east = pos === 'e' || pos === 'ne' || pos === 'se';
|
|
531
|
+
const west = pos === 'w' || pos === 'nw' || pos === 'sw';
|
|
532
|
+
const south = pos === 's' || pos === 'sw' || pos === 'se';
|
|
533
|
+
const north = pos === 'n' || pos === 'nw' || pos === 'ne';
|
|
534
|
+
const targets = snapTargetsForContext(context);
|
|
535
|
+
const xCandidates = xEdgeCandidates(targets);
|
|
536
|
+
const yCandidates = yEdgeCandidates(targets);
|
|
537
|
+
|
|
538
|
+
// Reuses the SAME per-handle helpers `computeResizePatch` calls (only
|
|
539
|
+
// `.match`, discarding the recomputed `.value`) — the guide can never
|
|
540
|
+
// drift from the patch it's describing.
|
|
541
|
+
let xMatch: SnapEdgeCandidate | null = null;
|
|
542
|
+
if (east) xMatch = resizeEastWidth(dx, orig, false, xCandidates, threshold).match;
|
|
543
|
+
else if (west) xMatch = resizeWestWidth(dx, orig, false, xCandidates, threshold).match;
|
|
544
|
+
|
|
545
|
+
let yMatch: SnapEdgeCandidate | null = null;
|
|
546
|
+
if (south) yMatch = resizeSouthHeight(dy, orig, false, yCandidates, threshold).match;
|
|
547
|
+
else if (north) yMatch = resizeNorthHeight(dy, orig, false, yCandidates, threshold).match;
|
|
548
|
+
|
|
549
|
+
return guidesFromMatches(orig, xMatch, yMatch);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Rotate-gesture patch (spec:322 "rotate handle writes `transform`"): the
|
|
554
|
+
* angle between the gesture's START cursor position and its CURRENT position,
|
|
555
|
+
* both measured from the selection's own center — `atan2(cursor−center) −
|
|
556
|
+
* atan2(start−center)` in degrees, snapped to 15° unless Alt (spec:322). Both
|
|
557
|
+
* `center`/`start`/`current` must be in the SAME coordinate frame (host-local,
|
|
558
|
+
* matching the rect the handle itself is drawn from) — the caller's job.
|
|
559
|
+
*/
|
|
560
|
+
export function computeRotatePatch(
|
|
561
|
+
center: { x: number; y: number },
|
|
562
|
+
start: { x: number; y: number },
|
|
563
|
+
current: { x: number; y: number },
|
|
564
|
+
free: boolean,
|
|
565
|
+
): { rotate: number } {
|
|
566
|
+
const toDeg = (rad: number): number => (rad * 180) / Math.PI;
|
|
567
|
+
const startAngle = toDeg(Math.atan2(start.y - center.y, start.x - center.x));
|
|
568
|
+
const curAngle = toDeg(Math.atan2(current.y - center.y, current.x - center.x));
|
|
569
|
+
const raw = curAngle - startAngle;
|
|
570
|
+
const deg = free ? raw : Math.round(raw / 15) * 15;
|
|
571
|
+
return { rotate: deg };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// --- Adapter-routing helpers (composite→owner-child routing, shared by
|
|
575
|
+
// `rectForId` and every capability-specific wrapper below) -----------
|
|
576
|
+
|
|
577
|
+
/** The child adapter that owns `id` — the composite's owning child's adapter
|
|
578
|
+
* for a `CompositeAuthoringAdapter`, or `adapter` itself for a bare adapter.
|
|
579
|
+
* `null` when a composite has no owning child for `id`. Not exported: every
|
|
580
|
+
* outside caller wants one of the two capability-specific wrappers below. */
|
|
581
|
+
function ownerAdapterFor(adapter: AuthoringAdapter, id: string): AuthoringAdapter | null {
|
|
582
|
+
if (adapter instanceof CompositeAuthoringAdapter) {
|
|
583
|
+
const worldId = adapter.ownerOf(id);
|
|
584
|
+
if (!worldId) return null;
|
|
585
|
+
return adapter.childAdapters().find((c) => c.worldId === worldId)?.adapter ?? null;
|
|
586
|
+
}
|
|
587
|
+
return adapter;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Resolve a node's rect through EITHER a bare adapter's own `rects`
|
|
592
|
+
* provider, OR — when the active adapter is a `CompositeAuthoringAdapter` —
|
|
593
|
+
* its OWNING child's `rects` provider. `rects` is deliberately NOT merged
|
|
594
|
+
* onto the composite (same stance as `pickable`/`boxEdit`/`text` —
|
|
595
|
+
* `composite-authoring-adapter.test.ts`'s T0 suite), so a composite caller
|
|
596
|
+
* must route through `ownerOf()` + `childAdapters()` itself — the SAME
|
|
597
|
+
* routing `ownerAdapterFor` above already does, reused here. `null` when the
|
|
598
|
+
* id has no owner, the owner has no `rects`, or the node is unmounted/
|
|
599
|
+
* offscreen. (D4 — moved here from `WorldSelectionOverlay.tsx`, which
|
|
600
|
+
* re-exports it, so `editor-hotkeys.ts`'s arrow-nudge action can use it
|
|
601
|
+
* without pulling in that component's whole dependency graph.)
|
|
602
|
+
*/
|
|
603
|
+
export function rectForId(adapter: AuthoringAdapter, id: string): DOMRectLike | null {
|
|
604
|
+
return ownerAdapterFor(adapter, id)?.rects?.rect(id) ?? null;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** `id`'s owning `BoxEditProvider`, or `null` when the id has no owner or its
|
|
608
|
+
* owner has no `boxEdit` (`boxEdit` is deliberately NOT merged onto the
|
|
609
|
+
* composite, same stance as `rects`/`pickable` — see `rectForId`'s doc
|
|
610
|
+
* comment above). Gates whether resize/move/rotate handles render for the
|
|
611
|
+
* current selection (spec:317). */
|
|
612
|
+
export function boxEditForId(adapter: AuthoringAdapter, id: string): BoxEditProvider | null {
|
|
613
|
+
return ownerAdapterFor(adapter, id)?.boxEdit ?? null;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** True when `id`'s owning adapter exposes `structure` (reorder/reparent/
|
|
617
|
+
* wrap/unwrap/duplicate/delete — spec:308-309's structural-gesture gate;
|
|
618
|
+
* those gestures themselves are built in Phase D, this predicate is the
|
|
619
|
+
* capability check other phases' handles gate on). Deliberately checks the
|
|
620
|
+
* OWNING CHILD, not the composite's own always-present `structure` (which
|
|
621
|
+
* merely forwards to whichever owner the id resolves to and would report
|
|
622
|
+
* `true` even for an id whose real owner has none). */
|
|
623
|
+
export function structureCapableForId(adapter: AuthoringAdapter, id: string): boolean {
|
|
624
|
+
return !!ownerAdapterFor(adapter, id)?.structure;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** `id`'s owning `rects.contextRects(id)` (parent/siblings/padding-box), or
|
|
628
|
+
* `undefined` when the id has no owner or the owner has no `rects`/
|
|
629
|
+
* `contextRects`. Feeds {@link computeMovePatch}'s edge-snap. */
|
|
630
|
+
export function contextRectsForId(
|
|
631
|
+
adapter: AuthoringAdapter,
|
|
632
|
+
id: string,
|
|
633
|
+
): MoveSnapContext | undefined {
|
|
634
|
+
return ownerAdapterFor(adapter, id)?.rects?.contextRects?.(id);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** `id`'s owning `StructureProvider`, or `null` when the id has no owner or
|
|
638
|
+
* its owner has no `structure` — the D3.a canvas context-menu's actual
|
|
639
|
+
* action target (Duplicate/Wrap/Unwrap/Delete/Insert-child all route through
|
|
640
|
+
* the OBJECT this returns, never a concrete adapter cast — rule zero). Not
|
|
641
|
+
* the same thing as {@link structureCapableForId} (a boolean gate) — a
|
|
642
|
+
* caller that already knows it wants the provider itself uses this instead
|
|
643
|
+
* of gate-then-reach-in-again. */
|
|
644
|
+
export function structureForId(adapter: AuthoringAdapter, id: string): StructureProvider | null {
|
|
645
|
+
return ownerAdapterFor(adapter, id)?.structure ?? null;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** `id`'s owning `TextProvider`, or `null` when the id has no owner or its
|
|
649
|
+
* owner has no `text` (`text` is deliberately NOT merged/forwarded onto the
|
|
650
|
+
* composite — unlike `structure`, it follows the same "leave it to the
|
|
651
|
+
* owner-lookup caller" stance as `rects`/`boxEdit`, see
|
|
652
|
+
* `WorldSelectionOverlay.rectForId`'s doc comment for the precedent this
|
|
653
|
+
* mirrors). Gates the D3.b inline text editor. */
|
|
654
|
+
export function textForId(adapter: AuthoringAdapter, id: string): TextProvider | null {
|
|
655
|
+
return ownerAdapterFor(adapter, id)?.text ?? null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** `id`'s owning `ColorSampleProvider`, or `null` when the id has no owner or
|
|
659
|
+
* its owner has no `colorSample` (not merged onto the composite either —
|
|
660
|
+
* same NOT-merged group as `rects`/`boxEdit`/`text`). Feeds the D3.d
|
|
661
|
+
* eyedropper fallback swatch. */
|
|
662
|
+
export function colorSampleForId(
|
|
663
|
+
adapter: AuthoringAdapter,
|
|
664
|
+
id: string,
|
|
665
|
+
): ColorSampleProvider | null {
|
|
666
|
+
return ownerAdapterFor(adapter, id)?.colorSample ?? null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Every empty-container hint reachable from `adapter`'s own `rects`
|
|
670
|
+
* provider — or, for a composite, from each VISIBLE child's own `rects`
|
|
671
|
+
* provider (D3.c). Mirrors `WorldSelectionOverlay.collectMarqueeCandidates`'s
|
|
672
|
+
* identical per-child-iteration/session-hidden-skip shape (that helper
|
|
673
|
+
* lives in the sibling file rather than here purely because it was written
|
|
674
|
+
* first — both walk the SAME composite-children/hidden-world contract). */
|
|
675
|
+
export function emptyContainerHintsFor(
|
|
676
|
+
adapter: AuthoringAdapter,
|
|
677
|
+
): Array<{ id: string; rect: DOMRectLike; displayName: string }> {
|
|
678
|
+
const children: ReadonlyArray<{ worldId: string; adapter: AuthoringAdapter }> =
|
|
679
|
+
adapter instanceof CompositeAuthoringAdapter
|
|
680
|
+
? adapter.childAdapters()
|
|
681
|
+
: [{ worldId: '', adapter }];
|
|
682
|
+
const out: Array<{ id: string; rect: DOMRectLike; displayName: string }> = [];
|
|
683
|
+
for (const { worldId, adapter: child } of children) {
|
|
684
|
+
if (!child.rects?.emptyContainers) continue;
|
|
685
|
+
if (worldId && isWorldHidden(worldId)) continue;
|
|
686
|
+
out.push(...child.rects.emptyContainers());
|
|
687
|
+
}
|
|
688
|
+
return out;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// --- B3 — box-model spacing bands (spec §4 B3, §5 :460/:465-466) ------
|
|
692
|
+
|
|
693
|
+
export type SpacingSide =
|
|
694
|
+
| 'paddingTop'
|
|
695
|
+
| 'paddingRight'
|
|
696
|
+
| 'paddingBottom'
|
|
697
|
+
| 'paddingLeft'
|
|
698
|
+
| 'marginTop'
|
|
699
|
+
| 'marginRight'
|
|
700
|
+
| 'marginBottom'
|
|
701
|
+
| 'marginLeft';
|
|
702
|
+
|
|
703
|
+
export interface SpacingValues {
|
|
704
|
+
paddingTop: number;
|
|
705
|
+
paddingRight: number;
|
|
706
|
+
paddingBottom: number;
|
|
707
|
+
paddingLeft: number;
|
|
708
|
+
marginTop: number;
|
|
709
|
+
marginRight: number;
|
|
710
|
+
marginBottom: number;
|
|
711
|
+
marginLeft: number;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Read the 8 spacing values off `adapter.inspector` (routed/merged on the
|
|
715
|
+
* composite, unlike `rects`/`boxEdit` — no owner lookup needed here) via the
|
|
716
|
+
* reserved `style.<prop>` inspector paths, parsed with the shared
|
|
717
|
+
* `numericStyleValue` (defensive: the declared `PropertyDescriptor.type` for
|
|
718
|
+
* each of these IS `'number'` on both react adapters today, so `get` already
|
|
719
|
+
* returns a parsed number — but a raw computed-style STRING like `"12px"`
|
|
720
|
+
* parses identically, so this stays correct even if that declared type ever
|
|
721
|
+
* changes, per this task's own note). Unresolved values default to `0`. */
|
|
722
|
+
export function readSpacingValues(adapter: AuthoringAdapter, id: string): SpacingValues {
|
|
723
|
+
const read = (prop: SpacingSide): number => {
|
|
724
|
+
const raw = adapter.inspector?.get(id, `style.${prop}`);
|
|
725
|
+
return numericStyleValue(raw as string | number | undefined) ?? 0;
|
|
726
|
+
};
|
|
727
|
+
return {
|
|
728
|
+
paddingTop: read('paddingTop'),
|
|
729
|
+
paddingRight: read('paddingRight'),
|
|
730
|
+
paddingBottom: read('paddingBottom'),
|
|
731
|
+
paddingLeft: read('paddingLeft'),
|
|
732
|
+
marginTop: read('marginTop'),
|
|
733
|
+
marginRight: read('marginRight'),
|
|
734
|
+
marginBottom: read('marginBottom'),
|
|
735
|
+
marginLeft: read('marginLeft'),
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export interface SpacingBand {
|
|
740
|
+
side: SpacingSide;
|
|
741
|
+
kind: 'padding' | 'margin';
|
|
742
|
+
x: number;
|
|
743
|
+
y: number;
|
|
744
|
+
width: number;
|
|
745
|
+
height: number;
|
|
746
|
+
cursor: string;
|
|
747
|
+
/** Label anchor — centered in the band's thickness. */
|
|
748
|
+
labelX: number;
|
|
749
|
+
labelY: number;
|
|
750
|
+
/** The side's REAL value (may be 0 even though the band itself is drawn at
|
|
751
|
+
* {@link ZERO_STRIP_PX} thickness so a zero side stays grabbable). */
|
|
752
|
+
value: number;
|
|
753
|
+
isZero: boolean;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** A zero-value padding/margin side still renders a thin grab strip (spec
|
|
757
|
+
* B3 "zero-value side → still render a ~4px grab strip so zero padding is
|
|
758
|
+
* draggable") — this is that strip's thickness. */
|
|
759
|
+
export const ZERO_STRIP_PX = 4;
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Padding (inner) + margin (outer) band geometry for a selection `rect` given
|
|
763
|
+
* its 8 parsed spacing values (spec:332-335). Padding bands sit INSIDE `rect`
|
|
764
|
+
* (spec:460's "box-model spacing bands"); margin bands sit OUTSIDE it. Uses
|
|
765
|
+
* `rect` + the raw values directly — NOT `rects.contextRects(id).paddingBox`
|
|
766
|
+
* (that reflects only the BORDER box per its own doc comment in the react
|
|
767
|
+
* adapters, i.e. ignores padding entirely — using it here would draw the
|
|
768
|
+
* padding band at the wrong thickness whenever the node also has a border).
|
|
769
|
+
* Pure; no DOM/adapter access.
|
|
770
|
+
*/
|
|
771
|
+
export function computeSpacingBands(rect: RectLike, values: SpacingValues): SpacingBand[] {
|
|
772
|
+
const thickness = (v: number): number => (v > 0 ? v : ZERO_STRIP_PX);
|
|
773
|
+
const pt = thickness(values.paddingTop);
|
|
774
|
+
const pr = thickness(values.paddingRight);
|
|
775
|
+
const pb = thickness(values.paddingBottom);
|
|
776
|
+
const pl = thickness(values.paddingLeft);
|
|
777
|
+
const mt = thickness(values.marginTop);
|
|
778
|
+
const mr = thickness(values.marginRight);
|
|
779
|
+
const mb = thickness(values.marginBottom);
|
|
780
|
+
const ml = thickness(values.marginLeft);
|
|
781
|
+
|
|
782
|
+
return [
|
|
783
|
+
{
|
|
784
|
+
side: 'paddingTop',
|
|
785
|
+
kind: 'padding',
|
|
786
|
+
x: rect.x,
|
|
787
|
+
y: rect.y,
|
|
788
|
+
width: rect.width,
|
|
789
|
+
height: pt,
|
|
790
|
+
cursor: 'ns-resize',
|
|
791
|
+
labelX: rect.x + rect.width / 2,
|
|
792
|
+
labelY: rect.y + pt / 2,
|
|
793
|
+
value: values.paddingTop,
|
|
794
|
+
isZero: values.paddingTop === 0,
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
side: 'paddingBottom',
|
|
798
|
+
kind: 'padding',
|
|
799
|
+
x: rect.x,
|
|
800
|
+
y: rect.y + rect.height - pb,
|
|
801
|
+
width: rect.width,
|
|
802
|
+
height: pb,
|
|
803
|
+
cursor: 'ns-resize',
|
|
804
|
+
labelX: rect.x + rect.width / 2,
|
|
805
|
+
labelY: rect.y + rect.height - pb / 2,
|
|
806
|
+
value: values.paddingBottom,
|
|
807
|
+
isZero: values.paddingBottom === 0,
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
side: 'paddingLeft',
|
|
811
|
+
kind: 'padding',
|
|
812
|
+
x: rect.x,
|
|
813
|
+
y: rect.y,
|
|
814
|
+
width: pl,
|
|
815
|
+
height: rect.height,
|
|
816
|
+
cursor: 'ew-resize',
|
|
817
|
+
labelX: rect.x + pl / 2,
|
|
818
|
+
labelY: rect.y + rect.height / 2,
|
|
819
|
+
value: values.paddingLeft,
|
|
820
|
+
isZero: values.paddingLeft === 0,
|
|
821
|
+
},
|
|
822
|
+
{
|
|
823
|
+
side: 'paddingRight',
|
|
824
|
+
kind: 'padding',
|
|
825
|
+
x: rect.x + rect.width - pr,
|
|
826
|
+
y: rect.y,
|
|
827
|
+
width: pr,
|
|
828
|
+
height: rect.height,
|
|
829
|
+
cursor: 'ew-resize',
|
|
830
|
+
labelX: rect.x + rect.width - pr / 2,
|
|
831
|
+
labelY: rect.y + rect.height / 2,
|
|
832
|
+
value: values.paddingRight,
|
|
833
|
+
isZero: values.paddingRight === 0,
|
|
834
|
+
},
|
|
835
|
+
{
|
|
836
|
+
side: 'marginTop',
|
|
837
|
+
kind: 'margin',
|
|
838
|
+
x: rect.x,
|
|
839
|
+
y: rect.y - mt,
|
|
840
|
+
width: rect.width,
|
|
841
|
+
height: mt,
|
|
842
|
+
cursor: 'ns-resize',
|
|
843
|
+
labelX: rect.x + rect.width / 2,
|
|
844
|
+
labelY: rect.y - mt / 2,
|
|
845
|
+
value: values.marginTop,
|
|
846
|
+
isZero: values.marginTop === 0,
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
side: 'marginBottom',
|
|
850
|
+
kind: 'margin',
|
|
851
|
+
x: rect.x,
|
|
852
|
+
y: rect.y + rect.height,
|
|
853
|
+
width: rect.width,
|
|
854
|
+
height: mb,
|
|
855
|
+
cursor: 'ns-resize',
|
|
856
|
+
labelX: rect.x + rect.width / 2,
|
|
857
|
+
labelY: rect.y + rect.height + mb / 2,
|
|
858
|
+
value: values.marginBottom,
|
|
859
|
+
isZero: values.marginBottom === 0,
|
|
860
|
+
},
|
|
861
|
+
{
|
|
862
|
+
side: 'marginLeft',
|
|
863
|
+
kind: 'margin',
|
|
864
|
+
x: rect.x - ml,
|
|
865
|
+
y: rect.y,
|
|
866
|
+
width: ml,
|
|
867
|
+
height: rect.height,
|
|
868
|
+
cursor: 'ew-resize',
|
|
869
|
+
labelX: rect.x - ml / 2,
|
|
870
|
+
labelY: rect.y + rect.height / 2,
|
|
871
|
+
value: values.marginLeft,
|
|
872
|
+
isZero: values.marginLeft === 0,
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
side: 'marginRight',
|
|
876
|
+
kind: 'margin',
|
|
877
|
+
x: rect.x + rect.width,
|
|
878
|
+
y: rect.y,
|
|
879
|
+
width: mr,
|
|
880
|
+
height: rect.height,
|
|
881
|
+
cursor: 'ew-resize',
|
|
882
|
+
labelX: rect.x + rect.width + mr / 2,
|
|
883
|
+
labelY: rect.y + rect.height / 2,
|
|
884
|
+
value: values.marginRight,
|
|
885
|
+
isZero: values.marginRight === 0,
|
|
886
|
+
},
|
|
887
|
+
];
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** Which raw delta axis (`dx`/`dy`) a given band drags along, and its sign —
|
|
891
|
+
* e.g. dragging the TOP padding band DOWN (`dy > 0`) grows `paddingTop`;
|
|
892
|
+
* dragging the TOP margin band UP (`dy < 0`, i.e. `-dy > 0`) grows
|
|
893
|
+
* `marginTop` (margin bands sit OUTSIDE the box, so their "grow" direction
|
|
894
|
+
* is the opposite screen direction from the same-named padding band). */
|
|
895
|
+
function axisDeltaForSide(side: SpacingSide, dx: number, dy: number): number {
|
|
896
|
+
switch (side) {
|
|
897
|
+
case 'paddingTop':
|
|
898
|
+
return dy;
|
|
899
|
+
case 'paddingBottom':
|
|
900
|
+
return -dy;
|
|
901
|
+
case 'paddingLeft':
|
|
902
|
+
return dx;
|
|
903
|
+
case 'paddingRight':
|
|
904
|
+
return -dx;
|
|
905
|
+
case 'marginTop':
|
|
906
|
+
return -dy;
|
|
907
|
+
case 'marginBottom':
|
|
908
|
+
return dy;
|
|
909
|
+
case 'marginLeft':
|
|
910
|
+
return -dx;
|
|
911
|
+
case 'marginRight':
|
|
912
|
+
return dx;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Spacing-band drag → `boxEdit` patch (spec:333-335): the delta along the
|
|
918
|
+
* band's own axis (see {@link axisDeltaForSide}) is added to the side's
|
|
919
|
+
* original value and clamped to ≥0. Deliberately whole-PIXEL (`Math.round`),
|
|
920
|
+
* NOT snapped to the 8px {@link GRID} B2's resize/move gestures use — a real
|
|
921
|
+
* padding/margin is routinely NOT a multiple of 8 (this task's own e2e fixture
|
|
922
|
+
* authors `padding: 12`), so grid-quantizing every spacing drag would make
|
|
923
|
+
* that starting value permanently unreachable by any drag distance (e.g.
|
|
924
|
+
* dragging `+8` from `12` would jump to `16` or `24`, never landing on the
|
|
925
|
+
* expected `20`). Whole-px rounding still keeps the value tidy without that
|
|
926
|
+
* unreachability trap.
|
|
927
|
+
*/
|
|
928
|
+
export function computeSpacingPatch(
|
|
929
|
+
side: SpacingSide,
|
|
930
|
+
dx: number,
|
|
931
|
+
dy: number,
|
|
932
|
+
origValue: number,
|
|
933
|
+
): Record<string, number> {
|
|
934
|
+
const delta = axisDeltaForSide(side, dx, dy);
|
|
935
|
+
const next = Math.max(0, Math.round(origValue + delta));
|
|
936
|
+
return { [side]: next };
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// --- D2 — marquee polish, drag-ghost, reorder clone-preview (spec §6 D2,
|
|
940
|
+
// §5 :461-462/:466) ---------------------------------------------------
|
|
941
|
+
|
|
942
|
+
/** A drag-ghost's geometry: the dragged element's OWN original rect, offset
|
|
943
|
+
* by the pointer's delta since the gesture started (spec:399 "a semi-
|
|
944
|
+
* transparent follow-cursor ghost ... offset by the drag delta"). Shared by
|
|
945
|
+
* the B2 move gesture (`applyGesturePatch`'s `'move'` branch) and the D2.b
|
|
946
|
+
* reorder-preview drag below — both are "dragging a selected element on the
|
|
947
|
+
* canvas" per the spec's own D2 framing, just with a different commit path
|
|
948
|
+
* at drop (a `boxEdit` patch vs. a `structure.reorder`). Pure; no DOM/adapter
|
|
949
|
+
* access. */
|
|
950
|
+
export function computeDragGhost(origRect: RectLike, dx: number, dy: number): RectLike {
|
|
951
|
+
return { x: origRect.x + dx, y: origRect.y + dy, width: origRect.width, height: origRect.height };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** One sibling candidate for the D2.b reorder gap math: an id (the
|
|
955
|
+
* `structure.reorder(id, beforeSiblingId)` argument this sibling would BE,
|
|
956
|
+
* if the pointer lands before it) paired with its current on-screen rect.
|
|
957
|
+
* Deliberately excludes the DRAGGED node itself — the caller builds this
|
|
958
|
+
* list from the dragged id's ordered siblings, filtering the dragged id out
|
|
959
|
+
* (its own rect is busy being dragged, not a valid gap boundary). */
|
|
960
|
+
export interface SiblingRectEntry {
|
|
961
|
+
id: string;
|
|
962
|
+
rect: RectLike;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** The live reorder-preview gap a pointer position resolves to among a set of
|
|
966
|
+
* sibling rects (spec:398-400 "live insertion preview ... updating as the
|
|
967
|
+
* pointer moves across sibling boundaries"). */
|
|
968
|
+
export interface ReorderGap {
|
|
969
|
+
/** The exact `structure.reorder(id, beforeSiblingId)` second argument:
|
|
970
|
+
* the sibling to insert BEFORE, or `null` to insert at the END (spec's
|
|
971
|
+
* own contract doc comment: "null = move to the end"). */
|
|
972
|
+
beforeSiblingId: string | null;
|
|
973
|
+
/** The gap's index among `siblings` in AXIS order — `0` is "before the
|
|
974
|
+
* first sibling", `siblings.length` is "after the last" (same case
|
|
975
|
+
* `beforeSiblingId: null` reports). Exposed mainly for tests: it's a
|
|
976
|
+
* denser signal than re-deriving position from `beforeSiblingId` alone
|
|
977
|
+
* when two siblings tie on id. */
|
|
978
|
+
index: number;
|
|
979
|
+
/** A thin marker rect at the gap boundary — the live insertion-preview
|
|
980
|
+
* line/marker to render. Spans the cross-axis extent of the flanking
|
|
981
|
+
* sibling(s); its thickness is {@link REORDER_MARKER_THICKNESS_PX}. */
|
|
982
|
+
marker: RectLike;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** Thickness (px) of the D2.b live insertion-preview marker line. */
|
|
986
|
+
export const REORDER_MARKER_THICKNESS_PX = 3;
|
|
987
|
+
|
|
988
|
+
/** A sibling rect's center coordinate along one axis — the sort/compare key
|
|
989
|
+
* {@link computeReorderGap} uses throughout. */
|
|
990
|
+
function centerOn(r: RectLike, axis: 'x' | 'y'): number {
|
|
991
|
+
return axis === 'x' ? r.x + r.width / 2 : r.y + r.height / 2;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** The gap boundary coordinate along `axis`, given `ordered` siblings (sorted
|
|
995
|
+
* along that axis already) and the gap `index` among them: the leading edge
|
|
996
|
+
* of the first sibling (index 0), the trailing edge of the last (index ===
|
|
997
|
+
* length), or the midpoint between the flanking pair's facing edges
|
|
998
|
+
* otherwise. Factored out of {@link computeReorderGap} purely to keep ITS
|
|
999
|
+
* cognitive complexity down. */
|
|
1000
|
+
function reorderGapBoundary(
|
|
1001
|
+
ordered: readonly SiblingRectEntry[],
|
|
1002
|
+
index: number,
|
|
1003
|
+
horizontal: boolean,
|
|
1004
|
+
): number {
|
|
1005
|
+
if (index === 0) {
|
|
1006
|
+
const first = ordered[0]!.rect;
|
|
1007
|
+
return horizontal ? first.x : first.y;
|
|
1008
|
+
}
|
|
1009
|
+
if (index === ordered.length) {
|
|
1010
|
+
const last = ordered[ordered.length - 1]!.rect;
|
|
1011
|
+
return horizontal ? last.x + last.width : last.y + last.height;
|
|
1012
|
+
}
|
|
1013
|
+
const prev = ordered[index - 1]!.rect;
|
|
1014
|
+
const next = ordered[index]!.rect;
|
|
1015
|
+
return horizontal ? (prev.x + prev.width + next.x) / 2 : (prev.y + prev.height + next.y) / 2;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** The D2.b insertion-preview marker rect: a thin {@link REORDER_MARKER_THICKNESS_PX}
|
|
1019
|
+
* line at `boundary` along `axis`, spanning the CROSS-axis extent of every
|
|
1020
|
+
* `ordered` sibling (so it reads as a full-width/height insertion line, not
|
|
1021
|
+
* just as wide as its immediate neighbors). Factored out of
|
|
1022
|
+
* {@link computeReorderGap} purely to keep ITS cognitive complexity down. */
|
|
1023
|
+
function reorderMarkerRect(
|
|
1024
|
+
ordered: readonly SiblingRectEntry[],
|
|
1025
|
+
boundary: number,
|
|
1026
|
+
horizontal: boolean,
|
|
1027
|
+
): RectLike {
|
|
1028
|
+
const crossMin = Math.min(...ordered.map((s) => (horizontal ? s.rect.y : s.rect.x)));
|
|
1029
|
+
const crossMax = Math.max(
|
|
1030
|
+
...ordered.map((s) => (horizontal ? s.rect.y + s.rect.height : s.rect.x + s.rect.width)),
|
|
1031
|
+
);
|
|
1032
|
+
const half = REORDER_MARKER_THICKNESS_PX / 2;
|
|
1033
|
+
return horizontal
|
|
1034
|
+
? {
|
|
1035
|
+
x: boundary - half,
|
|
1036
|
+
y: crossMin,
|
|
1037
|
+
width: REORDER_MARKER_THICKNESS_PX,
|
|
1038
|
+
height: crossMax - crossMin,
|
|
1039
|
+
}
|
|
1040
|
+
: {
|
|
1041
|
+
x: crossMin,
|
|
1042
|
+
y: boundary - half,
|
|
1043
|
+
width: crossMax - crossMin,
|
|
1044
|
+
height: REORDER_MARKER_THICKNESS_PX,
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* D2.b (spec:397-401) — resolve `pointer` to a live insertion gap among
|
|
1050
|
+
* `siblings` (the dragged node's OWN siblings, already excluding it — see
|
|
1051
|
+
* {@link SiblingRectEntry}'s doc comment). `null` only when `siblings` is
|
|
1052
|
+
* empty (nothing to compute a gap among — the caller's own "≥3 siblings"
|
|
1053
|
+
* capability gate should make this unreachable in practice, but this stays
|
|
1054
|
+
* honest about the degenerate input rather than fabricating a gap).
|
|
1055
|
+
*
|
|
1056
|
+
* Axis detection is PURE GEOMETRY, no CSS/flex-direction read needed: whichever
|
|
1057
|
+
* dimension the siblings' centers spread across MORE is the list's primary
|
|
1058
|
+
* axis (a horizontal flex-row spreads in x, a vertical stack spreads in y).
|
|
1059
|
+
* Siblings are then re-sorted along that axis (defensive — callers should
|
|
1060
|
+
* already pass DOM order, but the gap math only means something in spatial
|
|
1061
|
+
* order) and the pointer's coordinate along the SAME axis is compared against
|
|
1062
|
+
* each sibling's center to find how many siblings it has "passed" — that
|
|
1063
|
+
* count IS the gap index, and `beforeSiblingId` is whichever sibling sits at
|
|
1064
|
+
* that index (`null` past the last one).
|
|
1065
|
+
*/
|
|
1066
|
+
export function computeReorderGap(
|
|
1067
|
+
siblings: readonly SiblingRectEntry[],
|
|
1068
|
+
pointer: { x: number; y: number },
|
|
1069
|
+
): ReorderGap | null {
|
|
1070
|
+
if (siblings.length === 0) return null;
|
|
1071
|
+
|
|
1072
|
+
const xSpread =
|
|
1073
|
+
Math.max(...siblings.map((s) => centerOn(s.rect, 'x'))) -
|
|
1074
|
+
Math.min(...siblings.map((s) => centerOn(s.rect, 'x')));
|
|
1075
|
+
const ySpread =
|
|
1076
|
+
Math.max(...siblings.map((s) => centerOn(s.rect, 'y'))) -
|
|
1077
|
+
Math.min(...siblings.map((s) => centerOn(s.rect, 'y')));
|
|
1078
|
+
const horizontal = xSpread >= ySpread;
|
|
1079
|
+
const axis: 'x' | 'y' = horizontal ? 'x' : 'y';
|
|
1080
|
+
|
|
1081
|
+
const ordered = [...siblings].sort((a, b) => centerOn(a.rect, axis) - centerOn(b.rect, axis));
|
|
1082
|
+
|
|
1083
|
+
const pointerKey = horizontal ? pointer.x : pointer.y;
|
|
1084
|
+
let index = 0;
|
|
1085
|
+
while (index < ordered.length && centerOn(ordered[index]!.rect, axis) < pointerKey) index++;
|
|
1086
|
+
const beforeSiblingId = index < ordered.length ? ordered[index]!.id : null;
|
|
1087
|
+
|
|
1088
|
+
const boundary = reorderGapBoundary(ordered, index, horizontal);
|
|
1089
|
+
const marker = reorderMarkerRect(ordered, boundary, horizontal);
|
|
1090
|
+
|
|
1091
|
+
return { beforeSiblingId, index, marker };
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* `id`'s ordered sibling ids, INCLUDING `id` itself (the caller filters it
|
|
1096
|
+
* back out when building {@link SiblingRectEntry}s) — read off `adapter`'s
|
|
1097
|
+
* OWN `hierarchy` (merged/routed on a `CompositeAuthoringAdapter`, same
|
|
1098
|
+
* stance `labelForId`/`WorldSelectionOverlay.tsx` already document: no owner
|
|
1099
|
+
* lookup needed for `hierarchy`, unlike `rects`/`boxEdit`/`structure`). `null`
|
|
1100
|
+
* when `id` is unknown, or when it claims a `parentId` whose node can't be
|
|
1101
|
+
* resolved. A parentless (top-level) id's siblings are the OWNER'S OWN
|
|
1102
|
+
* `hierarchy.roots()` — for a composite, `roots()` returns synthetic
|
|
1103
|
+
* `world:<id>` GROUP nodes, so a top-level react-world node's real siblings
|
|
1104
|
+
* only resolve correctly through the group node's `childIds`, which is why
|
|
1105
|
+
* this reads `node.parentId` (already rewritten to the group id by the
|
|
1106
|
+
* composite for a child's own top-level root, per
|
|
1107
|
+
* `composite-authoring-adapter.ts`'s `node()`) rather than calling
|
|
1108
|
+
* `roots()` directly for every id.
|
|
1109
|
+
*/
|
|
1110
|
+
export function siblingIdsForId(adapter: AuthoringAdapter, id: string): string[] | null {
|
|
1111
|
+
const node = adapter.hierarchy.node(id);
|
|
1112
|
+
if (!node) return null;
|
|
1113
|
+
if (node.parentId) {
|
|
1114
|
+
const parent = adapter.hierarchy.node(node.parentId);
|
|
1115
|
+
return parent ? parent.childIds : null;
|
|
1116
|
+
}
|
|
1117
|
+
return adapter.hierarchy.roots().map((n) => n.id);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// --- D1.a — sibling-distance measure lines (spec §6 D1, §5 :461
|
|
1121
|
+
// "sibling-distance measure lines") -----------------------------------
|
|
1122
|
+
|
|
1123
|
+
/** One figma-style "hold-and-measure" gap line between two rects: a straight
|
|
1124
|
+
* segment from `(x1,y1)` to `(x2,y2)` (always axis-aligned — either
|
|
1125
|
+
* `y1 === y2`, a `'horizontal'` line measuring the x-axis gap, or
|
|
1126
|
+
* `x1 === x2`, a `'vertical'` line measuring the y-axis gap), the gap's
|
|
1127
|
+
* rounded px `distance`, and a `label` anchor at the segment's midpoint. */
|
|
1128
|
+
export interface MeasureLine {
|
|
1129
|
+
orientation: 'horizontal' | 'vertical';
|
|
1130
|
+
x1: number;
|
|
1131
|
+
y1: number;
|
|
1132
|
+
x2: number;
|
|
1133
|
+
y2: number;
|
|
1134
|
+
distance: number;
|
|
1135
|
+
labelX: number;
|
|
1136
|
+
labelY: number;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* D1.a (spec §6 "sibling-distance measure lines ... between selection and
|
|
1141
|
+
* hover"): the gap line(s) between `a` (the selection) and `b` (the hover
|
|
1142
|
+
* target). A `'horizontal'` line (gap along x) is produced when the two
|
|
1143
|
+
* rects DON'T overlap on the x-axis (one is fully left/right of the other);
|
|
1144
|
+
* a `'vertical'` line (gap along y) when they don't overlap on the y-axis.
|
|
1145
|
+
* Both can be produced at once (the rects are diagonal from each other —
|
|
1146
|
+
* neither axis overlaps); neither is produced when the rects overlap on
|
|
1147
|
+
* BOTH axes (no meaningful "gap" to measure — honest empty result, never a
|
|
1148
|
+
* fabricated negative/zero line, per this task's own anti-fabrication rule).
|
|
1149
|
+
*
|
|
1150
|
+
* The line's position on its PERPENDICULAR axis is the midpoint of the two
|
|
1151
|
+
* rects' overlapping range on that axis when they DO overlap there (the
|
|
1152
|
+
* common figma case — a sibling directly beside or below the selection);
|
|
1153
|
+
* when they don't overlap on that axis either (the diagonal case), it falls
|
|
1154
|
+
* back to the midpoint between the two rects' own centers on that axis, so
|
|
1155
|
+
* the line still reads as "between" them rather than snapping to an
|
|
1156
|
+
* arbitrary edge. Pure geometry; `a`/`b` are interchangeable (order doesn't
|
|
1157
|
+
* change which lines are produced, only which distance/endpoint is which
|
|
1158
|
+
* rect's edge — commutative).
|
|
1159
|
+
*/
|
|
1160
|
+
export function computeMeasureLines(a: RectLike, b: RectLike): MeasureLine[] {
|
|
1161
|
+
const aLeft = a.x;
|
|
1162
|
+
const aRight = a.x + a.width;
|
|
1163
|
+
const aTop = a.y;
|
|
1164
|
+
const aBottom = a.y + a.height;
|
|
1165
|
+
const bLeft = b.x;
|
|
1166
|
+
const bRight = b.x + b.width;
|
|
1167
|
+
const bTop = b.y;
|
|
1168
|
+
const bBottom = b.y + b.height;
|
|
1169
|
+
|
|
1170
|
+
const lines: MeasureLine[] = [];
|
|
1171
|
+
|
|
1172
|
+
// Horizontal gap (x-axis separation) — only when the rects don't overlap
|
|
1173
|
+
// on x.
|
|
1174
|
+
let gapX: { x1: number; x2: number } | null = null;
|
|
1175
|
+
if (aRight <= bLeft) gapX = { x1: aRight, x2: bLeft };
|
|
1176
|
+
else if (bRight <= aLeft) gapX = { x1: bRight, x2: aLeft };
|
|
1177
|
+
if (gapX) {
|
|
1178
|
+
const overlapTop = Math.max(aTop, bTop);
|
|
1179
|
+
const overlapBottom = Math.min(aBottom, bBottom);
|
|
1180
|
+
const y =
|
|
1181
|
+
overlapBottom > overlapTop
|
|
1182
|
+
? (overlapTop + overlapBottom) / 2
|
|
1183
|
+
: ((aTop + aBottom) / 2 + (bTop + bBottom) / 2) / 2;
|
|
1184
|
+
lines.push({
|
|
1185
|
+
orientation: 'horizontal',
|
|
1186
|
+
x1: gapX.x1,
|
|
1187
|
+
y1: y,
|
|
1188
|
+
x2: gapX.x2,
|
|
1189
|
+
y2: y,
|
|
1190
|
+
distance: Math.round(gapX.x2 - gapX.x1),
|
|
1191
|
+
labelX: (gapX.x1 + gapX.x2) / 2,
|
|
1192
|
+
labelY: y,
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Vertical gap (y-axis separation) — only when the rects don't overlap
|
|
1197
|
+
// on y.
|
|
1198
|
+
let gapY: { y1: number; y2: number } | null = null;
|
|
1199
|
+
if (aBottom <= bTop) gapY = { y1: aBottom, y2: bTop };
|
|
1200
|
+
else if (bBottom <= aTop) gapY = { y1: bBottom, y2: aTop };
|
|
1201
|
+
if (gapY) {
|
|
1202
|
+
const overlapLeft = Math.max(aLeft, bLeft);
|
|
1203
|
+
const overlapRight = Math.min(aRight, bRight);
|
|
1204
|
+
const x =
|
|
1205
|
+
overlapRight > overlapLeft
|
|
1206
|
+
? (overlapLeft + overlapRight) / 2
|
|
1207
|
+
: ((aLeft + aRight) / 2 + (bLeft + bRight) / 2) / 2;
|
|
1208
|
+
lines.push({
|
|
1209
|
+
orientation: 'vertical',
|
|
1210
|
+
x1: x,
|
|
1211
|
+
y1: gapY.y1,
|
|
1212
|
+
x2: x,
|
|
1213
|
+
y2: gapY.y2,
|
|
1214
|
+
distance: Math.round(gapY.y2 - gapY.y1),
|
|
1215
|
+
labelX: x,
|
|
1216
|
+
labelY: (gapY.y1 + gapY.y2) / 2,
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
return lines;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// --- D1.c — position/parent-layout badge (spec §6 D1, §5 :461
|
|
1224
|
+
// "position/parent-layout badges") --------------------------------
|
|
1225
|
+
|
|
1226
|
+
/** The D1.c badge's two independent segments — each `null` when the
|
|
1227
|
+
* underlying value couldn't be resolved (no `inspector`, no parent, an
|
|
1228
|
+
* unreadable/absent style value), per this task's own honesty rule: "if a
|
|
1229
|
+
* value can't be resolved, show nothing for it, never a fabricated
|
|
1230
|
+
* default." Never both `null` AND rendered — the caller's own gate. */
|
|
1231
|
+
export interface PositionBadgeInfo {
|
|
1232
|
+
/** `id`'s own `style.position` value (e.g. `'absolute'`), or `null`. */
|
|
1233
|
+
position: string | null;
|
|
1234
|
+
/** `id`'s PARENT's layout, summarized: `'flex row'`/`'flex col'` (from
|
|
1235
|
+
* `display: flex`/`inline-flex` + `flexDirection`), `'grid'` (from
|
|
1236
|
+
* `display: grid`/`inline-grid`), or the parent's raw `display` value for
|
|
1237
|
+
* anything else (e.g. `'block'`) — `null` when there's no parent, no
|
|
1238
|
+
* `inspector`, or the parent's `display` itself can't be read. */
|
|
1239
|
+
parentLayout: string | null;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** Read a possibly-unset inspector value as a non-empty string, or `null` —
|
|
1243
|
+
* the shared "honest unresolved" coercion both segments below use (an
|
|
1244
|
+
* inspector `get` may return `undefined`, `null`, or even a non-string for
|
|
1245
|
+
* an adapter that doesn't model the path at all). */
|
|
1246
|
+
function readStyleString(
|
|
1247
|
+
inspector: { get(id: string, path: string): unknown } | undefined,
|
|
1248
|
+
id: string,
|
|
1249
|
+
path: string,
|
|
1250
|
+
): string | null {
|
|
1251
|
+
const raw = inspector?.get(id, path);
|
|
1252
|
+
return typeof raw === 'string' && raw.length > 0 ? raw : null;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* D1.c (spec §6 "position/parent-layout badges") — `id`'s own position mode
|
|
1257
|
+
* plus its PARENT's summarized layout, read entirely through
|
|
1258
|
+
* `adapter.inspector.get`/`adapter.hierarchy.node` (both merged/routed on a
|
|
1259
|
+
* `CompositeAuthoringAdapter` already — `labelForId`/`siblingIdsForId`'s own
|
|
1260
|
+
* doc comments — so, unlike `rects`/`boxEdit`, no owner-child routing helper
|
|
1261
|
+
* is needed here; a bare adapter and a composite call this identically).
|
|
1262
|
+
* Each segment resolves independently — a missing/unreadable parent
|
|
1263
|
+
* `display` never blanks out an otherwise-resolved `position`, and vice
|
|
1264
|
+
* versa (spec's own "never a fabricated default" instruction).
|
|
1265
|
+
*/
|
|
1266
|
+
export function resolvePositionBadge(adapter: AuthoringAdapter, id: string): PositionBadgeInfo {
|
|
1267
|
+
const position = readStyleString(adapter.inspector, id, 'style.position');
|
|
1268
|
+
|
|
1269
|
+
let parentLayout: string | null = null;
|
|
1270
|
+
const parentId = adapter.hierarchy.node(id)?.parentId ?? null;
|
|
1271
|
+
if (parentId) {
|
|
1272
|
+
const display = readStyleString(adapter.inspector, parentId, 'style.display');
|
|
1273
|
+
if (display === 'flex' || display === 'inline-flex') {
|
|
1274
|
+
const direction =
|
|
1275
|
+
readStyleString(adapter.inspector, parentId, 'style.flexDirection') ?? 'row';
|
|
1276
|
+
parentLayout = `flex ${direction.startsWith('column') ? 'col' : 'row'}`;
|
|
1277
|
+
} else if (display === 'grid' || display === 'inline-grid') {
|
|
1278
|
+
parentLayout = 'grid';
|
|
1279
|
+
} else if (display) {
|
|
1280
|
+
parentLayout = display;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
return { position, parentLayout };
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// --- D4 — multi-select align/distribute toolbar (spec §6 D4, §5 :462
|
|
1288
|
+
// "alignment/distribute toolbar") -------------------------------------
|
|
1289
|
+
|
|
1290
|
+
/** One selected node's id paired with its current on-screen rect — reused
|
|
1291
|
+
* (not re-declared) from the D2.b reorder-gap math above: both are just
|
|
1292
|
+
* "an id + the rect it currently occupies", the same shape `AlignToolbar`
|
|
1293
|
+
* needs for the align/distribute math below. */
|
|
1294
|
+
export type AlignEntry = SiblingRectEntry;
|
|
1295
|
+
|
|
1296
|
+
export type AlignOp = 'left' | 'hcenter' | 'right' | 'top' | 'vcenter' | 'bottom';
|
|
1297
|
+
export type DistributeAxis = 'horizontal' | 'vertical';
|
|
1298
|
+
|
|
1299
|
+
/** One node's align/distribute RESULT: the absolute host-relative `x` and/or
|
|
1300
|
+
* `y` its rect should move to (only the axis the op touches is present —
|
|
1301
|
+
* align-left only ever sets `x`, never `y`), matching `boxEdit.apply`'s own
|
|
1302
|
+
* ABSOLUTE-coordinate contract (spec:322/B2 — the same convention
|
|
1303
|
+
* `computeMovePatch` already returns, NOT a delta). */
|
|
1304
|
+
export interface AlignTarget {
|
|
1305
|
+
id: string;
|
|
1306
|
+
x?: number;
|
|
1307
|
+
y?: number;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* D4.b (spec §6 D4 "a multi-select align/distribute toolbar ... align
|
|
1312
|
+
* left/hcenter/right/top/vcenter/bottom (≥2 selected)"): every entry's target
|
|
1313
|
+
* position for `op`, computed from the SELECTION's own union bounding box
|
|
1314
|
+
* (spec's own figma-parity framing — "align left" moves every selected
|
|
1315
|
+
* node's left edge to the leftmost selected node's left edge, which IS the
|
|
1316
|
+
* union bbox's left edge; center/right/top/vcenter/bottom are the analogous
|
|
1317
|
+
* bbox edges/midpoints). `[]` for fewer than 2 entries — the toolbar's own
|
|
1318
|
+
* capability gate (this function stays honest about the degenerate input
|
|
1319
|
+
* rather than fabricating a single-node "alignment"). Pure; no adapter/DOM
|
|
1320
|
+
* access — the caller (`AlignToolbar.tsx`) is the one that routes each
|
|
1321
|
+
* target through `boxEditForId`.
|
|
1322
|
+
*/
|
|
1323
|
+
export function computeAlignTargets(entries: readonly AlignEntry[], op: AlignOp): AlignTarget[] {
|
|
1324
|
+
if (entries.length < 2) return [];
|
|
1325
|
+
const rects = entries.map((e) => e.rect);
|
|
1326
|
+
const minX = Math.min(...rects.map((r) => r.x));
|
|
1327
|
+
const maxRight = Math.max(...rects.map((r) => r.x + r.width));
|
|
1328
|
+
const minY = Math.min(...rects.map((r) => r.y));
|
|
1329
|
+
const maxBottom = Math.max(...rects.map((r) => r.y + r.height));
|
|
1330
|
+
const centerX = (minX + maxRight) / 2;
|
|
1331
|
+
const centerY = (minY + maxBottom) / 2;
|
|
1332
|
+
const out: AlignTarget[] = [];
|
|
1333
|
+
for (const { id, rect } of entries) {
|
|
1334
|
+
switch (op) {
|
|
1335
|
+
case 'left':
|
|
1336
|
+
out.push({ id, x: minX });
|
|
1337
|
+
break;
|
|
1338
|
+
case 'hcenter':
|
|
1339
|
+
out.push({ id, x: centerX - rect.width / 2 });
|
|
1340
|
+
break;
|
|
1341
|
+
case 'right':
|
|
1342
|
+
out.push({ id, x: maxRight - rect.width });
|
|
1343
|
+
break;
|
|
1344
|
+
case 'top':
|
|
1345
|
+
out.push({ id, y: minY });
|
|
1346
|
+
break;
|
|
1347
|
+
case 'vcenter':
|
|
1348
|
+
out.push({ id, y: centerY - rect.height / 2 });
|
|
1349
|
+
break;
|
|
1350
|
+
case 'bottom':
|
|
1351
|
+
out.push({ id, y: maxBottom - rect.height });
|
|
1352
|
+
break;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
return out;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* D4.b (spec §6 D4 "distribute horizontally/vertically (≥3 selected)"):
|
|
1360
|
+
* equalize the GAP between consecutive entries along `axis`, keeping the
|
|
1361
|
+
* first (lowest-coordinate) and last (highest-coordinate) entries fixed at
|
|
1362
|
+
* their own current position — the standard figma distribute algorithm.
|
|
1363
|
+
* `[]` for fewer than 3 entries (spec's own "(≥3 selected)" gate — with only
|
|
1364
|
+
* 2 entries there is exactly one gap, nothing to "equalize" against, so an
|
|
1365
|
+
* honest empty result beats fabricating a no-op target). Pure; entries are
|
|
1366
|
+
* re-sorted internally by their CURRENT position along `axis` (the caller
|
|
1367
|
+
* doesn't need to pre-sort).
|
|
1368
|
+
*/
|
|
1369
|
+
export function computeDistributeTargets(
|
|
1370
|
+
entries: readonly AlignEntry[],
|
|
1371
|
+
axis: DistributeAxis,
|
|
1372
|
+
): AlignTarget[] {
|
|
1373
|
+
if (entries.length < 3) return [];
|
|
1374
|
+
const horizontal = axis === 'horizontal';
|
|
1375
|
+
const sorted = [...entries].sort((a, b) =>
|
|
1376
|
+
horizontal ? a.rect.x - b.rect.x : a.rect.y - b.rect.y,
|
|
1377
|
+
);
|
|
1378
|
+
const first = sorted[0]!;
|
|
1379
|
+
const last = sorted[sorted.length - 1]!;
|
|
1380
|
+
const totalSpan = horizontal
|
|
1381
|
+
? last.rect.x + last.rect.width - first.rect.x
|
|
1382
|
+
: last.rect.y + last.rect.height - first.rect.y;
|
|
1383
|
+
const totalSize = sorted.reduce((s, e) => s + (horizontal ? e.rect.width : e.rect.height), 0);
|
|
1384
|
+
const gap = (totalSpan - totalSize) / (sorted.length - 1);
|
|
1385
|
+
let cursor = horizontal ? first.rect.x : first.rect.y;
|
|
1386
|
+
const out: AlignTarget[] = [];
|
|
1387
|
+
for (const e of sorted) {
|
|
1388
|
+
out.push(horizontal ? { id: e.id, x: cursor } : { id: e.id, y: cursor });
|
|
1389
|
+
cursor += (horizontal ? e.rect.width : e.rect.height) + gap;
|
|
1390
|
+
}
|
|
1391
|
+
return out;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/** The union bounding box of `rects` (spec's own "the selection's own union
|
|
1395
|
+
* bounding box" framing — see {@link computeAlignTargets}'s doc comment) —
|
|
1396
|
+
* used by `AlignToolbar` purely to POSITION itself above the selection;
|
|
1397
|
+
* `null` for an empty input (never a fabricated zero-rect). */
|
|
1398
|
+
export function unionRect(rects: readonly RectLike[]): RectLike | null {
|
|
1399
|
+
if (rects.length === 0) return null;
|
|
1400
|
+
const x = Math.min(...rects.map((r) => r.x));
|
|
1401
|
+
const y = Math.min(...rects.map((r) => r.y));
|
|
1402
|
+
const right = Math.max(...rects.map((r) => r.x + r.width));
|
|
1403
|
+
const bottom = Math.max(...rects.map((r) => r.y + r.height));
|
|
1404
|
+
return { x, y, width: right - x, height: bottom - y };
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// --- D4.c — arrow-nudge (spec §6 D4 "arrow-nudge (↑↓←→, Shift=×10) ...
|
|
1408
|
+
// writes margins/offsets") --------------------------------------------
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* D4.c — the `boxEdit` patch one arrow-key nudge writes: for an
|
|
1412
|
+
* absolutely/fixed-positioned node, an ABSOLUTE `{x,y}` (the same convention
|
|
1413
|
+
* {@link computeMovePatch} already returns, matching B1's "x/y for
|
|
1414
|
+
* absolutely-positioned nodes → left/top"), offset by `(dx,dy)` from `rect`'s
|
|
1415
|
+
* current position. For anything else — the normal-document-flow case B1
|
|
1416
|
+
* also covers ("margin / padding → per-side style writes") — nudges
|
|
1417
|
+
* `marginLeft`/`marginTop` by the same delta instead (there is no `left`/
|
|
1418
|
+
* `top` to move on a non-positioned node — the only spatial lever B1 leaves
|
|
1419
|
+
* it is its own margin). Only the axis/axes actually nudged (`dx`/`dy`
|
|
1420
|
+
* non-zero) appear in the returned patch. Pure; no adapter/DOM access.
|
|
1421
|
+
*/
|
|
1422
|
+
export function computeArrowNudgePatch(
|
|
1423
|
+
dx: number,
|
|
1424
|
+
dy: number,
|
|
1425
|
+
isPositioned: boolean,
|
|
1426
|
+
rect: RectLike,
|
|
1427
|
+
margin: { left: number; top: number },
|
|
1428
|
+
): Record<string, number> {
|
|
1429
|
+
const patch: Record<string, number> = {};
|
|
1430
|
+
if (isPositioned) {
|
|
1431
|
+
if (dx !== 0) patch['x'] = rect.x + dx;
|
|
1432
|
+
if (dy !== 0) patch['y'] = rect.y + dy;
|
|
1433
|
+
return patch;
|
|
1434
|
+
}
|
|
1435
|
+
if (dx !== 0) patch['marginLeft'] = margin.left + dx;
|
|
1436
|
+
if (dy !== 0) patch['marginTop'] = margin.top + dy;
|
|
1437
|
+
return patch;
|
|
1438
|
+
}
|