@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,1175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surgical source writer (C7/C8) — the visual-edit "edit source by offset, not by
|
|
3
|
+
* AST rewrite" trick. Given the original source and an element's location (from the
|
|
4
|
+
* OID index), it edits the inline `style={{…}}` object IN PLACE, preserving all
|
|
5
|
+
* other properties (including dynamic ones), formatting, and comments — and refuses
|
|
6
|
+
* to touch expression-bound (dynamic) values (the literal-vs-dynamic guard). Plus a
|
|
7
|
+
* structural delete. Pure string ops (no parser, like visual-edit/writer.ts).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface StyleProperty {
|
|
11
|
+
name: string;
|
|
12
|
+
/** Raw value text as it appears in source. */
|
|
13
|
+
raw: string;
|
|
14
|
+
literal: boolean;
|
|
15
|
+
valueStart: number;
|
|
16
|
+
valueEnd: number;
|
|
17
|
+
/**
|
|
18
|
+
* The full `key: value` member span — from the first char of the key through the
|
|
19
|
+
* end of the (trimmed) value, EXCLUDING any leading/trailing whitespace or the
|
|
20
|
+
* separating comma (D-A2, `removeInlineStyle`). `memberEnd` always equals
|
|
21
|
+
* `valueEnd` (the member ends exactly where its value ends); kept as a distinct
|
|
22
|
+
* field for readability at removeInlineStyle's call site.
|
|
23
|
+
*/
|
|
24
|
+
memberStart: number;
|
|
25
|
+
memberEnd: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Find the `>` that closes the opening tag starting at `start` (a `<`). */
|
|
29
|
+
export function findTagEnd(code: string, start: number): number {
|
|
30
|
+
let depth = 0;
|
|
31
|
+
let str: string | null = null;
|
|
32
|
+
for (let i = start; i < code.length; i++) {
|
|
33
|
+
const c = code[i];
|
|
34
|
+
if (str) {
|
|
35
|
+
if (c === str && code[i - 1] !== '\\') str = null;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (c === '"' || c === "'" || c === '`') str = c;
|
|
39
|
+
else if (c === '{') depth++;
|
|
40
|
+
else if (c === '}') depth--;
|
|
41
|
+
else if (c === '>' && depth === 0) return i;
|
|
42
|
+
}
|
|
43
|
+
return -1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Locate the inner range of `style={{ … }}` within the opening tag, or null. */
|
|
47
|
+
function findStyleObject(
|
|
48
|
+
code: string,
|
|
49
|
+
tagStart: number,
|
|
50
|
+
tagEnd: number,
|
|
51
|
+
): { innerStart: number; innerEnd: number } | null {
|
|
52
|
+
const seg = code.slice(tagStart, tagEnd);
|
|
53
|
+
const m = seg.match(/style\s*=\s*\{\{/);
|
|
54
|
+
if (!m || m.index == null) return null;
|
|
55
|
+
const openBraceOuter = tagStart + m.index + m[0].length - 2; // position of first `{` of `{{`
|
|
56
|
+
// inner object starts after the two `{`
|
|
57
|
+
const innerStart = openBraceOuter + 2;
|
|
58
|
+
// find the matching `}}` for the object: track brace depth from innerStart
|
|
59
|
+
let depth = 2; // we are inside two open braces
|
|
60
|
+
let str: string | null = null;
|
|
61
|
+
for (let i = innerStart; i < tagEnd + 2 && i < code.length; i++) {
|
|
62
|
+
const c = code[i];
|
|
63
|
+
if (str) {
|
|
64
|
+
if (c === str && code[i - 1] !== '\\') str = null;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (c === '"' || c === "'" || c === '`') str = c;
|
|
68
|
+
else if (c === '{') depth++;
|
|
69
|
+
else if (c === '}') {
|
|
70
|
+
depth--;
|
|
71
|
+
if (depth === 1) {
|
|
72
|
+
// this `}` closes the object (one brace — the expression brace — remains)
|
|
73
|
+
return { innerStart, innerEnd: i };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const LITERAL_RE = /^\s*('[^']*'|"[^"]*"|`[^`]*`|-?\d+(\.\d+)?(px|em|rem|%|vh|vw)?)\s*$/;
|
|
81
|
+
|
|
82
|
+
/** Parse the top-level properties of a style object body (between the braces). */
|
|
83
|
+
export function analyzeStyleProperties(objBody: string, baseOffset: number): StyleProperty[] {
|
|
84
|
+
const props: StyleProperty[] = [];
|
|
85
|
+
let i = 0;
|
|
86
|
+
const n = objBody.length;
|
|
87
|
+
let str: string | null = null;
|
|
88
|
+
let depth = 0;
|
|
89
|
+
let keyStart = -1;
|
|
90
|
+
let colon = -1;
|
|
91
|
+
const flush = (end: number): void => {
|
|
92
|
+
if (keyStart >= 0 && colon >= 0) {
|
|
93
|
+
const name = objBody.slice(keyStart, colon).replace(/['"]/g, '').trim();
|
|
94
|
+
const raw = objBody.slice(colon + 1, end);
|
|
95
|
+
if (name) {
|
|
96
|
+
const trimmed = raw.trim();
|
|
97
|
+
const lead = raw.length - raw.trimStart().length;
|
|
98
|
+
// A template literal with `${...}` interpolation is DYNAMIC, not literal,
|
|
99
|
+
// even though it is delimited by backticks — never let it be editable.
|
|
100
|
+
const isLiteral = LITERAL_RE.test(raw) && !trimmed.includes('${');
|
|
101
|
+
const valueStart = baseOffset + colon + 1 + lead;
|
|
102
|
+
const valueEnd = valueStart + trimmed.length;
|
|
103
|
+
props.push({
|
|
104
|
+
name,
|
|
105
|
+
raw: trimmed,
|
|
106
|
+
literal: isLiteral,
|
|
107
|
+
valueStart,
|
|
108
|
+
valueEnd,
|
|
109
|
+
memberStart: baseOffset + keyStart,
|
|
110
|
+
memberEnd: valueEnd,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
keyStart = -1;
|
|
115
|
+
colon = -1;
|
|
116
|
+
};
|
|
117
|
+
for (; i < n; i++) {
|
|
118
|
+
const c = objBody[i] as string;
|
|
119
|
+
if (str) {
|
|
120
|
+
if (c === str && objBody[i - 1] !== '\\') str = null;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
124
|
+
str = c;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (c === '{' || c === '(' || c === '[') depth++;
|
|
128
|
+
else if (c === '}' || c === ')' || c === ']') depth--;
|
|
129
|
+
else if (depth === 0) {
|
|
130
|
+
if (c === ':' && colon < 0) colon = i;
|
|
131
|
+
else if (c === ',') {
|
|
132
|
+
flush(i);
|
|
133
|
+
} else if (keyStart < 0 && /\S/.test(c)) keyStart = i;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
flush(n);
|
|
137
|
+
return props;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface StyleEditResult {
|
|
141
|
+
code: string;
|
|
142
|
+
changed: boolean;
|
|
143
|
+
/** True if the edit was refused because the existing value is a JS expression. */
|
|
144
|
+
dynamic: boolean;
|
|
145
|
+
/**
|
|
146
|
+
* D-A1 (wave 13, `docs/WAVE13-JSX-SPREAD-UNDO-DESIGN.md`): true ONLY when
|
|
147
|
+
* `setInlineStyle` took the APPEND branch — the element's style object had no
|
|
148
|
+
* literal (and no dynamic expression) for this prop before the write, e.g. it
|
|
149
|
+
* was absent entirely or spread-derived (`style={{ ...vars }}`). False/absent on
|
|
150
|
+
* a replace, a dynamic-value refusal, or a no-op. Undo must know this: replaying
|
|
151
|
+
* the write with the prior value would find the NOW-appended literal and REPLACE
|
|
152
|
+
* it — restoring a hardcoded value into a spot the source never had one — instead
|
|
153
|
+
* of removing the prop to truly restore the pre-edit source.
|
|
154
|
+
*/
|
|
155
|
+
appended?: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Set an inline style property on the element at `elementStart` (a `<`). Replaces a
|
|
160
|
+
* literal value, refuses a dynamic one (guard), or appends a new property; inserts a
|
|
161
|
+
* `style={{…}}` if none exists. Other properties are untouched.
|
|
162
|
+
*/
|
|
163
|
+
export function setInlineStyle(
|
|
164
|
+
code: string,
|
|
165
|
+
elementStart: number,
|
|
166
|
+
prop: string,
|
|
167
|
+
value: string | number,
|
|
168
|
+
): StyleEditResult {
|
|
169
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
170
|
+
if (tagEnd < 0) return { code, changed: false, dynamic: false };
|
|
171
|
+
// Escape the value so a quote/backslash can't break source syntax.
|
|
172
|
+
const valueText =
|
|
173
|
+
typeof value === 'number'
|
|
174
|
+
? String(value)
|
|
175
|
+
: `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
176
|
+
const styleObj = findStyleObject(code, elementStart, tagEnd);
|
|
177
|
+
|
|
178
|
+
if (styleObj) {
|
|
179
|
+
const body = code.slice(styleObj.innerStart, styleObj.innerEnd);
|
|
180
|
+
const props = analyzeStyleProperties(body, styleObj.innerStart);
|
|
181
|
+
const existing = props.find((p) => p.name === prop);
|
|
182
|
+
if (existing) {
|
|
183
|
+
if (!existing.literal) return { code, changed: false, dynamic: true }; // guard
|
|
184
|
+
const next = code.slice(0, existing.valueStart) + valueText + code.slice(existing.valueEnd);
|
|
185
|
+
return { code: next, changed: true, dynamic: false };
|
|
186
|
+
}
|
|
187
|
+
// append a property, preserving existing content
|
|
188
|
+
const trimmedBody = body.trimEnd();
|
|
189
|
+
const sep = trimmedBody.length === 0 ? '' : trimmedBody.endsWith(',') ? ' ' : ', ';
|
|
190
|
+
const insertAt = styleObj.innerStart + trimmedBody.length;
|
|
191
|
+
const next = `${code.slice(0, insertAt)}${sep}${prop}: ${valueText}${code.slice(insertAt)}`;
|
|
192
|
+
return { code: next, changed: true, dynamic: false, appended: true };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A whole-attribute dynamic style (`style={expr}`, single brace) exists but is
|
|
196
|
+
// not an editable object literal — refuse rather than appending a duplicate attr.
|
|
197
|
+
const openSeg = code.slice(elementStart, tagEnd);
|
|
198
|
+
if (/style\s*=\s*\{/.test(openSeg)) {
|
|
199
|
+
return { code, changed: false, dynamic: true };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// no style attr — insert before the tag end (handle self-closing `/>`)
|
|
203
|
+
const selfClose = code[tagEnd - 1] === '/';
|
|
204
|
+
const insertAt = selfClose ? tagEnd - 1 : tagEnd;
|
|
205
|
+
const next = `${code.slice(0, insertAt)} style={{ ${prop}: ${valueText} }}${code.slice(insertAt)}`;
|
|
206
|
+
return { code: next, changed: true, dynamic: false };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* D-A2 (wave 13, `docs/WAVE13-JSX-SPREAD-UNDO-DESIGN.md`): surgically REMOVE a
|
|
211
|
+
* literal `prop: value` entry from the element's `style={{…}}` object at
|
|
212
|
+
* `elementStart` — the correct undo for a write that took `setInlineStyle`'s
|
|
213
|
+
* APPEND branch (`appended: true`). Fixes up the neighbouring separator/trailing
|
|
214
|
+
* comma so the remaining object stays syntactically valid; if `prop` was the
|
|
215
|
+
* object's ONLY content, collapses to an empty `style={{}}` rather than stripping
|
|
216
|
+
* the attribute entirely (keeps redo — a plain re-append — trivially reversible,
|
|
217
|
+
* and avoids re-deriving the whole-attribute insertion position `setInlineStyle`'s
|
|
218
|
+
* no-style-attr branch uses). `changed: false` (and no-op) if `prop` isn't present
|
|
219
|
+
* as a literal member at all — including a DYNAMIC member (mirrors
|
|
220
|
+
* `setInlineStyle`'s literal-vs-dynamic guard: never remove an expression-bound
|
|
221
|
+
* value the caller didn't write).
|
|
222
|
+
*/
|
|
223
|
+
export function removeInlineStyle(
|
|
224
|
+
code: string,
|
|
225
|
+
elementStart: number,
|
|
226
|
+
prop: string,
|
|
227
|
+
): StyleEditResult {
|
|
228
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
229
|
+
if (tagEnd < 0) return { code, changed: false, dynamic: false };
|
|
230
|
+
const styleObj = findStyleObject(code, elementStart, tagEnd);
|
|
231
|
+
if (!styleObj) return { code, changed: false, dynamic: false }; // no style attr — nothing to remove
|
|
232
|
+
|
|
233
|
+
const body = code.slice(styleObj.innerStart, styleObj.innerEnd);
|
|
234
|
+
const props = analyzeStyleProperties(body, styleObj.innerStart);
|
|
235
|
+
const existing = props.find((p) => p.name === prop);
|
|
236
|
+
if (!existing) return { code, changed: false, dynamic: false }; // absent — nothing to remove
|
|
237
|
+
if (!existing.literal) return { code, changed: false, dynamic: true }; // guard, mirrors setInlineStyle
|
|
238
|
+
|
|
239
|
+
// Prefer consuming a comma that FOLLOWS this member (it precedes others in the
|
|
240
|
+
// list); else one that PRECEDES it (it is last); else this was the ONLY thing in
|
|
241
|
+
// the object — collapse to `{}` instead.
|
|
242
|
+
let after = existing.memberEnd;
|
|
243
|
+
while (after < styleObj.innerEnd && /\s/.test(code[after] ?? '')) after++;
|
|
244
|
+
if (code[after] === ',') {
|
|
245
|
+
let end = after + 1;
|
|
246
|
+
if (code[end] === ' ') end++;
|
|
247
|
+
return {
|
|
248
|
+
code: code.slice(0, existing.memberStart) + code.slice(end),
|
|
249
|
+
changed: true,
|
|
250
|
+
dynamic: false,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let before = existing.memberStart - 1;
|
|
255
|
+
while (before >= styleObj.innerStart && /\s/.test(code[before] ?? '')) before--;
|
|
256
|
+
if (code[before] === ',') {
|
|
257
|
+
return {
|
|
258
|
+
code: code.slice(0, before) + code.slice(existing.memberEnd),
|
|
259
|
+
changed: true,
|
|
260
|
+
dynamic: false,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
code: code.slice(0, styleObj.innerStart) + code.slice(styleObj.innerEnd),
|
|
266
|
+
changed: true,
|
|
267
|
+
dynamic: false,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Find the end (exclusive) of a full JSX element starting at `<Tag` — balancing
|
|
273
|
+
* nested same-tag elements and skipping `{…}` expressions and strings (C8).
|
|
274
|
+
*/
|
|
275
|
+
export function findElementEnd(code: string, start: number): number {
|
|
276
|
+
const tagEnd = findTagEnd(code, start);
|
|
277
|
+
if (tagEnd < 0) return -1;
|
|
278
|
+
if (code[tagEnd - 1] === '/') return tagEnd + 1; // self-closing
|
|
279
|
+
const tagNameMatch = code.slice(start + 1, tagEnd).match(/^([A-Za-z0-9_.]+)/);
|
|
280
|
+
const tag = tagNameMatch?.[1];
|
|
281
|
+
if (!tag) return -1;
|
|
282
|
+
let depth = 1;
|
|
283
|
+
let i = tagEnd + 1;
|
|
284
|
+
let str: string | null = null;
|
|
285
|
+
while (i < code.length && depth > 0) {
|
|
286
|
+
const c = code[i];
|
|
287
|
+
if (str) {
|
|
288
|
+
if (c === str && code[i - 1] !== '\\') str = null;
|
|
289
|
+
i++;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
293
|
+
str = c;
|
|
294
|
+
i++;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (code.startsWith(`<${tag}`, i) && /[\s/>]/.test(code[i + 1 + tag.length] ?? '')) {
|
|
298
|
+
// A nested SAME-TAG element: only a non-self-closing one increments depth.
|
|
299
|
+
// A self-closing `<tag .../>` opens+closes in one tag and must NOT bump depth
|
|
300
|
+
// (else the balancer runs past the real closing tag and over-deletes).
|
|
301
|
+
const nestedTagEnd = findTagEnd(code, i);
|
|
302
|
+
if (nestedTagEnd > 0 && code[nestedTagEnd - 1] === '/') {
|
|
303
|
+
i = nestedTagEnd + 1; // skip the self-closing nested element whole
|
|
304
|
+
} else {
|
|
305
|
+
depth++;
|
|
306
|
+
i += 1 + tag.length;
|
|
307
|
+
}
|
|
308
|
+
} else if (code.startsWith(`</${tag}`, i)) {
|
|
309
|
+
depth--;
|
|
310
|
+
const close = code.indexOf('>', i);
|
|
311
|
+
i = close + 1;
|
|
312
|
+
if (depth === 0) return i;
|
|
313
|
+
} else {
|
|
314
|
+
i++;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return i;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// --- F5: multi-path style routing (inline handled above; className below) ---
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Encode a CSS prop:value as a Tailwind-style utility class. Numeric/color values
|
|
324
|
+
* use ARBITRARY values (`w-[200px]`, `bg-[#059669]`) so the round-trip is LOSSLESS
|
|
325
|
+
* (a named utility like `bg-emerald-600` would NOT recover the exact hex). Enums map
|
|
326
|
+
* to named utilities. This is the lossless-encoding rule from visual-edit/tailwind-map.
|
|
327
|
+
*/
|
|
328
|
+
// Cap 6 (React visual-edit parity): broadened to the grouped-inspector property set. Enum
|
|
329
|
+
// values map to NAMED utilities; each named utility string is unique across groups so the
|
|
330
|
+
// ENUM_GROUP reverse map (dedupe) stays unambiguous.
|
|
331
|
+
const ENUM_UTILITIES: Record<string, Record<string, string>> = {
|
|
332
|
+
display: {
|
|
333
|
+
flex: 'flex',
|
|
334
|
+
block: 'block',
|
|
335
|
+
grid: 'grid',
|
|
336
|
+
inline: 'inline',
|
|
337
|
+
'inline-block': 'inline-block',
|
|
338
|
+
'inline-flex': 'inline-flex',
|
|
339
|
+
none: 'hidden',
|
|
340
|
+
},
|
|
341
|
+
flexDirection: {
|
|
342
|
+
row: 'flex-row',
|
|
343
|
+
column: 'flex-col',
|
|
344
|
+
'row-reverse': 'flex-row-reverse',
|
|
345
|
+
'column-reverse': 'flex-col-reverse',
|
|
346
|
+
},
|
|
347
|
+
flexWrap: { wrap: 'flex-wrap', nowrap: 'flex-nowrap', 'wrap-reverse': 'flex-wrap-reverse' },
|
|
348
|
+
justifyContent: {
|
|
349
|
+
'flex-start': 'justify-start',
|
|
350
|
+
'flex-end': 'justify-end',
|
|
351
|
+
center: 'justify-center',
|
|
352
|
+
'space-between': 'justify-between',
|
|
353
|
+
'space-around': 'justify-around',
|
|
354
|
+
'space-evenly': 'justify-evenly',
|
|
355
|
+
},
|
|
356
|
+
alignItems: {
|
|
357
|
+
'flex-start': 'items-start',
|
|
358
|
+
'flex-end': 'items-end',
|
|
359
|
+
center: 'items-center',
|
|
360
|
+
baseline: 'items-baseline',
|
|
361
|
+
stretch: 'items-stretch',
|
|
362
|
+
},
|
|
363
|
+
textAlign: {
|
|
364
|
+
left: 'text-left',
|
|
365
|
+
center: 'text-center',
|
|
366
|
+
right: 'text-right',
|
|
367
|
+
justify: 'text-justify',
|
|
368
|
+
},
|
|
369
|
+
position: {
|
|
370
|
+
relative: 'relative',
|
|
371
|
+
absolute: 'absolute',
|
|
372
|
+
fixed: 'fixed',
|
|
373
|
+
sticky: 'sticky',
|
|
374
|
+
static: 'static',
|
|
375
|
+
},
|
|
376
|
+
overflow: {
|
|
377
|
+
visible: 'overflow-visible',
|
|
378
|
+
hidden: 'overflow-hidden',
|
|
379
|
+
scroll: 'overflow-scroll',
|
|
380
|
+
auto: 'overflow-auto',
|
|
381
|
+
},
|
|
382
|
+
borderStyle: {
|
|
383
|
+
none: 'border-none',
|
|
384
|
+
solid: 'border-solid',
|
|
385
|
+
dashed: 'border-dashed',
|
|
386
|
+
dotted: 'border-dotted',
|
|
387
|
+
double: 'border-double',
|
|
388
|
+
},
|
|
389
|
+
textTransform: {
|
|
390
|
+
uppercase: 'uppercase',
|
|
391
|
+
lowercase: 'lowercase',
|
|
392
|
+
capitalize: 'capitalize',
|
|
393
|
+
none: 'normal-case',
|
|
394
|
+
},
|
|
395
|
+
whiteSpace: {
|
|
396
|
+
normal: 'whitespace-normal',
|
|
397
|
+
nowrap: 'whitespace-nowrap',
|
|
398
|
+
pre: 'whitespace-pre',
|
|
399
|
+
'pre-wrap': 'whitespace-pre-wrap',
|
|
400
|
+
},
|
|
401
|
+
fontStyle: { italic: 'italic', normal: 'not-italic' },
|
|
402
|
+
mixBlendMode: {
|
|
403
|
+
normal: 'mix-blend-normal',
|
|
404
|
+
multiply: 'mix-blend-multiply',
|
|
405
|
+
screen: 'mix-blend-screen',
|
|
406
|
+
overlay: 'mix-blend-overlay',
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
// Reverse map: a named utility -> its logical group (for dedupe of enum utilities).
|
|
410
|
+
const ENUM_GROUP: Record<string, string> = (() => {
|
|
411
|
+
const out: Record<string, string> = {};
|
|
412
|
+
for (const [group, m] of Object.entries(ENUM_UTILITIES))
|
|
413
|
+
for (const u of Object.values(m)) out[u] = group;
|
|
414
|
+
return out;
|
|
415
|
+
})();
|
|
416
|
+
// Arbitrary-value utilities. `color`/`fontSize` both map to `text-`, so they use
|
|
417
|
+
// Tailwind TYPE HINTS (`text-[color:..]` vs `text-[length:..]`) to disambiguate —
|
|
418
|
+
// which also makes property-based dedupe unambiguous.
|
|
419
|
+
const ARB_MAP: Record<string, { prefix: string; hint?: 'color' | 'length' }> = {
|
|
420
|
+
backgroundColor: { prefix: 'bg' },
|
|
421
|
+
// `border` covers both border-color and border-width, so — like `text-` — they use type
|
|
422
|
+
// hints to stay unambiguous for property-based dedupe (Cap 6).
|
|
423
|
+
borderColor: { prefix: 'border', hint: 'color' },
|
|
424
|
+
borderWidth: { prefix: 'border', hint: 'length' },
|
|
425
|
+
color: { prefix: 'text', hint: 'color' },
|
|
426
|
+
fontSize: { prefix: 'text', hint: 'length' },
|
|
427
|
+
width: { prefix: 'w' },
|
|
428
|
+
height: { prefix: 'h' },
|
|
429
|
+
minWidth: { prefix: 'min-w' },
|
|
430
|
+
minHeight: { prefix: 'min-h' },
|
|
431
|
+
maxWidth: { prefix: 'max-w' },
|
|
432
|
+
maxHeight: { prefix: 'max-h' },
|
|
433
|
+
padding: { prefix: 'p' },
|
|
434
|
+
paddingTop: { prefix: 'pt' },
|
|
435
|
+
paddingRight: { prefix: 'pr' },
|
|
436
|
+
paddingBottom: { prefix: 'pb' },
|
|
437
|
+
paddingLeft: { prefix: 'pl' },
|
|
438
|
+
margin: { prefix: 'm' },
|
|
439
|
+
marginTop: { prefix: 'mt' },
|
|
440
|
+
marginRight: { prefix: 'mr' },
|
|
441
|
+
marginBottom: { prefix: 'mb' },
|
|
442
|
+
marginLeft: { prefix: 'ml' },
|
|
443
|
+
top: { prefix: 'top' },
|
|
444
|
+
right: { prefix: 'right' },
|
|
445
|
+
bottom: { prefix: 'bottom' },
|
|
446
|
+
left: { prefix: 'left' },
|
|
447
|
+
borderRadius: { prefix: 'rounded' },
|
|
448
|
+
borderTopLeftRadius: { prefix: 'rounded-tl' },
|
|
449
|
+
borderTopRightRadius: { prefix: 'rounded-tr' },
|
|
450
|
+
borderBottomLeftRadius: { prefix: 'rounded-bl' },
|
|
451
|
+
borderBottomRightRadius: { prefix: 'rounded-br' },
|
|
452
|
+
gap: { prefix: 'gap' },
|
|
453
|
+
fontWeight: { prefix: 'font' },
|
|
454
|
+
lineHeight: { prefix: 'leading' },
|
|
455
|
+
letterSpacing: { prefix: 'tracking' },
|
|
456
|
+
flexBasis: { prefix: 'basis' },
|
|
457
|
+
opacity: { prefix: 'opacity' },
|
|
458
|
+
zIndex: { prefix: 'z' },
|
|
459
|
+
boxShadow: { prefix: 'shadow' },
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const sanitizeClass = (s: string): string => s.replace(/\s+/g, '_');
|
|
463
|
+
|
|
464
|
+
/** Encode a CSS prop:value as a lossless Tailwind-style utility class. */
|
|
465
|
+
export function cssToUtilityClass(prop: string, value: string | number): string {
|
|
466
|
+
const v = String(value);
|
|
467
|
+
if (ENUM_UTILITIES[prop]?.[v]) return ENUM_UTILITIES[prop][v] as string;
|
|
468
|
+
const arb = ARB_MAP[prop];
|
|
469
|
+
if (!arb) return `[${sanitizeClass(prop)}:${sanitizeClass(v)}]`; // arbitrary property — lossless
|
|
470
|
+
const val = typeof value === 'number' ? `${value}px` : v;
|
|
471
|
+
const inner = arb.hint ? `${arb.hint}:${sanitizeClass(val)}` : sanitizeClass(val);
|
|
472
|
+
return `${arb.prefix}-[${inner}]`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** The dedupe group of an existing utility class (so a re-set replaces same-property classes). */
|
|
476
|
+
function classGroup(cls: string): string {
|
|
477
|
+
if (ENUM_GROUP[cls]) return ENUM_GROUP[cls];
|
|
478
|
+
const arb =
|
|
479
|
+
cls.indexOf('-[') >= 0 ? cls.slice(0, cls.indexOf('-[') + 2) : cls.startsWith('[') ? '[' : '';
|
|
480
|
+
if (!arb) return cls;
|
|
481
|
+
// include a type hint (color:/length:) in the group so text-[color:..] != text-[length:..]
|
|
482
|
+
const hintMatch = cls.match(/\[(color|length):/);
|
|
483
|
+
return hintMatch ? `${arb}${hintMatch[1]}:` : arb;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Merge a class into the element's `className="..."` literal, or add the
|
|
488
|
+
* attribute.
|
|
489
|
+
*
|
|
490
|
+
* D-A1 assessment (wave 13, `docs/WAVE13-JSX-SPREAD-UNDO-DESIGN.md`): unlike
|
|
491
|
+
* `setInlineStyle`, this function has NO distinguishable append-vs-replace
|
|
492
|
+
* branch to surface as `appended` — it always does the SAME operation (filter
|
|
493
|
+
* out any class in the target's dedupe GROUP, then push the new class),
|
|
494
|
+
* regardless of whether a same-group class existed before. Scoped OUT of this
|
|
495
|
+
* wave's fix: see the design doc's SHIPPED note for why the className route's
|
|
496
|
+
* undo correctness is a separate, broader pre-existing gap, not this bug.
|
|
497
|
+
*/
|
|
498
|
+
export function setClassName(code: string, elementStart: number, cls: string): StyleEditResult {
|
|
499
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
500
|
+
if (tagEnd < 0) return { code, changed: false, dynamic: false };
|
|
501
|
+
const openSeg = code.slice(elementStart, tagEnd);
|
|
502
|
+
const m = openSeg.match(/className\s*=\s*"([^"]*)"/);
|
|
503
|
+
if (m && m.index != null) {
|
|
504
|
+
// dedupe by PROPERTY GROUP (not raw prefix) so re-setting any prop replaces its
|
|
505
|
+
// prior class — incl. color-vs-fontSize (both `text-`) and enum/arbitrary-prop.
|
|
506
|
+
const group = classGroup(cls);
|
|
507
|
+
const existing = (m[1] ?? '')
|
|
508
|
+
.split(/\s+/)
|
|
509
|
+
.filter((c) => c && c !== cls && classGroup(c) !== group);
|
|
510
|
+
const merged = [...existing, cls].join(' ').trim();
|
|
511
|
+
const valStart = elementStart + (m.index ?? 0) + m[0].indexOf('"') + 1;
|
|
512
|
+
const valEnd = valStart + (m[1]?.length ?? 0);
|
|
513
|
+
return {
|
|
514
|
+
code: `${code.slice(0, valStart)}${merged}${code.slice(valEnd)}`,
|
|
515
|
+
changed: true,
|
|
516
|
+
dynamic: false,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
if (/className\s*=\s*\{/.test(openSeg)) return { code, changed: false, dynamic: true }; // dynamic className
|
|
520
|
+
const selfClose = code[tagEnd - 1] === '/';
|
|
521
|
+
const insertAt = selfClose ? tagEnd - 1 : tagEnd;
|
|
522
|
+
return {
|
|
523
|
+
code: `${code.slice(0, insertAt)} className="${cls}"${code.slice(insertAt)}`,
|
|
524
|
+
changed: true,
|
|
525
|
+
dynamic: false,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Route a style edit to the right target (F5): if the element has a literal
|
|
531
|
+
* `className`, write a (lossless) utility class; otherwise write inline style.
|
|
532
|
+
*/
|
|
533
|
+
export function writeStyleAuto(
|
|
534
|
+
code: string,
|
|
535
|
+
elementStart: number,
|
|
536
|
+
prop: string,
|
|
537
|
+
value: string | number,
|
|
538
|
+
): StyleEditResult & { route: 'class' | 'inline' } {
|
|
539
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
540
|
+
const openSeg = tagEnd > 0 ? code.slice(elementStart, tagEnd) : '';
|
|
541
|
+
if (/className\s*=\s*"/.test(openSeg)) {
|
|
542
|
+
return { ...setClassName(code, elementStart, cssToUtilityClass(prop, value)), route: 'class' };
|
|
543
|
+
}
|
|
544
|
+
return { ...setInlineStyle(code, elementStart, prop, value), route: 'inline' };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// --- Cap 2: CSS-file editing (visual-edit writer.ts surgicalCssEdit :1109) ---
|
|
548
|
+
|
|
549
|
+
/** camelCase CSS prop -> kebab-case, for matching a declaration in a CSS rule body. */
|
|
550
|
+
function cssCamelToKebab(s: string): string {
|
|
551
|
+
return s.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Find a standalone occurrence of `selectorText` in CSS source (not a substring of a
|
|
555
|
+
* longer selector). Returns the index or -1. */
|
|
556
|
+
function findSelectorIndex(cssSource: string, selectorText: string): number {
|
|
557
|
+
let searchFrom = 0;
|
|
558
|
+
while (searchFrom < cssSource.length) {
|
|
559
|
+
const idx = cssSource.indexOf(selectorText, searchFrom);
|
|
560
|
+
if (idx === -1) break;
|
|
561
|
+
const before = idx > 0 ? cssSource[idx - 1] : '\n';
|
|
562
|
+
if (idx === 0 || /[\s\n{},;]/.test(before ?? '')) {
|
|
563
|
+
const after = cssSource[idx + selectorText.length];
|
|
564
|
+
if (after === '{' || after === ' ' || after === '\n' || after === '\r' || after === '\t') {
|
|
565
|
+
return idx;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
searchFrom = idx + 1;
|
|
569
|
+
}
|
|
570
|
+
return -1;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** The `{ … }` body range of the rule whose selector starts at `selectorIdx`, or null. */
|
|
574
|
+
function findRuleBody(
|
|
575
|
+
cssSource: string,
|
|
576
|
+
selectorIdx: number,
|
|
577
|
+
selectorText: string,
|
|
578
|
+
): { bodyStart: number; bodyEnd: number } | null {
|
|
579
|
+
let i = selectorIdx + selectorText.length;
|
|
580
|
+
while (i < cssSource.length && cssSource[i] !== '{') i++;
|
|
581
|
+
if (i >= cssSource.length) return null;
|
|
582
|
+
const bodyStart = i + 1;
|
|
583
|
+
let depth = 1;
|
|
584
|
+
i++;
|
|
585
|
+
while (i < cssSource.length && depth > 0) {
|
|
586
|
+
if (cssSource[i] === '{') depth++;
|
|
587
|
+
else if (cssSource[i] === '}') depth--;
|
|
588
|
+
if (depth > 0) i++;
|
|
589
|
+
}
|
|
590
|
+
return { bodyStart, bodyEnd: i };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Surgically replace (or append) a property value inside a CSS RULE BODY, editing the
|
|
595
|
+
* source in place and preserving all other declarations/formatting (visual-edit
|
|
596
|
+
* `surgicalCssEdit` :1109). Returns the edited source, or `null` when the selector is not
|
|
597
|
+
* present in the file (i.e. it's GENERATED CSS — a Tailwind utility, styled-components —
|
|
598
|
+
* that must not be hand-edited; the caller falls back to class/inline routing).
|
|
599
|
+
*/
|
|
600
|
+
export function surgicalCssEdit(
|
|
601
|
+
cssSource: string,
|
|
602
|
+
selectorText: string,
|
|
603
|
+
propName: string,
|
|
604
|
+
newValue: string,
|
|
605
|
+
): string | null {
|
|
606
|
+
const selectorIdx = findSelectorIndex(cssSource, selectorText);
|
|
607
|
+
if (selectorIdx === -1) return null;
|
|
608
|
+
const rng = findRuleBody(cssSource, selectorIdx, selectorText);
|
|
609
|
+
if (!rng) return null;
|
|
610
|
+
const { bodyStart, bodyEnd } = rng;
|
|
611
|
+
const body = cssSource.slice(bodyStart, bodyEnd);
|
|
612
|
+
|
|
613
|
+
const kebabProp = cssCamelToKebab(propName);
|
|
614
|
+
const escaped = kebabProp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
615
|
+
const propRegex = new RegExp(`(^|[;\\n\\s])\\s*(${escaped})\\s*:`, 'm');
|
|
616
|
+
const match = propRegex.exec(body);
|
|
617
|
+
|
|
618
|
+
if (match) {
|
|
619
|
+
const matchStart = bodyStart + match.index + match[0].length; // right after the ':'
|
|
620
|
+
let valEnd = matchStart;
|
|
621
|
+
while (valEnd < bodyEnd && cssSource[valEnd] !== ';' && cssSource[valEnd] !== '}') valEnd++;
|
|
622
|
+
return `${cssSource.slice(0, matchStart)} ${newValue}${cssSource.slice(valEnd)}`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Property absent — append it before the closing '}', matching existing indentation.
|
|
626
|
+
let indent = ' ';
|
|
627
|
+
for (const line of body.split('\n')) {
|
|
628
|
+
if (line.trim().includes(':')) {
|
|
629
|
+
indent = line.match(/^(\s*)/)?.[1] ?? indent;
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const trimmed = body.trimEnd();
|
|
634
|
+
const needsSemi = trimmed.length > 0 && !trimmed.endsWith(';');
|
|
635
|
+
const insertion = `${needsSemi ? ';\n' : '\n'}${indent}${kebabProp}: ${newValue};\n`;
|
|
636
|
+
return cssSource.slice(0, bodyEnd) + insertion + cssSource.slice(bodyEnd);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** A matched CSS rule as produced by `inspect.ts`'s `getMatchedCssRules` — the subset the
|
|
640
|
+
* CSS write path needs. */
|
|
641
|
+
export interface CssRuleTarget {
|
|
642
|
+
selectorText: string;
|
|
643
|
+
sourceFile: string;
|
|
644
|
+
properties: Record<string, string>;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Pick the target rule for a property write: the LAST matched rule that declares the
|
|
649
|
+
* property (cascade — last declaration wins), mirroring visual-edit `writeCssRuleChange`
|
|
650
|
+
* :1198. Returns the rule + the prior value (for the undo inverse), or null when no
|
|
651
|
+
* matched rule declares the property (⇒ caller routes to class/inline instead).
|
|
652
|
+
*/
|
|
653
|
+
export function pickCssRuleTarget(
|
|
654
|
+
matchedRules: readonly CssRuleTarget[],
|
|
655
|
+
prop: string,
|
|
656
|
+
): { rule: CssRuleTarget; prevValue: string } | null {
|
|
657
|
+
const kebabProp = cssCamelToKebab(prop);
|
|
658
|
+
let target: CssRuleTarget | null = null;
|
|
659
|
+
for (const rule of matchedRules) {
|
|
660
|
+
if (kebabProp in rule.properties) target = rule;
|
|
661
|
+
}
|
|
662
|
+
if (!target) return null;
|
|
663
|
+
return { rule: target, prevValue: target.properties[kebabProp] ?? '' };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// --- Cap 3: text-content editing (visual-edit writer.ts getEditableText :817 /
|
|
667
|
+
// editTextContent :861) ---
|
|
668
|
+
|
|
669
|
+
/** The `<`-relative content range (between the opening tag's `>` and the closing `</`)
|
|
670
|
+
* of the element at `elementStart`, plus whether it is a pure-text (editable) body. */
|
|
671
|
+
function findTextContentRange(
|
|
672
|
+
code: string,
|
|
673
|
+
elementStart: number,
|
|
674
|
+
): { contentStart: number; closingTagStart: number; content: string } | null {
|
|
675
|
+
if (code[elementStart] !== '<') return null;
|
|
676
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
677
|
+
if (tagEnd < 0 || code[tagEnd - 1] === '/') return null; // self-closing ⇒ no text body
|
|
678
|
+
const contentStart = tagEnd + 1;
|
|
679
|
+
const elementEnd = findElementEnd(code, elementStart);
|
|
680
|
+
if (elementEnd < 0) return null;
|
|
681
|
+
// The closing tag is the last `</` before elementEnd.
|
|
682
|
+
let closingTagStart = elementEnd - 1;
|
|
683
|
+
while (
|
|
684
|
+
closingTagStart > contentStart &&
|
|
685
|
+
!(code[closingTagStart] === '<' && code[closingTagStart + 1] === '/')
|
|
686
|
+
) {
|
|
687
|
+
closingTagStart--;
|
|
688
|
+
}
|
|
689
|
+
return { contentStart, closingTagStart, content: code.slice(contentStart, closingTagStart) };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Return an element's editable text — its content trimmed — IFF the body is pure text (no
|
|
694
|
+
* `{expression}` and no child `<element>`); else null (visual-edit `getEditableText` :817).
|
|
695
|
+
* This is the double-click-to-edit gate: only a leaf text node is editable in place.
|
|
696
|
+
*/
|
|
697
|
+
export function getEditableText(code: string, elementStart: number): string | null {
|
|
698
|
+
const rng = findTextContentRange(code, elementStart);
|
|
699
|
+
if (!rng) return null;
|
|
700
|
+
if (rng.content.includes('{') || rng.content.includes('<')) return null; // dynamic/children
|
|
701
|
+
const trimmed = rng.content.trim();
|
|
702
|
+
return trimmed ? trimmed : null;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Escape the JSX-structural characters so replacement text can't break the source tree
|
|
706
|
+
* (`<`/`{`/`}` become entities; `&` first so we don't double-encode). */
|
|
707
|
+
function escapeJsxText(text: string): string {
|
|
708
|
+
return text
|
|
709
|
+
.replace(/&/g, '&')
|
|
710
|
+
.replace(/</g, '<')
|
|
711
|
+
.replace(/\{/g, '{')
|
|
712
|
+
.replace(/\}/g, '}');
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Replace an element's text content in place, preserving the surrounding whitespace
|
|
717
|
+
* (only the trimmed text portion is swapped — visual-edit `editTextContent` :861). Refuses
|
|
718
|
+
* (guard) when the body is not pure text (`dynamic: true`), exactly like the read gate.
|
|
719
|
+
*/
|
|
720
|
+
export function editTextContent(
|
|
721
|
+
code: string,
|
|
722
|
+
elementStart: number,
|
|
723
|
+
newText: string,
|
|
724
|
+
): StyleEditResult {
|
|
725
|
+
const rng = findTextContentRange(code, elementStart);
|
|
726
|
+
if (!rng) return { code, changed: false, dynamic: false };
|
|
727
|
+
const { contentStart, closingTagStart, content } = rng;
|
|
728
|
+
if (content.includes('{') || content.includes('<'))
|
|
729
|
+
return { code, changed: false, dynamic: true };
|
|
730
|
+
const leading = content.length - content.trimStart().length;
|
|
731
|
+
const trailing = content.length - content.trimEnd().length;
|
|
732
|
+
const replaceStart = contentStart + leading;
|
|
733
|
+
const replaceEnd = closingTagStart - trailing;
|
|
734
|
+
const next = code.slice(0, replaceStart) + escapeJsxText(newText) + code.slice(replaceEnd);
|
|
735
|
+
return { code: next, changed: next !== code, dynamic: false };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// --- Cap 4: JSX prop / attribute editing (visual-edit writer.ts analyzeJsxAttributes
|
|
739
|
+
// :913 / writePropChange :1018 / fetchPropAnalysis :1069) ---
|
|
740
|
+
|
|
741
|
+
export interface JsxAttrInfo {
|
|
742
|
+
name: string;
|
|
743
|
+
/** Value char range in `code` (exclusive of quotes/braces). -1 for a boolean shorthand. */
|
|
744
|
+
valueStart: number;
|
|
745
|
+
valueEnd: number;
|
|
746
|
+
rawValue: string;
|
|
747
|
+
/** True if the value is a `{…}` expression container (vs a `"…"` string). */
|
|
748
|
+
isExpression: boolean;
|
|
749
|
+
/** True if the value is a literal we can safely rewrite (string, number, boolean). */
|
|
750
|
+
isLiteral: boolean;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Skip a `{…}` region (spread or expression), respecting nested braces + strings. Returns
|
|
754
|
+
* the index just past the matching `}`. */
|
|
755
|
+
function skipBraces(code: string, from: number): number {
|
|
756
|
+
let i = from + 1;
|
|
757
|
+
let depth = 1;
|
|
758
|
+
let inStr: string | null = null;
|
|
759
|
+
while (i < code.length && depth > 0) {
|
|
760
|
+
const ch = code[i];
|
|
761
|
+
if (inStr) {
|
|
762
|
+
if (ch === inStr && code[i - 1] !== '\\') inStr = null;
|
|
763
|
+
} else if (ch === "'" || ch === '"' || ch === '`') inStr = ch;
|
|
764
|
+
else if (ch === '{') depth++;
|
|
765
|
+
else if (ch === '}') depth--;
|
|
766
|
+
i++;
|
|
767
|
+
}
|
|
768
|
+
return i;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Read a quoted string value starting at the opening quote; returns [valueStart, valueEnd,
|
|
772
|
+
* indexAfterClosingQuote]. */
|
|
773
|
+
function readQuotedValue(code: string, openQuote: number, delim: string): [number, number, number] {
|
|
774
|
+
let i = openQuote + 1;
|
|
775
|
+
const valueStart = i;
|
|
776
|
+
while (i < code.length && code[i] !== delim) {
|
|
777
|
+
if (code[i] === '\\') i++;
|
|
778
|
+
i++;
|
|
779
|
+
}
|
|
780
|
+
return [valueStart, i, i + 1];
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const LITERAL_EXPR_RE = /^(\d+(\.\d+)?|true|false|'[^']*'|"[^"]*")$/;
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Parse the attributes of a JSX opening tag `<Tag …>` (visual-edit `analyzeJsxAttributes`
|
|
787
|
+
* :913). Handles string values, `{expression}` values (literal detection), boolean
|
|
788
|
+
* shorthand, and `{...spread}` (skipped). Pure string scan — no parser.
|
|
789
|
+
*/
|
|
790
|
+
export function analyzeJsxAttributes(
|
|
791
|
+
code: string,
|
|
792
|
+
tagStart: number,
|
|
793
|
+
tagEnd: number,
|
|
794
|
+
): JsxAttrInfo[] {
|
|
795
|
+
const attrs: JsxAttrInfo[] = [];
|
|
796
|
+
const skipWs = (i: number): number => {
|
|
797
|
+
while (i <= tagEnd && /\s/.test(code[i] ?? '')) i++;
|
|
798
|
+
return i;
|
|
799
|
+
};
|
|
800
|
+
let i = tagStart + 1; // skip '<'
|
|
801
|
+
while (i <= tagEnd && !/[\s>/]/.test(code[i] ?? '>')) i++; // skip tag name
|
|
802
|
+
|
|
803
|
+
while (i <= tagEnd) {
|
|
804
|
+
i = skipWs(i);
|
|
805
|
+
if (i > tagEnd || code[i] === '>' || (code[i] === '/' && code[i + 1] === '>')) break;
|
|
806
|
+
if (code[i] === '{') {
|
|
807
|
+
i = skipBraces(code, i); // {...spread}
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
const nameStart = i;
|
|
811
|
+
while (i <= tagEnd && /[\w-]/.test(code[i] ?? '')) i++;
|
|
812
|
+
const name = code.slice(nameStart, i);
|
|
813
|
+
if (!name) {
|
|
814
|
+
i++;
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
i = skipWs(i);
|
|
818
|
+
if (code[i] !== '=') {
|
|
819
|
+
attrs.push(booleanShorthand(name)); // e.g. `disabled`
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
i = skipWs(i + 1); // skip '=' + whitespace
|
|
823
|
+
const parsed = readAttrValue(code, i);
|
|
824
|
+
if (!parsed) {
|
|
825
|
+
i++;
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
attrs.push(makeAttr(code, name, parsed));
|
|
829
|
+
i = parsed.next;
|
|
830
|
+
}
|
|
831
|
+
return attrs;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** A boolean-shorthand attribute descriptor (`disabled` ⇒ `true`, no value range). */
|
|
835
|
+
function booleanShorthand(name: string): JsxAttrInfo {
|
|
836
|
+
return {
|
|
837
|
+
name,
|
|
838
|
+
valueStart: -1,
|
|
839
|
+
valueEnd: -1,
|
|
840
|
+
rawValue: 'true',
|
|
841
|
+
isExpression: false,
|
|
842
|
+
isLiteral: true,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Read an attribute value starting at `i` (the delimiter after `=`). Returns the value
|
|
847
|
+
* range + whether it's an expression + the index past it, or null for an unrecognized
|
|
848
|
+
* delimiter (the caller advances one char). */
|
|
849
|
+
function readAttrValue(
|
|
850
|
+
code: string,
|
|
851
|
+
i: number,
|
|
852
|
+
): { valueStart: number; valueEnd: number; isExpression: boolean; next: number } | null {
|
|
853
|
+
const delim = code[i];
|
|
854
|
+
if (delim === '"' || delim === "'") {
|
|
855
|
+
const [valueStart, valueEnd, next] = readQuotedValue(code, i, delim);
|
|
856
|
+
return { valueStart, valueEnd, isExpression: false, next };
|
|
857
|
+
}
|
|
858
|
+
if (delim === '{') {
|
|
859
|
+
const valueEnd = skipBraces(code, i) - 1;
|
|
860
|
+
return { valueStart: i + 1, valueEnd, isExpression: true, next: valueEnd + 1 };
|
|
861
|
+
}
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** Build a `JsxAttrInfo` from a parsed value range (computes rawValue + literal flag). */
|
|
866
|
+
function makeAttr(
|
|
867
|
+
code: string,
|
|
868
|
+
name: string,
|
|
869
|
+
parsed: { valueStart: number; valueEnd: number; isExpression: boolean },
|
|
870
|
+
): JsxAttrInfo {
|
|
871
|
+
const rawValue = code.slice(parsed.valueStart, parsed.valueEnd).trim();
|
|
872
|
+
const isLiteral = !parsed.isExpression || LITERAL_EXPR_RE.test(rawValue);
|
|
873
|
+
return {
|
|
874
|
+
name,
|
|
875
|
+
valueStart: parsed.valueStart,
|
|
876
|
+
valueEnd: parsed.valueEnd,
|
|
877
|
+
rawValue,
|
|
878
|
+
isExpression: parsed.isExpression,
|
|
879
|
+
isLiteral,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** Props never surfaced as editable component props (visual-edit `fetchPropAnalysis` skip
|
|
884
|
+
* set + on* handlers — event handlers are always dynamic). */
|
|
885
|
+
const PROP_SKIP = new Set(['key', 'ref', 'data-oid', 'style', 'className', 'children']);
|
|
886
|
+
const isSkippedProp = (name: string): boolean => PROP_SKIP.has(name) || /^on[A-Z]/.test(name);
|
|
887
|
+
|
|
888
|
+
/** Split a component's JSX attributes into editable (literal) vs dynamic (expression) —
|
|
889
|
+
* the Props inspector uses this to gate which props can be written (visual-edit
|
|
890
|
+
* `fetchPropAnalysis` :1069). */
|
|
891
|
+
export function analyzeProps(
|
|
892
|
+
code: string,
|
|
893
|
+
elementStart: number,
|
|
894
|
+
): { editable: string[]; dynamic: string[] } {
|
|
895
|
+
const empty = { editable: [], dynamic: [] };
|
|
896
|
+
if (code[elementStart] !== '<') return empty;
|
|
897
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
898
|
+
if (tagEnd < 0) return empty;
|
|
899
|
+
const editable: string[] = [];
|
|
900
|
+
const dynamic: string[] = [];
|
|
901
|
+
for (const attr of analyzeJsxAttributes(code, elementStart, tagEnd)) {
|
|
902
|
+
if (isSkippedProp(attr.name)) continue;
|
|
903
|
+
(attr.isLiteral ? editable : dynamic).push(attr.name);
|
|
904
|
+
}
|
|
905
|
+
return { editable, dynamic };
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** Format the replacement for a prop write, matching the original value's shape (number/
|
|
909
|
+
* boolean/quoted expression, or a plain string attribute). */
|
|
910
|
+
function formatPropReplacement(attr: JsxAttrInfo, newValue: string): string {
|
|
911
|
+
if (!attr.isExpression) return newValue; // string attribute — content between the quotes
|
|
912
|
+
const raw = attr.rawValue;
|
|
913
|
+
if (/^\d+(\.\d+)?$/.test(raw)) {
|
|
914
|
+
const num = Number(newValue);
|
|
915
|
+
return Number.isNaN(num) ? `'${newValue}'` : String(num);
|
|
916
|
+
}
|
|
917
|
+
if (raw === 'true' || raw === 'false') {
|
|
918
|
+
return newValue === 'true' || newValue === 'false' ? newValue : `'${newValue}'`;
|
|
919
|
+
}
|
|
920
|
+
if (/^"[^"]*"$/.test(raw)) return `"${newValue.replace(/"/g, '\\"')}"`;
|
|
921
|
+
return `'${newValue.replace(/'/g, "\\'")}'`;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Write a component prop change back to source at the call site (visual-edit
|
|
926
|
+
* `writePropChange` :1018). Refuses (guards) a dynamic (non-literal) prop and skips the
|
|
927
|
+
* event-handler / structural props. `elementStart` is the call-site element's `<`.
|
|
928
|
+
*/
|
|
929
|
+
export function writePropChange(
|
|
930
|
+
code: string,
|
|
931
|
+
elementStart: number,
|
|
932
|
+
propName: string,
|
|
933
|
+
newValue: string,
|
|
934
|
+
): StyleEditResult {
|
|
935
|
+
if (code[elementStart] !== '<') return { code, changed: false, dynamic: false };
|
|
936
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
937
|
+
if (tagEnd < 0) return { code, changed: false, dynamic: false };
|
|
938
|
+
if (isSkippedProp(propName)) return { code, changed: false, dynamic: false };
|
|
939
|
+
const attr = analyzeJsxAttributes(code, elementStart, tagEnd).find((a) => a.name === propName);
|
|
940
|
+
if (!attr) return { code, changed: false, dynamic: false };
|
|
941
|
+
if (!attr.isLiteral || attr.valueStart < 0) return { code, changed: false, dynamic: true };
|
|
942
|
+
const replacement = formatPropReplacement(attr, newValue);
|
|
943
|
+
const next = code.slice(0, attr.valueStart) + replacement + code.slice(attr.valueEnd);
|
|
944
|
+
return { code: next, changed: next !== code, dynamic: false };
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// --- Cap 5: source structural ops (visual-edit writer.ts reorderChild :483 /
|
|
948
|
+
// duplicateElement :559 / wrapElement :634 / unwrapElement :679 /
|
|
949
|
+
// reparentElement :743) ---
|
|
950
|
+
|
|
951
|
+
export interface StructEditResult {
|
|
952
|
+
code: string;
|
|
953
|
+
changed: boolean;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** Expand a JSX element at `elementStart` to its full LINE block (leading whitespace on the
|
|
957
|
+
* first line, trailing newline on the last) — the unit these line-based ops move/copy. */
|
|
958
|
+
function lineBlockRange(
|
|
959
|
+
code: string,
|
|
960
|
+
elementStart: number,
|
|
961
|
+
): { lineStart: number; lineEnd: number } | null {
|
|
962
|
+
if (code[elementStart] !== '<') return null;
|
|
963
|
+
const end = findElementEnd(code, elementStart);
|
|
964
|
+
if (end < 0) return null;
|
|
965
|
+
let lineStart = elementStart;
|
|
966
|
+
while (lineStart > 0 && code[lineStart - 1] !== '\n') lineStart--;
|
|
967
|
+
let lineEnd = end;
|
|
968
|
+
while (lineEnd < code.length && code[lineEnd] !== '\n') lineEnd++;
|
|
969
|
+
if (lineEnd < code.length && code[lineEnd] === '\n') lineEnd++;
|
|
970
|
+
return { lineStart, lineEnd };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** The `<` of a parent's closing tag, given the parent's opening `<` (visual-edit's
|
|
974
|
+
* backwards scan from the element end). */
|
|
975
|
+
function closingTagStartOf(code: string, parentStart: number): number | null {
|
|
976
|
+
const parentEnd = findElementEnd(code, parentStart);
|
|
977
|
+
if (parentEnd < 0) return null;
|
|
978
|
+
let i = parentEnd;
|
|
979
|
+
while (i > parentStart && code[i - 1] !== '<') i--;
|
|
980
|
+
return i - 1; // point at the '<'
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** Duplicate the element's line block, inserting the copy immediately after it (visual-edit
|
|
984
|
+
* `duplicateElement` :559). */
|
|
985
|
+
export function duplicateElement(code: string, elementStart: number): StructEditResult {
|
|
986
|
+
const r = lineBlockRange(code, elementStart);
|
|
987
|
+
if (!r) return { code, changed: false };
|
|
988
|
+
const block = code.slice(r.lineStart, r.lineEnd);
|
|
989
|
+
return { code: code.slice(0, r.lineEnd) + block + code.slice(r.lineEnd), changed: true };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** Wrap the element in a new `<wrapperTag>…</wrapperTag>` container, re-indenting the
|
|
993
|
+
* wrapped block one level (visual-edit `wrapElement` :634). */
|
|
994
|
+
export function wrapElement(
|
|
995
|
+
code: string,
|
|
996
|
+
elementStart: number,
|
|
997
|
+
wrapperTag = 'div',
|
|
998
|
+
): StructEditResult {
|
|
999
|
+
const r = lineBlockRange(code, elementStart);
|
|
1000
|
+
if (!r) return { code, changed: false };
|
|
1001
|
+
const block = code.slice(r.lineStart, r.lineEnd);
|
|
1002
|
+
const indent = block.match(/^(\s*)/)?.[1] ?? '';
|
|
1003
|
+
const reindented = block
|
|
1004
|
+
.split('\n')
|
|
1005
|
+
.map((line) => (line.length > 0 ? ` ${line}` : line))
|
|
1006
|
+
.join('\n');
|
|
1007
|
+
const wrapper = `${indent}<${wrapperTag}>\n${reindented}${indent}</${wrapperTag}>\n`;
|
|
1008
|
+
return { code: code.slice(0, r.lineStart) + wrapper + code.slice(r.lineEnd), changed: true };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** Unwrap the element — replace it with its children, dedented one level (visual-edit
|
|
1012
|
+
* `unwrapElement` :679). A self-closing element simply deletes. */
|
|
1013
|
+
export function unwrapElement(code: string, elementStart: number): StructEditResult {
|
|
1014
|
+
if (code[elementStart] !== '<') return { code, changed: false };
|
|
1015
|
+
const end = findElementEnd(code, elementStart);
|
|
1016
|
+
const openTagEnd = findTagEnd(code, elementStart);
|
|
1017
|
+
if (end < 0 || openTagEnd < 0) return { code, changed: false };
|
|
1018
|
+
if (code[openTagEnd - 1] === '/') return deleteElement(code, elementStart); // self-closing
|
|
1019
|
+
let closingTagStart = end;
|
|
1020
|
+
while (closingTagStart > elementStart && code[closingTagStart - 1] !== '<') closingTagStart--;
|
|
1021
|
+
closingTagStart--; // point at '<'
|
|
1022
|
+
const children = code.slice(openTagEnd + 1, closingTagStart);
|
|
1023
|
+
const r = lineBlockRange(code, elementStart);
|
|
1024
|
+
if (!r) return { code, changed: false };
|
|
1025
|
+
const dedented = children
|
|
1026
|
+
.split('\n')
|
|
1027
|
+
.map((line) =>
|
|
1028
|
+
line.startsWith(' ') ? line.slice(2) : line.startsWith('\t') ? line.slice(1) : line,
|
|
1029
|
+
)
|
|
1030
|
+
.join('\n');
|
|
1031
|
+
return { code: code.slice(0, r.lineStart) + dedented + code.slice(r.lineEnd), changed: true };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Insert a new child element `<tag />` just before `parentStart`'s closing tag, indented
|
|
1035
|
+
* to match the parent's children (the "create" primitive). */
|
|
1036
|
+
export function insertChildElement(
|
|
1037
|
+
code: string,
|
|
1038
|
+
parentStart: number,
|
|
1039
|
+
tag = 'div',
|
|
1040
|
+
): StructEditResult {
|
|
1041
|
+
const closingTagStart = closingTagStartOf(code, parentStart);
|
|
1042
|
+
if (closingTagStart == null) return { code, changed: false };
|
|
1043
|
+
let parentLineStart = parentStart;
|
|
1044
|
+
while (parentLineStart > 0 && code[parentLineStart - 1] !== '\n') parentLineStart--;
|
|
1045
|
+
const parentIndent = code.slice(parentLineStart, parentStart).match(/^(\s*)/)?.[1] ?? '';
|
|
1046
|
+
let insertPos = closingTagStart;
|
|
1047
|
+
while (insertPos > 0 && code[insertPos - 1] !== '\n') insertPos--;
|
|
1048
|
+
const child = `${parentIndent} <${tag} />\n`;
|
|
1049
|
+
return { code: code.slice(0, insertPos) + child + code.slice(insertPos), changed: true };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** Reorder the element among its siblings: before `beforeStart` (an OID-resolved sibling
|
|
1053
|
+
* offset), or — when `beforeStart` is null — at the end of `parentStart` (visual-edit
|
|
1054
|
+
* `reorderChild` :483). OID-resolved offsets, immune to index races. */
|
|
1055
|
+
export function reorderChild(
|
|
1056
|
+
code: string,
|
|
1057
|
+
elementStart: number,
|
|
1058
|
+
beforeStart: number | null,
|
|
1059
|
+
parentStart: number | null,
|
|
1060
|
+
): StructEditResult {
|
|
1061
|
+
const r = lineBlockRange(code, elementStart);
|
|
1062
|
+
if (!r) return { code, changed: false };
|
|
1063
|
+
const { lineStart, lineEnd } = r;
|
|
1064
|
+
const block = code.slice(lineStart, lineEnd);
|
|
1065
|
+
const removedLength = lineEnd - lineStart;
|
|
1066
|
+
|
|
1067
|
+
let targetLineStart: number;
|
|
1068
|
+
if (beforeStart != null) {
|
|
1069
|
+
targetLineStart = beforeStart;
|
|
1070
|
+
while (targetLineStart > 0 && code[targetLineStart - 1] !== '\n') targetLineStart--;
|
|
1071
|
+
if (targetLineStart === lineStart) return { code, changed: false }; // same position
|
|
1072
|
+
} else if (parentStart != null) {
|
|
1073
|
+
const closingTagStart = closingTagStartOf(code, parentStart);
|
|
1074
|
+
if (closingTagStart == null) return { code, changed: false };
|
|
1075
|
+
targetLineStart = closingTagStart;
|
|
1076
|
+
while (targetLineStart > 0 && code[targetLineStart - 1] !== '\n') targetLineStart--;
|
|
1077
|
+
if (lineStart >= targetLineStart) return { code, changed: false }; // already at end
|
|
1078
|
+
} else {
|
|
1079
|
+
return { code, changed: false };
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const without = code.slice(0, lineStart) + code.slice(lineEnd);
|
|
1083
|
+
const adj = targetLineStart > lineStart ? targetLineStart - removedLength : targetLineStart;
|
|
1084
|
+
return { code: without.slice(0, adj) + block + without.slice(adj), changed: true };
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Reparent the element to be the last child of `parentStart`, re-indented to the parent's
|
|
1088
|
+
* child level (visual-edit `reparentElement` :743). Same-file only (the caller guards). */
|
|
1089
|
+
export function reparentElement(
|
|
1090
|
+
code: string,
|
|
1091
|
+
elementStart: number,
|
|
1092
|
+
parentStart: number,
|
|
1093
|
+
): StructEditResult {
|
|
1094
|
+
const r = lineBlockRange(code, elementStart);
|
|
1095
|
+
if (!r) return { code, changed: false };
|
|
1096
|
+
const { lineStart: elLineStart, lineEnd: elLineEnd } = r;
|
|
1097
|
+
const block = code.slice(elLineStart, elLineEnd);
|
|
1098
|
+
const closingTagStart = closingTagStartOf(code, parentStart);
|
|
1099
|
+
if (closingTagStart == null) return { code, changed: false };
|
|
1100
|
+
|
|
1101
|
+
let parentLineStart = parentStart;
|
|
1102
|
+
while (parentLineStart > 0 && code[parentLineStart - 1] !== '\n') parentLineStart--;
|
|
1103
|
+
const parentIndent = code.slice(parentLineStart, parentStart).match(/^(\s*)/)?.[1] ?? '';
|
|
1104
|
+
const childIndent = `${parentIndent} `;
|
|
1105
|
+
const reindented = block
|
|
1106
|
+
.split('\n')
|
|
1107
|
+
.map((line) => {
|
|
1108
|
+
const trimmed = line.replace(/^\s*/, '');
|
|
1109
|
+
return trimmed ? childIndent + trimmed : '';
|
|
1110
|
+
})
|
|
1111
|
+
.join('\n');
|
|
1112
|
+
|
|
1113
|
+
const without = code.slice(0, elLineStart) + code.slice(elLineEnd);
|
|
1114
|
+
let adjustedClosing = closingTagStart;
|
|
1115
|
+
if (elLineStart < closingTagStart) adjustedClosing -= elLineEnd - elLineStart;
|
|
1116
|
+
let insertPos = adjustedClosing;
|
|
1117
|
+
while (insertPos > 0 && without[insertPos - 1] !== '\n') insertPos--;
|
|
1118
|
+
return {
|
|
1119
|
+
code: without.slice(0, insertPos) + reindented + without.slice(insertPos),
|
|
1120
|
+
changed: true,
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** Delete the element starting at `elementStart` from source (C8). */
|
|
1125
|
+
export function deleteElement(
|
|
1126
|
+
code: string,
|
|
1127
|
+
elementStart: number,
|
|
1128
|
+
): { code: string; changed: boolean } {
|
|
1129
|
+
const end = findElementEnd(code, elementStart);
|
|
1130
|
+
if (end < 0) return { code, changed: false };
|
|
1131
|
+
// also consume leading whitespace on the line + trailing newline
|
|
1132
|
+
let s = elementStart;
|
|
1133
|
+
while (s > 0 && (code[s - 1] === ' ' || code[s - 1] === '\t')) s--;
|
|
1134
|
+
let e = end;
|
|
1135
|
+
if (code[e] === '\n') e++;
|
|
1136
|
+
return { code: code.slice(0, s) + code.slice(e), changed: true };
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Delete MULTIPLE elements from a SINGLE `code` snapshot, given every target's
|
|
1141
|
+
* `elementStart` offset resolved against that SAME snapshot (delete-order-residual
|
|
1142
|
+
* fix — `vite-plugin-ui-oid.ts`'s `handleStructMany`). Sound and caller-order-
|
|
1143
|
+
* independent: every offset is resolved from the file ONCE, up front, then applied
|
|
1144
|
+
* highest-offset-first — each `deleteElement` call only ever removes text AT OR
|
|
1145
|
+
* AFTER its own target, so every offset still queued (all of them numerically
|
|
1146
|
+
* SMALLER, since we process descending) is untouched by it and stays valid. A
|
|
1147
|
+
* nested descendant's `elementStart` is always numerically greater than its
|
|
1148
|
+
* ancestor's (it appears later in source), so this single offset-value rule also
|
|
1149
|
+
* deletes a selected descendant before a selected ancestor, with no separate
|
|
1150
|
+
* ancestor/descendant case to reason about. Unlike re-resolving each target by
|
|
1151
|
+
* re-scanning/re-transforming the file between deletes, this never recomputes an
|
|
1152
|
+
* OID's identity mid-batch — it only ever reads the ALREADY-RESOLVED offsets handed
|
|
1153
|
+
* to it, so it cannot suffer the occurrence-index churn a live re-transform would
|
|
1154
|
+
* introduce (deleting one same-tag sibling shifts every later sibling's own
|
|
1155
|
+
* `component:tag:nthOccurrence` signature, which would silently relabel it as a
|
|
1156
|
+
* DIFFERENT, already-used oid if resolution were re-derived from a fresh transform
|
|
1157
|
+
* mid-batch — the reason `handleStruct`'s per-call resolution is not simply looped
|
|
1158
|
+
* here without change).
|
|
1159
|
+
*/
|
|
1160
|
+
export function deleteElements(
|
|
1161
|
+
code: string,
|
|
1162
|
+
elementStarts: readonly number[],
|
|
1163
|
+
): { code: string; changed: boolean } {
|
|
1164
|
+
const sorted = [...elementStarts].sort((a, b) => b - a); // highest offset first
|
|
1165
|
+
let cur = code;
|
|
1166
|
+
let changed = false;
|
|
1167
|
+
for (const off of sorted) {
|
|
1168
|
+
const result = deleteElement(cur, off);
|
|
1169
|
+
if (result.changed) {
|
|
1170
|
+
cur = result.code;
|
|
1171
|
+
changed = true;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return { code: cur, changed };
|
|
1175
|
+
}
|