@volter/editor-core 0.5.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BUNDLED_NOTICES +1527 -0
- package/LICENSE +686 -0
- package/LICENSE-APACHE +202 -0
- package/NOTICE +18 -0
- package/README.md +24 -0
- package/dist/build/chunk-JS4D4XZ3.js +175 -0
- package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
- package/dist/build/vite-plugin-product-contributions.js +100 -0
- package/dist/build/vite-plugin-product-contributions.js.map +7 -0
- package/dist/build/vite-plugin-shared-react.js +25 -0
- package/dist/build/vite-plugin-shared-react.js.map +7 -0
- package/dist/build/vite-plugin-shared-three.js +86 -0
- package/dist/build/vite-plugin-shared-three.js.map +7 -0
- package/dist/server/chunk-KMJC7AGO.js +98 -0
- package/dist/server/chunk-KMJC7AGO.js.map +7 -0
- package/dist/server/chunk-PSILUMGS.js +46 -0
- package/dist/server/chunk-PSILUMGS.js.map +7 -0
- package/dist/server/frame-proxy.js +255 -0
- package/dist/server/frame-proxy.js.map +7 -0
- package/dist/server/open-browser.js +53 -0
- package/dist/server/open-browser.js.map +7 -0
- package/dist/server/process-shutdown.js +127 -0
- package/dist/server/process-shutdown.js.map +7 -0
- package/dist/server/session-registry.js +141 -0
- package/dist/server/session-registry.js.map +7 -0
- package/dist/server/spawn-opener.js +11 -0
- package/dist/server/spawn-opener.js.map +7 -0
- package/dist/server/worktree-identity.js +11 -0
- package/dist/server/worktree-identity.js.map +7 -0
- package/dist-server/packaged.mjs +88816 -0
- package/package.json +118 -0
- package/scripts/build-plugins.mjs +14 -0
- package/scripts/build-server.mjs +26 -0
- package/scripts/build-session.mjs +9 -0
- package/server/account-credentials.ts +60 -0
- package/server/account-service.ts +1434 -0
- package/server/adapter-region-includes.ts +47 -0
- package/server/asset-catalog-v2.ts +175 -0
- package/server/asset-history-snapshots.ts +196 -0
- package/server/asset-ledger-store.ts +195 -0
- package/server/asset-library-routes.ts +1595 -0
- package/server/blender-wasm-artifact.ts +430 -0
- package/server/boot-timings.ts +106 -0
- package/server/canonical-path.ts +50 -0
- package/server/checkout-workspace-preflight.ts +349 -0
- package/server/cloud-asset-catalog.ts +158 -0
- package/server/coding-inference-launch.ts +237 -0
- package/server/collaboration-account-client.ts +202 -0
- package/server/collaboration-attribution.ts +122 -0
- package/server/collaboration-session.ts +934 -0
- package/server/comfyui-bridge.ts +248 -0
- package/server/console-ledger.ts +499 -0
- package/server/creation-site-transform.ts +304 -0
- package/server/creation-site-write.ts +648 -0
- package/server/data-file-serialize.ts +158 -0
- package/server/editor-brand-html.ts +72 -0
- package/server/editor-control-socket.ts +366 -0
- package/server/editor-server-options.ts +190 -0
- package/server/editor-server.ts +1483 -0
- package/server/editor-sse.ts +352 -0
- package/server/engine-provenance.ts +169 -0
- package/server/engine-source-restart.ts +117 -0
- package/server/frame-bridge.ts +115 -0
- package/server/frame-proxy.ts +490 -0
- package/server/frame-workbench.ts +259 -0
- package/server/frontend-handoff.ts +229 -0
- package/server/game-globals-shadow.ts +113 -0
- package/server/gameplay-session-retention.ts +149 -0
- package/server/gameplay-sessions.ts +123 -0
- package/server/generation-jobs.ts +201 -0
- package/server/generation-reconciler.ts +137 -0
- package/server/generative-execution-context.ts +59 -0
- package/server/git-workflow.ts +612 -0
- package/server/harness-chat-caller.ts +36 -0
- package/server/harness-chat-service.ts +1681 -0
- package/server/idle-shutdown.ts +170 -0
- package/server/js-profiling-policy.ts +52 -0
- package/server/launcher-settings.ts +84 -0
- package/server/local-asset-catalog.ts +410 -0
- package/server/managed-account-defaults.ts +22 -0
- package/server/mock-control-plane.ts +540 -0
- package/server/model-import-conversion.ts +345 -0
- package/server/native-credential-store.ts +162 -0
- package/server/open-browser.ts +118 -0
- package/server/packaged.ts +1192 -0
- package/server/play-stall.ts +126 -0
- package/server/process-shutdown.ts +218 -0
- package/server/product-presets.ts +55 -0
- package/server/project-build-artifact.ts +95 -0
- package/server/project-components.ts +100 -0
- package/server/project-dependency-invalidation.ts +114 -0
- package/server/project-file-scan.ts +235 -0
- package/server/project-hmr-files.ts +215 -0
- package/server/project-install-roots.ts +210 -0
- package/server/project-kinds.ts +71 -0
- package/server/project-mcp-servers.ts +82 -0
- package/server/project-module-freshness.ts +109 -0
- package/server/project-module-instance.ts +375 -0
- package/server/project-optimize-deps-entries.ts +827 -0
- package/server/project-output-writer.ts +679 -0
- package/server/project-package-origin.ts +63 -0
- package/server/project-root-surface.ts +831 -0
- package/server/project-scratch-path.ts +47 -0
- package/server/project-script-hmr.ts +619 -0
- package/server/project-serving-plugins.ts +236 -0
- package/server/project-tools.ts +653 -0
- package/server/project-validation.ts +250 -0
- package/server/project-verbs.ts +127 -0
- package/server/project-view.ts +52 -0
- package/server/project-watch.ts +1134 -0
- package/server/project-work-coordinator.ts +288 -0
- package/server/provider-credentials.ts +331 -0
- package/server/recent-projects-store.ts +98 -0
- package/server/redact-secrets.ts +71 -0
- package/server/repository-presence.ts +267 -0
- package/server/routes/account.ts +357 -0
- package/server/routes/agents.ts +60 -0
- package/server/routes/assets.ts +154 -0
- package/server/routes/build.ts +13 -0
- package/server/routes/collaboration.ts +595 -0
- package/server/routes/configurations.ts +419 -0
- package/server/routes/context.ts +264 -0
- package/server/routes/control-plane.ts +1811 -0
- package/server/routes/logs.ts +272 -0
- package/server/routes/project-identity.ts +302 -0
- package/server/routes/project-open.ts +429 -0
- package/server/routes/project-source.ts +686 -0
- package/server/routes/project-state.ts +383 -0
- package/server/routes/relay.ts +1029 -0
- package/server/routes/route-helpers.ts +29 -0
- package/server/routes/served-modules.ts +138 -0
- package/server/routes/session-tabs.ts +196 -0
- package/server/routes/settings.ts +121 -0
- package/server/routes/share-control.ts +486 -0
- package/server/routes/themes.ts +116 -0
- package/server/routes/tools.ts +230 -0
- package/server/routes/worktrees.ts +595 -0
- package/server/scoped-game-css.ts +461 -0
- package/server/server-utils.ts +1194 -0
- package/server/session-product.ts +112 -0
- package/server/session-registry.ts +230 -0
- package/server/share-claims.ts +138 -0
- package/server/share-host.ts +526 -0
- package/server/share-session-gateway.ts +1362 -0
- package/server/share-tunnel.ts +123 -0
- package/server/spawn-opener.ts +74 -0
- package/server/support/play/log-format.ts +76 -0
- package/server/support/play/session-record.ts +199 -0
- package/server/support/project/build-discipline.ts +718 -0
- package/server/support/project/inspection-node.ts +119 -0
- package/server/support/project/log-naming.ts +54 -0
- package/server/support/project/provenance.ts +290 -0
- package/server/support/project/run-name.ts +18 -0
- package/server/support/project/session-journal.ts +830 -0
- package/server/support/project/shared.ts +327 -0
- package/server/tab-bootstrap.ts +62 -0
- package/server/tab-heartbeat.ts +477 -0
- package/server/tab-lifecycle.ts +550 -0
- package/server/tab-presence.ts +1508 -0
- package/server/team-agent-mirror.ts +95 -0
- package/server/twin-auth.ts +138 -0
- package/server/vendored-lock-recorder.ts +968 -0
- package/server/worktree-identity.ts +121 -0
- package/server/worktree-management.ts +398 -0
- package/server/worktree-retention.d.mts +13 -0
- package/server/worktree-retention.mjs +205 -0
- package/src/EditorContext.tsx +169 -0
- package/src/account.ts +333 -0
- package/src/action-registry.ts +532 -0
- package/src/active-product.ts +74 -0
- package/src/active-project.ts +155 -0
- package/src/adapter-editor-config.ts +25 -0
- package/src/adapter-observation.ts +49 -0
- package/src/animation/stage-transport.ts +379 -0
- package/src/animation/three-clips-subject.ts +172 -0
- package/src/api/asset-library-wire.ts +45 -0
- package/src/api/assets.ts +365 -0
- package/src/api/base.ts +10 -0
- package/src/api/build.ts +99 -0
- package/src/api/git-wire.ts +56 -0
- package/src/api/logs.ts +92 -0
- package/src/api/project-identity.ts +74 -0
- package/src/api/project-open.ts +355 -0
- package/src/api/project-source.ts +162 -0
- package/src/api/project-state.ts +107 -0
- package/src/api/relay.ts +268 -0
- package/src/api/settings.ts +36 -0
- package/src/api/themes.ts +45 -0
- package/src/api/worktrees.ts +257 -0
- package/src/asset-compare-core.ts +171 -0
- package/src/asset-compare.ts +294 -0
- package/src/asset-editor-context.tsx +100 -0
- package/src/asset-events.ts +97 -0
- package/src/asset-inspector-actions.ts +87 -0
- package/src/asset-preview-framing.ts +357 -0
- package/src/asset-preview.ts +2802 -0
- package/src/asset-selection-viewer-registry.ts +113 -0
- package/src/asset-selection.ts +133 -0
- package/src/asset-workflow/asset-capabilities.ts +344 -0
- package/src/asset-workflow/asset-import-jobs.ts +106 -0
- package/src/asset-workflow/asset-ledger-backend.ts +126 -0
- package/src/asset-workflow/asset-ledger.ts +156 -0
- package/src/asset-workflow/asset-materialization-report.ts +140 -0
- package/src/asset-workflow/asset-pack-manifest.ts +320 -0
- package/src/asset-workflow/asset-types.ts +142 -0
- package/src/asset-workflow/asset-workflow-quality.ts +61 -0
- package/src/asset-workflow/audio-preview-player.ts +193 -0
- package/src/asset-workflow/audio-waveform.ts +22 -0
- package/src/asset-workflow/cloud-asset-client.ts +263 -0
- package/src/asset-workflow/folder-preview.ts +345 -0
- package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
- package/src/asset-workflow/image-view-scale.ts +32 -0
- package/src/asset-workflow/import-contract.ts +124 -0
- package/src/asset-workflow/ledger-write-lock.ts +244 -0
- package/src/asset-workflow/model-inspection.ts +818 -0
- package/src/asset-workflow/pixi-spritesheet.ts +197 -0
- package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
- package/src/asset-workflow/project-asset-commands.ts +23 -0
- package/src/asset-workflow/project-asset-health.ts +139 -0
- package/src/asset-workflow/project-asset-operations.ts +154 -0
- package/src/asset-workflow/project-asset-roots.ts +68 -0
- package/src/asset-workflow/project-content.ts +288 -0
- package/src/asset-workflow/project-source-index.ts +550 -0
- package/src/asset-workflow/thumbnail-system.ts +256 -0
- package/src/authoring/active-adapter.ts +199 -0
- package/src/authoring/active-systems.ts +422 -0
- package/src/authoring/adapter-key.ts +18 -0
- package/src/authoring/authoring-asset-url.ts +27 -0
- package/src/authoring/bootstrap-state.ts +49 -0
- package/src/authoring/boundary-authoring-adapter.ts +184 -0
- package/src/authoring/breakpoint-state.ts +43 -0
- package/src/authoring/canvas-scene-guides.ts +76 -0
- package/src/authoring/component-instance-root.ts +171 -0
- package/src/authoring/composite-authoring-adapter.ts +2110 -0
- package/src/authoring/consumer-actions.ts +520 -0
- package/src/authoring/css-numeric-style.ts +97 -0
- package/src/authoring/design-time-layers.ts +849 -0
- package/src/authoring/design-time-mount-registry.ts +235 -0
- package/src/authoring/design-time-settle.ts +343 -0
- package/src/authoring/edit-mode-authoring.ts +619 -0
- package/src/authoring/eyedropper-session.ts +60 -0
- package/src/authoring/instance-source-menu-register.ts +13 -0
- package/src/authoring/instance-source-menu.ts +135 -0
- package/src/authoring/layered-pick.ts +183 -0
- package/src/authoring/live-gesture-lock.ts +50 -0
- package/src/authoring/live-object-transform.ts +62 -0
- package/src/authoring/mount-failure-report.ts +154 -0
- package/src/authoring/mounted-root-subjects.ts +144 -0
- package/src/authoring/no-authoring-adapter.ts +55 -0
- package/src/authoring/null-inspection-subjects.tsx +75 -0
- package/src/authoring/object3d-document-persistence.ts +106 -0
- package/src/authoring/object3d-document-session-registry.ts +120 -0
- package/src/authoring/object3d-document-session.ts +1326 -0
- package/src/authoring/object3d-gesture-controller.ts +113 -0
- package/src/authoring/panel-authoring.ts +121 -0
- package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
- package/src/authoring/prefab-instance-section-model.ts +55 -0
- package/src/authoring/project-authoring-session.ts +105 -0
- package/src/authoring/provenance.ts +99 -0
- package/src/authoring/quarks-particle-systems.ts +19 -0
- package/src/authoring/react-canvas-navigation.ts +255 -0
- package/src/authoring/react-design-canvas-style.ts +20 -0
- package/src/authoring/react-story-board.ts +937 -0
- package/src/authoring/selection-scope.ts +195 -0
- package/src/authoring/shell-document-ops.ts +169 -0
- package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
- package/src/authoring/shell-viewport-policy.ts +48 -0
- package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
- package/src/authoring/stories-scope.ts +35 -0
- package/src/authoring/story-board-chrome-fit.ts +107 -0
- package/src/authoring/story-board-presentation.ts +111 -0
- package/src/authoring/three-projection-core.ts +226 -0
- package/src/authoring/viewport-pick-context.ts +40 -0
- package/src/authoring/viewport-raycast.ts +240 -0
- package/src/authoring/viewport-tool-context.ts +73 -0
- package/src/authoring/world-canvas-viewport-state.ts +35 -0
- package/src/authoring/world-hidden-viewport.ts +152 -0
- package/src/authoring/world-pan-state.ts +198 -0
- package/src/authoring/world-session-state.ts +101 -0
- package/src/authoring/write-pipe.ts +173 -0
- package/src/availability-tick.ts +66 -0
- package/src/bitmap-label.ts +120 -0
- package/src/blender-tab-metrics.ts +161 -0
- package/src/board-open-actions.ts +20 -0
- package/src/boot-routing.ts +391 -0
- package/src/build-identity.ts +15 -0
- package/src/bytes-codec.ts +62 -0
- package/src/camera-authoring.ts +175 -0
- package/src/cancellation-reason.ts +58 -0
- package/src/canvas-preview-frames.ts +482 -0
- package/src/capture-camera-pose.ts +77 -0
- package/src/chrome-registry.ts +159 -0
- package/src/chrome-slot-registry.ts +91 -0
- package/src/collaboration-client.ts +264 -0
- package/src/collaboration-presence.ts +41 -0
- package/src/command-dispatch.ts +19 -0
- package/src/command-listener.ts +2743 -0
- package/src/command-registry.ts +70 -0
- package/src/component-board-registry.ts +205 -0
- package/src/component-states-registry.ts +200 -0
- package/src/components/AgentPresentationNotice.tsx +75 -0
- package/src/components/AlignToolbar.tsx +204 -0
- package/src/components/AppRoot.tsx +394 -0
- package/src/components/ApplicationMenus.tsx +415 -0
- package/src/components/AssetBrowser.tsx +2843 -0
- package/src/components/AssetEditorShell.tsx +216 -0
- package/src/components/AssetInspectorToolSection.tsx +124 -0
- package/src/components/BoardRulers.tsx +354 -0
- package/src/components/CameraInfo.tsx +81 -0
- package/src/components/CanvasSceneViewport.tsx +556 -0
- package/src/components/CapabilityCoverageSection.tsx +185 -0
- package/src/components/CenterDocuments.tsx +22 -0
- package/src/components/ChromeSlot.tsx +20 -0
- package/src/components/CodeView.tsx +470 -0
- package/src/components/CollaborationHeaderControl.css +74 -0
- package/src/components/CompactInspectorCard.tsx +190 -0
- package/src/components/CompactInspectorShell.tsx +39 -0
- package/src/components/ConsolePanel.css +55 -0
- package/src/components/ConsolePanel.tsx +270 -0
- package/src/components/DefaultEditorLayout.tsx +106 -0
- package/src/components/DocumentHeaderStrip.tsx +96 -0
- package/src/components/DocumentShelfRail.tsx +45 -0
- package/src/components/DocumentThumbnail.tsx +345 -0
- package/src/components/EditorLeaseGuard.tsx +372 -0
- package/src/components/ErrorBanner.tsx +123 -0
- package/src/components/FolderPreviewTile.tsx +159 -0
- package/src/components/GameHierarchy.tsx +3842 -0
- package/src/components/GameplaySessionTimeline.tsx +295 -0
- package/src/components/InspectionProjection.tsx +968 -0
- package/src/components/Inspector.tsx +233 -0
- package/src/components/InspectorCanvasPreview.tsx +35 -0
- package/src/components/InspectorFieldsSection.tsx +290 -0
- package/src/components/InspectorObjectPreview.tsx +57 -0
- package/src/components/InspectorStoriesSection.tsx +92 -0
- package/src/components/InspectorToolSection.tsx +96 -0
- package/src/components/InspectorTransformSection.tsx +245 -0
- package/src/components/LightExplorerPanel.tsx +433 -0
- package/src/components/MediaProperties.tsx +145 -0
- package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
- package/src/components/Object3DDocumentToolbar.css +100 -0
- package/src/components/Object3DDocumentToolbar.tsx +454 -0
- package/src/components/Object3DDocumentViewport.tsx +58 -0
- package/src/components/ProjectHeader.css +617 -0
- package/src/components/ProjectHeader.tsx +328 -0
- package/src/components/ProjectLayout.tsx +249 -0
- package/src/components/ReactCanvasControls.tsx +284 -0
- package/src/components/RootSelectionOverlay.tsx +3162 -0
- package/src/components/RootTextEditor.tsx +79 -0
- package/src/components/SaveStatus.tsx +70 -0
- package/src/components/StageHost.tsx +1954 -0
- package/src/components/StageOverlays.tsx +21 -0
- package/src/components/StartupErrorScreen.tsx +76 -0
- package/src/components/StartupLoadingScreen.tsx +65 -0
- package/src/components/StatsOverlay.tsx +78 -0
- package/src/components/SurfaceStateOverlay.tsx +24 -0
- package/src/components/ToolContributionSurfaces.tsx +65 -0
- package/src/components/ToolHost.tsx +370 -0
- package/src/components/ToolObject3DPreview.tsx +39 -0
- package/src/components/Toolbar.tsx +595 -0
- package/src/components/TransientHint.tsx +44 -0
- package/src/components/TransportStrip.tsx +174 -0
- package/src/components/VersionControlSection.tsx +470 -0
- package/src/components/VgaiLogo.css +83 -0
- package/src/components/VgaiLogo.tsx +35 -0
- package/src/components/ViewportControlsHint.tsx +60 -0
- package/src/components/ViewportFurniture.tsx +401 -0
- package/src/components/ViewportOverlay.tsx +145 -0
- package/src/components/ViewportOverlaysMenu.tsx +153 -0
- package/src/components/ViewportShadingMenu.tsx +300 -0
- package/src/components/ViewportViewMenu.tsx +101 -0
- package/src/components/WorkspaceDocumentSurface.tsx +204 -0
- package/src/components/WorkspaceUtilitySurface.tsx +22 -0
- package/src/components/WorktreeSwitcher.css +239 -0
- package/src/components/WorktreeSwitcher.tsx +890 -0
- package/src/components/account-documents.tsx +1162 -0
- package/src/components/asset-documents.tsx +812 -0
- package/src/components/asset-editor-persistence.ts +216 -0
- package/src/components/asset-selection-section.tsx +535 -0
- package/src/components/asset-thumbnails.tsx +312 -0
- package/src/components/asset-viewers/AudioViewer.tsx +201 -0
- package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
- package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
- package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
- package/src/components/asset-viewers/ImageViewer.tsx +300 -0
- package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
- package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
- package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
- package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
- package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
- package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
- package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
- package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
- package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
- package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
- package/src/components/asset-viewers/VideoViewer.tsx +101 -0
- package/src/components/asset-viewers/shader-source.ts +144 -0
- package/src/components/asset-workflow.css +731 -0
- package/src/components/board-guides.ts +150 -0
- package/src/components/compact-inspector.css +539 -0
- package/src/components/core-utilities.tsx +90 -0
- package/src/components/editor-notifications.css +40 -0
- package/src/components/engine-workspace.css +274 -0
- package/src/components/inspector-preview-section.tsx +238 -0
- package/src/components/inspector-property-grouping.ts +64 -0
- package/src/components/inspector-revert-label.ts +20 -0
- package/src/components/inspector-selection.ts +42 -0
- package/src/components/inspector-stories-gating.ts +171 -0
- package/src/components/inspector-transform-subject.ts +11 -0
- package/src/components/inspector-transform.ts +75 -0
- package/src/components/kind-documents.tsx +500 -0
- package/src/components/palette-action-publisher.tsx +140 -0
- package/src/components/primitives/DraftColorInput.tsx +74 -0
- package/src/components/product-shell.css +1829 -0
- package/src/components/project-tool-documents.tsx +410 -0
- package/src/components/scene-documents.tsx +223 -0
- package/src/components/stage-keyboard.tsx +37 -0
- package/src/components/stage-overlay-set.tsx +107 -0
- package/src/components/stage-presence-markers.ts +478 -0
- package/src/components/standard-viewport-dressing.ts +417 -0
- package/src/components/status-contributions.tsx +403 -0
- package/src/components/tool-documents.tsx +309 -0
- package/src/components/tool-schema-form.tsx +262 -0
- package/src/components/use-after-paint.ts +41 -0
- package/src/components/use-project-image-assets.ts +86 -0
- package/src/components/viewport-header-controls.css +90 -0
- package/src/components/viewport-surface-status.tsx +55 -0
- package/src/components/workspace-history.ts +32 -0
- package/src/components/workspace-static-panel-registry.tsx +152 -0
- package/src/components/workspace-surfaces.css +621 -0
- package/src/components/world-documents.tsx +586 -0
- package/src/components/world-overlay-gestures.ts +1694 -0
- package/src/composite-screenshot.ts +1641 -0
- package/src/console-sync.ts +131 -0
- package/src/constraint-helper.ts +338 -0
- package/src/content-entry-source-registry.ts +184 -0
- package/src/coverage/authoring-seam-evidence.ts +300 -0
- package/src/coverage/canvas-reveal.ts +192 -0
- package/src/coverage/design-time-surfaces.ts +101 -0
- package/src/coverage/live-seam-evidence.ts +11 -0
- package/src/coverage/ontology-invariants.ts +466 -0
- package/src/coverage/session-vitals.ts +501 -0
- package/src/coverage/system-seam-evidence.ts +72 -0
- package/src/crash-null-boundary.ts +36 -0
- package/src/creation-site-edit.ts +1479 -0
- package/src/creation-site-registry.ts +160 -0
- package/src/delegate-harness-registry.ts +143 -0
- package/src/document-context-registry.ts +177 -0
- package/src/document-open-registry.ts +200 -0
- package/src/document-preview-source.ts +20 -0
- package/src/document-renderer-session.ts +138 -0
- package/src/editor-api.ts +46 -0
- package/src/editor-chrome-capture.ts +139 -0
- package/src/editor-commands.ts +164 -0
- package/src/editor-console.ts +563 -0
- package/src/editor-current-view.ts +82 -0
- package/src/editor-document-probe.ts +881 -0
- package/src/editor-git-client.ts +115 -0
- package/src/editor-host-door.ts +462 -0
- package/src/editor-hotkeys.ts +842 -0
- package/src/editor-lease-view.ts +39 -0
- package/src/editor-lease.ts +415 -0
- package/src/editor-mode.ts +19 -0
- package/src/editor-notifications.ts +140 -0
- package/src/editor-presence.ts +563 -0
- package/src/editor-presentation-activity.ts +50 -0
- package/src/editor-presentation-notice.ts +42 -0
- package/src/editor-runtime.tsx +145 -0
- package/src/editor-server-response.ts +86 -0
- package/src/editor-session-attribution.ts +75 -0
- package/src/editor-session-mode.ts +54 -0
- package/src/editor-shell-store.ts +1304 -0
- package/src/editor-state-facets.ts +74 -0
- package/src/editor-styles.css +33 -0
- package/src/editor-view-presentation.ts +810 -0
- package/src/editor-viewport.ts +5302 -0
- package/src/entity-lod.ts +31 -0
- package/src/entity-object.ts +91 -0
- package/src/files/file-provider.ts +62 -0
- package/src/files/project-files.ts +264 -0
- package/src/finders/index.ts +136 -0
- package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
- package/src/frame/bridge.tsx +1408 -0
- package/src/frame/product.ts +77 -0
- package/src/gameplay-dom-recording.ts +319 -0
- package/src/gameplay-export-state.ts +14 -0
- package/src/gameplay-replay.ts +417 -0
- package/src/gameplay-session-time.ts +9 -0
- package/src/gameplay-sessions.ts +204 -0
- package/src/harness-chat-types.ts +253 -0
- package/src/hierarchy-component-marks.ts +298 -0
- package/src/hierarchy-drop.ts +91 -0
- package/src/hierarchy-expansion-state.ts +80 -0
- package/src/hierarchy-header-slot.ts +52 -0
- package/src/hierarchy-internals.ts +197 -0
- package/src/hierarchy-kind-icon.ts +217 -0
- package/src/hierarchy-mark-reader.ts +73 -0
- package/src/hierarchy-menu-registry.ts +67 -0
- package/src/hierarchy-node-rows.ts +307 -0
- package/src/hierarchy-panel-view.ts +280 -0
- package/src/hierarchy-projection.ts +76 -0
- package/src/hierarchy-row-cache.ts +243 -0
- package/src/hierarchy-row-model.ts +308 -0
- package/src/hierarchy-rows.ts +11 -0
- package/src/hierarchy-walk.ts +86 -0
- package/src/history/editor-session.ts +25 -0
- package/src/history/history-commands.ts +147 -0
- package/src/history/history-delegate.ts +163 -0
- package/src/history/history-limit-notices.ts +43 -0
- package/src/history/history-service.ts +1173 -0
- package/src/history/persistence-coordinator.ts +35 -0
- package/src/history/project-file-history.ts +386 -0
- package/src/history/project-root-history-backends.ts +139 -0
- package/src/history/resource-registry.ts +209 -0
- package/src/history/snapshot-store.ts +103 -0
- package/src/history/source-history-backend.ts +546 -0
- package/src/history/types.ts +124 -0
- package/src/hmr-registration-group.ts +67 -0
- package/src/hmr-stable-react-context.ts +23 -0
- package/src/hotkeys.ts +188 -0
- package/src/inference-diagnostics.ts +69 -0
- package/src/initial-project.ts +80 -0
- package/src/inspection/active-subject.ts +578 -0
- package/src/inspection/active-surface.ts +142 -0
- package/src/inspection/compose.ts +1064 -0
- package/src/inspection/display.ts +170 -0
- package/src/inspection/document-subject.ts +109 -0
- package/src/inspection/game-subject.ts +85 -0
- package/src/inspection/model.ts +542 -0
- package/src/inspection/null-subject.ts +115 -0
- package/src/inspection/serialize.ts +357 -0
- package/src/inspection/use-active-inspection.ts +180 -0
- package/src/inspector-presentation.ts +201 -0
- package/src/inspector-section-registry.ts +221 -0
- package/src/instance-source-actions.ts +163 -0
- package/src/instanced-presentation.ts +164 -0
- package/src/js-heap.ts +71 -0
- package/src/key-actions.ts +91 -0
- package/src/keymap-presets.ts +401 -0
- package/src/layout-policy.ts +31 -0
- package/src/learn-links.ts +73 -0
- package/src/light-explorer-model.ts +134 -0
- package/src/live-canvas-frame.ts +55 -0
- package/src/live-document.ts +279 -0
- package/src/live-module-source.ts +230 -0
- package/src/live-session-registry.ts +220 -0
- package/src/live-transition.ts +633 -0
- package/src/manifest-project.ts +107 -0
- package/src/model-thumbnail.ts +523 -0
- package/src/native-selection-style.ts +202 -0
- package/src/object3d-document-write-policy.ts +137 -0
- package/src/packaged-runtime.ts +108 -0
- package/src/palettes/maya.palette.json +57 -0
- package/src/palettes/substance.palette.json +57 -0
- package/src/pasteboard-module.ts +81 -0
- package/src/performance-profiler.ts +367 -0
- package/src/performance-sources.ts +69 -0
- package/src/play-boot-phase.ts +145 -0
- package/src/presentation-surface.ts +248 -0
- package/src/project-adapter.ts +1143 -0
- package/src/project-asset-refresh.ts +26 -0
- package/src/project-declaration-refresh.ts +31 -0
- package/src/project-local-state.ts +118 -0
- package/src/project-manager.ts +243 -0
- package/src/project-module-changes.ts +163 -0
- package/src/project-module-split.ts +266 -0
- package/src/project-provenance.ts +115 -0
- package/src/project-ready.ts +42 -0
- package/src/project-session-reset.ts +67 -0
- package/src/project-shape.ts +68 -0
- package/src/project-tool-discovery.ts +66 -0
- package/src/project-tools.ts +107 -0
- package/src/project-work-types.ts +149 -0
- package/src/projection/three.ts +898 -0
- package/src/projection/types.ts +44 -0
- package/src/readiness.ts +113 -0
- package/src/reflection-probe-helper.ts +142 -0
- package/src/reported-play-state.ts +90 -0
- package/src/resolve-relative-specifier.ts +33 -0
- package/src/scene-document-plan.ts +315 -0
- package/src/scene-framing.ts +315 -0
- package/src/scene-live-open.ts +210 -0
- package/src/scene-view-fog.ts +89 -0
- package/src/scoped-game-css.ts +152 -0
- package/src/session-orphan-record.ts +193 -0
- package/src/session-tombstone.ts +126 -0
- package/src/settings/settings-provider.ts +82 -0
- package/src/settings-store.ts +345 -0
- package/src/shared-view-restore.ts +42 -0
- package/src/shell-store-door.ts +45 -0
- package/src/source-conflict.ts +122 -0
- package/src/spatial-handle-visuals.ts +332 -0
- package/src/stage-context.ts +341 -0
- package/src/stage-store-registry.ts +63 -0
- package/src/stale-chunk-recovery.ts +34 -0
- package/src/state-report-deferral.ts +73 -0
- package/src/storage/host-files-storage.ts +97 -0
- package/src/storage/http-storage.ts +174 -0
- package/src/storage/index.ts +75 -0
- package/src/storage/mem-storage.ts +149 -0
- package/src/storage/path-lock.ts +44 -0
- package/src/storage/paths.ts +26 -0
- package/src/storage/types.ts +116 -0
- package/src/stories/StoryComponentThumbnail.tsx +184 -0
- package/src/stories/StoryPreviewMount.tsx +306 -0
- package/src/stories/component-content-source.tsx +106 -0
- package/src/stories/component-states-source.ts +78 -0
- package/src/stories/compose-project-stories.ts +255 -0
- package/src/stories/pixi-story-model.ts +30 -0
- package/src/stories/prefabs-finder.ts +54 -0
- package/src/stories/prefabs-from-stories.ts +182 -0
- package/src/stories/project-story-discovery.ts +78 -0
- package/src/stories/project-story-regions.ts +24 -0
- package/src/stories/story-actions.ts +54 -0
- package/src/stories/story-capture-command.ts +124 -0
- package/src/stories/story-capture.ts +584 -0
- package/src/stories/story-declared-medium.ts +126 -0
- package/src/stories/story-discovery.ts +176 -0
- package/src/stories/story-document-openers.ts +36 -0
- package/src/stories/story-dom-runtime.ts +78 -0
- package/src/stories/story-grouping.ts +111 -0
- package/src/stories/story-lane.ts +88 -0
- package/src/stories/story-mount-turn.ts +27 -0
- package/src/stories/story-opener.ts +260 -0
- package/src/stories/story-pixi-preview.ts +408 -0
- package/src/stories/story-presentation.ts +215 -0
- package/src/stories/story-registry.ts +517 -0
- package/src/stories/story-three-preview.ts +807 -0
- package/src/stories/three-story-model.ts +96 -0
- package/src/story-three-preview-runtime.ts +56 -0
- package/src/surface-keyboard.ts +112 -0
- package/src/surface-state.ts +124 -0
- package/src/tab-bootstrap.js +628 -0
- package/src/tab-census.ts +198 -0
- package/src/tab-lifecycle-client.ts +196 -0
- package/src/theme-library.ts +844 -0
- package/src/theme-preference.ts +378 -0
- package/src/theme.css +3437 -0
- package/src/three-viewport/bone-selection-highlight.ts +119 -0
- package/src/three-viewport/camera-fit.ts +41 -0
- package/src/three-viewport/interactive-renderer.ts +132 -0
- package/src/three-viewport/selection-brackets.ts +310 -0
- package/src/three-viewport/selection-outline.ts +183 -0
- package/src/three-viewport/skeleton-helper.ts +61 -0
- package/src/three-viewport/source-color.ts +167 -0
- package/src/three-viewport/studio-environment.ts +96 -0
- package/src/three-viewport-presentation.ts +23 -0
- package/src/tool-contribution-play.ts +74 -0
- package/src/tool-loader.ts +1798 -0
- package/src/transform-mode-request.ts +57 -0
- package/src/transient-hint.ts +78 -0
- package/src/trigger-volume-helper.ts +116 -0
- package/src/ui-source/adapter-region-includes.ts +241 -0
- package/src/ui-source/ensure-import.ts +132 -0
- package/src/ui-source/file-region-resolver.ts +302 -0
- package/src/ui-source/inspect.ts +775 -0
- package/src/ui-source/oid-transform.ts +1845 -0
- package/src/ui-source/plan-csf-story.ts +196 -0
- package/src/ui-source/plan-extract-component.ts +421 -0
- package/src/ui-source/plan-fork-component.ts +621 -0
- package/src/ui-source/plan-named-style.ts +95 -0
- package/src/ui-source/plan-source-edit.ts +353 -0
- package/src/ui-source/r3f-contract-resolver.ts +327 -0
- package/src/ui-source/r3f-diagnostic-index.ts +165 -0
- package/src/ui-source/r3f-environment-binding.ts +133 -0
- package/src/ui-source/r3f-joint-binding.ts +251 -0
- package/src/ui-source/r3f-lod-binding.ts +91 -0
- package/src/ui-source/r3f-particle-binding.ts +235 -0
- package/src/ui-source/r3f-physics-binding.ts +483 -0
- package/src/ui-source/r3f-project-contracts.ts +444 -0
- package/src/ui-source/relative-import-specifier.ts +34 -0
- package/src/ui-source/reparent-guard.ts +544 -0
- package/src/ui-source/source-edit-request.ts +97 -0
- package/src/ui-source/source-write-backend.ts +618 -0
- package/src/ui-source/syntactic-prop-specs.ts +296 -0
- package/src/ui-source/tier-source-write-backend.ts +279 -0
- package/src/ui-source/ts-ast.ts +171 -0
- package/src/ui-source/utility-class-support.ts +169 -0
- package/src/ui-source/write-component-default.ts +133 -0
- package/src/ui-source/writer.ts +1989 -0
- package/src/viewport-activation-timings.ts +840 -0
- package/src/viewport-authoring-policy.ts +186 -0
- package/src/viewport-controls-hint.ts +57 -0
- package/src/viewport-door.ts +237 -0
- package/src/viewport-shading-boundary.ts +12 -0
- package/src/vite-error-surface.ts +141 -0
- package/src/wait-until.ts +37 -0
- package/src/workspace-areas.ts +156 -0
- package/src/workspace-aux-commands.ts +11 -0
- package/src/workspace-available-documents.ts +127 -0
- package/src/workspace-core-utilities.ts +31 -0
- package/src/workspace-document-ids.ts +59 -0
- package/src/workspace-document-registry.ts +612 -0
- package/src/workspace-document-restore.ts +145 -0
- package/src/workspace-host-commands.ts +141 -0
- package/src/workspace-persistence-gate.ts +40 -0
- package/src/workspace-play-utilities.ts +44 -0
- package/src/workspace-presets.ts +425 -0
- package/src/workspace-regions.ts +294 -0
- package/src/workspace-state-persistence.ts +536 -0
- package/src/workspace-static-panels.ts +73 -0
- package/src/workspace-status-registry.ts +121 -0
- package/src/workspace-style.ts +239 -0
- package/src/workspace-utility-commands.ts +74 -0
- package/src/workspace-utility-registry.ts +263 -0
- package/src/workspace-viewport-rect.ts +97 -0
- package/src/world-adoption.ts +115 -0
- package/src/world-document-routing.ts +104 -0
- package/vite-plugin-creation-site-write.ts +184 -0
- package/vite-plugin-creation-site.ts +80 -0
- package/vite-plugin-game-static.ts +303 -0
- package/vite-plugin-module-doorways.ts +336 -0
- package/vite-plugin-product-contributions.ts +197 -0
- package/vite-plugin-project-game-static.ts +125 -0
- package/vite-plugin-project-jsx-js.ts +65 -0
- package/vite-plugin-project-root-absolute-assets.ts +359 -0
- package/vite-plugin-shared-react.ts +420 -0
- package/vite-plugin-shared-three.ts +203 -0
- package/vite-plugin-ui-oid.ts +2106 -0
|
@@ -0,0 +1,1989 @@
|
|
|
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
|
+
import { UTILITY_CLASSES_UNPROVEN, type UtilityClassSupport } from './utility-class-support';
|
|
11
|
+
|
|
12
|
+
export interface StyleProperty {
|
|
13
|
+
name: string;
|
|
14
|
+
/** Raw value text as it appears in source. */
|
|
15
|
+
raw: string;
|
|
16
|
+
literal: boolean;
|
|
17
|
+
valueStart: number;
|
|
18
|
+
valueEnd: number;
|
|
19
|
+
/**
|
|
20
|
+
* The full `key: value` member span — from the first char of the key through the
|
|
21
|
+
* end of the (trimmed) value, EXCLUDING any leading/trailing whitespace or the
|
|
22
|
+
* separating comma (D-A2, `removeInlineStyle`). `memberEnd` always equals
|
|
23
|
+
* `valueEnd` (the member ends exactly where its value ends); kept as a distinct
|
|
24
|
+
* field for readability at removeInlineStyle's call site.
|
|
25
|
+
*/
|
|
26
|
+
memberStart: number;
|
|
27
|
+
memberEnd: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Find the `>` that closes the opening tag starting at `start` (a `<`). */
|
|
31
|
+
export function findTagEnd(code: string, start: number): number {
|
|
32
|
+
let depth = 0;
|
|
33
|
+
let str: string | null = null;
|
|
34
|
+
for (let i = start; i < code.length; i++) {
|
|
35
|
+
const c = code[i];
|
|
36
|
+
if (str) {
|
|
37
|
+
if (c === str && code[i - 1] !== '\\') str = null;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
// A COMMENT. JSX allows one between attributes and inside an attribute's
|
|
41
|
+
// `{…}` expression alike, and an apostrophe in its prose ("the tab's drag
|
|
42
|
+
// handle", "the palette's active ink") otherwise opens a string that
|
|
43
|
+
// swallows the tag's own `>` and every element after it — measured on
|
|
44
|
+
// `GameHierarchy.tsx` and its neighbours. A self-closing `/` is
|
|
45
|
+
// followed by `>`, never by `/` or `*`, so it cannot be mistaken for one.
|
|
46
|
+
if (c === '/' && code[i + 1] === '/') {
|
|
47
|
+
const newline = code.indexOf('\n', i);
|
|
48
|
+
if (newline < 0) return -1;
|
|
49
|
+
i = newline;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (c === '/' && code[i + 1] === '*') {
|
|
53
|
+
const close = code.indexOf('*/', i + 2);
|
|
54
|
+
if (close < 0) return -1;
|
|
55
|
+
i = close + 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (c === '"' || c === "'" || c === '`') str = c;
|
|
59
|
+
else if (c === '{') depth++;
|
|
60
|
+
else if (c === '}') depth--;
|
|
61
|
+
else if (c === '>' && depth === 0) return i;
|
|
62
|
+
}
|
|
63
|
+
return -1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Locate the inner range of `style={{ … }}` within the opening tag, or null. */
|
|
67
|
+
function findStyleObject(
|
|
68
|
+
code: string,
|
|
69
|
+
tagStart: number,
|
|
70
|
+
tagEnd: number,
|
|
71
|
+
): { innerStart: number; innerEnd: number } | null {
|
|
72
|
+
const seg = code.slice(tagStart, tagEnd);
|
|
73
|
+
const m = seg.match(/style\s*=\s*\{\{/);
|
|
74
|
+
if (!m || m.index == null) return null;
|
|
75
|
+
const openBraceOuter = tagStart + m.index + m[0].length - 2; // position of first `{` of `{{`
|
|
76
|
+
// inner object starts after the two `{`
|
|
77
|
+
const innerStart = openBraceOuter + 2;
|
|
78
|
+
// find the matching `}}` for the object: track brace depth from innerStart
|
|
79
|
+
let depth = 2; // we are inside two open braces
|
|
80
|
+
let str: string | null = null;
|
|
81
|
+
for (let i = innerStart; i < tagEnd + 2 && i < code.length; i++) {
|
|
82
|
+
const c = code[i];
|
|
83
|
+
if (str) {
|
|
84
|
+
if (c === str && code[i - 1] !== '\\') str = null;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (c === '"' || c === "'" || c === '`') str = c;
|
|
88
|
+
else if (c === '{') depth++;
|
|
89
|
+
else if (c === '}') {
|
|
90
|
+
depth--;
|
|
91
|
+
if (depth === 1) {
|
|
92
|
+
// this `}` closes the object (one brace — the expression brace — remains)
|
|
93
|
+
return { innerStart, innerEnd: i };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const LITERAL_RE = /^\s*('[^']*'|"[^"]*"|`[^`]*`|-?\d+(\.\d+)?(px|em|rem|%|vh|vw)?)\s*$/;
|
|
101
|
+
|
|
102
|
+
/** Parse the top-level properties of a style object body (between the braces). */
|
|
103
|
+
export function analyzeStyleProperties(objBody: string, baseOffset: number): StyleProperty[] {
|
|
104
|
+
const props: StyleProperty[] = [];
|
|
105
|
+
let i = 0;
|
|
106
|
+
const n = objBody.length;
|
|
107
|
+
let str: string | null = null;
|
|
108
|
+
let depth = 0;
|
|
109
|
+
let keyStart = -1;
|
|
110
|
+
let colon = -1;
|
|
111
|
+
const flush = (end: number): void => {
|
|
112
|
+
if (keyStart >= 0 && colon >= 0) {
|
|
113
|
+
const name = objBody.slice(keyStart, colon).replace(/['"]/g, '').trim();
|
|
114
|
+
const raw = objBody.slice(colon + 1, end);
|
|
115
|
+
if (name) {
|
|
116
|
+
const trimmed = raw.trim();
|
|
117
|
+
const lead = raw.length - raw.trimStart().length;
|
|
118
|
+
// A template literal with `${...}` interpolation is DYNAMIC, not literal,
|
|
119
|
+
// even though it is delimited by backticks — never let it be editable.
|
|
120
|
+
const isLiteral = LITERAL_RE.test(raw) && !trimmed.includes('${');
|
|
121
|
+
const valueStart = baseOffset + colon + 1 + lead;
|
|
122
|
+
const valueEnd = valueStart + trimmed.length;
|
|
123
|
+
props.push({
|
|
124
|
+
name,
|
|
125
|
+
raw: trimmed,
|
|
126
|
+
literal: isLiteral,
|
|
127
|
+
valueStart,
|
|
128
|
+
valueEnd,
|
|
129
|
+
memberStart: baseOffset + keyStart,
|
|
130
|
+
memberEnd: valueEnd,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
keyStart = -1;
|
|
135
|
+
colon = -1;
|
|
136
|
+
};
|
|
137
|
+
for (; i < n; i++) {
|
|
138
|
+
const c = objBody[i] as string;
|
|
139
|
+
if (str) {
|
|
140
|
+
if (c === str && objBody[i - 1] !== '\\') str = null;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
144
|
+
str = c;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (c === '{' || c === '(' || c === '[') depth++;
|
|
148
|
+
else if (c === '}' || c === ')' || c === ']') depth--;
|
|
149
|
+
else if (depth === 0) {
|
|
150
|
+
if (c === ':' && colon < 0) colon = i;
|
|
151
|
+
else if (c === ',') {
|
|
152
|
+
flush(i);
|
|
153
|
+
} else if (keyStart < 0 && /\S/.test(c)) keyStart = i;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
flush(n);
|
|
157
|
+
return props;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface StyleEditResult {
|
|
161
|
+
code: string;
|
|
162
|
+
changed: boolean;
|
|
163
|
+
/** True if the edit was refused because the existing value is a JS expression. */
|
|
164
|
+
dynamic: boolean;
|
|
165
|
+
/**
|
|
166
|
+
* D-A1: true ONLY when `setInlineStyle` took the APPEND branch — the
|
|
167
|
+
* element's style object had no literal (and no dynamic expression) for this prop
|
|
168
|
+
* before the write, e.g. it was absent entirely or spread-derived (`style={{
|
|
169
|
+
* ...vars }}`). False/absent on a replace, a dynamic-value refusal, or a no-op.
|
|
170
|
+
* Undo must know this: replaying the write with the prior value would find the
|
|
171
|
+
* NOW-appended literal and REPLACE it — restoring a hardcoded value into a spot
|
|
172
|
+
* the source never had one — instead of removing the prop to truly restore the
|
|
173
|
+
* pre-edit source.
|
|
174
|
+
*/
|
|
175
|
+
appended?: boolean;
|
|
176
|
+
/**
|
|
177
|
+
* Refused because the property's existing value is a CSS CUSTOM-PROPERTY
|
|
178
|
+
* REFERENCE — `backgroundColor: 'var(--brand-color)'`. Carries the reference
|
|
179
|
+
* text so the refusal can name the token the author would otherwise have lost.
|
|
180
|
+
*
|
|
181
|
+
* A `var()` is a literal string, so the dynamic-expression guard above never
|
|
182
|
+
* saw it: an ordinary colour-picker commit rewrote a design-token binding to
|
|
183
|
+
* `#ff0000`, silently, on one click. Guarding it is the same idiom for the
|
|
184
|
+
* same reason — the source value is a REFERENCE, and replacing it with a
|
|
185
|
+
* literal is a loss no widget can compose back.
|
|
186
|
+
*/
|
|
187
|
+
tokenRef?: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* THE ONE SENTENCE a `var()` refusal is said in — the writer's error, the
|
|
192
|
+
* adapter's console warning, and the inspector field's read-only reason are
|
|
193
|
+
* all this string, so an author reads the same thing wherever they meet it.
|
|
194
|
+
*/
|
|
195
|
+
export function tokenReferenceGuardText(prop: string, tokenRef: string): string {
|
|
196
|
+
return (
|
|
197
|
+
`"${prop}" is a design-token reference (${tokenRef}) in this source, so replacing ` +
|
|
198
|
+
'it with a literal is guarded — the binding would be lost and no widget can compose ' +
|
|
199
|
+
'a var() back.'
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The `var(--token, …)` reference a style value's SOURCE TEXT contains, or
|
|
204
|
+
* `null`. Deliberately a scan of the whole value, not an equality test: a
|
|
205
|
+
* composed value (`'1px solid var(--line)'`) loses the reference just as
|
|
206
|
+
* completely when a widget overwrites it. */
|
|
207
|
+
export function customPropertyReference(valueText: string): string | null {
|
|
208
|
+
return /var\(\s*--[A-Za-z0-9_-]+[^)]*\)/.exec(valueText)?.[0] ?? null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Set an inline style property on the element at `elementStart` (a `<`). Replaces a
|
|
213
|
+
* literal value, refuses a dynamic one or a `var()` token reference (guards), or
|
|
214
|
+
* appends a new property; inserts a `style={{…}}` if none exists. Other properties
|
|
215
|
+
* are untouched.
|
|
216
|
+
*/
|
|
217
|
+
export function setInlineStyle(
|
|
218
|
+
code: string,
|
|
219
|
+
elementStart: number,
|
|
220
|
+
prop: string,
|
|
221
|
+
value: string | number,
|
|
222
|
+
): StyleEditResult {
|
|
223
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
224
|
+
if (tagEnd < 0) return { code, changed: false, dynamic: false };
|
|
225
|
+
// Escape the value so a quote/backslash can't break source syntax.
|
|
226
|
+
const valueText =
|
|
227
|
+
typeof value === 'number'
|
|
228
|
+
? String(value)
|
|
229
|
+
: `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
230
|
+
const styleObj = findStyleObject(code, elementStart, tagEnd);
|
|
231
|
+
|
|
232
|
+
if (styleObj) {
|
|
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) {
|
|
237
|
+
if (!existing.literal) return { code, changed: false, dynamic: true }; // guard
|
|
238
|
+
// A literal that REFERENCES a design token is guarded too — see
|
|
239
|
+
// `StyleEditResult.tokenRef`. `dynamic: false`: this is not an
|
|
240
|
+
// expression, and calling it one would put the wrong sentence in front
|
|
241
|
+
// of the author. The ONE sanctioned replacement is another token
|
|
242
|
+
// reference: rebinding `var(--a)` → `var(--b)` is the compose gesture
|
|
243
|
+
// the guard exists to protect (the destruction it prevents is a token
|
|
244
|
+
// silently INLINED to a literal), so a new value that is itself a
|
|
245
|
+
// `var()` passes through.
|
|
246
|
+
const token = customPropertyReference(code.slice(existing.valueStart, existing.valueEnd));
|
|
247
|
+
const rebinding = typeof value === 'string' && customPropertyReference(value) !== null;
|
|
248
|
+
if (token && !rebinding) return { code, changed: false, dynamic: false, tokenRef: token };
|
|
249
|
+
const next = code.slice(0, existing.valueStart) + valueText + code.slice(existing.valueEnd);
|
|
250
|
+
return { code: next, changed: true, dynamic: false };
|
|
251
|
+
}
|
|
252
|
+
// append a property, preserving existing content
|
|
253
|
+
const trimmedBody = body.trimEnd();
|
|
254
|
+
const sep = trimmedBody.length === 0 ? '' : trimmedBody.endsWith(',') ? ' ' : ', ';
|
|
255
|
+
const insertAt = styleObj.innerStart + trimmedBody.length;
|
|
256
|
+
const next = `${code.slice(0, insertAt)}${sep}${prop}: ${valueText}${code.slice(insertAt)}`;
|
|
257
|
+
return { code: next, changed: true, dynamic: false, appended: true };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// A whole-attribute dynamic style (`style={expr}`, single brace) exists but is
|
|
261
|
+
// not an editable object literal — refuse rather than appending a duplicate attr.
|
|
262
|
+
const openSeg = code.slice(elementStart, tagEnd);
|
|
263
|
+
if (/style\s*=\s*\{/.test(openSeg)) {
|
|
264
|
+
return { code, changed: false, dynamic: true };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// no style attr — insert before the tag end (handle self-closing `/>`)
|
|
268
|
+
const selfClose = code[tagEnd - 1] === '/';
|
|
269
|
+
const insertAt = selfClose ? tagEnd - 1 : tagEnd;
|
|
270
|
+
const next = `${code.slice(0, insertAt)} style={{ ${prop}: ${valueText} }}${code.slice(insertAt)}`;
|
|
271
|
+
return { code: next, changed: true, dynamic: false };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* D-A2: surgically REMOVE a literal `prop: value` entry from the
|
|
276
|
+
* element's `style={{…}}` object at `elementStart` — the correct undo for a write
|
|
277
|
+
* that took `setInlineStyle`'s APPEND branch (`appended: true`). Fixes up the
|
|
278
|
+
* neighbouring separator/trailing comma so the remaining object stays
|
|
279
|
+
* syntactically valid; if `prop` was the object's ONLY content, collapses to an
|
|
280
|
+
* empty `style={{}}` rather than stripping the attribute entirely (keeps redo — a
|
|
281
|
+
* plain re-append — trivially reversible, and avoids re-deriving the
|
|
282
|
+
* whole-attribute insertion position `setInlineStyle`'s no-style-attr branch
|
|
283
|
+
* uses). `changed: false` (and no-op) if `prop` isn't present as a literal member
|
|
284
|
+
* at all — including a DYNAMIC member (mirrors `setInlineStyle`'s
|
|
285
|
+
* literal-vs-dynamic guard: never remove an expression-bound value the caller
|
|
286
|
+
* didn't write).
|
|
287
|
+
*/
|
|
288
|
+
export function removeInlineStyle(
|
|
289
|
+
code: string,
|
|
290
|
+
elementStart: number,
|
|
291
|
+
prop: string,
|
|
292
|
+
): StyleEditResult {
|
|
293
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
294
|
+
if (tagEnd < 0) return { code, changed: false, dynamic: false };
|
|
295
|
+
const styleObj = findStyleObject(code, elementStart, tagEnd);
|
|
296
|
+
if (!styleObj) return { code, changed: false, dynamic: false }; // no style attr — nothing to remove
|
|
297
|
+
|
|
298
|
+
const body = code.slice(styleObj.innerStart, styleObj.innerEnd);
|
|
299
|
+
const props = analyzeStyleProperties(body, styleObj.innerStart);
|
|
300
|
+
const existing = props.find((p) => p.name === prop);
|
|
301
|
+
if (!existing) return { code, changed: false, dynamic: false }; // absent — nothing to remove
|
|
302
|
+
if (!existing.literal) return { code, changed: false, dynamic: true }; // guard, mirrors setInlineStyle
|
|
303
|
+
|
|
304
|
+
// Prefer consuming a comma that FOLLOWS this member (it precedes others in the
|
|
305
|
+
// list); else one that PRECEDES it (it is last); else this was the ONLY thing in
|
|
306
|
+
// the object — collapse to `{}` instead.
|
|
307
|
+
let after = existing.memberEnd;
|
|
308
|
+
while (after < styleObj.innerEnd && /\s/.test(code[after] ?? '')) after++;
|
|
309
|
+
if (code[after] === ',') {
|
|
310
|
+
let end = after + 1;
|
|
311
|
+
if (code[end] === ' ') end++;
|
|
312
|
+
return {
|
|
313
|
+
code: code.slice(0, existing.memberStart) + code.slice(end),
|
|
314
|
+
changed: true,
|
|
315
|
+
dynamic: false,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let before = existing.memberStart - 1;
|
|
320
|
+
while (before >= styleObj.innerStart && /\s/.test(code[before] ?? '')) before--;
|
|
321
|
+
if (code[before] === ',') {
|
|
322
|
+
return {
|
|
323
|
+
code: code.slice(0, before) + code.slice(existing.memberEnd),
|
|
324
|
+
changed: true,
|
|
325
|
+
dynamic: false,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
code: code.slice(0, styleObj.innerStart) + code.slice(styleObj.innerEnd),
|
|
331
|
+
changed: true,
|
|
332
|
+
dynamic: false,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Skip one `{…}` JSX expression container whole, from its opening brace to the
|
|
338
|
+
* index AFTER the matching `}` (or `-1` when the file ends first).
|
|
339
|
+
*
|
|
340
|
+
* This is the ONLY place between two tags where a quote opens a string and
|
|
341
|
+
* `//` / `/* */` open comments — see {@link findElementEnd}, whose walk
|
|
342
|
+
* this exists to keep out of them.
|
|
343
|
+
*/
|
|
344
|
+
function skipJsxExpression(code: string, open: number): number {
|
|
345
|
+
let depth = 0;
|
|
346
|
+
let i = open;
|
|
347
|
+
while (i < code.length) {
|
|
348
|
+
const c = code[i];
|
|
349
|
+
if (c === '/' && code[i + 1] === '/') {
|
|
350
|
+
const newline = code.indexOf('\n', i);
|
|
351
|
+
i = newline < 0 ? code.length : newline + 1;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (c === '/' && code[i + 1] === '*') {
|
|
355
|
+
const close = code.indexOf('*/', i + 2);
|
|
356
|
+
i = close < 0 ? code.length : close + 2;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
360
|
+
i++;
|
|
361
|
+
while (i < code.length && !(code[i] === c && code[i - 1] !== '\\')) i++;
|
|
362
|
+
i++;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (c === '{') {
|
|
366
|
+
depth++;
|
|
367
|
+
i++;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (c === '}') {
|
|
371
|
+
depth--;
|
|
372
|
+
i++;
|
|
373
|
+
if (depth === 0) return i;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
// NESTED JSX — `{cond ? <div>the shot's takes</div> : null}`. Inside the
|
|
377
|
+
// element the quotes are TEXT again, so the element is handed back to
|
|
378
|
+
// `findElementEnd`, whose walk knows that; scanning it here would read
|
|
379
|
+
// that apostrophe as a string exactly like the defect this pair fixes.
|
|
380
|
+
// A `<` is only a tag in EXPRESSION-START position — after an identifier,
|
|
381
|
+
// `)` or `]` it is the less-than operator (`a < b`), never JSX.
|
|
382
|
+
if (c === '<' && opensNestedJsx(code, i)) {
|
|
383
|
+
const elementEnd = findElementEnd(code, i);
|
|
384
|
+
if (elementEnd > i) {
|
|
385
|
+
i = elementEnd;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
i++;
|
|
390
|
+
}
|
|
391
|
+
return -1;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Is the `<` at `i`, inside a `{…}` expression, the start of a JSX element? */
|
|
395
|
+
function opensNestedJsx(code: string, i: number): boolean {
|
|
396
|
+
const next = code[i + 1];
|
|
397
|
+
if (next === undefined || !(next === '>' || /[A-Za-z]/.test(next))) return false;
|
|
398
|
+
let before = i - 1;
|
|
399
|
+
while (before >= 0 && /\s/.test(code[before] ?? '')) before--;
|
|
400
|
+
return !/[A-Za-z0-9_$)\]]/.test(code[before] ?? '');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** `<` starts a TAG only before `/`, a name, `>` (a fragment) or `!`. Anything
|
|
404
|
+
* else is ordinary text this walk steps over one character at a time. */
|
|
405
|
+
function startsTag(code: string, i: number): boolean {
|
|
406
|
+
const next = code[i + 1];
|
|
407
|
+
return (
|
|
408
|
+
next !== undefined && (next === '/' || next === '>' || next === '!' || /[A-Za-z]/.test(next))
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Find the end (exclusive) of a full JSX element starting at `<Tag` — balancing
|
|
414
|
+
* nested same-tag elements and skipping `{…}` expressions whole (C8).
|
|
415
|
+
*
|
|
416
|
+
* **A QUOTE BETWEEN TWO TAGS IS TEXT, NOT A STRING**, and reading it as one
|
|
417
|
+
* destroyed source. This walk used to flip into "inside a string" on any `'`,
|
|
418
|
+
* `"` or backtick it met, exactly as {@link findTagEnd} does INSIDE a tag —
|
|
419
|
+
* but between tags those characters are JSX children, and an apostrophe is an
|
|
420
|
+
* ordinary English one. Measured 2026-09-19 on the game template's own
|
|
421
|
+
* `MainScene.tsx`: the JSX comment inside `<group name="Light Rig">` says
|
|
422
|
+
* *"The sun's position… that component's header… than this scene's."* — three
|
|
423
|
+
* apostrophes, so the walk was left mid-"string", never saw `</group>`, and
|
|
424
|
+
* answered with the END OF THE FILE. Every consumer of that answer takes the
|
|
425
|
+
* whole rest of the component: Delete on the Light Rig row would have removed
|
|
426
|
+
* it, copy would have copied it, and `group` wrote a structurally broken file
|
|
427
|
+
* (that is how this was found).
|
|
428
|
+
*
|
|
429
|
+
* So: enter a string only inside a `{…}` expression container, where
|
|
430
|
+
* {@link skipJsxExpression} also handles `//` and block comments — which is
|
|
431
|
+
* what makes a JSX comment's prose invisible to this walk — and step over any
|
|
432
|
+
* OTHER element's opening tag with {@link findTagEnd}, so its attributes'
|
|
433
|
+
* quotes and braces never reach here either. Only our own tag moves `depth`,
|
|
434
|
+
* so another element's children stay in this walk and cost nothing.
|
|
435
|
+
*/
|
|
436
|
+
export function findElementEnd(code: string, start: number): number {
|
|
437
|
+
const tagEnd = findTagEnd(code, start);
|
|
438
|
+
if (tagEnd < 0) return -1;
|
|
439
|
+
if (code[tagEnd - 1] === '/') return tagEnd + 1; // self-closing
|
|
440
|
+
const tagNameMatch = code.slice(start + 1, tagEnd).match(/^([A-Za-z0-9_.]+)/);
|
|
441
|
+
const tag = tagNameMatch?.[1];
|
|
442
|
+
if (!tag) return -1;
|
|
443
|
+
let depth = 1;
|
|
444
|
+
let i = tagEnd + 1;
|
|
445
|
+
while (i < code.length && depth > 0) {
|
|
446
|
+
const c = code[i];
|
|
447
|
+
if (c === '{') {
|
|
448
|
+
const after = skipJsxExpression(code, i);
|
|
449
|
+
i = after < 0 ? code.length : after;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (c !== '<' || !startsTag(code, i)) {
|
|
453
|
+
i++;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (code.startsWith(`</${tag}`, i)) {
|
|
457
|
+
depth--;
|
|
458
|
+
const close = code.indexOf('>', i);
|
|
459
|
+
i = close < 0 ? code.length : close + 1;
|
|
460
|
+
if (depth === 0) return i;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (code.startsWith(`<${tag}`, i) && /[\s/>]/.test(code[i + 1 + tag.length] ?? '')) {
|
|
464
|
+
// A nested SAME-TAG element: only a non-self-closing one increments depth.
|
|
465
|
+
// A self-closing `<tag .../>` opens+closes in one tag and must NOT bump depth
|
|
466
|
+
// (else the balancer runs past the real closing tag and over-deletes).
|
|
467
|
+
const nestedTagEnd = findTagEnd(code, i);
|
|
468
|
+
if (nestedTagEnd < 0) {
|
|
469
|
+
// An unreadable opening tag is still an OPEN one: count it and step
|
|
470
|
+
// past the name. Returning here would end the element at its own first
|
|
471
|
+
// child, which is a worse answer than the imperfect scan.
|
|
472
|
+
depth++;
|
|
473
|
+
i += 1 + tag.length;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (code[nestedTagEnd - 1] !== '/') depth++;
|
|
477
|
+
i = nestedTagEnd + 1;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const otherTagEnd = findTagEnd(code, i);
|
|
481
|
+
i = otherTagEnd < 0 ? i + 1 : otherTagEnd + 1;
|
|
482
|
+
}
|
|
483
|
+
return i;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// --- F5: multi-path style routing (inline handled above; className below) ---
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Encode a CSS prop:value as a Tailwind-style utility class. Numeric/color values
|
|
490
|
+
* use ARBITRARY values (`w-[200px]`, `bg-[#059669]`) so the round-trip is LOSSLESS
|
|
491
|
+
* (a named utility like `bg-emerald-600` would NOT recover the exact hex). Enums map
|
|
492
|
+
* to named utilities.
|
|
493
|
+
*/
|
|
494
|
+
// Cap 6 (React visual-edit parity): broadened to the grouped-inspector property set. Enum
|
|
495
|
+
// values map to NAMED utilities; each named utility string is unique across groups so the
|
|
496
|
+
// ENUM_GROUP reverse map (dedupe) stays unambiguous.
|
|
497
|
+
const ENUM_UTILITIES: Record<string, Record<string, string>> = {
|
|
498
|
+
display: {
|
|
499
|
+
flex: 'flex',
|
|
500
|
+
block: 'block',
|
|
501
|
+
grid: 'grid',
|
|
502
|
+
inline: 'inline',
|
|
503
|
+
'inline-block': 'inline-block',
|
|
504
|
+
'inline-flex': 'inline-flex',
|
|
505
|
+
none: 'hidden',
|
|
506
|
+
},
|
|
507
|
+
flexDirection: {
|
|
508
|
+
row: 'flex-row',
|
|
509
|
+
column: 'flex-col',
|
|
510
|
+
'row-reverse': 'flex-row-reverse',
|
|
511
|
+
'column-reverse': 'flex-col-reverse',
|
|
512
|
+
},
|
|
513
|
+
flexWrap: { wrap: 'flex-wrap', nowrap: 'flex-nowrap', 'wrap-reverse': 'flex-wrap-reverse' },
|
|
514
|
+
justifyContent: {
|
|
515
|
+
'flex-start': 'justify-start',
|
|
516
|
+
'flex-end': 'justify-end',
|
|
517
|
+
center: 'justify-center',
|
|
518
|
+
'space-between': 'justify-between',
|
|
519
|
+
'space-around': 'justify-around',
|
|
520
|
+
'space-evenly': 'justify-evenly',
|
|
521
|
+
},
|
|
522
|
+
alignItems: {
|
|
523
|
+
'flex-start': 'items-start',
|
|
524
|
+
'flex-end': 'items-end',
|
|
525
|
+
center: 'items-center',
|
|
526
|
+
baseline: 'items-baseline',
|
|
527
|
+
stretch: 'items-stretch',
|
|
528
|
+
},
|
|
529
|
+
textAlign: {
|
|
530
|
+
left: 'text-left',
|
|
531
|
+
center: 'text-center',
|
|
532
|
+
right: 'text-right',
|
|
533
|
+
justify: 'text-justify',
|
|
534
|
+
},
|
|
535
|
+
position: {
|
|
536
|
+
relative: 'relative',
|
|
537
|
+
absolute: 'absolute',
|
|
538
|
+
fixed: 'fixed',
|
|
539
|
+
sticky: 'sticky',
|
|
540
|
+
static: 'static',
|
|
541
|
+
},
|
|
542
|
+
overflow: {
|
|
543
|
+
visible: 'overflow-visible',
|
|
544
|
+
hidden: 'overflow-hidden',
|
|
545
|
+
scroll: 'overflow-scroll',
|
|
546
|
+
auto: 'overflow-auto',
|
|
547
|
+
},
|
|
548
|
+
borderStyle: {
|
|
549
|
+
none: 'border-none',
|
|
550
|
+
solid: 'border-solid',
|
|
551
|
+
dashed: 'border-dashed',
|
|
552
|
+
dotted: 'border-dotted',
|
|
553
|
+
double: 'border-double',
|
|
554
|
+
},
|
|
555
|
+
textTransform: {
|
|
556
|
+
uppercase: 'uppercase',
|
|
557
|
+
lowercase: 'lowercase',
|
|
558
|
+
capitalize: 'capitalize',
|
|
559
|
+
none: 'normal-case',
|
|
560
|
+
},
|
|
561
|
+
whiteSpace: {
|
|
562
|
+
normal: 'whitespace-normal',
|
|
563
|
+
nowrap: 'whitespace-nowrap',
|
|
564
|
+
pre: 'whitespace-pre',
|
|
565
|
+
'pre-wrap': 'whitespace-pre-wrap',
|
|
566
|
+
},
|
|
567
|
+
fontStyle: { italic: 'italic', normal: 'not-italic' },
|
|
568
|
+
mixBlendMode: {
|
|
569
|
+
normal: 'mix-blend-normal',
|
|
570
|
+
multiply: 'mix-blend-multiply',
|
|
571
|
+
screen: 'mix-blend-screen',
|
|
572
|
+
overlay: 'mix-blend-overlay',
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
// Reverse map: a named utility -> its logical group (for dedupe of enum utilities).
|
|
576
|
+
const ENUM_GROUP: Record<string, string> = (() => {
|
|
577
|
+
const out: Record<string, string> = {};
|
|
578
|
+
for (const [group, m] of Object.entries(ENUM_UTILITIES))
|
|
579
|
+
for (const u of Object.values(m)) out[u] = group;
|
|
580
|
+
return out;
|
|
581
|
+
})();
|
|
582
|
+
// Arbitrary-value utilities. `color`/`fontSize` both map to `text-`, so they use
|
|
583
|
+
// Tailwind TYPE HINTS (`text-[color:..]` vs `text-[length:..]`) to disambiguate —
|
|
584
|
+
// which also makes property-based dedupe unambiguous.
|
|
585
|
+
const ARB_MAP: Record<string, { prefix: string; hint?: 'color' | 'length' }> = {
|
|
586
|
+
backgroundColor: { prefix: 'bg' },
|
|
587
|
+
// `border` covers both border-color and border-width, so — like `text-` — they use type
|
|
588
|
+
// hints to stay unambiguous for property-based dedupe (Cap 6).
|
|
589
|
+
borderColor: { prefix: 'border', hint: 'color' },
|
|
590
|
+
borderWidth: { prefix: 'border', hint: 'length' },
|
|
591
|
+
color: { prefix: 'text', hint: 'color' },
|
|
592
|
+
fontSize: { prefix: 'text', hint: 'length' },
|
|
593
|
+
width: { prefix: 'w' },
|
|
594
|
+
height: { prefix: 'h' },
|
|
595
|
+
minWidth: { prefix: 'min-w' },
|
|
596
|
+
minHeight: { prefix: 'min-h' },
|
|
597
|
+
maxWidth: { prefix: 'max-w' },
|
|
598
|
+
maxHeight: { prefix: 'max-h' },
|
|
599
|
+
padding: { prefix: 'p' },
|
|
600
|
+
paddingTop: { prefix: 'pt' },
|
|
601
|
+
paddingRight: { prefix: 'pr' },
|
|
602
|
+
paddingBottom: { prefix: 'pb' },
|
|
603
|
+
paddingLeft: { prefix: 'pl' },
|
|
604
|
+
margin: { prefix: 'm' },
|
|
605
|
+
marginTop: { prefix: 'mt' },
|
|
606
|
+
marginRight: { prefix: 'mr' },
|
|
607
|
+
marginBottom: { prefix: 'mb' },
|
|
608
|
+
marginLeft: { prefix: 'ml' },
|
|
609
|
+
top: { prefix: 'top' },
|
|
610
|
+
right: { prefix: 'right' },
|
|
611
|
+
bottom: { prefix: 'bottom' },
|
|
612
|
+
left: { prefix: 'left' },
|
|
613
|
+
borderRadius: { prefix: 'rounded' },
|
|
614
|
+
borderTopLeftRadius: { prefix: 'rounded-tl' },
|
|
615
|
+
borderTopRightRadius: { prefix: 'rounded-tr' },
|
|
616
|
+
borderBottomLeftRadius: { prefix: 'rounded-bl' },
|
|
617
|
+
borderBottomRightRadius: { prefix: 'rounded-br' },
|
|
618
|
+
gap: { prefix: 'gap' },
|
|
619
|
+
fontWeight: { prefix: 'font' },
|
|
620
|
+
lineHeight: { prefix: 'leading' },
|
|
621
|
+
letterSpacing: { prefix: 'tracking' },
|
|
622
|
+
flexBasis: { prefix: 'basis' },
|
|
623
|
+
opacity: { prefix: 'opacity' },
|
|
624
|
+
zIndex: { prefix: 'z' },
|
|
625
|
+
boxShadow: { prefix: 'shadow' },
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
const sanitizeClass = (s: string): string => s.replace(/\s+/g, '_');
|
|
629
|
+
|
|
630
|
+
/** Encode a CSS prop:value as a lossless Tailwind-style utility class. */
|
|
631
|
+
export function cssToUtilityClass(prop: string, value: string | number): string {
|
|
632
|
+
const v = String(value);
|
|
633
|
+
if (ENUM_UTILITIES[prop]?.[v]) return ENUM_UTILITIES[prop][v] as string;
|
|
634
|
+
const arb = ARB_MAP[prop];
|
|
635
|
+
if (!arb) return `[${sanitizeClass(prop)}:${sanitizeClass(v)}]`; // arbitrary property — lossless
|
|
636
|
+
const val = typeof value === 'number' ? `${value}px` : v;
|
|
637
|
+
const inner = arb.hint ? `${arb.hint}:${sanitizeClass(val)}` : sanitizeClass(val);
|
|
638
|
+
return `${arb.prefix}-[${inner}]`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** The dedupe group of an existing utility class (so a re-set replaces same-property classes). */
|
|
642
|
+
function classGroup(cls: string): string {
|
|
643
|
+
if (ENUM_GROUP[cls]) return ENUM_GROUP[cls];
|
|
644
|
+
const arb =
|
|
645
|
+
cls.indexOf('-[') >= 0 ? cls.slice(0, cls.indexOf('-[') + 2) : cls.startsWith('[') ? '[' : '';
|
|
646
|
+
if (!arb) return cls;
|
|
647
|
+
// include a type hint (color:/length:) in the group so text-[color:..] != text-[length:..]
|
|
648
|
+
const hintMatch = cls.match(/\[(color|length):/);
|
|
649
|
+
return hintMatch ? `${arb}${hintMatch[1]}:` : arb;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Merge a class into the element's `className="..."` literal, or add the
|
|
654
|
+
* attribute.
|
|
655
|
+
*
|
|
656
|
+
* D-A1 assessment: unlike `setInlineStyle`, this function has NO
|
|
657
|
+
* distinguishable append-vs-replace branch to surface as `appended` — it
|
|
658
|
+
* always does the SAME operation (filter out any class in the target's dedupe
|
|
659
|
+
* GROUP, then push the new class), regardless of whether a same-group class
|
|
660
|
+
* existed before. Scoped OUT of this wave's fix: see the design doc's SHIPPED
|
|
661
|
+
* note for why the className route's undo correctness is a separate, broader
|
|
662
|
+
* pre-existing gap, not this bug.
|
|
663
|
+
*/
|
|
664
|
+
export function setClassName(code: string, elementStart: number, cls: string): StyleEditResult {
|
|
665
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
666
|
+
if (tagEnd < 0) return { code, changed: false, dynamic: false };
|
|
667
|
+
const openSeg = code.slice(elementStart, tagEnd);
|
|
668
|
+
const m = openSeg.match(/className\s*=\s*"([^"]*)"/);
|
|
669
|
+
if (m && m.index != null) {
|
|
670
|
+
// dedupe by PROPERTY GROUP (not raw prefix) so re-setting any prop replaces its
|
|
671
|
+
// prior class — incl. color-vs-fontSize (both `text-`) and enum/arbitrary-prop.
|
|
672
|
+
const group = classGroup(cls);
|
|
673
|
+
const existing = (m[1] ?? '')
|
|
674
|
+
.split(/\s+/)
|
|
675
|
+
.filter((c) => c && c !== cls && classGroup(c) !== group);
|
|
676
|
+
const merged = [...existing, cls].join(' ').trim();
|
|
677
|
+
const valStart = elementStart + (m.index ?? 0) + m[0].indexOf('"') + 1;
|
|
678
|
+
const valEnd = valStart + (m[1]?.length ?? 0);
|
|
679
|
+
return {
|
|
680
|
+
code: `${code.slice(0, valStart)}${merged}${code.slice(valEnd)}`,
|
|
681
|
+
changed: true,
|
|
682
|
+
dynamic: false,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
if (/className\s*=\s*\{/.test(openSeg)) return { code, changed: false, dynamic: true }; // dynamic className
|
|
686
|
+
const selfClose = code[tagEnd - 1] === '/';
|
|
687
|
+
const insertAt = selfClose ? tagEnd - 1 : tagEnd;
|
|
688
|
+
return {
|
|
689
|
+
code: `${code.slice(0, insertAt)} className="${cls}"${code.slice(insertAt)}`,
|
|
690
|
+
changed: true,
|
|
691
|
+
dynamic: false,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Route a style edit to the right target (F5). TWO conditions gate the class
|
|
697
|
+
* route, and the second one is the whole point:
|
|
698
|
+
*
|
|
699
|
+
* 1. the element has a literal `className` the class can be merged into, AND
|
|
700
|
+
* 2. the PROJECT was observed to interpret utility classes
|
|
701
|
+
* (`support.supported` — see `utility-class-support.ts` for the probe and
|
|
702
|
+
* why it is strict).
|
|
703
|
+
*
|
|
704
|
+
* Without (2) a `bg-[#059669]` written into a plain-CSS project is inert: it
|
|
705
|
+
* paints nothing in the editor after a remount and nothing in the game's own
|
|
706
|
+
* build, because no transformer exists on either side to compile it. So no
|
|
707
|
+
* evidence ⇒ the inline `style={{…}}` route, which paints in EVERY project,
|
|
708
|
+
* leaving the element's literal className untouched.
|
|
709
|
+
*
|
|
710
|
+
* When neither target is honest — the element's style attribute is a dynamic
|
|
711
|
+
* expression and there is no compiled class route to fall back to — this
|
|
712
|
+
* REFUSES with a named reason (`changed: false`, `dynamic: true`, `error`)
|
|
713
|
+
* rather than writing bytes that never paint.
|
|
714
|
+
*/
|
|
715
|
+
export function writeStyleAuto(
|
|
716
|
+
code: string,
|
|
717
|
+
elementStart: number,
|
|
718
|
+
prop: string,
|
|
719
|
+
value: string | number,
|
|
720
|
+
support: UtilityClassSupport = UTILITY_CLASSES_UNPROVEN,
|
|
721
|
+
): StyleEditResult & { route: 'class' | 'inline'; error?: string } {
|
|
722
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
723
|
+
const openSeg = tagEnd > 0 ? code.slice(elementStart, tagEnd) : '';
|
|
724
|
+
const inline = setInlineStyle(code, elementStart, prop, value);
|
|
725
|
+
// THE TOKEN GUARD SITS AHEAD OF THE CLASS ROUTE. An inline `var()` beats any
|
|
726
|
+
// utility class, so routing around it would write a class that never paints
|
|
727
|
+
// — the very outcome the refusal below exists to prevent — while leaving the
|
|
728
|
+
// author with no sign their token binding was ignored.
|
|
729
|
+
if (inline.tokenRef) {
|
|
730
|
+
return {
|
|
731
|
+
...inline,
|
|
732
|
+
route: 'inline',
|
|
733
|
+
error: tokenReferenceGuardText(prop, inline.tokenRef),
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
if (support.supported && /className\s*=\s*"/.test(openSeg)) {
|
|
737
|
+
return { ...setClassName(code, elementStart, cssToUtilityClass(prop, value)), route: 'class' };
|
|
738
|
+
}
|
|
739
|
+
if (!inline.changed && inline.dynamic) {
|
|
740
|
+
const why = support.supported
|
|
741
|
+
? 'the element has no literal className to carry a utility class'
|
|
742
|
+
: `this project does not compile utility classes (${support.evidence})`;
|
|
743
|
+
return {
|
|
744
|
+
...inline,
|
|
745
|
+
route: 'inline',
|
|
746
|
+
error:
|
|
747
|
+
`no honest target for "${prop}": the element's style is a dynamic expression and ` +
|
|
748
|
+
`${why} — refusing rather than writing source that never paints.`,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
return { ...inline, route: 'inline' };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// --- Cap 2: CSS-file editing (visual-edit writer.ts surgicalCssEdit :1109) ---
|
|
755
|
+
|
|
756
|
+
/** camelCase CSS prop -> kebab-case, for matching a declaration in a CSS rule body. */
|
|
757
|
+
export function cssCamelToKebab(s: string): string {
|
|
758
|
+
return s.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Find a standalone occurrence of `selectorText` in CSS source (not a substring of a
|
|
762
|
+
* longer selector). Returns the index or -1. */
|
|
763
|
+
function findSelectorIndex(cssSource: string, selectorText: string): number {
|
|
764
|
+
let searchFrom = 0;
|
|
765
|
+
while (searchFrom < cssSource.length) {
|
|
766
|
+
const idx = cssSource.indexOf(selectorText, searchFrom);
|
|
767
|
+
if (idx === -1) break;
|
|
768
|
+
const before = idx > 0 ? cssSource[idx - 1] : '\n';
|
|
769
|
+
if (idx === 0 || /[\s\n{},;]/.test(before ?? '')) {
|
|
770
|
+
const after = cssSource[idx + selectorText.length];
|
|
771
|
+
if (after === '{' || after === ' ' || after === '\n' || after === '\r' || after === '\t') {
|
|
772
|
+
return idx;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
searchFrom = idx + 1;
|
|
776
|
+
}
|
|
777
|
+
return -1;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** The `{ … }` body range of the rule whose selector starts at `selectorIdx`, or null. */
|
|
781
|
+
function findRuleBody(
|
|
782
|
+
cssSource: string,
|
|
783
|
+
selectorIdx: number,
|
|
784
|
+
selectorText: string,
|
|
785
|
+
): { bodyStart: number; bodyEnd: number } | null {
|
|
786
|
+
let i = selectorIdx + selectorText.length;
|
|
787
|
+
while (i < cssSource.length && cssSource[i] !== '{') i++;
|
|
788
|
+
if (i >= cssSource.length) return null;
|
|
789
|
+
const bodyStart = i + 1;
|
|
790
|
+
let depth = 1;
|
|
791
|
+
i++;
|
|
792
|
+
while (i < cssSource.length && depth > 0) {
|
|
793
|
+
if (cssSource[i] === '{') depth++;
|
|
794
|
+
else if (cssSource[i] === '}') depth--;
|
|
795
|
+
if (depth > 0) i++;
|
|
796
|
+
}
|
|
797
|
+
return { bodyStart, bodyEnd: i };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Surgically replace (or append) a property value inside a CSS RULE BODY, editing the
|
|
802
|
+
* source in place and preserving all other declarations/formatting (visual-edit
|
|
803
|
+
* `surgicalCssEdit` :1109). Returns the edited source, or `null` when the selector is not
|
|
804
|
+
* present in the file (i.e. it's GENERATED CSS — a Tailwind utility, styled-components —
|
|
805
|
+
* that must not be hand-edited; the caller falls back to class/inline routing).
|
|
806
|
+
*/
|
|
807
|
+
export function surgicalCssEdit(
|
|
808
|
+
cssSource: string,
|
|
809
|
+
selectorText: string,
|
|
810
|
+
propName: string,
|
|
811
|
+
newValue: string,
|
|
812
|
+
): string | null {
|
|
813
|
+
const selectorIdx = findSelectorIndex(cssSource, selectorText);
|
|
814
|
+
if (selectorIdx === -1) return null;
|
|
815
|
+
const rng = findRuleBody(cssSource, selectorIdx, selectorText);
|
|
816
|
+
if (!rng) return null;
|
|
817
|
+
const { bodyStart, bodyEnd } = rng;
|
|
818
|
+
const body = cssSource.slice(bodyStart, bodyEnd);
|
|
819
|
+
|
|
820
|
+
const kebabProp = cssCamelToKebab(propName);
|
|
821
|
+
const escaped = kebabProp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
822
|
+
const propRegex = new RegExp(`(^|[;\\n\\s])\\s*(${escaped})\\s*:`, 'm');
|
|
823
|
+
const match = propRegex.exec(body);
|
|
824
|
+
|
|
825
|
+
if (match) {
|
|
826
|
+
const matchStart = bodyStart + match.index + match[0].length; // right after the ':'
|
|
827
|
+
let valEnd = matchStart;
|
|
828
|
+
while (valEnd < bodyEnd && cssSource[valEnd] !== ';' && cssSource[valEnd] !== '}') valEnd++;
|
|
829
|
+
return `${cssSource.slice(0, matchStart)} ${newValue}${cssSource.slice(valEnd)}`;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Property absent — append it before the closing '}', matching existing indentation.
|
|
833
|
+
let indent = ' ';
|
|
834
|
+
for (const line of body.split('\n')) {
|
|
835
|
+
if (line.trim().includes(':')) {
|
|
836
|
+
indent = line.match(/^(\s*)/)?.[1] ?? indent;
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
const trimmed = body.trimEnd();
|
|
841
|
+
const needsSemi = trimmed.length > 0 && !trimmed.endsWith(';');
|
|
842
|
+
const insertion = `${needsSemi ? ';\n' : '\n'}${indent}${kebabProp}: ${newValue};\n`;
|
|
843
|
+
return cssSource.slice(0, bodyEnd) + insertion + cssSource.slice(bodyEnd);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* The breakpoint write (design ledger: breakpoints): surgically edit — or
|
|
848
|
+
* create — `selector`'s rule INSIDE a top-level `@media <media>` block,
|
|
849
|
+
* creating the block itself when the stylesheet has none. The source keeps
|
|
850
|
+
* real `@media` truth; the dev server's serve-time rewrite is what turns it
|
|
851
|
+
* into a per-frame container query for preview. Conditions compare
|
|
852
|
+
* whitespace-normalized, so `(max-width:767px)` finds `(max-width: 767px)`.
|
|
853
|
+
*/
|
|
854
|
+
export function surgicalCssEditInMedia(
|
|
855
|
+
cssSource: string,
|
|
856
|
+
media: string,
|
|
857
|
+
selectorText: string,
|
|
858
|
+
propName: string,
|
|
859
|
+
newValue: string,
|
|
860
|
+
): string {
|
|
861
|
+
const kebabProp = cssCamelToKebab(propName);
|
|
862
|
+
const normalize = (t: string): string => t.replace(/\s+/g, ' ').trim();
|
|
863
|
+
const wanted = normalize(media);
|
|
864
|
+
// Find a top-level @media block with the same condition.
|
|
865
|
+
const mediaRe = /@media([^{]+)\{/g;
|
|
866
|
+
let match = mediaRe.exec(cssSource);
|
|
867
|
+
while (match) {
|
|
868
|
+
if (normalize(match[1] ?? '') === wanted) {
|
|
869
|
+
// Block body range by brace balance.
|
|
870
|
+
let depth = 1;
|
|
871
|
+
let i = match.index + match[0].length;
|
|
872
|
+
while (i < cssSource.length && depth > 0) {
|
|
873
|
+
if (cssSource[i] === '{') depth++;
|
|
874
|
+
else if (cssSource[i] === '}') depth--;
|
|
875
|
+
if (depth > 0) i++;
|
|
876
|
+
}
|
|
877
|
+
const bodyStart = match.index + match[0].length;
|
|
878
|
+
const bodyEnd = i;
|
|
879
|
+
const body = cssSource.slice(bodyStart, bodyEnd);
|
|
880
|
+
const editedBody = surgicalCssEdit(body, selectorText, propName, newValue);
|
|
881
|
+
if (editedBody !== null) {
|
|
882
|
+
return cssSource.slice(0, bodyStart) + editedBody + cssSource.slice(bodyEnd);
|
|
883
|
+
}
|
|
884
|
+
// Selector absent inside the block — insert a fresh rule before its `}`.
|
|
885
|
+
const insertion = ` ${selectorText} {\n ${kebabProp}: ${newValue};\n }\n`;
|
|
886
|
+
const needsNl = bodyEnd > bodyStart && cssSource[bodyEnd - 1] !== '\n';
|
|
887
|
+
return (
|
|
888
|
+
cssSource.slice(0, bodyEnd) + (needsNl ? '\n' : '') + insertion + cssSource.slice(bodyEnd)
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
match = mediaRe.exec(cssSource);
|
|
892
|
+
}
|
|
893
|
+
// No block with this condition — append one.
|
|
894
|
+
const lead = cssSource.length === 0 || cssSource.endsWith('\n') ? '' : '\n';
|
|
895
|
+
return (
|
|
896
|
+
`${cssSource}${lead}\n@media ${media.trim()} {\n` +
|
|
897
|
+
` ${selectorText} {\n ${kebabProp}: ${newValue};\n }\n}\n`
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** A matched CSS rule as produced by `inspect.ts`'s `getMatchedCssRules` — the subset the
|
|
902
|
+
* CSS write path needs. */
|
|
903
|
+
export interface CssRuleTarget {
|
|
904
|
+
selectorText: string;
|
|
905
|
+
sourceFile: string;
|
|
906
|
+
properties: Record<string, string>;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Pick the target rule for a property write: the LAST matched rule that declares the
|
|
911
|
+
* property (cascade — last declaration wins), mirroring visual-edit `writeCssRuleChange`
|
|
912
|
+
* :1198. Returns the rule + the prior value (for the undo inverse), or null when no
|
|
913
|
+
* matched rule declares the property (⇒ caller routes to class/inline instead).
|
|
914
|
+
*/
|
|
915
|
+
export function pickCssRuleTarget(
|
|
916
|
+
matchedRules: readonly CssRuleTarget[],
|
|
917
|
+
prop: string,
|
|
918
|
+
): { rule: CssRuleTarget; prevValue: string } | null {
|
|
919
|
+
const kebabProp = cssCamelToKebab(prop);
|
|
920
|
+
let target: CssRuleTarget | null = null;
|
|
921
|
+
for (const rule of matchedRules) {
|
|
922
|
+
if (kebabProp in rule.properties) target = rule;
|
|
923
|
+
}
|
|
924
|
+
if (!target) return null;
|
|
925
|
+
return { rule: target, prevValue: target.properties[kebabProp] ?? '' };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* The element's NAMED STYLE, if it wears one: the LAST matched first-party rule
|
|
930
|
+
* whose selector is exactly one of the element's own classes (`.chip`). This is
|
|
931
|
+
* the Webflow-shaped routing extension to {@link pickCssRuleTarget}: once an
|
|
932
|
+
* element carries a named style, a property the class does not declare YET
|
|
933
|
+
* should land IN the class (surgicalCssEdit appends absent declarations), so
|
|
934
|
+
* the class stays the element's one style home instead of new edits forking
|
|
935
|
+
* back to inline. Only consulted when no matched rule declares the property and
|
|
936
|
+
* the element has no inline declaration of it (inline would win the cascade and
|
|
937
|
+
* the appended declaration would never paint).
|
|
938
|
+
*/
|
|
939
|
+
export function namedStyleRuleFor(
|
|
940
|
+
matchedRules: readonly CssRuleTarget[],
|
|
941
|
+
classList: readonly string[],
|
|
942
|
+
): CssRuleTarget | null {
|
|
943
|
+
let target: CssRuleTarget | null = null;
|
|
944
|
+
for (const rule of matchedRules) {
|
|
945
|
+
const selector = rule.selectorText.trim();
|
|
946
|
+
if (classList.some((cls) => selector === `.${cls}`)) target = rule;
|
|
947
|
+
}
|
|
948
|
+
return target;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** A literal inline-style member, ready to move into a class rule. */
|
|
952
|
+
export interface LiteralInlineStyle {
|
|
953
|
+
/** The JSX member name (camelCase, e.g. `fontSize`). */
|
|
954
|
+
name: string;
|
|
955
|
+
/** The literal value text as authored (quotes included for strings). */
|
|
956
|
+
raw: string;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* The element's LITERAL inline-style members — the declarations
|
|
961
|
+
* "create a named style from this selection" moves into the new class rule.
|
|
962
|
+
* Dynamic members (expressions, interpolated templates) are simply not listed:
|
|
963
|
+
* they are not the writer's to move, and leaving them inline is correct
|
|
964
|
+
* (the element keeps them, cascade-above the class).
|
|
965
|
+
* Empty when there is no literal `style={{…}}` at all.
|
|
966
|
+
*/
|
|
967
|
+
export function collectLiteralInlineStyles(
|
|
968
|
+
code: string,
|
|
969
|
+
elementStart: number,
|
|
970
|
+
): LiteralInlineStyle[] {
|
|
971
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
972
|
+
if (tagEnd < 0) return [];
|
|
973
|
+
const styleObj = findStyleObject(code, elementStart, tagEnd);
|
|
974
|
+
if (!styleObj) return [];
|
|
975
|
+
const body = code.slice(styleObj.innerStart, styleObj.innerEnd);
|
|
976
|
+
return analyzeStyleProperties(body, styleObj.innerStart)
|
|
977
|
+
.filter((p) => p.literal)
|
|
978
|
+
.map((p) => ({ name: p.name, raw: p.raw }));
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Add ONE class token to a literal `className` (inserting the attribute when
|
|
983
|
+
* absent) — the named-style APPLY gesture. Unlike {@link setClassName} there is
|
|
984
|
+
* no utility-group dedupe: a named style is an identity, not a per-property
|
|
985
|
+
* utility, and applying `.chip` must never evict `.card`. No-op when the token
|
|
986
|
+
* is already present; `dynamic: true` refusal on a `className={expr}`.
|
|
987
|
+
*/
|
|
988
|
+
export function addClassNameToken(
|
|
989
|
+
code: string,
|
|
990
|
+
elementStart: number,
|
|
991
|
+
cls: string,
|
|
992
|
+
): StyleEditResult {
|
|
993
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
994
|
+
if (tagEnd < 0) return { code, changed: false, dynamic: false };
|
|
995
|
+
const openSeg = code.slice(elementStart, tagEnd);
|
|
996
|
+
const m = openSeg.match(/className\s*=\s*"([^"]*)"/);
|
|
997
|
+
if (m && m.index != null) {
|
|
998
|
+
const existing = (m[1] ?? '').split(/\s+/).filter(Boolean);
|
|
999
|
+
if (existing.includes(cls)) return { code, changed: false, dynamic: false };
|
|
1000
|
+
const merged = [...existing, cls].join(' ');
|
|
1001
|
+
const valStart = elementStart + m.index + m[0].indexOf('"') + 1;
|
|
1002
|
+
const valEnd = valStart + (m[1]?.length ?? 0);
|
|
1003
|
+
return {
|
|
1004
|
+
code: `${code.slice(0, valStart)}${merged}${code.slice(valEnd)}`,
|
|
1005
|
+
changed: true,
|
|
1006
|
+
dynamic: false,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
if (/className\s*=\s*\{/.test(openSeg)) return { code, changed: false, dynamic: true };
|
|
1010
|
+
const selfClose = code[tagEnd - 1] === '/';
|
|
1011
|
+
const insertAt = selfClose ? tagEnd - 1 : tagEnd;
|
|
1012
|
+
return {
|
|
1013
|
+
code: `${code.slice(0, insertAt)} className="${cls}"${code.slice(insertAt)}`,
|
|
1014
|
+
changed: true,
|
|
1015
|
+
dynamic: false,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Remove ONE class token from a literal `className` — dropping the attribute
|
|
1021
|
+
* entirely when it empties (an empty `className=""` is authored noise). No-op
|
|
1022
|
+
* when the token is absent; `dynamic: true` refusal on a `className={expr}`.
|
|
1023
|
+
*/
|
|
1024
|
+
export function removeClassNameToken(
|
|
1025
|
+
code: string,
|
|
1026
|
+
elementStart: number,
|
|
1027
|
+
cls: string,
|
|
1028
|
+
): StyleEditResult {
|
|
1029
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
1030
|
+
if (tagEnd < 0) return { code, changed: false, dynamic: false };
|
|
1031
|
+
const openSeg = code.slice(elementStart, tagEnd);
|
|
1032
|
+
const m = openSeg.match(/className\s*=\s*"([^"]*)"/);
|
|
1033
|
+
if (!m || m.index == null) {
|
|
1034
|
+
return /className\s*=\s*\{/.test(openSeg)
|
|
1035
|
+
? { code, changed: false, dynamic: true }
|
|
1036
|
+
: { code, changed: false, dynamic: false };
|
|
1037
|
+
}
|
|
1038
|
+
const existing = (m[1] ?? '').split(/\s+/).filter(Boolean);
|
|
1039
|
+
if (!existing.includes(cls)) return { code, changed: false, dynamic: false };
|
|
1040
|
+
const remaining = existing.filter((c) => c !== cls);
|
|
1041
|
+
if (remaining.length > 0) {
|
|
1042
|
+
const valStart = elementStart + m.index + m[0].indexOf('"') + 1;
|
|
1043
|
+
const valEnd = valStart + (m[1]?.length ?? 0);
|
|
1044
|
+
return {
|
|
1045
|
+
code: `${code.slice(0, valStart)}${remaining.join(' ')}${code.slice(valEnd)}`,
|
|
1046
|
+
changed: true,
|
|
1047
|
+
dynamic: false,
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
// Attribute empties — remove it, and the one space that separated it.
|
|
1051
|
+
let attrStart = elementStart + m.index;
|
|
1052
|
+
while (attrStart > elementStart && /\s/.test(code[attrStart - 1] ?? '')) attrStart--;
|
|
1053
|
+
const attrEnd = elementStart + m.index + m[0].length;
|
|
1054
|
+
return { code: code.slice(0, attrStart) + code.slice(attrEnd), changed: true, dynamic: false };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// --- Cap 3: text-content editing (visual-edit writer.ts getEditableText :817 /
|
|
1058
|
+
// editTextContent :861) ---
|
|
1059
|
+
|
|
1060
|
+
/** The `<`-relative content range (between the opening tag's `>` and the closing `</`)
|
|
1061
|
+
* of the element at `elementStart`, plus whether it is a pure-text (editable) body. */
|
|
1062
|
+
function findTextContentRange(
|
|
1063
|
+
code: string,
|
|
1064
|
+
elementStart: number,
|
|
1065
|
+
): { contentStart: number; closingTagStart: number; content: string } | null {
|
|
1066
|
+
if (code[elementStart] !== '<') return null;
|
|
1067
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
1068
|
+
if (tagEnd < 0 || code[tagEnd - 1] === '/') return null; // self-closing ⇒ no text body
|
|
1069
|
+
const contentStart = tagEnd + 1;
|
|
1070
|
+
const elementEnd = findElementEnd(code, elementStart);
|
|
1071
|
+
if (elementEnd < 0) return null;
|
|
1072
|
+
// The closing tag is the last `</` before elementEnd.
|
|
1073
|
+
let closingTagStart = elementEnd - 1;
|
|
1074
|
+
while (
|
|
1075
|
+
closingTagStart > contentStart &&
|
|
1076
|
+
!(code[closingTagStart] === '<' && code[closingTagStart + 1] === '/')
|
|
1077
|
+
) {
|
|
1078
|
+
closingTagStart--;
|
|
1079
|
+
}
|
|
1080
|
+
return { contentStart, closingTagStart, content: code.slice(contentStart, closingTagStart) };
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Return an element's editable text — its content trimmed — IFF the body is pure text (no
|
|
1085
|
+
* `{expression}` and no child `<element>`); else null (visual-edit `getEditableText` :817).
|
|
1086
|
+
* This is the double-click-to-edit gate: only a leaf text node is editable in place.
|
|
1087
|
+
*/
|
|
1088
|
+
export function getEditableText(code: string, elementStart: number): string | null {
|
|
1089
|
+
const rng = findTextContentRange(code, elementStart);
|
|
1090
|
+
if (!rng) return null;
|
|
1091
|
+
if (rng.content.includes('{') || rng.content.includes('<')) return null; // dynamic/children
|
|
1092
|
+
const trimmed = rng.content.trim();
|
|
1093
|
+
return trimmed ? trimmed : null;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** Escape the JSX-structural characters so replacement text can't break the source tree
|
|
1097
|
+
* (`<`/`{`/`}` become entities; `&` first so we don't double-encode). */
|
|
1098
|
+
function escapeJsxText(text: string): string {
|
|
1099
|
+
return text
|
|
1100
|
+
.replace(/&/g, '&')
|
|
1101
|
+
.replace(/</g, '<')
|
|
1102
|
+
.replace(/\{/g, '{')
|
|
1103
|
+
.replace(/\}/g, '}');
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Replace an element's text content in place, preserving the surrounding whitespace
|
|
1108
|
+
* (only the trimmed text portion is swapped — visual-edit `editTextContent` :861). Refuses
|
|
1109
|
+
* (guard) when the body is not pure text (`dynamic: true`), exactly like the read gate.
|
|
1110
|
+
*/
|
|
1111
|
+
export function editTextContent(
|
|
1112
|
+
code: string,
|
|
1113
|
+
elementStart: number,
|
|
1114
|
+
newText: string,
|
|
1115
|
+
): StyleEditResult {
|
|
1116
|
+
const rng = findTextContentRange(code, elementStart);
|
|
1117
|
+
if (!rng) return { code, changed: false, dynamic: false };
|
|
1118
|
+
const { contentStart, closingTagStart, content } = rng;
|
|
1119
|
+
if (content.includes('{') || content.includes('<'))
|
|
1120
|
+
return { code, changed: false, dynamic: true };
|
|
1121
|
+
const leading = content.length - content.trimStart().length;
|
|
1122
|
+
const trailing = content.length - content.trimEnd().length;
|
|
1123
|
+
const replaceStart = contentStart + leading;
|
|
1124
|
+
const replaceEnd = closingTagStart - trailing;
|
|
1125
|
+
const next = code.slice(0, replaceStart) + escapeJsxText(newText) + code.slice(replaceEnd);
|
|
1126
|
+
return { code: next, changed: next !== code, dynamic: false };
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// --- Cap 4: JSX prop / attribute editing (visual-edit writer.ts analyzeJsxAttributes
|
|
1130
|
+
// :913 / writePropChange :1018 / fetchPropAnalysis :1069) ---
|
|
1131
|
+
|
|
1132
|
+
export interface JsxAttrInfo {
|
|
1133
|
+
name: string;
|
|
1134
|
+
/** Value char range in `code` (exclusive of quotes/braces). -1 for a boolean shorthand. */
|
|
1135
|
+
valueStart: number;
|
|
1136
|
+
valueEnd: number;
|
|
1137
|
+
rawValue: string;
|
|
1138
|
+
/** True if the value is a `{…}` expression container (vs a `"…"` string). */
|
|
1139
|
+
isExpression: boolean;
|
|
1140
|
+
/** True if the value is a literal we can safely rewrite (string, number, boolean). */
|
|
1141
|
+
isLiteral: boolean;
|
|
1142
|
+
/** Byte range of the WHOLE attribute (`speed={2}`), name included — the unit
|
|
1143
|
+
* {@link removePropAttribute} deletes. */
|
|
1144
|
+
attrStart: number;
|
|
1145
|
+
attrEnd: number;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** Skip a `{…}` region (spread or expression), respecting nested braces + strings. Returns
|
|
1149
|
+
* the index just past the matching `}`. */
|
|
1150
|
+
function skipBraces(code: string, from: number): number {
|
|
1151
|
+
let i = from + 1;
|
|
1152
|
+
let depth = 1;
|
|
1153
|
+
let inStr: string | null = null;
|
|
1154
|
+
while (i < code.length && depth > 0) {
|
|
1155
|
+
const ch = code[i];
|
|
1156
|
+
if (inStr) {
|
|
1157
|
+
if (ch === inStr && code[i - 1] !== '\\') inStr = null;
|
|
1158
|
+
} else if (ch === "'" || ch === '"' || ch === '`') inStr = ch;
|
|
1159
|
+
else if (ch === '{') depth++;
|
|
1160
|
+
else if (ch === '}') depth--;
|
|
1161
|
+
i++;
|
|
1162
|
+
}
|
|
1163
|
+
return i;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/** Read a quoted string value starting at the opening quote; returns [valueStart, valueEnd,
|
|
1167
|
+
* indexAfterClosingQuote]. */
|
|
1168
|
+
function readQuotedValue(code: string, openQuote: number, delim: string): [number, number, number] {
|
|
1169
|
+
let i = openQuote + 1;
|
|
1170
|
+
const valueStart = i;
|
|
1171
|
+
while (i < code.length && code[i] !== delim) {
|
|
1172
|
+
if (code[i] === '\\') i++;
|
|
1173
|
+
i++;
|
|
1174
|
+
}
|
|
1175
|
+
return [valueStart, i, i + 1];
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const NUMBER_LITERAL_SOURCE = String.raw`[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?`;
|
|
1179
|
+
const NUMBER_LITERAL_RE = new RegExp(`^${NUMBER_LITERAL_SOURCE}$`);
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* A NUMBER-TUPLE literal (`[0, 1.5, 0]`, any arity incl. empty,
|
|
1183
|
+
* ints/floats/negatives, whitespace/newlines/trailing comma tolerated). This
|
|
1184
|
+
* is the 3D value shape R3F props use for
|
|
1185
|
+
* `position`/`rotation`/`scale`/`args`; a tuple containing anything but a
|
|
1186
|
+
* plain signed number literal (`[x, 1, 0]`, `[f(), 0]`) is DYNAMIC and stays
|
|
1187
|
+
* behind the literal-vs-dynamic guard.
|
|
1188
|
+
*/
|
|
1189
|
+
export function isNumberTupleLiteral(raw: string): boolean {
|
|
1190
|
+
const trimmed = raw.trim();
|
|
1191
|
+
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return false;
|
|
1192
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
1193
|
+
if (inner === '') return true; // empty tuple — degenerate but literal
|
|
1194
|
+
const parts = inner.split(',').map((p) => p.trim());
|
|
1195
|
+
// A trailing comma yields one empty final part — tolerate exactly that.
|
|
1196
|
+
if (parts.length > 1 && parts[parts.length - 1] === '') parts.pop();
|
|
1197
|
+
return parts.every((p) => NUMBER_LITERAL_RE.test(p));
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const LITERAL_EXPR_RE = new RegExp(`^(?:${NUMBER_LITERAL_SOURCE}|true|false|'[^']*'|"[^"]*")$`);
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Parse the attributes of a JSX opening tag `<Tag …>` (visual-edit `analyzeJsxAttributes`
|
|
1204
|
+
* :913). Handles string values, `{expression}` values (literal detection), boolean
|
|
1205
|
+
* shorthand, and `{...spread}` (skipped). Pure string scan — no parser.
|
|
1206
|
+
*/
|
|
1207
|
+
export function analyzeJsxAttributes(
|
|
1208
|
+
code: string,
|
|
1209
|
+
tagStart: number,
|
|
1210
|
+
tagEnd: number,
|
|
1211
|
+
): JsxAttrInfo[] {
|
|
1212
|
+
const attrs: JsxAttrInfo[] = [];
|
|
1213
|
+
const skipWs = (i: number): number => {
|
|
1214
|
+
while (i <= tagEnd && /\s/.test(code[i] ?? '')) i++;
|
|
1215
|
+
return i;
|
|
1216
|
+
};
|
|
1217
|
+
let i = tagStart + 1; // skip '<'
|
|
1218
|
+
while (i <= tagEnd && !/[\s>/]/.test(code[i] ?? '>')) i++; // skip tag name
|
|
1219
|
+
|
|
1220
|
+
while (i <= tagEnd) {
|
|
1221
|
+
i = skipWs(i);
|
|
1222
|
+
if (i > tagEnd || code[i] === '>' || (code[i] === '/' && code[i + 1] === '>')) break;
|
|
1223
|
+
if (code[i] === '{') {
|
|
1224
|
+
i = skipBraces(code, i); // {...spread}
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
const nameStart = i;
|
|
1228
|
+
while (i <= tagEnd && /[\w-]/.test(code[i] ?? '')) i++;
|
|
1229
|
+
const name = code.slice(nameStart, i);
|
|
1230
|
+
if (!name) {
|
|
1231
|
+
i++;
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
const nameEnd = i;
|
|
1235
|
+
i = skipWs(i);
|
|
1236
|
+
if (code[i] !== '=') {
|
|
1237
|
+
attrs.push(booleanShorthand(name, nameStart, nameEnd)); // e.g. `disabled`
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
i = skipWs(i + 1); // skip '=' + whitespace
|
|
1241
|
+
const parsed = readAttrValue(code, i);
|
|
1242
|
+
if (!parsed) {
|
|
1243
|
+
i++;
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
attrs.push(makeAttr(code, name, parsed, nameStart));
|
|
1247
|
+
i = parsed.next;
|
|
1248
|
+
}
|
|
1249
|
+
return attrs;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** A boolean-shorthand attribute descriptor (`disabled` ⇒ `true`, no value range). */
|
|
1253
|
+
function booleanShorthand(name: string, attrStart: number, attrEnd: number): JsxAttrInfo {
|
|
1254
|
+
return {
|
|
1255
|
+
name,
|
|
1256
|
+
valueStart: -1,
|
|
1257
|
+
valueEnd: -1,
|
|
1258
|
+
rawValue: 'true',
|
|
1259
|
+
isExpression: false,
|
|
1260
|
+
isLiteral: true,
|
|
1261
|
+
attrStart,
|
|
1262
|
+
attrEnd,
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/** Read an attribute value starting at `i` (the delimiter after `=`). Returns the value
|
|
1267
|
+
* range + whether it's an expression + the index past it, or null for an unrecognized
|
|
1268
|
+
* delimiter (the caller advances one char). */
|
|
1269
|
+
function readAttrValue(
|
|
1270
|
+
code: string,
|
|
1271
|
+
i: number,
|
|
1272
|
+
): { valueStart: number; valueEnd: number; isExpression: boolean; next: number } | null {
|
|
1273
|
+
const delim = code[i];
|
|
1274
|
+
if (delim === '"' || delim === "'") {
|
|
1275
|
+
const [valueStart, valueEnd, next] = readQuotedValue(code, i, delim);
|
|
1276
|
+
return { valueStart, valueEnd, isExpression: false, next };
|
|
1277
|
+
}
|
|
1278
|
+
if (delim === '{') {
|
|
1279
|
+
const valueEnd = skipBraces(code, i) - 1;
|
|
1280
|
+
return { valueStart: i + 1, valueEnd, isExpression: true, next: valueEnd + 1 };
|
|
1281
|
+
}
|
|
1282
|
+
return null;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/** Build a `JsxAttrInfo` from a parsed value range (computes rawValue + literal flag). */
|
|
1286
|
+
function makeAttr(
|
|
1287
|
+
code: string,
|
|
1288
|
+
name: string,
|
|
1289
|
+
parsed: { valueStart: number; valueEnd: number; isExpression: boolean; next: number },
|
|
1290
|
+
attrStart: number,
|
|
1291
|
+
): JsxAttrInfo {
|
|
1292
|
+
const rawValue = code.slice(parsed.valueStart, parsed.valueEnd).trim();
|
|
1293
|
+
const isLiteral =
|
|
1294
|
+
!parsed.isExpression || LITERAL_EXPR_RE.test(rawValue) || isNumberTupleLiteral(rawValue);
|
|
1295
|
+
return {
|
|
1296
|
+
name,
|
|
1297
|
+
valueStart: parsed.valueStart,
|
|
1298
|
+
valueEnd: parsed.valueEnd,
|
|
1299
|
+
rawValue,
|
|
1300
|
+
isExpression: parsed.isExpression,
|
|
1301
|
+
isLiteral,
|
|
1302
|
+
attrStart,
|
|
1303
|
+
attrEnd: parsed.next,
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** Props never surfaced as editable component props (visual-edit `fetchPropAnalysis` skip
|
|
1308
|
+
* set + on* handlers — event handlers are always dynamic). */
|
|
1309
|
+
const PROP_SKIP = new Set(['key', 'ref', 'data-oid', 'style', 'className', 'children']);
|
|
1310
|
+
const isSkippedProp = (name: string): boolean => PROP_SKIP.has(name) || /^on[A-Z]/.test(name);
|
|
1311
|
+
|
|
1312
|
+
/** Split a component's JSX attributes into editable (literal) vs dynamic (expression) —
|
|
1313
|
+
* the Props inspector uses this to gate which props can be written (visual-edit
|
|
1314
|
+
* `fetchPropAnalysis` :1069). */
|
|
1315
|
+
export function analyzeProps(
|
|
1316
|
+
code: string,
|
|
1317
|
+
elementStart: number,
|
|
1318
|
+
): { editable: string[]; dynamic: string[] } {
|
|
1319
|
+
const empty = { editable: [], dynamic: [] };
|
|
1320
|
+
if (code[elementStart] !== '<') return empty;
|
|
1321
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
1322
|
+
if (tagEnd < 0) return empty;
|
|
1323
|
+
const editable: string[] = [];
|
|
1324
|
+
const dynamic: string[] = [];
|
|
1325
|
+
for (const attr of analyzeJsxAttributes(code, elementStart, tagEnd)) {
|
|
1326
|
+
if (isSkippedProp(attr.name)) continue;
|
|
1327
|
+
(attr.isLiteral ? editable : dynamic).push(attr.name);
|
|
1328
|
+
}
|
|
1329
|
+
return { editable, dynamic };
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** Format the replacement for a prop write, matching the original value's shape (number/
|
|
1333
|
+
* boolean/tuple/quoted expression, or a plain string attribute). Returns `null` when the
|
|
1334
|
+
* new value cannot take the original's shape (a non-tuple replacing a tuple) — the caller
|
|
1335
|
+
* refuses rather than clobbering a structured literal with a quoted string.
|
|
1336
|
+
*
|
|
1337
|
+
* `allowShapeUpgrade` (R2 — OPT-IN, default off so react-dom and every existing path are
|
|
1338
|
+
* byte-identical): permits exactly ONE shape change — a plain number literal replaced by
|
|
1339
|
+
* a number-TUPLE literal for the same prop (the R3F `scale={1.5}` scalar shorthand
|
|
1340
|
+
* upgraded to `scale={[x, y, z]}` by a non-uniform gizmo drag). Dynamic values stay
|
|
1341
|
+
* refused upstream (the `isLiteral` guard in {@link writePropChange} runs before this
|
|
1342
|
+
* function is ever reached), and no other shape pair is upgraded. */
|
|
1343
|
+
function formatPropReplacement(
|
|
1344
|
+
attr: JsxAttrInfo,
|
|
1345
|
+
newValue: string,
|
|
1346
|
+
allowShapeUpgrade = false,
|
|
1347
|
+
): string | null {
|
|
1348
|
+
if (!attr.isExpression) return newValue; // string attribute — content between the quotes
|
|
1349
|
+
const raw = attr.rawValue;
|
|
1350
|
+
if (NUMBER_LITERAL_RE.test(raw)) {
|
|
1351
|
+
if (isNumberTupleLiteral(newValue)) {
|
|
1352
|
+
// R2: a tuple replacing a plain number is a SHAPE CHANGE — allowed only
|
|
1353
|
+
// under the explicit opt-in; otherwise refused outright (never the
|
|
1354
|
+
// quoted-string fallback below, which would clobber `scale={1.5}` with
|
|
1355
|
+
// a junk string literal).
|
|
1356
|
+
return allowShapeUpgrade ? newValue.trim() : null;
|
|
1357
|
+
}
|
|
1358
|
+
const num = Number(newValue);
|
|
1359
|
+
return Number.isNaN(num) ? `'${newValue}'` : String(num);
|
|
1360
|
+
}
|
|
1361
|
+
if (raw === 'true' || raw === 'false') {
|
|
1362
|
+
return newValue === 'true' || newValue === 'false' ? newValue : `'${newValue}'`;
|
|
1363
|
+
}
|
|
1364
|
+
// W2 — a number-tuple literal is replaced ONLY by another number-tuple
|
|
1365
|
+
// literal, verbatim (the caller controls spacing inside the brackets).
|
|
1366
|
+
if (isNumberTupleLiteral(raw)) {
|
|
1367
|
+
return isNumberTupleLiteral(newValue) ? newValue.trim() : null;
|
|
1368
|
+
}
|
|
1369
|
+
if (/^"[^"]*"$/.test(raw)) return `"${newValue.replace(/"/g, '\\"')}"`;
|
|
1370
|
+
return `'${newValue.replace(/'/g, "\\'")}'`;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Format a BRAND-NEW attribute's source text for {@link writePropChange}'s
|
|
1375
|
+
* opt-in `addIfMissing` branch: tuples/numbers/booleans get an expression
|
|
1376
|
+
* container, everything else a double-quoted string attribute.
|
|
1377
|
+
*/
|
|
1378
|
+
function formatNewAttr(propName: string, newValue: string): string {
|
|
1379
|
+
if (
|
|
1380
|
+
isNumberTupleLiteral(newValue) ||
|
|
1381
|
+
NUMBER_LITERAL_RE.test(newValue) ||
|
|
1382
|
+
newValue === 'true' ||
|
|
1383
|
+
newValue === 'false'
|
|
1384
|
+
) {
|
|
1385
|
+
return `${propName}={${newValue.trim()}}`;
|
|
1386
|
+
}
|
|
1387
|
+
return `${propName}="${newValue.replace(/"/g, '"')}"`;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/**
|
|
1391
|
+
* Write a component prop change back to source at the call site (visual-edit
|
|
1392
|
+
* `writePropChange` :1018). Refuses (guards) a dynamic (non-literal) prop and skips the
|
|
1393
|
+
* event-handler / structural props. `elementStart` is the call-site element's `<`.
|
|
1394
|
+
*
|
|
1395
|
+
* `opts.addIfMissing` (W2 — OPT-IN, default off so react-dom prop-write behavior is
|
|
1396
|
+
* unchanged): when the prop is absent from the opening tag, APPEND it (` prop={value}`
|
|
1397
|
+
* / ` prop="value"`) before the tag end instead of no-op'ing — the R3F gizmo path needs
|
|
1398
|
+
* this for a mesh whose source never authored a `position`/`rotation`/`scale` tuple
|
|
1399
|
+
* yet.
|
|
1400
|
+
*
|
|
1401
|
+
* `opts.allowShapeUpgrade` (R2 — OPT-IN, default off): permit a number-tuple literal to
|
|
1402
|
+
* replace a plain NUMBER literal for the same prop (scalar→tuple upgrade; see {@link
|
|
1403
|
+
* formatPropReplacement}). The R3F adapter passes it only for `scale` writes coming
|
|
1404
|
+
* from a non-uniform gizmo drag.
|
|
1405
|
+
*/
|
|
1406
|
+
/**
|
|
1407
|
+
* Where to put a new attribute on a tag whose attributes each occupy their own
|
|
1408
|
+
* line, and with what indentation — or null when the tag is single-line and the
|
|
1409
|
+
* inline insert is right. Keyed off the LAST attribute actually starting a
|
|
1410
|
+
* line, so a tag mixing both styles follows its own majority tail.
|
|
1411
|
+
*/
|
|
1412
|
+
function ownLineAttributeInsert(
|
|
1413
|
+
code: string,
|
|
1414
|
+
elementStart: number,
|
|
1415
|
+
attrs: readonly JsxAttrInfo[],
|
|
1416
|
+
): { at: number; indent: string } | null {
|
|
1417
|
+
const last = attrs.at(-1);
|
|
1418
|
+
if (!last) return null;
|
|
1419
|
+
// Does this attribute begin its own line?
|
|
1420
|
+
let lineStart = last.attrStart;
|
|
1421
|
+
while (lineStart > elementStart && code[lineStart - 1] !== '\n') lineStart--;
|
|
1422
|
+
if (lineStart <= elementStart) return null; // same line as the tag name
|
|
1423
|
+
const indent = code.slice(lineStart, last.attrStart);
|
|
1424
|
+
if (/\S/.test(indent)) return null; // something else precedes it on the line
|
|
1425
|
+
return { at: last.attrEnd, indent };
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
export function writePropChange(
|
|
1429
|
+
code: string,
|
|
1430
|
+
elementStart: number,
|
|
1431
|
+
propName: string,
|
|
1432
|
+
newValue: string,
|
|
1433
|
+
opts?: { addIfMissing?: boolean; allowShapeUpgrade?: boolean },
|
|
1434
|
+
): StyleEditResult {
|
|
1435
|
+
if (code[elementStart] !== '<') return { code, changed: false, dynamic: false };
|
|
1436
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
1437
|
+
if (tagEnd < 0) return { code, changed: false, dynamic: false };
|
|
1438
|
+
if (isSkippedProp(propName)) return { code, changed: false, dynamic: false };
|
|
1439
|
+
const attrs = analyzeJsxAttributes(code, elementStart, tagEnd);
|
|
1440
|
+
const attr = attrs.find((a) => a.name === propName);
|
|
1441
|
+
if (!attr) {
|
|
1442
|
+
if (!opts?.addIfMissing) return { code, changed: false, dynamic: false };
|
|
1443
|
+
// A tag that puts its attributes on their own lines gets the new one on its
|
|
1444
|
+
// own line too, indented to match. Appending inline before the `>` is
|
|
1445
|
+
// syntactically fine but reflows the author's formatting — which matters
|
|
1446
|
+
// now that removing and re-adding a prop is an ordinary inspector round
|
|
1447
|
+
// trip (revert to default, then override again).
|
|
1448
|
+
const ownLine = ownLineAttributeInsert(code, elementStart, attrs);
|
|
1449
|
+
if (ownLine) {
|
|
1450
|
+
const next =
|
|
1451
|
+
code.slice(0, ownLine.at) +
|
|
1452
|
+
`\n${ownLine.indent}${formatNewAttr(propName, newValue)}` +
|
|
1453
|
+
code.slice(ownLine.at);
|
|
1454
|
+
return { code: next, changed: true, dynamic: false };
|
|
1455
|
+
}
|
|
1456
|
+
const selfClose = code[tagEnd - 1] === '/';
|
|
1457
|
+
const insertAt = selfClose ? tagEnd - 1 : tagEnd;
|
|
1458
|
+
// Byte-minimal insert: a single leading space only when the preceding
|
|
1459
|
+
// char isn't already whitespace; a trailing space only before a `/` that
|
|
1460
|
+
// isn't already preceded by one. Everything around the insert survives
|
|
1461
|
+
// byte-for-byte.
|
|
1462
|
+
const lead = /\s/.test(code[insertAt - 1] ?? '') ? '' : ' ';
|
|
1463
|
+
const trail = selfClose ? ' ' : '';
|
|
1464
|
+
const next =
|
|
1465
|
+
code.slice(0, insertAt) +
|
|
1466
|
+
`${lead}${formatNewAttr(propName, newValue)}${trail}` +
|
|
1467
|
+
code.slice(insertAt);
|
|
1468
|
+
return { code: next, changed: true, dynamic: false };
|
|
1469
|
+
}
|
|
1470
|
+
if (!attr.isLiteral) return { code, changed: false, dynamic: true };
|
|
1471
|
+
if (attr.valueStart < 0) {
|
|
1472
|
+
// Native JSX boolean shorthand is still a literal. Preserve it for `true`;
|
|
1473
|
+
// expand it surgically for `false` so `<mesh visible />` is as editable as
|
|
1474
|
+
// `<mesh visible={true} />` without teaching the adapter a second writer.
|
|
1475
|
+
if (newValue === 'true') return { code, changed: false, dynamic: false };
|
|
1476
|
+
if (newValue !== 'false') return { code, changed: false, dynamic: false };
|
|
1477
|
+
const next = `${code.slice(0, attr.attrStart)}${propName}={false}${code.slice(attr.attrEnd)}`;
|
|
1478
|
+
return { code: next, changed: true, dynamic: false };
|
|
1479
|
+
}
|
|
1480
|
+
const replacement = formatPropReplacement(attr, newValue, opts?.allowShapeUpgrade === true);
|
|
1481
|
+
if (replacement === null) return { code, changed: false, dynamic: false }; // shape mismatch — refuse
|
|
1482
|
+
const next = code.slice(0, attr.valueStart) + replacement + code.slice(attr.valueEnd);
|
|
1483
|
+
return { code: next, changed: next !== code, dynamic: false };
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* REMOVE a whole attribute from the element at `elementStart` — the inverse of
|
|
1488
|
+
* {@link writePropChange}'s `addIfMissing` append, and what "revert to default"
|
|
1489
|
+
* means in a JSX world: with no attribute, the value in force is the one the
|
|
1490
|
+
* component's own signature declares. (Contrast `removeInlineStyle`, which
|
|
1491
|
+
* removes one member from inside a `style={{…}}` object.)
|
|
1492
|
+
*
|
|
1493
|
+
* Refuses a DYNAMIC value, mirroring every other writer here: never delete an
|
|
1494
|
+
* expression the caller did not write. Deleting the attribute takes its leading
|
|
1495
|
+
* whitespace with it, so `<A a b />` → `<A a />` and a prop on its own line
|
|
1496
|
+
* takes the line, leaving the rest of the tag byte-identical.
|
|
1497
|
+
*/
|
|
1498
|
+
export function removePropAttribute(
|
|
1499
|
+
code: string,
|
|
1500
|
+
elementStart: number,
|
|
1501
|
+
propName: string,
|
|
1502
|
+
): StyleEditResult {
|
|
1503
|
+
if (code[elementStart] !== '<') return { code, changed: false, dynamic: false };
|
|
1504
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
1505
|
+
if (tagEnd < 0) return { code, changed: false, dynamic: false };
|
|
1506
|
+
if (isSkippedProp(propName)) return { code, changed: false, dynamic: false };
|
|
1507
|
+
const attr = analyzeJsxAttributes(code, elementStart, tagEnd).find((a) => a.name === propName);
|
|
1508
|
+
if (!attr) return { code, changed: false, dynamic: false }; // absent — nothing to revert
|
|
1509
|
+
if (!attr.isLiteral) return { code, changed: false, dynamic: true }; // guard
|
|
1510
|
+
|
|
1511
|
+
// Take the separator with the attribute, so a prop on its own line takes its
|
|
1512
|
+
// line and an inline one takes its space. Which side to eat depends on
|
|
1513
|
+
// position: normally the whitespace BEFORE it, but the FIRST attribute must
|
|
1514
|
+
// eat the whitespace after it instead — consuming its leading space would
|
|
1515
|
+
// weld the next attribute onto the tag name.
|
|
1516
|
+
let nameEnd = elementStart + 1;
|
|
1517
|
+
while (nameEnd <= tagEnd && !/[\s>/]/.test(code[nameEnd] ?? '>')) nameEnd++;
|
|
1518
|
+
|
|
1519
|
+
let start = attr.attrStart;
|
|
1520
|
+
while (start > nameEnd && /\s/.test(code[start - 1] ?? '')) start--;
|
|
1521
|
+
let end = attr.attrEnd;
|
|
1522
|
+
if (start <= nameEnd) {
|
|
1523
|
+
start = attr.attrStart;
|
|
1524
|
+
while (end < tagEnd && /\s/.test(code[end] ?? '')) end++;
|
|
1525
|
+
}
|
|
1526
|
+
const next = code.slice(0, start) + code.slice(end);
|
|
1527
|
+
return { code: next, changed: next !== code, dynamic: false };
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// --- Cap 5: source structural ops (visual-edit writer.ts reorderChild :483 /
|
|
1531
|
+
// duplicateElement :559 / wrapElement :634 / unwrapElement :679 /
|
|
1532
|
+
// reparentElement :743) ---
|
|
1533
|
+
|
|
1534
|
+
export interface StructEditResult {
|
|
1535
|
+
code: string;
|
|
1536
|
+
changed: boolean;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/** Expand a JSX element at `elementStart` to its full LINE block (leading whitespace on the
|
|
1540
|
+
* first line, trailing newline on the last) — the unit these line-based ops move/copy. */
|
|
1541
|
+
function lineBlockRange(
|
|
1542
|
+
code: string,
|
|
1543
|
+
elementStart: number,
|
|
1544
|
+
): { lineStart: number; lineEnd: number } | null {
|
|
1545
|
+
if (code[elementStart] !== '<') return null;
|
|
1546
|
+
const end = findElementEnd(code, elementStart);
|
|
1547
|
+
if (end < 0) return null;
|
|
1548
|
+
let lineStart = elementStart;
|
|
1549
|
+
while (lineStart > 0 && code[lineStart - 1] !== '\n') lineStart--;
|
|
1550
|
+
let lineEnd = end;
|
|
1551
|
+
while (lineEnd < code.length && code[lineEnd] !== '\n') lineEnd++;
|
|
1552
|
+
if (lineEnd < code.length && code[lineEnd] === '\n') lineEnd++;
|
|
1553
|
+
return { lineStart, lineEnd };
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/** The `<` of a parent's closing tag, given the parent's opening `<` (visual-edit's
|
|
1557
|
+
* backwards scan from the element end). */
|
|
1558
|
+
function closingTagStartOf(code: string, parentStart: number): number | null {
|
|
1559
|
+
const parentEnd = findElementEnd(code, parentStart);
|
|
1560
|
+
if (parentEnd < 0) return null;
|
|
1561
|
+
let i = parentEnd;
|
|
1562
|
+
while (i > parentStart && code[i - 1] !== '<') i--;
|
|
1563
|
+
return i - 1; // point at the '<'
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/** What a lane may ask {@link duplicateElement} to change on the COPY — the
|
|
1567
|
+
* three lane's "a duplicate is visibly a second thing": a fresh `name` (only
|
|
1568
|
+
* when the original carries a literal one — never invented) and a literal
|
|
1569
|
+
* `position` nudged by `positionOffset` (added when absent, since an absent
|
|
1570
|
+
* position IS the origin; left alone when dynamic). */
|
|
1571
|
+
export interface DuplicateRewrite {
|
|
1572
|
+
name?: string;
|
|
1573
|
+
positionOffset?: readonly [number, number, number];
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/** Nudge one element's literal `position` by an offset — added when absent
|
|
1577
|
+
* (absent IS the origin), left alone when dynamic. Shared by the duplicate
|
|
1578
|
+
* rewrite and the paste-snippet offset. */
|
|
1579
|
+
function offsetElementPosition(
|
|
1580
|
+
code: string,
|
|
1581
|
+
elementStart: number,
|
|
1582
|
+
[dx, dy, dz]: readonly [number, number, number],
|
|
1583
|
+
): string {
|
|
1584
|
+
const fmt = (n: number): string => String(Number(n.toFixed(3)));
|
|
1585
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
1586
|
+
const attr =
|
|
1587
|
+
tagEnd < 0
|
|
1588
|
+
? undefined
|
|
1589
|
+
: analyzeJsxAttributes(code, elementStart, tagEnd).find((a) => a.name === 'position');
|
|
1590
|
+
if (!attr) {
|
|
1591
|
+
return writePropChange(code, elementStart, 'position', `[${fmt(dx)}, ${fmt(dy)}, ${fmt(dz)}]`, {
|
|
1592
|
+
addIfMissing: true,
|
|
1593
|
+
}).code;
|
|
1594
|
+
}
|
|
1595
|
+
if (!attr.isLiteral || !isNumberTupleLiteral(attr.rawValue)) return code;
|
|
1596
|
+
const parts = attr.rawValue
|
|
1597
|
+
.replace(/^\s*\[|\]\s*$/g, '')
|
|
1598
|
+
.split(',')
|
|
1599
|
+
.map((part) => Number(part.trim()));
|
|
1600
|
+
if (parts.length !== 3 || !parts.every((n) => Number.isFinite(n))) return code;
|
|
1601
|
+
const [x, y, z] = parts as [number, number, number];
|
|
1602
|
+
return writePropChange(
|
|
1603
|
+
code,
|
|
1604
|
+
elementStart,
|
|
1605
|
+
'position',
|
|
1606
|
+
`[${fmt(x + dx)}, ${fmt(y + dy)}, ${fmt(z + dz)}]`,
|
|
1607
|
+
).code;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* Shift every TOP-LEVEL element in a paste snippet by one offset — the block
|
|
1612
|
+
* moves as a unit, so relative arrangement inside the copy survives. A paste
|
|
1613
|
+
* that landed byte-identically ON its source read as "nothing was pasted"
|
|
1614
|
+
* to a human tester until they happened to drag the top one off.
|
|
1615
|
+
*/
|
|
1616
|
+
export function offsetSnippetPositions(
|
|
1617
|
+
snippet: string,
|
|
1618
|
+
offset: readonly [number, number, number],
|
|
1619
|
+
): string {
|
|
1620
|
+
let out = snippet;
|
|
1621
|
+
let searchFrom = 0;
|
|
1622
|
+
while (searchFrom < out.length) {
|
|
1623
|
+
const start = out.indexOf('<', searchFrom);
|
|
1624
|
+
if (start < 0) break;
|
|
1625
|
+
if (findElementEnd(out, start) < 0) break;
|
|
1626
|
+
out = offsetElementPosition(out, start, offset);
|
|
1627
|
+
const end = findElementEnd(out, start);
|
|
1628
|
+
if (end <= start) break;
|
|
1629
|
+
searchFrom = end + 1;
|
|
1630
|
+
}
|
|
1631
|
+
return out;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/** Duplicate the element's line block, inserting the copy immediately after it (visual-edit
|
|
1635
|
+
* `duplicateElement` :559). `rewrite` edits the copy only — see {@link DuplicateRewrite}. */
|
|
1636
|
+
export function duplicateElement(
|
|
1637
|
+
code: string,
|
|
1638
|
+
elementStart: number,
|
|
1639
|
+
rewrite?: DuplicateRewrite,
|
|
1640
|
+
): StructEditResult {
|
|
1641
|
+
const r = lineBlockRange(code, elementStart);
|
|
1642
|
+
if (!r) return { code, changed: false };
|
|
1643
|
+
const block = code.slice(r.lineStart, r.lineEnd);
|
|
1644
|
+
let next = code.slice(0, r.lineEnd) + block + code.slice(r.lineEnd);
|
|
1645
|
+
if (!rewrite) return { code: next, changed: true };
|
|
1646
|
+
const copyStart = r.lineEnd + (elementStart - r.lineStart);
|
|
1647
|
+
if (rewrite.name !== undefined) {
|
|
1648
|
+
next = writePropChange(next, copyStart, 'name', rewrite.name).code;
|
|
1649
|
+
}
|
|
1650
|
+
if (rewrite.positionOffset) {
|
|
1651
|
+
next = offsetElementPosition(next, copyStart, rewrite.positionOffset);
|
|
1652
|
+
}
|
|
1653
|
+
return { code: next, changed: true };
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/** Wrap the element in a new `<wrapperTag>…</wrapperTag>` container, re-indenting the
|
|
1657
|
+
* wrapped block one level (visual-edit `wrapElement` :634). */
|
|
1658
|
+
export function wrapElement(
|
|
1659
|
+
code: string,
|
|
1660
|
+
elementStart: number,
|
|
1661
|
+
wrapperTag = 'div',
|
|
1662
|
+
): StructEditResult {
|
|
1663
|
+
const r = lineBlockRange(code, elementStart);
|
|
1664
|
+
if (!r) return { code, changed: false };
|
|
1665
|
+
const block = code.slice(r.lineStart, r.lineEnd);
|
|
1666
|
+
const indent = block.match(/^(\s*)/)?.[1] ?? '';
|
|
1667
|
+
const reindented = block
|
|
1668
|
+
.split('\n')
|
|
1669
|
+
.map((line) => (line.length > 0 ? ` ${line}` : line))
|
|
1670
|
+
.join('\n');
|
|
1671
|
+
const wrapper = `${indent}<${wrapperTag}>\n${reindented}${indent}</${wrapperTag}>\n`;
|
|
1672
|
+
return { code: code.slice(0, r.lineStart) + wrapper + code.slice(r.lineEnd), changed: true };
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/** Unwrap the element — replace it with its children, dedented one level (visual-edit
|
|
1676
|
+
* `unwrapElement` :679). A self-closing element simply deletes.
|
|
1677
|
+
*
|
|
1678
|
+
* For the LINE-BLOCK shape — the opening tag ends its line and the closing tag sits alone
|
|
1679
|
+
* on its own line, exactly what {@link wrapElement} writes — the newline after the opening
|
|
1680
|
+
* tag and the closing tag's leading indent are consumed too, making unwrap the exact BYTE
|
|
1681
|
+
* INVERSE of wrap (previously those survived as a stray blank line plus loose indentation
|
|
1682
|
+
* merged into the following line). Inline children keep the original splice behavior. */
|
|
1683
|
+
export function unwrapElement(code: string, elementStart: number): StructEditResult {
|
|
1684
|
+
if (code[elementStart] !== '<') return { code, changed: false };
|
|
1685
|
+
const end = findElementEnd(code, elementStart);
|
|
1686
|
+
const openTagEnd = findTagEnd(code, elementStart);
|
|
1687
|
+
if (end < 0 || openTagEnd < 0) return { code, changed: false };
|
|
1688
|
+
if (code[openTagEnd - 1] === '/') return deleteElement(code, elementStart); // self-closing
|
|
1689
|
+
let closingTagStart = end;
|
|
1690
|
+
while (closingTagStart > elementStart && code[closingTagStart - 1] !== '<') closingTagStart--;
|
|
1691
|
+
closingTagStart--; // point at '<'
|
|
1692
|
+
const children = code.slice(openTagEnd + 1, closingTagStart);
|
|
1693
|
+
const r = lineBlockRange(code, elementStart);
|
|
1694
|
+
if (!r) return { code, changed: false };
|
|
1695
|
+
const dedentLine = (line: string): string =>
|
|
1696
|
+
line.startsWith(' ') ? line.slice(2) : line.startsWith('\t') ? line.slice(1) : line;
|
|
1697
|
+
const lastNewline = children.lastIndexOf('\n');
|
|
1698
|
+
if (
|
|
1699
|
+
children.startsWith('\n') &&
|
|
1700
|
+
lastNewline > 0 &&
|
|
1701
|
+
/^[ \t]*$/.test(children.slice(lastNewline + 1))
|
|
1702
|
+
) {
|
|
1703
|
+
// Line-block shape: children are whole lines between the tags.
|
|
1704
|
+
const dedented = children
|
|
1705
|
+
.slice(1, lastNewline + 1)
|
|
1706
|
+
.split('\n')
|
|
1707
|
+
.map(dedentLine)
|
|
1708
|
+
.join('\n');
|
|
1709
|
+
return { code: code.slice(0, r.lineStart) + dedented + code.slice(r.lineEnd), changed: true };
|
|
1710
|
+
}
|
|
1711
|
+
const dedented = children.split('\n').map(dedentLine).join('\n');
|
|
1712
|
+
return { code: code.slice(0, r.lineStart) + dedented + code.slice(r.lineEnd), changed: true };
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/** Insert a new child element `<tag />` just before `parentStart`'s closing tag, indented
|
|
1716
|
+
* to match the parent's children (the "create" primitive).
|
|
1717
|
+
*
|
|
1718
|
+
* `snippet` (R3 — optional; `tag` is ignored when it is provided): a MULTI-LINE JSX
|
|
1719
|
+
* snippet inserted verbatim instead of the bare `<tag />`, each line re-indented one
|
|
1720
|
+
* level under the parent (the R3F preset-create path needs a `<mesh>` with
|
|
1721
|
+
* geometry+material children, not an empty tag). The snippet is authored flush-left with
|
|
1722
|
+
* its own 2-space internal nesting; blank lines stay blank (no trailing whitespace).
|
|
1723
|
+
* Must start with `<` — anything else is refused (`changed: false`), the same
|
|
1724
|
+
* honest-refusal convention every other writer op uses. */
|
|
1725
|
+
export function insertChildElement(
|
|
1726
|
+
code: string,
|
|
1727
|
+
parentStart: number,
|
|
1728
|
+
tag = 'div',
|
|
1729
|
+
snippet?: string,
|
|
1730
|
+
): StructEditResult {
|
|
1731
|
+
const closingTagStart = closingTagStartOf(code, parentStart);
|
|
1732
|
+
if (closingTagStart == null) return { code, changed: false };
|
|
1733
|
+
let parentLineStart = parentStart;
|
|
1734
|
+
while (parentLineStart > 0 && code[parentLineStart - 1] !== '\n') parentLineStart--;
|
|
1735
|
+
const parentIndent = code.slice(parentLineStart, parentStart).match(/^(\s*)/)?.[1] ?? '';
|
|
1736
|
+
let insertPos = closingTagStart;
|
|
1737
|
+
while (insertPos > 0 && code[insertPos - 1] !== '\n') insertPos--;
|
|
1738
|
+
const childIndent = `${parentIndent} `;
|
|
1739
|
+
let child: string;
|
|
1740
|
+
if (snippet !== undefined) {
|
|
1741
|
+
const trimmed = snippet.trim();
|
|
1742
|
+
if (!trimmed.startsWith('<')) return { code, changed: false }; // not a JSX snippet — refuse
|
|
1743
|
+
child = `${trimmed
|
|
1744
|
+
.split('\n')
|
|
1745
|
+
.map((line) => (line.trim().length > 0 ? childIndent + line.trimEnd() : ''))
|
|
1746
|
+
.join('\n')}\n`;
|
|
1747
|
+
} else {
|
|
1748
|
+
child = `${childIndent}<${tag} />\n`;
|
|
1749
|
+
}
|
|
1750
|
+
return { code: code.slice(0, insertPos) + child + code.slice(insertPos), changed: true };
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
/**
|
|
1754
|
+
* Insert a JSX `snippet` as a new SIBLING immediately after the element at
|
|
1755
|
+
* `elementStart`, at that element's own indentation.
|
|
1756
|
+
*
|
|
1757
|
+
* The sibling counterpart to {@link insertChildElement}, and the only way to add
|
|
1758
|
+
* a top-level element to a world whose root JSX is a FRAGMENT: a fragment carries
|
|
1759
|
+
* no OID, so there is no parent offset to insert INTO — but every one of its
|
|
1760
|
+
* children has one, so "append to the world" is expressible as "put this after the
|
|
1761
|
+
* last top-level element". Same line-block unit `duplicateElement` moves, and the
|
|
1762
|
+
* same snippet contract `insertChildElement` documents: flush-left with its own
|
|
1763
|
+
* 2-space internal nesting, must start with `<` (anything else is refused with
|
|
1764
|
+
* `changed: false`), blank lines stay blank.
|
|
1765
|
+
*/
|
|
1766
|
+
export function insertSiblingElement(
|
|
1767
|
+
code: string,
|
|
1768
|
+
elementStart: number,
|
|
1769
|
+
snippet: string,
|
|
1770
|
+
): StructEditResult {
|
|
1771
|
+
const trimmed = snippet.trim();
|
|
1772
|
+
if (!trimmed.startsWith('<')) return { code, changed: false };
|
|
1773
|
+
const r = lineBlockRange(code, elementStart);
|
|
1774
|
+
if (!r) return { code, changed: false };
|
|
1775
|
+
const indent = code.slice(r.lineStart, elementStart).match(/^(\s*)/)?.[1] ?? '';
|
|
1776
|
+
const block = `${trimmed
|
|
1777
|
+
.split('\n')
|
|
1778
|
+
.map((line) => (line.trim().length > 0 ? indent + line.trimEnd() : ''))
|
|
1779
|
+
.join('\n')}\n`;
|
|
1780
|
+
return { code: code.slice(0, r.lineEnd) + block + code.slice(r.lineEnd), changed: true };
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
/** Reorder the element among its siblings: before `beforeStart` (an OID-resolved sibling
|
|
1784
|
+
* offset), or — when `beforeStart` is null — at the end of `parentStart` (visual-edit
|
|
1785
|
+
* `reorderChild` :483). OID-resolved offsets, immune to index races. */
|
|
1786
|
+
export function reorderChild(
|
|
1787
|
+
code: string,
|
|
1788
|
+
elementStart: number,
|
|
1789
|
+
beforeStart: number | null,
|
|
1790
|
+
parentStart: number | null,
|
|
1791
|
+
): StructEditResult {
|
|
1792
|
+
const r = lineBlockRange(code, elementStart);
|
|
1793
|
+
if (!r) return { code, changed: false };
|
|
1794
|
+
const { lineStart, lineEnd } = r;
|
|
1795
|
+
const block = code.slice(lineStart, lineEnd);
|
|
1796
|
+
const removedLength = lineEnd - lineStart;
|
|
1797
|
+
|
|
1798
|
+
let targetLineStart: number;
|
|
1799
|
+
if (beforeStart != null) {
|
|
1800
|
+
targetLineStart = beforeStart;
|
|
1801
|
+
while (targetLineStart > 0 && code[targetLineStart - 1] !== '\n') targetLineStart--;
|
|
1802
|
+
if (targetLineStart === lineStart) return { code, changed: false }; // same position
|
|
1803
|
+
} else if (parentStart != null) {
|
|
1804
|
+
const closingTagStart = closingTagStartOf(code, parentStart);
|
|
1805
|
+
if (closingTagStart == null) return { code, changed: false };
|
|
1806
|
+
targetLineStart = closingTagStart;
|
|
1807
|
+
while (targetLineStart > 0 && code[targetLineStart - 1] !== '\n') targetLineStart--;
|
|
1808
|
+
if (lineStart >= targetLineStart) return { code, changed: false }; // already at end
|
|
1809
|
+
} else {
|
|
1810
|
+
return { code, changed: false };
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
const without = code.slice(0, lineStart) + code.slice(lineEnd);
|
|
1814
|
+
const adj = targetLineStart > lineStart ? targetLineStart - removedLength : targetLineStart;
|
|
1815
|
+
return { code: without.slice(0, adj) + block + without.slice(adj), changed: true };
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/**
|
|
1819
|
+
* Turn `<Tag … />` into the paired `<Tag …>` / `</Tag>` shape, so the element has an
|
|
1820
|
+
* authored children slot to receive one.
|
|
1821
|
+
*
|
|
1822
|
+
* Why this exists (R3F reparent hardening, R3): in idiomatic R3F source almost every
|
|
1823
|
+
* hierarchy row IS a self-closing component callsite (`<JumpPad name="West" … />`), and
|
|
1824
|
+
* {@link reparentElement}'s `closingTagStartOf` scan finds the element's OWN `<` for such
|
|
1825
|
+
* a tag — so "drop into this row" silently became "insert a PRECEDING SIBLING". Opening
|
|
1826
|
+
* the slot is the honest edit an author would make by hand; it writes no value and
|
|
1827
|
+
* invents no data, it only gives the tag a body. The caller
|
|
1828
|
+
* (`reparent-guard.ts`) has already proven the destination renders its children.
|
|
1829
|
+
*
|
|
1830
|
+
* Refuses (`changed: false`) anything that is not a well-formed self-closing tag.
|
|
1831
|
+
*/
|
|
1832
|
+
export function openChildrenSlot(code: string, elementStart: number): StructEditResult {
|
|
1833
|
+
if (code[elementStart] !== '<') return { code, changed: false };
|
|
1834
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
1835
|
+
if (tagEnd < 0 || code[tagEnd - 1] !== '/') return { code, changed: false };
|
|
1836
|
+
let nameEnd = elementStart + 1;
|
|
1837
|
+
while (nameEnd < code.length && !/[\s>/]/.test(code[nameEnd] ?? '>')) nameEnd++;
|
|
1838
|
+
const tag = code.slice(elementStart + 1, nameEnd);
|
|
1839
|
+
if (!/^[A-Za-z0-9_.]+$/.test(tag)) return { code, changed: false };
|
|
1840
|
+
let lineStart = elementStart;
|
|
1841
|
+
while (lineStart > 0 && code[lineStart - 1] !== '\n') lineStart--;
|
|
1842
|
+
const indent = code.slice(lineStart, elementStart).match(/^(\s*)/)?.[1] ?? '';
|
|
1843
|
+
// Drop the `/` and the whitespace that only existed to separate it.
|
|
1844
|
+
let cut = tagEnd - 1;
|
|
1845
|
+
while (cut > nameEnd && /\s/.test(code[cut - 1] ?? '')) cut--;
|
|
1846
|
+
return {
|
|
1847
|
+
code: `${code.slice(0, cut)}>\n${indent}</${tag}>${code.slice(tagEnd + 1)}`,
|
|
1848
|
+
changed: true,
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/** Reparent the element to be the last child of `parentStart`, re-indented to the parent's
|
|
1853
|
+
* child level (visual-edit `reparentElement` :743). Same-file only (the caller guards).
|
|
1854
|
+
*
|
|
1855
|
+
* The R3F hierarchy calls this through `planReparent` (`reparent-guard.ts`), which owns
|
|
1856
|
+
* the scope/transform/legality gates; this function stays the raw textual move. */
|
|
1857
|
+
export function reparentElement(
|
|
1858
|
+
code: string,
|
|
1859
|
+
elementStart: number,
|
|
1860
|
+
parentStart: number,
|
|
1861
|
+
): StructEditResult {
|
|
1862
|
+
const r = lineBlockRange(code, elementStart);
|
|
1863
|
+
if (!r) return { code, changed: false };
|
|
1864
|
+
const { lineStart: elLineStart, lineEnd: elLineEnd } = r;
|
|
1865
|
+
const block = code.slice(elLineStart, elLineEnd);
|
|
1866
|
+
const closingTagStart = closingTagStartOf(code, parentStart);
|
|
1867
|
+
if (closingTagStart == null) return { code, changed: false };
|
|
1868
|
+
|
|
1869
|
+
let parentLineStart = parentStart;
|
|
1870
|
+
while (parentLineStart > 0 && code[parentLineStart - 1] !== '\n') parentLineStart--;
|
|
1871
|
+
const parentIndent = code.slice(parentLineStart, parentStart).match(/^(\s*)/)?.[1] ?? '';
|
|
1872
|
+
const childIndent = `${parentIndent} `;
|
|
1873
|
+
const reindented = block
|
|
1874
|
+
.split('\n')
|
|
1875
|
+
.map((line) => {
|
|
1876
|
+
const trimmed = line.replace(/^\s*/, '');
|
|
1877
|
+
return trimmed ? childIndent + trimmed : '';
|
|
1878
|
+
})
|
|
1879
|
+
.join('\n');
|
|
1880
|
+
|
|
1881
|
+
const without = code.slice(0, elLineStart) + code.slice(elLineEnd);
|
|
1882
|
+
let adjustedClosing = closingTagStart;
|
|
1883
|
+
if (elLineStart < closingTagStart) adjustedClosing -= elLineEnd - elLineStart;
|
|
1884
|
+
let insertPos = adjustedClosing;
|
|
1885
|
+
while (insertPos > 0 && without[insertPos - 1] !== '\n') insertPos--;
|
|
1886
|
+
return {
|
|
1887
|
+
code: without.slice(0, insertPos) + reindented + without.slice(insertPos),
|
|
1888
|
+
changed: true,
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
/** Delete the element starting at `elementStart` from source (C8). */
|
|
1893
|
+
export function deleteElement(
|
|
1894
|
+
code: string,
|
|
1895
|
+
elementStart: number,
|
|
1896
|
+
): { code: string; changed: boolean } {
|
|
1897
|
+
const end = findElementEnd(code, elementStart);
|
|
1898
|
+
if (end < 0) return { code, changed: false };
|
|
1899
|
+
// also consume leading whitespace on the line + trailing newline
|
|
1900
|
+
let s = elementStart;
|
|
1901
|
+
while (s > 0 && (code[s - 1] === ' ' || code[s - 1] === '\t')) s--;
|
|
1902
|
+
let e = end;
|
|
1903
|
+
if (code[e] === '\n') e++;
|
|
1904
|
+
return { code: code.slice(0, s) + code.slice(e), changed: true };
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
/**
|
|
1908
|
+
* Delete MULTIPLE elements from a SINGLE `code` snapshot, given every target's
|
|
1909
|
+
* `elementStart` offset resolved against that SAME snapshot (delete-order-residual
|
|
1910
|
+
* fix — `vite-plugin-ui-oid.ts`'s `handleStructMany`). Sound and caller-order-
|
|
1911
|
+
* independent: every offset is resolved from the file ONCE, up front, then applied
|
|
1912
|
+
* highest-offset-first — each `deleteElement` call only ever removes text AT OR
|
|
1913
|
+
* AFTER its own target, so every offset still queued (all of them numerically
|
|
1914
|
+
* SMALLER, since we process descending) is untouched by it and stays valid. A
|
|
1915
|
+
* nested descendant's `elementStart` is always numerically greater than its
|
|
1916
|
+
* ancestor's (it appears later in source), so this single offset-value rule also
|
|
1917
|
+
* deletes a selected descendant before a selected ancestor, with no separate
|
|
1918
|
+
* ancestor/descendant case to reason about. Unlike re-resolving each target by
|
|
1919
|
+
* re-scanning/re-transforming the file between deletes, this never recomputes an
|
|
1920
|
+
* OID's identity mid-batch — it only ever reads the ALREADY-RESOLVED offsets handed
|
|
1921
|
+
* to it, so it cannot suffer the occurrence-index churn a live re-transform would
|
|
1922
|
+
* introduce (deleting one same-tag sibling shifts every later sibling's own
|
|
1923
|
+
* `component:tag:nthOccurrence` signature, which would silently relabel it as a
|
|
1924
|
+
* DIFFERENT, already-used oid if resolution were re-derived from a fresh transform
|
|
1925
|
+
* mid-batch — the reason `handleStruct`'s per-call resolution is not simply looped
|
|
1926
|
+
* here without change).
|
|
1927
|
+
*/
|
|
1928
|
+
export function deleteElements(
|
|
1929
|
+
code: string,
|
|
1930
|
+
elementStarts: readonly number[],
|
|
1931
|
+
): { code: string; changed: boolean } {
|
|
1932
|
+
const sorted = [...elementStarts].sort((a, b) => b - a); // highest offset first
|
|
1933
|
+
let cur = code;
|
|
1934
|
+
let changed = false;
|
|
1935
|
+
for (const off of sorted) {
|
|
1936
|
+
const result = deleteElement(cur, off);
|
|
1937
|
+
if (result.changed) {
|
|
1938
|
+
cur = result.code;
|
|
1939
|
+
changed = true;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
return { code: cur, changed };
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
/** Group source-addressed sibling JSX elements beneath one ordinary R3F
|
|
1946
|
+
* `<group name="Group">`. The adapter verifies common hierarchy ownership;
|
|
1947
|
+
* this writer additionally requires non-overlapping line blocks with identical
|
|
1948
|
+
* indentation, then performs one offset-stable rewrite from a single snapshot. */
|
|
1949
|
+
export function groupSiblingElements(
|
|
1950
|
+
code: string,
|
|
1951
|
+
elementStarts: readonly number[],
|
|
1952
|
+
wrapperTag = 'group',
|
|
1953
|
+
): StructEditResult {
|
|
1954
|
+
const starts = [...new Set(elementStarts)].sort((a, b) => a - b);
|
|
1955
|
+
if (starts.length < 2) return { code, changed: false };
|
|
1956
|
+
const blocks = starts.map((start) => ({ start, range: lineBlockRange(code, start) }));
|
|
1957
|
+
if (blocks.some(({ range }) => !range)) return { code, changed: false };
|
|
1958
|
+
const resolved = blocks as { start: number; range: { lineStart: number; lineEnd: number } }[];
|
|
1959
|
+
for (let index = 1; index < resolved.length; index++) {
|
|
1960
|
+
if (resolved[index - 1]!.range.lineEnd > resolved[index]!.range.lineStart) {
|
|
1961
|
+
return { code, changed: false };
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
const indents = resolved.map(({ start, range }) => code.slice(range.lineStart, start));
|
|
1965
|
+
const indent = indents[0] ?? '';
|
|
1966
|
+
if (!/^[ \t]*$/.test(indent) || indents.some((candidate) => candidate !== indent)) {
|
|
1967
|
+
return { code, changed: false };
|
|
1968
|
+
}
|
|
1969
|
+
const selected = resolved
|
|
1970
|
+
.map(({ range }) => code.slice(range.lineStart, range.lineEnd))
|
|
1971
|
+
.map((block) =>
|
|
1972
|
+
block
|
|
1973
|
+
.split('\n')
|
|
1974
|
+
.map((line) => (line.length > 0 ? ` ${line}` : line))
|
|
1975
|
+
.join('\n'),
|
|
1976
|
+
)
|
|
1977
|
+
.join('');
|
|
1978
|
+
const insertion = resolved[0]!.range.lineStart;
|
|
1979
|
+
let without = code;
|
|
1980
|
+
for (const { range } of [...resolved].reverse()) {
|
|
1981
|
+
without = without.slice(0, range.lineStart) + without.slice(range.lineEnd);
|
|
1982
|
+
}
|
|
1983
|
+
const labelProp = wrapperTag === 'group' ? 'name' : 'label';
|
|
1984
|
+
const wrapper = `${indent}<${wrapperTag} ${labelProp}="Group">\n${selected}${indent}</${wrapperTag}>\n`;
|
|
1985
|
+
return {
|
|
1986
|
+
code: without.slice(0, insertion) + wrapper + without.slice(insertion),
|
|
1987
|
+
changed: true,
|
|
1988
|
+
};
|
|
1989
|
+
}
|