@volter/editor-game 0.5.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/NOTICE +23 -0
- package/contributions/asset-budget-asset.menu.ts +26 -0
- package/contributions/asset-budget.action.ts +18 -0
- package/contributions/asset-budget.document.tsx +21 -0
- package/contributions/asset-budget.menu.ts +22 -0
- package/contributions/audio-unlock.service.ts +17 -0
- package/contributions/audio.utility.tsx +15 -0
- package/contributions/autoplay.service.ts +48 -0
- package/contributions/bridge.command.ts +172 -0
- package/contributions/build-profiles.document.tsx +21 -0
- package/contributions/build-progress.status.tsx +45 -0
- package/contributions/build.action.ts +26 -0
- package/contributions/build.command.ts +27 -0
- package/contributions/build.header.tsx +37 -0
- package/contributions/build.menu.ts +31 -0
- package/contributions/build.service.ts +31 -0
- package/contributions/connection.status.tsx +43 -0
- package/contributions/coverage.service.ts +118 -0
- package/contributions/edit-mode-audio.service.ts +23 -0
- package/contributions/edit-mode-networking.service.ts +30 -0
- package/contributions/game-document.service.ts +25 -0
- package/contributions/game-eval.command.ts +210 -0
- package/contributions/game.layout.ts +19 -0
- package/contributions/gameplay.command.ts +255 -0
- package/contributions/generation.service.ts +96 -0
- package/contributions/generations.status.tsx +53 -0
- package/contributions/ingest.service.ts +80 -0
- package/contributions/instances.command.ts +75 -0
- package/contributions/navmesh.menu.ts +39 -0
- package/contributions/navmesh.service.ts +19 -0
- package/contributions/network.utility.tsx +17 -0
- package/contributions/play.command.ts +355 -0
- package/contributions/profiler.action.ts +16 -0
- package/contributions/profiler.menu.ts +22 -0
- package/contributions/profiler.utility.tsx +17 -0
- package/contributions/react/component-board.service.ts +21 -0
- package/contributions/react/design-time-mount.service.ts +60 -0
- package/contributions/react/pasteboard.action.ts +41 -0
- package/contributions/react/react-inspector.service.ts +85 -0
- package/contributions/react/story-documents.service.ts +44 -0
- package/contributions/scene-document.service.ts +32 -0
- package/contributions/state-watch.action.ts +17 -0
- package/contributions/state-watch.menu.ts +23 -0
- package/contributions/state-watch.utility.tsx +20 -0
- package/contributions/team-playtest.service.ts +124 -0
- package/contributions/three/camera-runtime.inspector.tsx +34 -0
- package/contributions/three/component-board.service.ts +24 -0
- package/contributions/three/component-verbs.command.ts +110 -0
- package/contributions/three/component-verbs.service.ts +92 -0
- package/contributions/three/constraints.inspector.tsx +34 -0
- package/contributions/three/model-asset-sections.service.ts +26 -0
- package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
- package/contributions/three/story-documents.service.ts +31 -0
- package/contributions/three/three-authoring.service.ts +66 -0
- package/contributions/transport.header.tsx +19 -0
- package/contributions/xstate-behavior.action.ts +42 -0
- package/contributions/xstate-behavior.document.tsx +73 -0
- package/contributions/xstate-behavior.inspector.tsx +28 -0
- package/contributions/xstate-behavior.menu.ts +23 -0
- package/package.json +144 -0
- package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
- package/src/asset-budget/asset-budget-model.ts +799 -0
- package/src/asset-budget/basis-encoder.ts +165 -0
- package/src/asset-budget/gltf-io.ts +154 -0
- package/src/asset-budget/gltf-optimize.ts +384 -0
- package/src/asset-budget/image-dims.ts +78 -0
- package/src/asset-budget/optimize-apply.ts +221 -0
- package/src/audio/AudioDebuggerPanel.tsx +457 -0
- package/src/audio/audio-debugger-model.ts +87 -0
- package/src/bridge/call.ts +60 -0
- package/src/bridge/dispatch.ts +459 -0
- package/src/bridge/live-frames.ts +25 -0
- package/src/bridge/screenshot.ts +316 -0
- package/src/build/BuildProfilesPanel.tsx +476 -0
- package/src/build/build-session.ts +247 -0
- package/src/build/format-bytes.ts +14 -0
- package/src/command-results.ts +36 -0
- package/src/coverage/live-authoring-surface.ts +30 -0
- package/src/coverage/live-project-verbs.ts +162 -0
- package/src/coverage/native-system-coverage.ts +143 -0
- package/src/coverage/root-coverage.ts +79 -0
- package/src/coverage/session-coverage.ts +193 -0
- package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
- package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
- package/src/edit-mode/edit-mode-audio.ts +56 -0
- package/src/edit-mode/edit-mode-networking.ts +112 -0
- package/src/game-document/DevicePresetPicker.tsx +84 -0
- package/src/game-document/GameCaptureFrameButton.tsx +45 -0
- package/src/game-document/GameDocument.tsx +215 -0
- package/src/game-document/GamePanel.tsx +662 -0
- package/src/game-document/InstanceInspectorPicker.tsx +183 -0
- package/src/game-document/crowd-debug.ts +183 -0
- package/src/game-document/device-preview.ts +336 -0
- package/src/game-document/game-view-store.ts +107 -0
- package/src/game-document/physics-debug.ts +187 -0
- package/src/generation/GenerationActivity.tsx +421 -0
- package/src/generation/GenerationGallery.css +231 -0
- package/src/generation/generation-documents.tsx +338 -0
- package/src/generation/generation-jobs.ts +128 -0
- package/src/generation/generation-presentation.ts +257 -0
- package/src/host/adapter-reach.ts +445 -0
- package/src/host/adapter-runtime-bindings.ts +303 -0
- package/src/host/after-paint.ts +112 -0
- package/src/host/api/configurations.ts +66 -0
- package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
- package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
- package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
- package/src/host/authoring/contract-scenes-stories.ts +204 -0
- package/src/host/authoring/creation-site-related.ts +55 -0
- package/src/host/authoring/ephemeral-persistence.ts +29 -0
- package/src/host/authoring/gesture-persist.ts +84 -0
- package/src/host/authoring/ingest-data-writer.ts +232 -0
- package/src/host/authoring/ingest-source-persistence.ts +826 -0
- package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
- package/src/host/authoring/mounted-authoring.ts +41 -0
- package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
- package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
- package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
- package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
- package/src/host/authoring/pixi-isolation-assets.ts +25 -0
- package/src/host/authoring/pixi-live-write-target.ts +979 -0
- package/src/host/authoring/pixi-source-identity.ts +141 -0
- package/src/host/authoring/pixi-still-presentation.ts +71 -0
- package/src/host/authoring/pixi-structure-history.ts +237 -0
- package/src/host/authoring/pixi-transform-channels.ts +205 -0
- package/src/host/authoring/selection-remount-handoff.ts +23 -0
- package/src/host/authoring/source-persistence-backend.ts +373 -0
- package/src/host/authoring/source-refresh-revisions.ts +81 -0
- package/src/host/authoring/struct-write-pipe.ts +143 -0
- package/src/host/auto-frame-window.ts +89 -0
- package/src/host/binding-resolver.ts +393 -0
- package/src/host/browser-transpile.ts +631 -0
- package/src/host/canvas-entry-runtime.ts +95 -0
- package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
- package/src/host/components/HeaderTelemetry.tsx +341 -0
- package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
- package/src/host/components/ResolutionPicker.tsx +89 -0
- package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
- package/src/host/components/frame-debugger-model.ts +579 -0
- package/src/host/components/header-telemetry-model.ts +74 -0
- package/src/host/components/scene-document.tsx +447 -0
- package/src/host/components/utility-view-state.ts +87 -0
- package/src/host/components/world-root-stage-binding.tsx +133 -0
- package/src/host/components/world-root-stage.ts +1046 -0
- package/src/host/coverage/authoring-read-probe.ts +583 -0
- package/src/host/coverage/capability-coverage.ts +1587 -0
- package/src/host/coverage/coverage-accounting.ts +261 -0
- package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
- package/src/host/coverage/project-verb-coverage.ts +148 -0
- package/src/host/coverage/system-adapter-coverage.ts +373 -0
- package/src/host/design-system-stories/StoryLayout.tsx +104 -0
- package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
- package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
- package/src/host/document-preview-three.ts +110 -0
- package/src/host/entry-adjudication.ts +89 -0
- package/src/host/game-location-guard.ts +150 -0
- package/src/host/game-module-access.ts +196 -0
- package/src/host/game-realm-page.ts +358 -0
- package/src/host/game-realm-reclaim.ts +55 -0
- package/src/host/game-realm-storage.ts +104 -0
- package/src/host/gameplay-export.ts +288 -0
- package/src/host/gameplay-recording.ts +717 -0
- package/src/host/gated-globals.ts +1511 -0
- package/src/host/history/json-history-resource.ts +254 -0
- package/src/host/ingest/registry.ts +261 -0
- package/src/host/instance-extract-actions.ts +120 -0
- package/src/host/instance-fork-actions.ts +120 -0
- package/src/host/play-control-hook.ts +26 -0
- package/src/host/playwright-shim.ts +479 -0
- package/src/host/projection/dom.ts +295 -0
- package/src/host/projection/pixi.ts +288 -0
- package/src/host/r3f-entry-runtime.ts +77 -0
- package/src/host/react-mount-runtime.ts +162 -0
- package/src/host/realm-services.ts +148 -0
- package/src/host/recording-preview.ts +106 -0
- package/src/host/roots/module-root.ts +203 -0
- package/src/host/roots/react-root.ts +181 -0
- package/src/host/same-realm-loop-gate.ts +544 -0
- package/src/host/scene-view-drawability.ts +69 -0
- package/src/host/sdk/tools.ts +31 -0
- package/src/host/served-bundle-runtime-modules.ts +331 -0
- package/src/host/server-log-bridge.ts +60 -0
- package/src/host/staged-projects.ts +24 -0
- package/src/host/stories/mounted-story-viewport-source.ts +64 -0
- package/src/host/stories/story-arg-descriptors.ts +50 -0
- package/src/host/stories/story-media-presence.ts +152 -0
- package/src/host/surface-content.ts +87 -0
- package/src/host/take-named-export.ts +23 -0
- package/src/host/three-ingest-runtime.ts +76 -0
- package/src/host/types-fastnoise-lite.d.ts +7 -0
- package/src/host/types-mikktspace.d.ts +20 -0
- package/src/host/types-troika-three-text.d.ts +7 -0
- package/src/host/use-active-performance-source.ts +53 -0
- package/src/host/viewport-pose-memory.ts +48 -0
- package/src/host/viewport-root-presentation.ts +40 -0
- package/src/ingest/active-ingest.ts +251 -0
- package/src/ingest/active-scene-navigation.ts +43 -0
- package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
- package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
- package/src/ingest/capture-wait-report.ts +122 -0
- package/src/ingest/deferred-ingest-play.ts +216 -0
- package/src/ingest/deferred-ingest-session.ts +23 -0
- package/src/ingest/discovery-public-ingest.ts +242 -0
- package/src/ingest/dom-stub-mark.ts +7 -0
- package/src/ingest/entry-load.ts +56 -0
- package/src/ingest/game-contract-realm.ts +34 -0
- package/src/ingest/game-pointer-lock.ts +89 -0
- package/src/ingest/held-scene-repaint.ts +73 -0
- package/src/ingest/host-surface-box.ts +174 -0
- package/src/ingest/ingest-boot-viewport.ts +54 -0
- package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
- package/src/ingest/ingest-canvas-scene.ts +50 -0
- package/src/ingest/ingest-evidence-hook.ts +131 -0
- package/src/ingest/ingest-frame-snapshot.ts +183 -0
- package/src/ingest/ingest-play-commands.ts +134 -0
- package/src/ingest/ingest-play-control.ts +299 -0
- package/src/ingest/ingest-render-debug.ts +320 -0
- package/src/ingest/ingest-siblings.ts +487 -0
- package/src/ingest/ingest-status.ts +62 -0
- package/src/ingest/live-ingest-facet.ts +46 -0
- package/src/ingest/module-mode.ts +229 -0
- package/src/ingest/mount-canvas-ingest-root.ts +899 -0
- package/src/ingest/mount-coverage.ts +296 -0
- package/src/ingest/mount-dom-ingest-root.ts +282 -0
- package/src/ingest/mount-ingest-root.ts +744 -0
- package/src/ingest/mount-three-ingest-root.ts +366 -0
- package/src/ingest/resolve-canvas.ts +47 -0
- package/src/ingest/resolve-three.ts +123 -0
- package/src/ingest/served-bundle.ts +109 -0
- package/src/ingest/served-html-boot.ts +292 -0
- package/src/ingest/surface-canvas.ts +84 -0
- package/src/ingest/surface-dom.ts +84 -0
- package/src/ingest/surface-three.ts +128 -0
- package/src/ingest/types.ts +76 -0
- package/src/ingest/unmount-ingest-root.ts +224 -0
- package/src/navmesh/navmesh-actions.ts +27 -0
- package/src/navmesh/navmesh-handler.ts +237 -0
- package/src/navmesh/navmesh-workflow-store.ts +78 -0
- package/src/network/NetworkInspectorPanel.tsx +644 -0
- package/src/network/network-inspector-model.ts +225 -0
- package/src/play/play-boot-stall.ts +118 -0
- package/src/play/play-log-events.ts +26 -0
- package/src/play/play-mode.ts +2553 -0
- package/src/play/play-recording.ts +335 -0
- package/src/play/react-play-live-authoring.ts +165 -0
- package/src/play-bar/PlayBar.tsx +488 -0
- package/src/play-bar/PlayerCountPicker.tsx +100 -0
- package/src/profiler/FrameDebuggerPanel.tsx +458 -0
- package/src/profiler/PerformancePanel.tsx +1068 -0
- package/src/profiler/ProfilerPanel.tsx +53 -0
- package/src/profiler/frame-debugger-store.ts +97 -0
- package/src/profiler/main-thread-busy.ts +90 -0
- package/src/react/design-time-react-mount.ts +782 -0
- package/src/react/dom-authoring-adapter.ts +764 -0
- package/src/react/pasteboard-materialize.ts +154 -0
- package/src/react/react-inspector-section.tsx +2496 -0
- package/src/react/react-world-authoring-adapter.ts +3795 -0
- package/src/react/story-documents/story-args-section.ts +19 -0
- package/src/react/story-documents/story-documents.tsx +1380 -0
- package/src/react/story-paint-bounds.ts +94 -0
- package/src/react/ui-board-document.tsx +101 -0
- package/src/react/ui-board-title.ts +9 -0
- package/src/react/ui-component-board.ts +72 -0
- package/src/services/audio-pose-guard.ts +81 -0
- package/src/services/game-audio-unlock.ts +48 -0
- package/src/state-watch/StateWatchPanel.tsx +535 -0
- package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
- package/src/three/authoring/constraint-inspector-section.tsx +189 -0
- package/src/three/authoring/design-time-renderer.ts +189 -0
- package/src/three/authoring/model-asset-inspector-section.css +41 -0
- package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
- package/src/three/authoring/oid-source-persistence.ts +557 -0
- package/src/three/authoring/r3f-design-session.ts +1174 -0
- package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
- package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
- package/src/three/authoring/spatial-audio-handles.ts +215 -0
- package/src/three/authoring/spatial-collider-handles.ts +229 -0
- package/src/three/authoring/spatial-joint-handles.ts +114 -0
- package/src/three/authoring/spatial-light-handles.ts +178 -0
- package/src/three/authoring/spatial-lod-handles.ts +93 -0
- package/src/three/authoring/spatial-particle-handles.ts +368 -0
- package/src/three/authoring/three-authoring-adapter.ts +1953 -0
- package/src/three/authoring/three-scene-identity.ts +19 -0
- package/src/three/authoring/three-spatial-handles.ts +161 -0
- package/src/three/authoring/typed-three-inspector.ts +528 -0
- package/src/three/component-verbs/extract-menu.ts +71 -0
- package/src/three/component-verbs/fork-menu.ts +78 -0
- package/src/three/component-verbs/internals-menu.ts +91 -0
- package/src/three/story-documents/three-story-documents.tsx +607 -0
- package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
- package/src/three/three-board/board-framing.ts +412 -0
- package/src/three/three-board/board-layout.ts +401 -0
- package/src/three/three-board/board-scene.ts +901 -0
- package/src/three/three-board/three-component-board.ts +62 -0
- package/src/xstate/XStateBehaviorSection.tsx +130 -0
- package/src/xstate/XStateMachineInspector.tsx +566 -0
- package/src/xstate/character-animation-machine.fixture.ts +74 -0
- package/src/xstate/live-behaviors.ts +106 -0
- package/src/xstate/use-live-actor-state.ts +44 -0
- package/src/xstate/xstate-graph.ts +235 -0
- package/src/xstate/xstate-layout.ts +76 -0
|
@@ -0,0 +1,3795 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReactRootAuthoringAdapter — the editor's {@link AuthoringAdapter} for a react-kind
|
|
3
|
+
* world (T6.2 slice 2).
|
|
4
|
+
*
|
|
5
|
+
* A react world has no ticking and no mirror (D8) — its authoring
|
|
6
|
+
* entities ARE the `data-oid`-stamped elements of its rendered DOM (the OID
|
|
7
|
+
* instrumentation from the UI visual-edit program, `../ui-source/oid-transform.ts`,
|
|
8
|
+
* whose vite-plugin include this slice widens from the `editable-components` fixture
|
|
9
|
+
* dir to project scope — see `../../vite-plugin-ui-oid.ts`). This adapter derives
|
|
10
|
+
* its hierarchy from the shared DOM projector (`../projection/dom.ts`) under the
|
|
11
|
+
* OID identity, over the world's live DOM root (`RootInstance.reactRoot()`);
|
|
12
|
+
* there is no cached/mirrored state to fall out of sync — every `hierarchy`
|
|
13
|
+
* call re-projects the live DOM.
|
|
14
|
+
*
|
|
15
|
+
* Writes (style/className/delete) reuse the EXISTING T3.2-slice-3 source-write seam
|
|
16
|
+
* verbatim (`../ui-source/source-write-backend.ts`'s `SourceWriteBackend`, the same
|
|
17
|
+
* `/__ui-source/write` + `/__ui-source/struct` dev-server endpoints
|
|
18
|
+
* `vite-plugin-ui-oid.ts` serves for `UIAuthoringAdapter`/`SourceEditPanel`) — this
|
|
19
|
+
* file does NOT invent a second source-writer. Every edit prepares complete next-file
|
|
20
|
+
* text and commits it through one checksum-guarded source history resource. Style,
|
|
21
|
+
* text, prop, and structural changes therefore share exact-byte undo/redo semantics.
|
|
22
|
+
*
|
|
23
|
+
* Persistence mirrors `UIAuthoringAdapter`'s de-stubbed pattern exactly: writes are
|
|
24
|
+
* immediate (server-side, on commit), so `save()` is an honest no-op; `destination`
|
|
25
|
+
* reports whether a source-write backend even exists in this session (absent in a
|
|
26
|
+
* hosted/no-dev-server build — selection/inspection still work, writes report
|
|
27
|
+
* unavailable via a loud console warning instead of silently no-op'ing).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { activeBreakpoint } from '@volter/editor-core/authoring/breakpoint-state';
|
|
31
|
+
import {
|
|
32
|
+
cssTextForStyleValue,
|
|
33
|
+
numericStyleValue,
|
|
34
|
+
preserveNumericStyleUnit,
|
|
35
|
+
} from '@volter/editor-core/authoring/css-numeric-style';
|
|
36
|
+
import { WORLD_SCOPE_NODE_ID } from '@volter/editor-core/authoring/stories-scope';
|
|
37
|
+
import { createStructWritePipe, type StructOpOptions } from '../host/authoring/struct-write-pipe';
|
|
38
|
+
import { getRootPan } from '@volter/editor-core/authoring/world-pan-state';
|
|
39
|
+
import {
|
|
40
|
+
resolvesLiveOnly,
|
|
41
|
+
runWritePipe,
|
|
42
|
+
type WriteAck,
|
|
43
|
+
type WriteResolution,
|
|
44
|
+
} from '@volter/editor-core/authoring/write-pipe';
|
|
45
|
+
import { guideClientEdges } from '@volter/editor-core/components/board-guides';
|
|
46
|
+
import { editorConsole } from '@volter/editor-core/editor-console';
|
|
47
|
+
import type { EditorShellStore } from '@volter/editor-core/editor-shell-store';
|
|
48
|
+
import { withProjectSourceHistory } from '@volter/editor-core/history/source-history-backend';
|
|
49
|
+
import { DomProjector, oidDomIdentity, projectOidDom } from '../host/projection/dom';
|
|
50
|
+
import { storyArgPropertyDescriptors } from '../host/stories/story-arg-descriptors';
|
|
51
|
+
import { storyDiscoveryUnavailable } from '@volter/editor-core/stories/story-discovery';
|
|
52
|
+
import { deriveStoryGroupPath, formatStoryGroupPath } from '@volter/editor-core/stories/story-grouping';
|
|
53
|
+
import type { StoryPresentationIndex } from '@volter/editor-core/stories/story-presentation';
|
|
54
|
+
import { subscribeProjectStoryModules } from '@volter/editor-core/stories/story-registry';
|
|
55
|
+
import { showTransientHint } from '@volter/editor-core/transient-hint';
|
|
56
|
+
import {
|
|
57
|
+
browserOrInlineResolver,
|
|
58
|
+
type ComputedStyleResolver,
|
|
59
|
+
camelToKebab,
|
|
60
|
+
type DesignToken,
|
|
61
|
+
type EmptyCandidate,
|
|
62
|
+
findClassRuleSource,
|
|
63
|
+
findEmptyContainers,
|
|
64
|
+
firstPartyStylesheetFiles,
|
|
65
|
+
getCallSiteOid,
|
|
66
|
+
getComponentProps,
|
|
67
|
+
getComputedStyleValue,
|
|
68
|
+
getDesignTokens,
|
|
69
|
+
getMatchedCssRules,
|
|
70
|
+
getReactComponentName,
|
|
71
|
+
type MatchableElement,
|
|
72
|
+
} from '@volter/editor-core/ui-source/inspect';
|
|
73
|
+
import type { ComponentPropSpec, OidEntry } from '@volter/editor-core/ui-source/oid-transform';
|
|
74
|
+
import { relativeImportSpecifier } from '@volter/editor-core/ui-source/relative-import-specifier';
|
|
75
|
+
import type { SourceWriteBackend } from '@volter/editor-core/ui-source/source-write-backend';
|
|
76
|
+
import { writeCsfStory, writeNamedStyle } from '@volter/editor-core/ui-source/source-write-backend';
|
|
77
|
+
import {
|
|
78
|
+
type CssRuleTarget,
|
|
79
|
+
namedStyleRuleFor,
|
|
80
|
+
pickCssRuleTarget,
|
|
81
|
+
tokenReferenceGuardText,
|
|
82
|
+
} from '@volter/editor-core/ui-source/writer';
|
|
83
|
+
import { UI_COMPONENTS_DOCUMENT_ID } from '@volter/editor-core/workspace-document-ids';
|
|
84
|
+
import { activateWorkspaceDocument } from '@volter/editor-core/workspace-document-registry';
|
|
85
|
+
import type {
|
|
86
|
+
AssetDropContext,
|
|
87
|
+
AssetDropProvider,
|
|
88
|
+
AssetSubjectProvider,
|
|
89
|
+
AuthoringAdapter,
|
|
90
|
+
AuthoringAssetSubject,
|
|
91
|
+
AuthoringCapabilities,
|
|
92
|
+
AuthoringProvenance,
|
|
93
|
+
BoxEditProvider,
|
|
94
|
+
ColorSampleProvider,
|
|
95
|
+
ComponentInstanceApplyResult,
|
|
96
|
+
ComponentInstancesProvider,
|
|
97
|
+
DOMRectLike,
|
|
98
|
+
EditorNode,
|
|
99
|
+
HierarchyProvider,
|
|
100
|
+
InspectorProvider,
|
|
101
|
+
NodeCreationSite,
|
|
102
|
+
PersistenceProvider,
|
|
103
|
+
PickProvider,
|
|
104
|
+
PropertyDescriptor,
|
|
105
|
+
RectProvider,
|
|
106
|
+
RelatedSubjectsProvider,
|
|
107
|
+
SelectionProvider,
|
|
108
|
+
StoriesProvider,
|
|
109
|
+
StoryRef,
|
|
110
|
+
StructureProvider,
|
|
111
|
+
TextProvider,
|
|
112
|
+
TruthProvider,
|
|
113
|
+
WriteAnchorKind,
|
|
114
|
+
} from '@volter/editor-project/adapter';
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The minimal structural shape this adapter needs from a live DOM element —
|
|
118
|
+
* deliberately NOT `HTMLElement` so it stays testable with a plain-object fixture
|
|
119
|
+
* headlessly (this repo's vitest environment is `node`, no jsdom — use the
|
|
120
|
+
* repo's DOM-stub + fixture patterns instead). A real `HTMLElement` satisfies
|
|
121
|
+
* this structurally: `tagName`
|
|
122
|
+
* (uppercase, per the DOM spec — lower-cased for labels/kind below), `children`
|
|
123
|
+
* (an `HTMLCollection`, `Array.from`-able), and `getAttribute` reading the REAL
|
|
124
|
+
* `data-oid="…"` attribute `transformSource` stamped into the JSX (a genuine DOM
|
|
125
|
+
* attribute at runtime, not a mirror).
|
|
126
|
+
*/
|
|
127
|
+
export interface OidElementLike {
|
|
128
|
+
readonly tagName: string;
|
|
129
|
+
/** Real DOM nodes expose this. Optional keeps headless fixtures minimal. */
|
|
130
|
+
readonly namespaceURI?: string | null;
|
|
131
|
+
readonly children: ArrayLike<OidElementLike>;
|
|
132
|
+
getAttribute(name: string): string | null;
|
|
133
|
+
/**
|
|
134
|
+
* `unknown` rather than a structural record — a real `CSSStyleDeclaration` has
|
|
135
|
+
* NO string index signature (TS models it as a fixed set of named properties
|
|
136
|
+
* plus methods), so it can't structurally satisfy `Record<string, unknown>`.
|
|
137
|
+
* Read through {@link styleProp} below, which handles both shapes.
|
|
138
|
+
*/
|
|
139
|
+
readonly style?: unknown;
|
|
140
|
+
/**
|
|
141
|
+
* D12 (B4) — the element's live viewport rect, for `pickable.pick`'s
|
|
142
|
+
* geometric hit-test (see that provider's doc comment for why NOT
|
|
143
|
+
* `elementFromPoint`). Optional so existing plain-object test fixtures
|
|
144
|
+
* (which never provide one) keep type-checking unchanged — a node with no
|
|
145
|
+
* `getBoundingClientRect` simply never wins a pick (its rect is treated as
|
|
146
|
+
* absent, never a fabricated 0×0 that could win a tie).
|
|
147
|
+
*/
|
|
148
|
+
getBoundingClientRect?(): {
|
|
149
|
+
left: number;
|
|
150
|
+
top: number;
|
|
151
|
+
right: number;
|
|
152
|
+
bottom: number;
|
|
153
|
+
width: number;
|
|
154
|
+
height: number;
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* T0 (spec 27 §2) — the element's rendered text, read for `TextProvider.get`'s
|
|
158
|
+
* "has child elements" gate below. Optional so pre-existing `OidElementLike`
|
|
159
|
+
* fixtures (none of which set it) keep satisfying the interface unchanged.
|
|
160
|
+
*/
|
|
161
|
+
readonly textContent?: string | null;
|
|
162
|
+
/** Present on real DOM/SVG elements; used only for atomic inline-SVG assets. */
|
|
163
|
+
readonly outerHTML?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const MAX_ELEMENT_LABEL_LENGTH = 64;
|
|
167
|
+
|
|
168
|
+
/** Compact the browser's semantic text into a stable hierarchy-row label. */
|
|
169
|
+
function normalizeElementText(value: string | null | undefined): string | null {
|
|
170
|
+
const text = value?.replace(/\s+/g, ' ').trim();
|
|
171
|
+
if (!text) return null;
|
|
172
|
+
if (text.length <= MAX_ELEMENT_LABEL_LENGTH) return text;
|
|
173
|
+
return `${text.slice(0, MAX_ELEMENT_LABEL_LENGTH - 1).trimEnd()}…`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Read one style property off an `OidElementLike.style` of either shape (a real
|
|
177
|
+
* `CSSStyleDeclaration` or a plain-object test fixture). */
|
|
178
|
+
export function styleProp(style: unknown, prop: string): unknown {
|
|
179
|
+
return style ? (style as Record<string, unknown>)[prop] : undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const RGB_RE = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Normalize a CSS color VALUE into `#rrggbb` for a `type: 'color'`
|
|
186
|
+
* {@link PropertyDescriptor} (`<input type="color">` only accepts exactly
|
|
187
|
+
* that shape — an unparseable value makes the browser silently coerce the
|
|
188
|
+
* input to black). A real `CSSStyleDeclaration` always serializes an inline
|
|
189
|
+
* color property as `rgb(r, g, b)`/`rgba(r, g, b, a)` — EVEN WHEN the author
|
|
190
|
+
* wrote a hex literal in JSX (`el.style.color = '#3ddc65'` reads back as
|
|
191
|
+
* `"rgb(61, 220, 101)"`, verified empirically against a real Chromium page,
|
|
192
|
+
* not assumed) — never hex. Without this normalization every color-typed
|
|
193
|
+
* style field would show black regardless of its real value against a real
|
|
194
|
+
* browser DOM (the plain-object test fixtures never caught this: a
|
|
195
|
+
* hand-built fixture's `style` can hold the author's hex string directly,
|
|
196
|
+
* which real CSSOM never does). An already-hex value (a fixture, or a
|
|
197
|
+
* property this repo's CSSOM happens to serialize as hex) passes through
|
|
198
|
+
* unchanged; a value matching neither shape returns `undefined` (the
|
|
199
|
+
* generic inspector's own '#ffffff' fallback) rather than handing the input
|
|
200
|
+
* an invalid string.
|
|
201
|
+
*/
|
|
202
|
+
export function cssColorToHex(raw: unknown): string | undefined {
|
|
203
|
+
if (typeof raw !== 'string' || raw === '') return undefined;
|
|
204
|
+
if (raw.startsWith('#')) return raw;
|
|
205
|
+
const m = RGB_RE.exec(raw);
|
|
206
|
+
if (!m) return undefined;
|
|
207
|
+
const toHex = (n: string) => Number.parseInt(n, 10).toString(16).padStart(2, '0');
|
|
208
|
+
return `#${toHex(m[1]!)}${toHex(m[2]!)}${toHex(m[3]!)}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** One resolved `BoxEditProvider` patch-key mapping (spec 27 §4, B1). */
|
|
212
|
+
export interface BoxEditPropMapping {
|
|
213
|
+
/** The CSS style prop to write (already the target, e.g. `x` → `left`). */
|
|
214
|
+
prop: string;
|
|
215
|
+
/** Live-preview CSS VALUE for a raw patch number — always px-suffixed for
|
|
216
|
+
* spatial/spacing props, the `rotate(<deg>deg)` transform string for `rotate`. */
|
|
217
|
+
cssValue: (v: number) => string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* `BoxEditProvider` patch-key → CSS-prop mapping (spec 27 B1), shared by BOTH
|
|
222
|
+
* authoring adapters' `boxEdit.apply/end` (`dom-authoring-adapter.ts`
|
|
223
|
+
* imports this rather than redefining it — same DRY reuse as
|
|
224
|
+
* {@link cssColorToHex}/{@link numericStyleValue}/{@link styleProp} below).
|
|
225
|
+
* `width`/`height`/`margin*`/`padding*` map 1:1 to their same-named style
|
|
226
|
+
* prop. `x`/`y` map to `left`/`top` ONLY when the node is
|
|
227
|
+
* absolutely/fixed-positioned (there is no `left`/`top` to move on a
|
|
228
|
+
* static/relative node) — `isPositioned` is a caller-resolved boolean (each
|
|
229
|
+
* adapter reads its own computed-style resolver) — a mismatched key returns
|
|
230
|
+
* `null` to mean "drop this key"; the CALLER does its own loud
|
|
231
|
+
* `console.warn` so the adapter's own name appears in the message. `rotate`
|
|
232
|
+
* (deg) maps to the `transform` prop as a `rotate(<deg>deg)` string.
|
|
233
|
+
*/
|
|
234
|
+
export function mapBoxEditPatchKey(key: string, isPositioned: boolean): BoxEditPropMapping | null {
|
|
235
|
+
switch (key) {
|
|
236
|
+
case 'width':
|
|
237
|
+
case 'height':
|
|
238
|
+
case 'marginTop':
|
|
239
|
+
case 'marginRight':
|
|
240
|
+
case 'marginBottom':
|
|
241
|
+
case 'marginLeft':
|
|
242
|
+
case 'paddingTop':
|
|
243
|
+
case 'paddingRight':
|
|
244
|
+
case 'paddingBottom':
|
|
245
|
+
case 'paddingLeft':
|
|
246
|
+
return { prop: key, cssValue: (v) => `${v}px` };
|
|
247
|
+
case 'x':
|
|
248
|
+
return isPositioned ? { prop: 'left', cssValue: (v) => `${v}px` } : null;
|
|
249
|
+
case 'y':
|
|
250
|
+
return isPositioned ? { prop: 'top', cssValue: (v) => `${v}px` } : null;
|
|
251
|
+
case 'rotate':
|
|
252
|
+
return { prop: 'transform', cssValue: (v) => `rotate(${v}deg)` };
|
|
253
|
+
default:
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A pasteboard `<At>` mount — detected by the STRUCTURAL marker the pasteboard
|
|
260
|
+
* capability's helper renders (`data-vgai-at="true"`), never by filename or
|
|
261
|
+
* import path, so a project's freely edited copy of the helper keeps working
|
|
262
|
+
* (the LiveModuleDocument precedent: detection is structural). An `x`/`y` drag
|
|
263
|
+
* on one of these writes the callsite's `x`/`y` JSX literals instead of inline
|
|
264
|
+
* `left`/`top` style — the authored form the pasteboard design ratified
|
|
265
|
+
* (docs/PARITY-PROGRAM-HANDOFF.md, queue item 4: "drag = OID literal write to
|
|
266
|
+
* x/y").
|
|
267
|
+
*/
|
|
268
|
+
function isPasteboardAtElement(el: OidElementLike): boolean {
|
|
269
|
+
return el.getAttribute('data-vgai-at') === 'true';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const OID_ATTR = 'data-oid';
|
|
273
|
+
const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
|
|
274
|
+
const VOID_HTML_ELEMENTS = new Set([
|
|
275
|
+
'area',
|
|
276
|
+
'base',
|
|
277
|
+
'br',
|
|
278
|
+
'col',
|
|
279
|
+
'embed',
|
|
280
|
+
'hr',
|
|
281
|
+
'img',
|
|
282
|
+
'input',
|
|
283
|
+
'link',
|
|
284
|
+
'meta',
|
|
285
|
+
'param',
|
|
286
|
+
'source',
|
|
287
|
+
'track',
|
|
288
|
+
'wbr',
|
|
289
|
+
]);
|
|
290
|
+
|
|
291
|
+
/** Empty-layout hints are for HTML elements that could receive content.
|
|
292
|
+
* SVG geometry (especially zero-width `line`/`path` bounds) and HTML void
|
|
293
|
+
* elements are leaves by definition, not collapsed layout containers. */
|
|
294
|
+
function canContainDomChildren(el: OidElementLike, tag: string): boolean {
|
|
295
|
+
const namespace = el.namespaceURI;
|
|
296
|
+
if (namespace !== undefined && namespace !== null && namespace !== HTML_NAMESPACE) return false;
|
|
297
|
+
return !VOID_HTML_ELEMENTS.has(tag);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function projectSourcePath(path: string): string {
|
|
301
|
+
const normalized = path.replaceAll('\\', '/').replace(/^\.\//, '');
|
|
302
|
+
const marker = normalized.lastIndexOf('/src/');
|
|
303
|
+
return marker >= 0 ? normalized.slice(marker + 1) : normalized;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** One OID-tagged DOM element resolved into the adapter's internal tree. */
|
|
307
|
+
interface OidNode {
|
|
308
|
+
/** Disambiguated entity id — see {@link walkOidTree}'s doc comment. */
|
|
309
|
+
id: string;
|
|
310
|
+
/** The raw OID (may repeat across sibling `OidNode`s — see below). */
|
|
311
|
+
oid: string;
|
|
312
|
+
tag: string;
|
|
313
|
+
el: OidElementLike;
|
|
314
|
+
parentId: string | null;
|
|
315
|
+
childIds: string[];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export interface OidTree {
|
|
319
|
+
/** Every OID-tagged node, keyed by its (disambiguated) entity id. */
|
|
320
|
+
nodes: Map<string, OidNode>;
|
|
321
|
+
/** Top-level entity ids (no OID-tagged ancestor) in DOM/tree order. */
|
|
322
|
+
rootIds: string[];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Walk a react world's live DOM root for `data-oid`-carrying elements.
|
|
327
|
+
*
|
|
328
|
+
* This is the adapter-facing view over {@link projectOidDom}: the projector
|
|
329
|
+
* owns identity, nesting, and the SVG-atomic rule; this wrapper keeps the
|
|
330
|
+
* `OidNode` shape existing callers (tests, ingest sibling probe, play-live
|
|
331
|
+
* authoring) already read — `oid` and `tag` are derived from the live
|
|
332
|
+
* element the projector handed back.
|
|
333
|
+
*
|
|
334
|
+
* OID → entity id disambiguation lives on the projector (`oidDomIdentity`):
|
|
335
|
+
* the first element carrying a given oid keeps `id === oid`; the Nth repeat
|
|
336
|
+
* gets `id === "${oid}#${n}"`. An unstamped wrapper is transparent — its
|
|
337
|
+
* children attach to the nearest stamped ancestor.
|
|
338
|
+
*/
|
|
339
|
+
export function walkOidTree(root: OidElementLike): OidTree {
|
|
340
|
+
return oidTreeFromProjection(projectOidDom(root));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function oidTreeFromProjection(projection: {
|
|
344
|
+
readonly nodes: ReadonlyMap<
|
|
345
|
+
string,
|
|
346
|
+
{ object: OidElementLike; parentId: string | null; childIds: readonly string[] }
|
|
347
|
+
>;
|
|
348
|
+
readonly rootIds: readonly string[];
|
|
349
|
+
}): OidTree {
|
|
350
|
+
const nodes = new Map<string, OidNode>();
|
|
351
|
+
for (const [id, node] of projection.nodes) {
|
|
352
|
+
nodes.set(id, {
|
|
353
|
+
id,
|
|
354
|
+
oid: node.object.getAttribute(OID_ATTR) ?? id,
|
|
355
|
+
tag: node.object.tagName.toLowerCase(),
|
|
356
|
+
el: node.object,
|
|
357
|
+
parentId: node.parentId,
|
|
358
|
+
childIds: [...node.childIds],
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
return { nodes, rootIds: [...projection.rootIds] };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Cap 6 (React visual-edit parity): the RICH GROUPED inspector model — the figma-style
|
|
366
|
+
* panels (Layout / Position / Spacing / Type / Fill / Stroke / Effects / Transform), each
|
|
367
|
+
* property tagged with a `group` the generic inspector renders as a titled sub-section
|
|
368
|
+
* (`PropertyDescriptor.group`). Every property here is covered by the writer's broadened
|
|
369
|
+
* `ARB_MAP`/`ENUM_UTILITIES` class routing (or inline style), so a set writes real source.
|
|
370
|
+
* (Bespoke widgets — HSV picker, scrub inputs, gradient/multi-shadow editors — are the
|
|
371
|
+
* one descoped part of Cap 6; the generic color/number/enum/string inputs render each of
|
|
372
|
+
* these functionally.)
|
|
373
|
+
*/
|
|
374
|
+
const STYLE_PROPERTIES: ReadonlyArray<{
|
|
375
|
+
prop: string;
|
|
376
|
+
label: string;
|
|
377
|
+
type: PropertyDescriptor['type'];
|
|
378
|
+
group: string;
|
|
379
|
+
options?: string[];
|
|
380
|
+
}> = [
|
|
381
|
+
// -- Layout --
|
|
382
|
+
{
|
|
383
|
+
prop: 'display',
|
|
384
|
+
label: 'Display',
|
|
385
|
+
type: 'enum',
|
|
386
|
+
group: 'Layout',
|
|
387
|
+
options: ['block', 'flex', 'grid', 'inline', 'inline-block', 'inline-flex', 'none'],
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
prop: 'flexDirection',
|
|
391
|
+
label: 'Direction',
|
|
392
|
+
type: 'enum',
|
|
393
|
+
group: 'Layout',
|
|
394
|
+
options: ['row', 'column', 'row-reverse', 'column-reverse'],
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
prop: 'flexWrap',
|
|
398
|
+
label: 'Wrap',
|
|
399
|
+
type: 'enum',
|
|
400
|
+
group: 'Layout',
|
|
401
|
+
options: ['nowrap', 'wrap', 'wrap-reverse'],
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
prop: 'justifyContent',
|
|
405
|
+
label: 'Justify',
|
|
406
|
+
type: 'enum',
|
|
407
|
+
group: 'Layout',
|
|
408
|
+
options: ['flex-start', 'center', 'flex-end', 'space-between', 'space-around', 'space-evenly'],
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
prop: 'alignItems',
|
|
412
|
+
label: 'Align',
|
|
413
|
+
type: 'enum',
|
|
414
|
+
group: 'Layout',
|
|
415
|
+
options: ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'],
|
|
416
|
+
},
|
|
417
|
+
{ prop: 'gap', label: 'Gap', type: 'number', group: 'Layout' },
|
|
418
|
+
// -- Grid (design ledger: grid authoring). Figma's GridLayoutMixin is a
|
|
419
|
+
// strict subset of CSS Grid (counts/sizes/gaps/placement), so the carriers
|
|
420
|
+
// ARE the CSS properties: templates as strings (repeat()/minmax()/fr all
|
|
421
|
+
// legal — nothing dumbed down), gaps split per axis, placement on the
|
|
422
|
+
// child. SemanticLayout renders the container set when display is grid,
|
|
423
|
+
// and the grid-item set behind its own disclosure. --
|
|
424
|
+
{ prop: 'gridTemplateColumns', label: 'Columns', type: 'string', group: 'Layout' },
|
|
425
|
+
{ prop: 'gridTemplateRows', label: 'Rows', type: 'string', group: 'Layout' },
|
|
426
|
+
{
|
|
427
|
+
prop: 'gridAutoFlow',
|
|
428
|
+
label: 'Flow',
|
|
429
|
+
type: 'enum',
|
|
430
|
+
group: 'Layout',
|
|
431
|
+
options: ['row', 'column', 'row dense', 'column dense'],
|
|
432
|
+
},
|
|
433
|
+
{ prop: 'rowGap', label: 'Row Gap', type: 'number', group: 'Layout' },
|
|
434
|
+
{ prop: 'columnGap', label: 'Col Gap', type: 'number', group: 'Layout' },
|
|
435
|
+
{ prop: 'gridColumn', label: 'Grid Col', type: 'string', group: 'Layout' },
|
|
436
|
+
{ prop: 'gridRow', label: 'Grid Row', type: 'string', group: 'Layout' },
|
|
437
|
+
{
|
|
438
|
+
prop: 'justifySelf',
|
|
439
|
+
label: 'Justify Self',
|
|
440
|
+
type: 'enum',
|
|
441
|
+
group: 'Layout',
|
|
442
|
+
options: ['auto', 'start', 'center', 'end', 'stretch'],
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
prop: 'overflow',
|
|
446
|
+
label: 'Overflow',
|
|
447
|
+
type: 'enum',
|
|
448
|
+
group: 'Layout',
|
|
449
|
+
options: ['visible', 'hidden', 'scroll', 'auto'],
|
|
450
|
+
},
|
|
451
|
+
{ prop: 'width', label: 'Width', type: 'number', group: 'Layout' },
|
|
452
|
+
{ prop: 'height', label: 'Height', type: 'number', group: 'Layout' },
|
|
453
|
+
{ prop: 'minWidth', label: 'Min W', type: 'number', group: 'Layout' },
|
|
454
|
+
{ prop: 'minHeight', label: 'Min H', type: 'number', group: 'Layout' },
|
|
455
|
+
{ prop: 'maxWidth', label: 'Max W', type: 'number', group: 'Layout' },
|
|
456
|
+
{ prop: 'maxHeight', label: 'Max H', type: 'number', group: 'Layout' },
|
|
457
|
+
// -- Position --
|
|
458
|
+
{
|
|
459
|
+
prop: 'position',
|
|
460
|
+
label: 'Position',
|
|
461
|
+
type: 'enum',
|
|
462
|
+
group: 'Position',
|
|
463
|
+
options: ['static', 'relative', 'absolute', 'fixed', 'sticky'],
|
|
464
|
+
},
|
|
465
|
+
{ prop: 'top', label: 'Top', type: 'number', group: 'Position' },
|
|
466
|
+
{ prop: 'right', label: 'Right', type: 'number', group: 'Position' },
|
|
467
|
+
{ prop: 'bottom', label: 'Bottom', type: 'number', group: 'Position' },
|
|
468
|
+
{ prop: 'left', label: 'Left', type: 'number', group: 'Position' },
|
|
469
|
+
{ prop: 'zIndex', label: 'Z Index', type: 'number', group: 'Position' },
|
|
470
|
+
{ prop: 'flexGrow', label: 'Grow', type: 'number', group: 'Position' },
|
|
471
|
+
{ prop: 'flexShrink', label: 'Shrink', type: 'number', group: 'Position' },
|
|
472
|
+
{ prop: 'flexBasis', label: 'Basis', type: 'string', group: 'Position' },
|
|
473
|
+
{
|
|
474
|
+
prop: 'alignSelf',
|
|
475
|
+
label: 'Self Align',
|
|
476
|
+
type: 'enum',
|
|
477
|
+
group: 'Position',
|
|
478
|
+
options: ['auto', 'stretch', 'flex-start', 'center', 'flex-end', 'baseline'],
|
|
479
|
+
},
|
|
480
|
+
// -- Spacing (per-side) --
|
|
481
|
+
{ prop: 'marginTop', label: 'Margin T', type: 'number', group: 'Spacing' },
|
|
482
|
+
{ prop: 'marginRight', label: 'Margin R', type: 'number', group: 'Spacing' },
|
|
483
|
+
{ prop: 'marginBottom', label: 'Margin B', type: 'number', group: 'Spacing' },
|
|
484
|
+
{ prop: 'marginLeft', label: 'Margin L', type: 'number', group: 'Spacing' },
|
|
485
|
+
{ prop: 'paddingTop', label: 'Padding T', type: 'number', group: 'Spacing' },
|
|
486
|
+
{ prop: 'paddingRight', label: 'Padding R', type: 'number', group: 'Spacing' },
|
|
487
|
+
{ prop: 'paddingBottom', label: 'Padding B', type: 'number', group: 'Spacing' },
|
|
488
|
+
{ prop: 'paddingLeft', label: 'Padding L', type: 'number', group: 'Spacing' },
|
|
489
|
+
// -- Type --
|
|
490
|
+
{ prop: 'color', label: 'Color', type: 'color', group: 'Type' },
|
|
491
|
+
{ prop: 'fontSize', label: 'Size', type: 'number', group: 'Type' },
|
|
492
|
+
{ prop: 'fontWeight', label: 'Weight', type: 'string', group: 'Type' },
|
|
493
|
+
{ prop: 'lineHeight', label: 'Line H', type: 'string', group: 'Type' },
|
|
494
|
+
{ prop: 'letterSpacing', label: 'Spacing', type: 'string', group: 'Type' },
|
|
495
|
+
{
|
|
496
|
+
prop: 'textAlign',
|
|
497
|
+
label: 'Align',
|
|
498
|
+
type: 'enum',
|
|
499
|
+
group: 'Type',
|
|
500
|
+
options: ['left', 'center', 'right', 'justify'],
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
prop: 'textTransform',
|
|
504
|
+
label: 'Transform',
|
|
505
|
+
type: 'enum',
|
|
506
|
+
group: 'Type',
|
|
507
|
+
options: ['none', 'uppercase', 'lowercase', 'capitalize'],
|
|
508
|
+
},
|
|
509
|
+
{ prop: 'fontStyle', label: 'Style', type: 'enum', group: 'Type', options: ['normal', 'italic'] },
|
|
510
|
+
{ prop: 'fontFamily', label: 'Font', type: 'string', group: 'Type' },
|
|
511
|
+
{
|
|
512
|
+
prop: 'textDecoration',
|
|
513
|
+
label: 'Decoration',
|
|
514
|
+
type: 'enum',
|
|
515
|
+
group: 'Type',
|
|
516
|
+
options: ['none', 'underline', 'line-through', 'overline'],
|
|
517
|
+
},
|
|
518
|
+
// -- Fill --
|
|
519
|
+
{ prop: 'backgroundColor', label: 'Background', type: 'color', group: 'Fill' },
|
|
520
|
+
// Cap 6 (§5 gap-fill): computed style never round-trips the `background`
|
|
521
|
+
// shorthand — `backgroundImage` is the prop that actually reads back
|
|
522
|
+
// (see `inspector.get`'s computed-style path below), so the gradient
|
|
523
|
+
// widget for a STYLE property must key off it, not `background`.
|
|
524
|
+
{ prop: 'backgroundImage', label: 'Fills', type: 'string', group: 'Fill' },
|
|
525
|
+
// Image-fill companions (design ledger: fill-as-list) — global, not
|
|
526
|
+
// per-layer, in v1; SemanticFill shows them only when an image layer exists.
|
|
527
|
+
{
|
|
528
|
+
prop: 'backgroundSize',
|
|
529
|
+
label: 'Size',
|
|
530
|
+
type: 'enum',
|
|
531
|
+
group: 'Fill',
|
|
532
|
+
options: ['auto', 'cover', 'contain'],
|
|
533
|
+
},
|
|
534
|
+
{ prop: 'backgroundPosition', label: 'Pos', type: 'string', group: 'Fill' },
|
|
535
|
+
{
|
|
536
|
+
prop: 'backgroundRepeat',
|
|
537
|
+
label: 'Repeat',
|
|
538
|
+
type: 'enum',
|
|
539
|
+
group: 'Fill',
|
|
540
|
+
options: ['repeat', 'no-repeat', 'repeat-x', 'repeat-y'],
|
|
541
|
+
},
|
|
542
|
+
// -- Stroke --
|
|
543
|
+
{ prop: 'borderColor', label: 'Border Color', type: 'color', group: 'Stroke' },
|
|
544
|
+
{ prop: 'borderWidth', label: 'Border Width', type: 'number', group: 'Stroke' },
|
|
545
|
+
{
|
|
546
|
+
prop: 'borderStyle',
|
|
547
|
+
label: 'Border Style',
|
|
548
|
+
type: 'enum',
|
|
549
|
+
group: 'Stroke',
|
|
550
|
+
options: ['none', 'solid', 'dashed', 'dotted', 'double'],
|
|
551
|
+
},
|
|
552
|
+
{ prop: 'borderRadius', label: 'Radius', type: 'number', group: 'Stroke' },
|
|
553
|
+
// -- Effects --
|
|
554
|
+
{ prop: 'opacity', label: 'Opacity', type: 'number', group: 'Effects' },
|
|
555
|
+
{ prop: 'boxShadow', label: 'Box Shadow', type: 'string', group: 'Effects' },
|
|
556
|
+
{
|
|
557
|
+
prop: 'mixBlendMode',
|
|
558
|
+
label: 'Blend',
|
|
559
|
+
type: 'enum',
|
|
560
|
+
group: 'Effects',
|
|
561
|
+
options: [
|
|
562
|
+
'normal',
|
|
563
|
+
'multiply',
|
|
564
|
+
'screen',
|
|
565
|
+
'overlay',
|
|
566
|
+
'darken',
|
|
567
|
+
'lighten',
|
|
568
|
+
'color-dodge',
|
|
569
|
+
'color-burn',
|
|
570
|
+
'hard-light',
|
|
571
|
+
'soft-light',
|
|
572
|
+
'difference',
|
|
573
|
+
'exclusion',
|
|
574
|
+
'hue',
|
|
575
|
+
'saturation',
|
|
576
|
+
'color',
|
|
577
|
+
'luminosity',
|
|
578
|
+
],
|
|
579
|
+
},
|
|
580
|
+
{ prop: 'filter', label: 'Filter', type: 'string', group: 'Effects' },
|
|
581
|
+
{ prop: 'backdropFilter', label: 'Backdrop', type: 'string', group: 'Effects' },
|
|
582
|
+
{ prop: 'textShadow', label: 'Text Shadow', type: 'string', group: 'Effects' },
|
|
583
|
+
{
|
|
584
|
+
prop: 'cursor',
|
|
585
|
+
label: 'Cursor',
|
|
586
|
+
type: 'enum',
|
|
587
|
+
group: 'Effects',
|
|
588
|
+
options: [
|
|
589
|
+
'auto',
|
|
590
|
+
'default',
|
|
591
|
+
'pointer',
|
|
592
|
+
'text',
|
|
593
|
+
'move',
|
|
594
|
+
'wait',
|
|
595
|
+
'help',
|
|
596
|
+
'crosshair',
|
|
597
|
+
'not-allowed',
|
|
598
|
+
'grab',
|
|
599
|
+
'grabbing',
|
|
600
|
+
'zoom-in',
|
|
601
|
+
'zoom-out',
|
|
602
|
+
'col-resize',
|
|
603
|
+
'row-resize',
|
|
604
|
+
'none',
|
|
605
|
+
],
|
|
606
|
+
},
|
|
607
|
+
// -- Transform --
|
|
608
|
+
{ prop: 'transform', label: 'Transform', type: 'string', group: 'Transform' },
|
|
609
|
+
];
|
|
610
|
+
const STYLE_PATH_PREFIX = 'style.';
|
|
611
|
+
/** Where this adapter's writes land — the ONE spelling, read by both the
|
|
612
|
+
* save-status provider and the per-edit ack the pipe returns, so the two can
|
|
613
|
+
* never name different places. */
|
|
614
|
+
const JSX_SOURCE_DESTINATION =
|
|
615
|
+
'component source (JSX, via /__ui-source — writes are immediate; nothing pending to flush)';
|
|
616
|
+
/** …and the honest floor when no writer is bound. */
|
|
617
|
+
const NO_BACKEND_DESTINATION = 'component source (JSX) — no source-write backend in this session';
|
|
618
|
+
/** Cap 4: inspector path prefix for a component's editable props (`prop.<name>`). */
|
|
619
|
+
const PROP_PATH_PREFIX = 'prop.';
|
|
620
|
+
/** Cap 7: inspector path prefix for the document's design tokens (`token.<name>`). */
|
|
621
|
+
const TOKEN_PATH_PREFIX = 'token.';
|
|
622
|
+
const PORTABLE_STORY_ARG_PATH_PREFIX = 'story.arg.';
|
|
623
|
+
/** D3.R1 (spec 27 §2/§5 U4 precedent, reopen fix) — the `guardedPaths` key for
|
|
624
|
+
* Cap 3's text edit (`editText`), so a dynamic-body refusal is recorded and
|
|
625
|
+
* surfaced through the SAME session-scoped after-touch mechanism the
|
|
626
|
+
* style/prop U4 widgets use — not a real inspector path (text has no
|
|
627
|
+
* `properties()` descriptor), just a stable key for `` `${id}|${TEXT_PATH}` ``. */
|
|
628
|
+
const TEXT_PATH = 'text';
|
|
629
|
+
/** The dynamic-expression guard's ONE sentence — the console warning and the
|
|
630
|
+
* field's read-only reason are the same string, so an author reads the same
|
|
631
|
+
* thing wherever they meet it (`tokenReferenceGuardText` is its var() twin). */
|
|
632
|
+
/**
|
|
633
|
+
* The selection-repair watch: 30 × 40ms ≈ 1.2s after an HMR update.
|
|
634
|
+
* MEASURED: the first drop lands 100-150ms after the update, and one edit can
|
|
635
|
+
* produce more than one update — so the window covers the whole remount storm
|
|
636
|
+
* without outliving the gesture that caused it.
|
|
637
|
+
*/
|
|
638
|
+
const SELECTION_REPAIR_ATTEMPTS = 30;
|
|
639
|
+
/**
|
|
640
|
+
* The REPAIRED case is named ONCE PER SESSION, the `reportUndeclaredStoryMedium`
|
|
641
|
+
* idiom: it is a real open defect and has to be visible, but it fires on every
|
|
642
|
+
* source edit in this lane, and a console that is permanently non-empty is a
|
|
643
|
+
* console nobody can use to find the next real thing. A repair that CANNOT be
|
|
644
|
+
* made stays loud every time — there the author actually lost something.
|
|
645
|
+
*/
|
|
646
|
+
let reportedSelectionRepair = false;
|
|
647
|
+
const SELECTION_REPAIR_INTERVAL_MS = 40;
|
|
648
|
+
|
|
649
|
+
const DYNAMIC_EXPRESSION_GUARD =
|
|
650
|
+
'The source value is a dynamic expression, so replacing it with a literal is guarded.';
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* WHICH LONGHANDS EACH CSS SHORTHAND OWNS — the table behind
|
|
654
|
+
* {@link ReactRootAuthoringAdapter.expandShorthandsFor}.
|
|
655
|
+
*
|
|
656
|
+
* React refuses to hold both halves of one property at once: writing
|
|
657
|
+
* `paddingTop` beside an authored `padding: 16` raises its own "Removing a
|
|
658
|
+
* style property during rerender (paddingTop) when a conflicting property is
|
|
659
|
+
* set (padding) can lead to styling bugs", and which of the two wins becomes
|
|
660
|
+
* key-order dependent.
|
|
661
|
+
*
|
|
662
|
+
* The combo widgets already solved the UNIFORM direction by REMOVING the
|
|
663
|
+
* longhands (`RadiusRow`/`ComboRow` call `inspector.remove` for every corner
|
|
664
|
+
* and side). This is that same gesture in the other direction: on the first
|
|
665
|
+
* per-side write, the shorthand is EXPANDED into its longhands and removed, so
|
|
666
|
+
* the element is authored in exactly one vocabulary either way.
|
|
667
|
+
*
|
|
668
|
+
* Nested on purpose — `border` owns three shorthands that each own four
|
|
669
|
+
* longhands — so an expansion walks outermost-in and each level is one entry.
|
|
670
|
+
*/
|
|
671
|
+
const SHORTHAND_LONGHANDS: Readonly<Record<string, readonly string[]>> = {
|
|
672
|
+
border: ['borderWidth', 'borderStyle', 'borderColor'],
|
|
673
|
+
borderWidth: ['borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'],
|
|
674
|
+
borderStyle: ['borderTopStyle', 'borderRightStyle', 'borderBottomStyle', 'borderLeftStyle'],
|
|
675
|
+
borderColor: ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'],
|
|
676
|
+
borderRadius: [
|
|
677
|
+
'borderTopLeftRadius',
|
|
678
|
+
'borderTopRightRadius',
|
|
679
|
+
'borderBottomRightRadius',
|
|
680
|
+
'borderBottomLeftRadius',
|
|
681
|
+
],
|
|
682
|
+
padding: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'],
|
|
683
|
+
margin: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'],
|
|
684
|
+
inset: ['top', 'right', 'bottom', 'left'],
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
/** Longhand -> its immediate shorthand, derived from the table above so the
|
|
688
|
+
* two directions cannot disagree. */
|
|
689
|
+
const SHORTHAND_OF: ReadonlyMap<string, string> = new Map(
|
|
690
|
+
Object.entries(SHORTHAND_LONGHANDS).flatMap(([shorthand, longhands]) =>
|
|
691
|
+
longhands.map((longhand) => [longhand, shorthand] as const),
|
|
692
|
+
),
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
/** The shorthands covering `prop`, OUTERMOST FIRST — `borderTopWidth` gives
|
|
696
|
+
* `['border', 'borderWidth']`. Empty for a property no shorthand owns. */
|
|
697
|
+
function shorthandChain(prop: string): readonly string[] {
|
|
698
|
+
const chain: string[] = [];
|
|
699
|
+
for (let cursor = SHORTHAND_OF.get(prop); cursor; cursor = SHORTHAND_OF.get(cursor)) {
|
|
700
|
+
chain.unshift(cursor);
|
|
701
|
+
}
|
|
702
|
+
return chain;
|
|
703
|
+
}
|
|
704
|
+
/** D20 — fallback id for callers that supply portable stories without the
|
|
705
|
+
* richer per-CSF-document projection. Story ids remain Storybook-owned. */
|
|
706
|
+
const PORTABLE_DOCUMENT_ID = 'portable-document';
|
|
707
|
+
/** Name of a portable document that has neither an explicit label nor a
|
|
708
|
+
* module path to derive a group path from. */
|
|
709
|
+
const PORTABLE_DOCUMENT_LABEL = 'React Preview';
|
|
710
|
+
/** `prop -> declared type`, so `inspector.get()` knows when to run a style
|
|
711
|
+
* value through {@link numericStyleValue} instead of handing it back raw
|
|
712
|
+
* (a real CSSOM length value is always a unit-suffixed string). */
|
|
713
|
+
const STYLE_PROPERTY_TYPE: ReadonlyMap<string, PropertyDescriptor['type']> = new Map(
|
|
714
|
+
STYLE_PROPERTIES.map(({ prop, type }) => [prop, type]),
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* U2 (spec 27 §5 C2) — a per-side/per-corner CSS LONGHAND → the SHORTHAND that
|
|
719
|
+
* governs it in the longhand's absence. Used by `inspector.remove` (below): when
|
|
720
|
+
* a uniform border/radius edit removes a stale longhand override, the removed
|
|
721
|
+
* longhand's optimistic echo is set to the shorthand's current (just-committed)
|
|
722
|
+
* value — so an `inspector.get(longhand)` right after removal reports the value
|
|
723
|
+
* the corner actually renders at (the shorthand), not a stale override or an
|
|
724
|
+
* empty computed read, and it stays consistent once HMR clears the echo (the
|
|
725
|
+
* longhand is gone from source, so the shorthand cascades to it). Purely the
|
|
726
|
+
* longhands the C2 combo rows can write — the shorthand set itself is uniform.
|
|
727
|
+
*/
|
|
728
|
+
const LONGHAND_TO_SHORTHAND: Readonly<Record<string, string>> = {
|
|
729
|
+
borderTopLeftRadius: 'borderRadius',
|
|
730
|
+
borderTopRightRadius: 'borderRadius',
|
|
731
|
+
borderBottomLeftRadius: 'borderRadius',
|
|
732
|
+
borderBottomRightRadius: 'borderRadius',
|
|
733
|
+
borderTopWidth: 'borderWidth',
|
|
734
|
+
borderRightWidth: 'borderWidth',
|
|
735
|
+
borderBottomWidth: 'borderWidth',
|
|
736
|
+
borderLeftWidth: 'borderWidth',
|
|
737
|
+
borderTopStyle: 'borderStyle',
|
|
738
|
+
borderRightStyle: 'borderStyle',
|
|
739
|
+
borderBottomStyle: 'borderStyle',
|
|
740
|
+
borderLeftStyle: 'borderStyle',
|
|
741
|
+
borderTopColor: 'borderColor',
|
|
742
|
+
borderRightColor: 'borderColor',
|
|
743
|
+
borderBottomColor: 'borderColor',
|
|
744
|
+
borderLeftColor: 'borderColor',
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* D3.e (spec 27 §2 T0 leftover, §6 D3 "Insert-child submenu") — the kinds
|
|
749
|
+
* `structure.create`'s `wrapperTag` genuinely inserts. `insertChildElement`
|
|
750
|
+
* (`ui-source/writer.ts:1036`) writes `<tag />` VERBATIM for whatever tag
|
|
751
|
+
* string it is given — it has no allow-list of its own, and no void/non-void
|
|
752
|
+
* element distinction (every insert is self-closing, syntactically valid
|
|
753
|
+
* JSX for every one of these real HTML element names). So this is a CURATED
|
|
754
|
+
* subset, not a writer-enforced ceiling. A React root node's native kind is
|
|
755
|
+
* its literal HTML tag name.
|
|
756
|
+
*/
|
|
757
|
+
const CREATABLE_KINDS: ReadonlyArray<{ kind: string; label: string }> = [
|
|
758
|
+
{ kind: 'div', label: 'Container' },
|
|
759
|
+
{ kind: 'span', label: 'Text' },
|
|
760
|
+
{ kind: 'p', label: 'Paragraph' },
|
|
761
|
+
{ kind: 'button', label: 'Button' },
|
|
762
|
+
{ kind: 'a', label: 'Link' },
|
|
763
|
+
{ kind: 'img', label: 'Image' },
|
|
764
|
+
{ kind: 'ul', label: 'List' },
|
|
765
|
+
{ kind: 'li', label: 'List Item' },
|
|
766
|
+
];
|
|
767
|
+
|
|
768
|
+
const IMAGE_ASSET_RE = /\.(?:avif|gif|jpe?g|png|svg|webp)(?:[?#].*)?$/i;
|
|
769
|
+
|
|
770
|
+
type DomAssetDropPlan =
|
|
771
|
+
| {
|
|
772
|
+
ok: true;
|
|
773
|
+
parentId: string;
|
|
774
|
+
snippet: string;
|
|
775
|
+
ensureImport?: { name: string; module: string; kind: 'default' | 'named' };
|
|
776
|
+
}
|
|
777
|
+
| { ok: false; reason: string };
|
|
778
|
+
|
|
779
|
+
function serializedComponentValue(value: unknown, spec: ComponentPropSpec): string {
|
|
780
|
+
if (spec.type === 'number') return String(Number(value));
|
|
781
|
+
if (spec.type === 'boolean') return value === true || value === 'true' ? 'true' : 'false';
|
|
782
|
+
if (spec.type === 'vec3' && Array.isArray(value)) {
|
|
783
|
+
return `[${value.map((entry) => Number(entry)).join(', ')}]`;
|
|
784
|
+
}
|
|
785
|
+
if (spec.type === 'json') return JSON.stringify(value);
|
|
786
|
+
return JSON.stringify(String(value));
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export interface ReactRootAuthoringOptions {
|
|
790
|
+
/** Optional wider DOM root used only for atomic asset discovery. The
|
|
791
|
+
* hierarchy still walks the constructor root; a multi-story board can thus
|
|
792
|
+
* expose assets from every mounted frame without mixing their node trees. */
|
|
793
|
+
assetRoot?: OidElementLike | undefined;
|
|
794
|
+
/** T3.2 slice-3 write seam. Absent ⇒ no dev-server backend in this session (a
|
|
795
|
+
* hosted/browser build) — selection/inspection still work; writes report
|
|
796
|
+
* unavailable (see the class doc comment). */
|
|
797
|
+
writeBackend?: SourceWriteBackend | undefined;
|
|
798
|
+
/** Cap 1 (React visual-edit parity): resolves an element to its COMPUTED style so
|
|
799
|
+
* `inspector.get` reflects class- and CSS-file-styled properties, not just inline
|
|
800
|
+
* style. Defaults to `window.getComputedStyle` in a browser, or the element's inline
|
|
801
|
+
* `.style` under vitest's `node` env (so headless fixtures work). Injectable for
|
|
802
|
+
* headless tests that want to drive the real computed-value path. */
|
|
803
|
+
computedStyle?: ComputedStyleResolver | undefined;
|
|
804
|
+
/** Cap 2 (React visual-edit parity): resolves an element to the first-party CSS rules
|
|
805
|
+
* that match it (source file + selector + declared properties), so a style edit whose
|
|
806
|
+
* property lives in a CSS FILE routes to that file instead of inline/class. Defaults to
|
|
807
|
+
* `inspect.ts`'s `getMatchedCssRules` against the live document. Injectable for headless
|
|
808
|
+
* tests. */
|
|
809
|
+
matchedCssRules?: ((el: unknown) => CssRuleTarget[]) | undefined;
|
|
810
|
+
/** Cap 7 (React visual-edit parity): the document's design tokens (`:root` custom
|
|
811
|
+
* properties). Defaults to `inspect.ts`'s `getDesignTokens` against the live `:root`
|
|
812
|
+
* (empty under vitest's `node` env). Injectable for headless tests. */
|
|
813
|
+
designTokens?: (() => DesignToken[]) | undefined;
|
|
814
|
+
/** D20 — portable Storybook CSF stories associated with this native React
|
|
815
|
+
* root. They are the canonical provider exposed to the shell. */
|
|
816
|
+
portableStories?: ReadonlyArray<PortableStoryRef> | undefined;
|
|
817
|
+
/** Source documents projected above their own portable CSF stories. A
|
|
818
|
+
* document with no explicit `label` is named by its GROUP PATH — the one
|
|
819
|
+
* presentation-only grouping model (`stories/story-grouping.ts`): the
|
|
820
|
+
* CSF meta `title` when authored, else the module path relative to the
|
|
821
|
+
* project `src/`. Grouping is labels only here; ids, roles, kinds, and
|
|
822
|
+
* which stories a document owns are unchanged by it. */
|
|
823
|
+
portableDocuments?:
|
|
824
|
+
| ReadonlyArray<{
|
|
825
|
+
id: string;
|
|
826
|
+
label?: string | undefined;
|
|
827
|
+
path?: string | undefined;
|
|
828
|
+
/** Composed CSF meta title when the module authored one. */
|
|
829
|
+
title?: string | undefined;
|
|
830
|
+
storyIds: ReadonlyArray<string>;
|
|
831
|
+
}>
|
|
832
|
+
| undefined;
|
|
833
|
+
/** Native Storybook Category → Folder → Component index. When present,
|
|
834
|
+
* both the hierarchy and the board consume this same derived projection;
|
|
835
|
+
* CSF remains the only authored data. */
|
|
836
|
+
portableHierarchy?: StoryPresentationIndex | undefined;
|
|
837
|
+
/** Initially mounted portable story id (normally the explicit/default CSF
|
|
838
|
+
* story selected by the design-time layer). */
|
|
839
|
+
activePortableStoryId?: string | undefined;
|
|
840
|
+
/** Re-render hook for a portable CSF selection. The design-time layer owns
|
|
841
|
+
* Storybook mounting; this adapter owns only selection/provider routing. */
|
|
842
|
+
onPortableStoryApplied?: ((storyId: string | null) => void) | undefined;
|
|
843
|
+
/** Session-scoped Storybook Controls write. The design-time layer remounts
|
|
844
|
+
* the same composed story with these args; CSF remains the canonical
|
|
845
|
+
* definition and Reset restores its composed defaults. */
|
|
846
|
+
onPortableStoryArgsChanged?:
|
|
847
|
+
| ((storyId: string, args: Record<string, unknown>) => void)
|
|
848
|
+
| undefined;
|
|
849
|
+
/**
|
|
850
|
+
* Overrides the JSX-source provenance below. The one caller is play mode
|
|
851
|
+
* (`react-play-live-authoring.ts`), whose `writeBackend` writes the LIVE DOM
|
|
852
|
+
* rather than source — the badge/tooltip must say so, exactly as the adopted
|
|
853
|
+
* three scene and the live Pixi stage do. Absent ⇒ the source-code provenance
|
|
854
|
+
* every authoring session gets.
|
|
855
|
+
*/
|
|
856
|
+
provenance?: AuthoringProvenance | undefined;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
export interface PortableStoryRef extends StoryRef {
|
|
860
|
+
args?: Readonly<Record<string, unknown>>;
|
|
861
|
+
/** The CSF export name — what the story WRITE half (save-as/rename/delete)
|
|
862
|
+
* addresses in source. Optional so fixtures stay minimal; a ref without it
|
|
863
|
+
* simply cannot be written. */
|
|
864
|
+
name?: string;
|
|
865
|
+
/** Project-relative `*.stories.tsx` path — the write half's file. */
|
|
866
|
+
modulePath?: string;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** The default: this adapter's own truth is the component's JSX source. */
|
|
870
|
+
const JSX_SOURCE_PROVENANCE: AuthoringProvenance = {
|
|
871
|
+
source: 'source-code',
|
|
872
|
+
label: 'jsx',
|
|
873
|
+
detail: 'The JSX source is the document — edits write back to the component source file.',
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
/** The element's literal class tokens, off the live DOM `class` attribute. */
|
|
877
|
+
function classTokensOf(el: unknown): string[] {
|
|
878
|
+
const e = el as { getAttribute?(name: string): string | null };
|
|
879
|
+
const raw = typeof e?.getAttribute === 'function' ? (e.getAttribute('class') ?? '') : '';
|
|
880
|
+
return raw.split(/\s+/).filter(Boolean);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** Does the LIVE inline style declare this property? Deliberately the live
|
|
884
|
+
* read, not the source: a preview-patched inline value also wins the cascade,
|
|
885
|
+
* so the conservative answer keeps a class-routed write from landing a
|
|
886
|
+
* declaration that never paints. */
|
|
887
|
+
function liveInlineDeclares(el: unknown, prop: string): boolean {
|
|
888
|
+
const style = (el as { style?: { getPropertyValue?(name: string): string } }).style;
|
|
889
|
+
if (typeof style?.getPropertyValue !== 'function') return false;
|
|
890
|
+
return style.getPropertyValue(camelToKebab(prop)) !== '';
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
export class ReactRootAuthoringAdapter implements AuthoringAdapter {
|
|
894
|
+
readonly capabilities: AuthoringCapabilities;
|
|
895
|
+
|
|
896
|
+
/** Assigned in the constructor (an option may override it — see
|
|
897
|
+
* {@link ReactRootAuthoringOptions.provenance}), never re-assigned after. */
|
|
898
|
+
readonly provenance: AuthoringProvenance;
|
|
899
|
+
private readonly writeBackend: SourceWriteBackend | undefined;
|
|
900
|
+
/** The shared DOM projector under the OID identity. */
|
|
901
|
+
private readonly projector = new DomProjector(oidDomIdentity<OidElementLike>());
|
|
902
|
+
private hierarchySnapshotCache: OidTree | null = null;
|
|
903
|
+
private hierarchySnapshotClearQueued = false;
|
|
904
|
+
private readonly computedStyle: ComputedStyleResolver;
|
|
905
|
+
private readonly matchedCssRules: (el: unknown) => CssRuleTarget[];
|
|
906
|
+
private readonly designTokens: () => DesignToken[];
|
|
907
|
+
private readonly designTokenValue: (name: string) => string | undefined;
|
|
908
|
+
/** OID → {component, tag, …} labels, fetched once (if a backend exists) from the
|
|
909
|
+
* SAME `/__ui-source/index` the OID store already serves — see `index()` on
|
|
910
|
+
* `SourceWriteBackend`. Absent/unresolved ⇒ nodes label from the DOM tag alone
|
|
911
|
+
* (honest degradation, not a second index). */
|
|
912
|
+
private oidIndex: Map<string, OidEntry> = new Map();
|
|
913
|
+
private dirty = false;
|
|
914
|
+
/**
|
|
915
|
+
* A4 (spec 27 §3) — the optimistic value echo. `inspector.set` writes the just-committed
|
|
916
|
+
* value here BEFORE its async source-write returns; `inspector.get` reads THROUGH it (a
|
|
917
|
+
* hit wins over the live-DOM walk), so an edited field shows the NEW value within one
|
|
918
|
+
* frame of commit instead of snapping back to the old value until the source-write + Vite
|
|
919
|
+
* HMR re-render land (that timing WAS the desync bug). Keyed by OID-signature entity id
|
|
920
|
+
* (which survives the react remount — see {@link walkOidTree}) → full inspector path →
|
|
921
|
+
* value. Cleared per-entry the instant a write is refused/coerced-away (so it can't go
|
|
922
|
+
* stale showing a value the source never took), and wholesale once HMR actually lands
|
|
923
|
+
* ({@link reconcileEchoAfterReload}) so the now-updated live DOM is authoritative again.
|
|
924
|
+
*/
|
|
925
|
+
private readonly valueEcho = new Map<string, Map<string, unknown>>();
|
|
926
|
+
/**
|
|
927
|
+
* U4 (spec 27 §5 "Widgets: dynamic-expression read-only indicator") — the
|
|
928
|
+
* smallest honest version of the indicator: no new backend literality
|
|
929
|
+
* PROBE (that's real new `SourceWriteBackend` surface, deferred to Phase-E
|
|
930
|
+
* scale). Instead this surfaces the write path's EXISTING refusal signal —
|
|
931
|
+
* `writeStyleEntry`/`writePropEdit` already set `res.dynamic` when the
|
|
932
|
+
* backend refuses because the target is a dynamic `{expression}`, clearing
|
|
933
|
+
* the optimistic echo and warning. Once THAT has happened for a given
|
|
934
|
+
* `id|path`, `properties()` marks its descriptor `readonly: true` on every
|
|
935
|
+
* subsequent call, so the widget disables itself (`KindRowProps.disabled`)
|
|
936
|
+
* instead of silently re-offering an edit the source will refuse again.
|
|
937
|
+
* This is PREDICTIVE-AFTER-TOUCH, not predictive-BEFORE-touch (the field is
|
|
938
|
+
* still editable — and will visibly refuse once — the very first time);
|
|
939
|
+
* a pre-touch indicator needs the backend probe noted above. Session-scoped
|
|
940
|
+
* (never persisted) — cleared for an id on `dispose()`-adjacent resets only
|
|
941
|
+
* via normal adapter lifetime, same scope as `valueEcho`.
|
|
942
|
+
*/
|
|
943
|
+
private readonly guardedPaths = new Map<string, string>();
|
|
944
|
+
/**
|
|
945
|
+
* D4 (spec27 §6 D4, layer-tree "lock" toggle) — SESSION-LOCAL node ids the
|
|
946
|
+
* layer tree/overlay has marked locked. Deliberately NOT persisted/written
|
|
947
|
+
* to source: a react
|
|
948
|
+
* component has no lock schema field, and authoring one with no
|
|
949
|
+
* runtime reader would violate this repo's "no described field without a
|
|
950
|
+
* consumer" rule (CLAUDE.md; the SAME reason `ui.ts` dropped its dead
|
|
951
|
+
* `locked` field). Mirrors `world-session-state.ts`'s per-world pick-lock.
|
|
952
|
+
* D4.R1 — now READ by `pickable.pick` below (a locked node is skipped by
|
|
953
|
+
* canvas click/marquee pick, matching the first-party three raycast's
|
|
954
|
+
* own locked-skip in `viewport-raycast.ts`) and by
|
|
955
|
+
* `RootSelectionOverlay.collectMarqueeCandidates` (via
|
|
956
|
+
* `inspector.get(id, 'locked')`, which reads this Set — see the
|
|
957
|
+
* `inspector.get`/`set` `'locked'` case below) for the marquee pool.
|
|
958
|
+
* Deliberately NOT consulted by `hierarchy`/`selection.set` — a locked
|
|
959
|
+
* node stays selectable/unlockable from the layer tree, mirroring the
|
|
960
|
+
* first-party adapter's own "locked blocks the raycast, not the
|
|
961
|
+
* hierarchy" behavior. Cleared only by adapter disposal (new Set per
|
|
962
|
+
* adapter instance/mount).
|
|
963
|
+
*/
|
|
964
|
+
private readonly lockedIds = new Set<string>();
|
|
965
|
+
private readonly assetRoot: OidElementLike;
|
|
966
|
+
/**
|
|
967
|
+
* D3.R4 (reopen fix), widened by D3.R5 — a COUNTER (not a boolean) of source writes
|
|
968
|
+
* whose own pre-HMR `notifyIngestEdit()` carries no fresh DOM shape, still awaiting
|
|
969
|
+
* their post-HMR "reload landed" reconcile. Incremented by every SUCCESSFUL write on
|
|
970
|
+
* a path with no `valueEcho` of its own: structural writes and `editText`.
|
|
971
|
+
* A successful text write flips `hasText`, itself a
|
|
972
|
+
* `findEmptyContainers` hint-eligibility criterion — text populates no `valueEcho`,
|
|
973
|
+
* so neither existing reconcile branch fired for it).
|
|
974
|
+
*
|
|
975
|
+
* A structural/text op's own `notifyIngestEdit()` (right after the write response
|
|
976
|
+
* lands) fires BEFORE the HMR remount that actually changes the DOM — so a memoized
|
|
977
|
+
* hover-render cache keyed on that notify's `storeVersion`
|
|
978
|
+
* (`RootSelectionOverlay`'s `emptyHintsCacheRef`) pins the PRE-HMR tree shape.
|
|
979
|
+
* `reconcileEchoAfterReload` is the "HMR actually landed" signal; it used to no-op
|
|
980
|
+
* whenever `valueEcho` was empty — true for every one of the sites above, since only
|
|
981
|
+
* A4's value-echo path (style/prop) ever populates it.
|
|
982
|
+
*
|
|
983
|
+
* COUNTER, not boolean (the D3.R5 shape): a boolean cleared unconditionally on the
|
|
984
|
+
* FIRST reconcile after it was set, so two rapid writes each of whose OWN HMR fires a
|
|
985
|
+
* separate `vite:afterUpdate` would reconcile once and then no-op on the second
|
|
986
|
+
* reload-landed signal — the second write's hint delta going stale forever (a
|
|
987
|
+
* residual the boolean shape left undocumented). Each successful qualifying write
|
|
988
|
+
* increments this counter; `reconcileEchoAfterReload` notifies and decrements by
|
|
989
|
+
* exactly one whenever it is above zero (in addition to notifying whenever `valueEcho`
|
|
990
|
+
* is non-empty), so N pending writes need N reconciles to fully drain — matching N
|
|
991
|
+
* real `vite:afterUpdate` events in production.
|
|
992
|
+
*/
|
|
993
|
+
private pendingSourceReconcile = 0;
|
|
994
|
+
/**
|
|
995
|
+
* The last non-empty selection this adapter's tree served — the input to
|
|
996
|
+
* {@link repairSelectionAfterReprojection}.
|
|
997
|
+
*/
|
|
998
|
+
private lastResolvedSelection: readonly string[] = [];
|
|
999
|
+
/** One repair watcher per re-projection; see {@link scheduleSelectionRepair}. */
|
|
1000
|
+
private selectionRepairWatching = false;
|
|
1001
|
+
/** A4 — unsubscribes this adapter's `vite:afterUpdate` reconcile hook (see the constructor);
|
|
1002
|
+
* `undefined` when there is no HMR context. */
|
|
1003
|
+
private readonly disposeReloadSignal: (() => void) | undefined;
|
|
1004
|
+
// Mutable on purpose: `writeStory` keeps this list truthful about the file
|
|
1005
|
+
// it just rewrote (rename updates the addressed entry, delete drops it) —
|
|
1006
|
+
// see the blind-walk finding in that method.
|
|
1007
|
+
private readonly portableStories: PortableStoryRef[];
|
|
1008
|
+
private readonly portableStoryArgs = new Map<string, Record<string, unknown>>();
|
|
1009
|
+
private readonly portableDocuments: ReadonlyArray<{
|
|
1010
|
+
id: string;
|
|
1011
|
+
label: string;
|
|
1012
|
+
path?: string | undefined;
|
|
1013
|
+
storyIds: ReadonlyArray<string>;
|
|
1014
|
+
}>;
|
|
1015
|
+
private readonly portableHierarchy: StoryPresentationIndex | null;
|
|
1016
|
+
private readonly portableDefaultStoryId: string | null;
|
|
1017
|
+
private readonly onPortableStoryApplied: ((storyId: string | null) => void) | undefined;
|
|
1018
|
+
private readonly onPortableStoryArgsChanged:
|
|
1019
|
+
| ((storyId: string, args: Record<string, unknown>) => void)
|
|
1020
|
+
| undefined;
|
|
1021
|
+
/** The currently applied portable story, or `null` when this root has no
|
|
1022
|
+
* portable CSF document. */
|
|
1023
|
+
private activeStoryId: string | null = null;
|
|
1024
|
+
/**
|
|
1025
|
+
* T0 (spec 27 §4, B1) — the currently-open `boxEdit` begin/apply×N/end gesture (a
|
|
1026
|
+
* drag), or `null` between gestures. `touched` maps the RESOLVED CSS prop (already
|
|
1027
|
+
* patch-key-mapped, e.g. `x` → `left`) to the FINAL value `end` should commit — a
|
|
1028
|
+
* `Map` so the last `apply` in the gesture wins, matching an in-flight drag's most
|
|
1029
|
+
* recent pointer position. `priorInline` captures each touched prop's ORIGINAL inline
|
|
1030
|
+
* value, LAZILY on the prop's first `apply` in this gesture (i.e. BEFORE `apply`
|
|
1031
|
+
* mutates the live style) — `apply` writes the live DOM directly for zero-latency
|
|
1032
|
+
* preview, which would otherwise corrupt `writeStyleEntry`'s own prior-value capture
|
|
1033
|
+
* (it reads `n.el.style` fresh, and by `end` time that already holds the LAST applied
|
|
1034
|
+
* preview value, not the true pre-gesture one) — this is why `writeStyleEntry` takes
|
|
1035
|
+
* an explicit override rather than re-deriving `prev` itself for a box-edit commit.
|
|
1036
|
+
*/
|
|
1037
|
+
private boxEditSession: {
|
|
1038
|
+
id: string;
|
|
1039
|
+
touched: Map<string, string | number>;
|
|
1040
|
+
priorInline: Map<string, string>;
|
|
1041
|
+
/** The one hint this gesture already showed for a move it cannot write. */
|
|
1042
|
+
hinted?: boolean;
|
|
1043
|
+
} | null = null;
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Serializes {@link commitBoxEdit} runs so two gestures never overlap.
|
|
1047
|
+
*
|
|
1048
|
+
* `boxEdit.end()` fire-and-forgets an ASYNC commit (it cannot await — the
|
|
1049
|
+
* gesture API is synchronous, driven straight from pointer/key handlers), and
|
|
1050
|
+
* `editor-hotkeys.ts` drives `apply()`+`end()` on EVERY keypress. Hold an
|
|
1051
|
+
* arrow key, or align a multi-selection, and the second commit calls
|
|
1052
|
+
* a second source-history gesture while the first is still awaiting its
|
|
1053
|
+
* writes. Chaining here keeps adapter preview/echo updates ordered too; the
|
|
1054
|
+
* scoped history backend independently serializes each complete callback.
|
|
1055
|
+
*/
|
|
1056
|
+
private boxEditTail: Promise<void> = Promise.resolve();
|
|
1057
|
+
|
|
1058
|
+
constructor(
|
|
1059
|
+
private readonly root: OidElementLike,
|
|
1060
|
+
private readonly store: EditorShellStore,
|
|
1061
|
+
opts: ReactRootAuthoringOptions = {},
|
|
1062
|
+
) {
|
|
1063
|
+
this.assetRoot = opts.assetRoot ?? root;
|
|
1064
|
+
this.provenance = opts.provenance ?? JSX_SOURCE_PROVENANCE;
|
|
1065
|
+
this.writeBackend = withProjectSourceHistory(opts.writeBackend, store.projectHistory);
|
|
1066
|
+
this.computedStyle = opts.computedStyle ?? browserOrInlineResolver;
|
|
1067
|
+
this.matchedCssRules =
|
|
1068
|
+
opts.matchedCssRules ??
|
|
1069
|
+
((el) => getMatchedCssRules(el as MatchableElement) as CssRuleTarget[]);
|
|
1070
|
+
// Game CSS is SCOPED (`@scope ([data-vgai-game-styles])`, measured:
|
|
1071
|
+
// a project stylesheet's `:root { --x }` never reaches the editor page's
|
|
1072
|
+
// root), so the document root sees no game token — the adapter's OWN
|
|
1073
|
+
// mounted root is inside the scope and inherits them all. Fall back to
|
|
1074
|
+
// the page root for a fixture root that is not a real element.
|
|
1075
|
+
const tokenStyle = () => {
|
|
1076
|
+
const root = this.root as unknown as Element;
|
|
1077
|
+
// Tokens inherit DOWNWARD from each game-CSS scope root
|
|
1078
|
+
// (`[data-vgai-game-styles]`), which sits BELOW this adapter's layer —
|
|
1079
|
+
// so read the first scope root's computed style, not the layer's
|
|
1080
|
+
// (measured: the layer sees none of the game's custom properties).
|
|
1081
|
+
const el =
|
|
1082
|
+
typeof Element !== 'undefined' && root instanceof Element
|
|
1083
|
+
? (root.querySelector('[data-vgai-game-styles]') ??
|
|
1084
|
+
root.closest?.('[data-vgai-game-styles]') ??
|
|
1085
|
+
root)
|
|
1086
|
+
: typeof document !== 'undefined'
|
|
1087
|
+
? document.documentElement
|
|
1088
|
+
: null;
|
|
1089
|
+
const computed =
|
|
1090
|
+
typeof getComputedStyle === 'function' && el instanceof Element
|
|
1091
|
+
? (getComputedStyle(el) as unknown as Parameters<typeof getDesignTokens>[0])
|
|
1092
|
+
: undefined;
|
|
1093
|
+
return computed;
|
|
1094
|
+
};
|
|
1095
|
+
this.designTokens = opts.designTokens ?? (() => getDesignTokens(tokenStyle()));
|
|
1096
|
+
// A field read is ONE computed custom property. Re-enumerating and tracing
|
|
1097
|
+
// the entire token catalog for each field made coverage/selection quadratic
|
|
1098
|
+
// (3,324 reads cost 6.6s under Code-OSS). Read live so same-turn CSS changes
|
|
1099
|
+
// remain visible; this is not a stale-value cache.
|
|
1100
|
+
this.designTokenValue = opts.designTokens
|
|
1101
|
+
? (name) => opts.designTokens!().find((token) => token.name === name)?.value
|
|
1102
|
+
: (name) => tokenStyle()?.getPropertyValue(name).trim() || undefined;
|
|
1103
|
+
this.portableStories = [...(opts.portableStories ?? [])];
|
|
1104
|
+
for (const story of this.portableStories) {
|
|
1105
|
+
this.portableStoryArgs.set(story.id, { ...(story.args ?? {}) });
|
|
1106
|
+
}
|
|
1107
|
+
const declaredPortableDocuments =
|
|
1108
|
+
opts.portableDocuments ??
|
|
1109
|
+
(this.portableStories.length > 0
|
|
1110
|
+
? [
|
|
1111
|
+
{
|
|
1112
|
+
id: PORTABLE_DOCUMENT_ID,
|
|
1113
|
+
label: PORTABLE_DOCUMENT_LABEL,
|
|
1114
|
+
storyIds: this.portableStories.map((story) => story.id),
|
|
1115
|
+
},
|
|
1116
|
+
]
|
|
1117
|
+
: []);
|
|
1118
|
+
const knownStoryIds = new Set(this.portableStories.map((story) => story.id));
|
|
1119
|
+
const claimedStoryIds = new Set<string>();
|
|
1120
|
+
const documentIds = new Set<string>();
|
|
1121
|
+
const portableDocuments = declaredPortableDocuments.flatMap((document) => {
|
|
1122
|
+
if (documentIds.has(document.id)) return [];
|
|
1123
|
+
documentIds.add(document.id);
|
|
1124
|
+
const storyIds = document.storyIds.filter((storyId) => {
|
|
1125
|
+
if (!knownStoryIds.has(storyId) || claimedStoryIds.has(storyId)) return false;
|
|
1126
|
+
claimedStoryIds.add(storyId);
|
|
1127
|
+
return true;
|
|
1128
|
+
});
|
|
1129
|
+
if (storyIds.length === 0) return [];
|
|
1130
|
+
return [
|
|
1131
|
+
{
|
|
1132
|
+
id: document.id,
|
|
1133
|
+
// The group path IS the document's name when the caller doesn't
|
|
1134
|
+
// impose one — an authored CSF title, else the module's own path
|
|
1135
|
+
// under the project `src/` (which degrades to the per-module
|
|
1136
|
+
// grouping this hierarchy already had).
|
|
1137
|
+
label:
|
|
1138
|
+
document.label ??
|
|
1139
|
+
(document.path
|
|
1140
|
+
? formatStoryGroupPath(
|
|
1141
|
+
deriveStoryGroupPath({ modulePath: document.path, title: document.title }),
|
|
1142
|
+
)
|
|
1143
|
+
: PORTABLE_DOCUMENT_LABEL),
|
|
1144
|
+
...(document.path ? { path: document.path } : {}),
|
|
1145
|
+
storyIds,
|
|
1146
|
+
},
|
|
1147
|
+
];
|
|
1148
|
+
});
|
|
1149
|
+
const unassignedStoryIds = this.portableStories
|
|
1150
|
+
.map((story) => story.id)
|
|
1151
|
+
.filter((storyId) => !claimedStoryIds.has(storyId));
|
|
1152
|
+
if (unassignedStoryIds.length > 0) {
|
|
1153
|
+
portableDocuments.push({
|
|
1154
|
+
id: documentIds.has(PORTABLE_DOCUMENT_ID)
|
|
1155
|
+
? `${PORTABLE_DOCUMENT_ID}:unassigned`
|
|
1156
|
+
: PORTABLE_DOCUMENT_ID,
|
|
1157
|
+
label: PORTABLE_DOCUMENT_LABEL,
|
|
1158
|
+
storyIds: unassignedStoryIds,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
this.portableDocuments = portableDocuments;
|
|
1162
|
+
this.portableHierarchy = opts.portableHierarchy ?? null;
|
|
1163
|
+
this.portableDefaultStoryId = this.portableStories.some(
|
|
1164
|
+
(story) => story.id === opts.activePortableStoryId,
|
|
1165
|
+
)
|
|
1166
|
+
? (opts.activePortableStoryId ?? null)
|
|
1167
|
+
: (this.portableStories[0]?.id ?? null);
|
|
1168
|
+
this.onPortableStoryApplied = opts.onPortableStoryApplied;
|
|
1169
|
+
this.onPortableStoryArgsChanged = opts.onPortableStoryArgsChanged;
|
|
1170
|
+
this.activeStoryId = this.portableDefaultStoryId;
|
|
1171
|
+
this.capabilities = {
|
|
1172
|
+
transform: false, // no 3D gizmo — DOM has no Object3D pose (§1.F)
|
|
1173
|
+
inspectorFields: true,
|
|
1174
|
+
persist: true,
|
|
1175
|
+
};
|
|
1176
|
+
// A4 (spec 27 §3) — re-sync the optimistic echo once HMR has actually re-rendered the
|
|
1177
|
+
// react tree. Vite fires `vite:afterUpdate` after it applies an HMR update — here, the
|
|
1178
|
+
// dev-server source-write this adapter triggered (→ file watcher → react-refresh
|
|
1179
|
+
// re-render) — so that event IS the "reload landed" signal (the adapter otherwise never
|
|
1180
|
+
// learns about reloads; play-mode.ts owns the component-class HMR handler, not this
|
|
1181
|
+
// per-world adapter). Absent under a hosted/no-dev-server build and under vitest's `node`
|
|
1182
|
+
// env (no `import.meta.hot`), where the unit test drives `reconcileEchoAfterReload()`
|
|
1183
|
+
// directly to simulate a landed reload.
|
|
1184
|
+
const hot = import.meta.hot;
|
|
1185
|
+
if (hot) {
|
|
1186
|
+
const onAfterUpdate = (): void => this.reconcileEchoAfterReload();
|
|
1187
|
+
hot.on('vite:afterUpdate', onAfterUpdate);
|
|
1188
|
+
this.disposeReloadSignal = () => hot.off('vite:afterUpdate', onAfterUpdate);
|
|
1189
|
+
} else {
|
|
1190
|
+
// THE HOSTED TIER'S "RELOAD LANDED" SIGNAL. No HMR here: a source write
|
|
1191
|
+
// re-projects this root when the story registry re-publishes (the
|
|
1192
|
+
// content-write refresh in `project-story-discovery.ts` reloads the
|
|
1193
|
+
// story modules the write reached and the board re-renders its
|
|
1194
|
+
// frames). That re-projection drops the selection exactly like the
|
|
1195
|
+
// HMR one, and with no signal the repair above never ran — measured on
|
|
1196
|
+
// production build 57: drag a HUD element on the board, the write
|
|
1197
|
+
// lands, and the selection box, hierarchy row and Inspector all go
|
|
1198
|
+
// empty until the author clicks again. The registry's publish is the
|
|
1199
|
+
// moment to watch.
|
|
1200
|
+
const unsubscribe = subscribeProjectStoryModules(() => this.reconcileEchoAfterReload());
|
|
1201
|
+
this.disposeReloadSignal = unsubscribe;
|
|
1202
|
+
}
|
|
1203
|
+
if (this.writeBackend?.index) {
|
|
1204
|
+
this.writeBackend
|
|
1205
|
+
.index()
|
|
1206
|
+
.then((idx) => {
|
|
1207
|
+
this.oidIndex = new Map(Object.entries(idx));
|
|
1208
|
+
this.store.notifyIngestEdit();
|
|
1209
|
+
})
|
|
1210
|
+
.catch(() => {
|
|
1211
|
+
// Honest degradation: labels stay tag-only if the index can't be fetched.
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
private snapshot(): OidTree {
|
|
1217
|
+
this.projector.project(this.root);
|
|
1218
|
+
return oidTreeFromProjection({
|
|
1219
|
+
nodes: this.projector.nodes,
|
|
1220
|
+
rootIds: [...this.projector.rootIds],
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* One coherent tree for a synchronous hierarchy consumer. Composite roots
|
|
1226
|
+
* walk every child adapter by calling `node(id)` once per row; projecting the
|
|
1227
|
+
* complete DOM on every lookup turns that ordinary walk into O(rows²).
|
|
1228
|
+
* Inspector and write paths keep using {@link snapshot} directly, so only
|
|
1229
|
+
* hierarchy reads share this task-bounded view and every later task sees the
|
|
1230
|
+
* current DOM.
|
|
1231
|
+
*/
|
|
1232
|
+
private hierarchySnapshot(): OidTree {
|
|
1233
|
+
this.hierarchySnapshotCache ??= this.snapshot();
|
|
1234
|
+
if (!this.hierarchySnapshotClearQueued) {
|
|
1235
|
+
this.hierarchySnapshotClearQueued = true;
|
|
1236
|
+
queueMicrotask(() => {
|
|
1237
|
+
this.hierarchySnapshotCache = null;
|
|
1238
|
+
this.hierarchySnapshotClearQueued = false;
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
return this.hierarchySnapshotCache;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
private async refreshOidIndex(): Promise<void> {
|
|
1245
|
+
const index = await this.writeBackend?.index?.();
|
|
1246
|
+
if (!index) return;
|
|
1247
|
+
this.oidIndex = new Map(Object.entries(index));
|
|
1248
|
+
this.store.notifyIngestEdit();
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// --- A4: optimistic value echo (see {@link valueEcho}) ---
|
|
1252
|
+
|
|
1253
|
+
/** Record the value just committed by `inspector.set`, so `inspector.get(id, path)`
|
|
1254
|
+
* returns it immediately (before the async source-write + HMR land). */
|
|
1255
|
+
private setEcho(id: string, path: string, value: unknown): void {
|
|
1256
|
+
let byPath = this.valueEcho.get(id);
|
|
1257
|
+
if (!byPath) {
|
|
1258
|
+
byPath = new Map();
|
|
1259
|
+
this.valueEcho.set(id, byPath);
|
|
1260
|
+
}
|
|
1261
|
+
byPath.set(path, value);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/** Drop one echoed entry — used the instant a write is refused/no-op'd, so the field
|
|
1265
|
+
* reverts to the live-DOM (unchanged) value rather than sticking on a value the source
|
|
1266
|
+
* never took (the "stale in the other direction" guard for rejected writes). */
|
|
1267
|
+
private clearEcho(id: string, path: string): void {
|
|
1268
|
+
const byPath = this.valueEcho.get(id);
|
|
1269
|
+
if (!byPath) return;
|
|
1270
|
+
byPath.delete(path);
|
|
1271
|
+
if (byPath.size === 0) this.valueEcho.delete(id);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/** U4 — record that `id`'s `path` was REFUSED BY A SOURCE GUARD, with the
|
|
1275
|
+
* guard's own sentence, so the NEXT `properties()` call marks its descriptor
|
|
1276
|
+
* `readonly` and shows that sentence. The reason is carried rather than
|
|
1277
|
+
* re-derived because the guards differ: a dynamic `{expression}` and a
|
|
1278
|
+
* `var(--token)` reference are both un-overwritable literals to the writer
|
|
1279
|
+
* and completely different facts to the author. */
|
|
1280
|
+
private markGuarded(id: string, path: string, reason: string): void {
|
|
1281
|
+
this.guardedPaths.set(`${id}|${path}`, reason);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/** Read-through for `inspector.get`: `{hit:true}` when this id+path was optimistically
|
|
1285
|
+
* echoed and not yet reconciled; `{hit:false}` otherwise (fall back to the live DOM). A
|
|
1286
|
+
* distinct `hit` flag (not a sentinel value) so a legitimately-`undefined` echoed value
|
|
1287
|
+
* still wins over the live-DOM walk. */
|
|
1288
|
+
private readEcho(id: string, path: string): { hit: boolean; value: unknown } {
|
|
1289
|
+
const byPath = this.valueEcho.get(id);
|
|
1290
|
+
if (byPath?.has(path)) return { hit: true, value: byPath.get(path) };
|
|
1291
|
+
return { hit: false, value: undefined };
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* A4 — the "HMR reload landed" reconcile (fired by the constructor's `vite:afterUpdate`
|
|
1296
|
+
* hook, or called directly to simulate a landed reload). The echo held the freshly-set
|
|
1297
|
+
* values while the async source-write + HMR re-render were in flight; once HMR has
|
|
1298
|
+
* re-rendered the react tree the LIVE DOM is authoritative again — including any
|
|
1299
|
+
* server-side value coercion — so drop the whole echo and notify, and every open inspector
|
|
1300
|
+
* re-reads the now-updated DOM. (An unrelated module's `afterUpdate` that fires before THIS
|
|
1301
|
+
* edit's HMR is a benign race: the field momentarily re-reads the old DOM, then this
|
|
1302
|
+
* edit's own `afterUpdate` reconciles it — the echo self-heals on the next tick.)
|
|
1303
|
+
*
|
|
1304
|
+
* SELECTION IS NOT UNTOUCHED, and this comment used to say it was. The ids ARE
|
|
1305
|
+
* OID-signature ids in the store, re-resolvable against the remounted tree — but
|
|
1306
|
+
* MEASURED 2026-08-30 the remount empties the edit tab's selection set anyway, ~100-150ms
|
|
1307
|
+
* after the update lands, for a remount from ANY source (a hand edit of the file does it
|
|
1308
|
+
* with no inspector write involved). {@link scheduleSelectionRepair} is the repair, and
|
|
1309
|
+
* its doc comment carries the measurement and what is still unknown about the cause.
|
|
1310
|
+
*
|
|
1311
|
+
* D3.R4 (reopen fix), widened by D3.R5 — ALSO the "landed" signal for every pending
|
|
1312
|
+
* source write counted by {@link pendingSourceReconcile} (see its doc comment for the
|
|
1313
|
+
* full site list and the counter-vs-boolean rationale): each of those writes' own
|
|
1314
|
+
* `notifyIngestEdit()` fires before the HMR remount lands, so it never carries fresh
|
|
1315
|
+
* DOM shape on its own; this reconcile is what does, once the remount has actually
|
|
1316
|
+
* happened. Decrements the counter by exactly one per call (never resets it to zero)
|
|
1317
|
+
* so N pending writes drain over N reconciles, each one notifying — not just the
|
|
1318
|
+
* first.
|
|
1319
|
+
*/
|
|
1320
|
+
reconcileEchoAfterReload(): void {
|
|
1321
|
+
// BEFORE the early return: a re-projection triggered by a source edit this
|
|
1322
|
+
// adapter did NOT make (a hand edit, another lane's write) has neither an
|
|
1323
|
+
// echo nor a pending reconcile, and it drops the selection exactly the
|
|
1324
|
+
// same way.
|
|
1325
|
+
this.scheduleSelectionRepair();
|
|
1326
|
+
if (this.valueEcho.size === 0 && this.pendingSourceReconcile === 0) return;
|
|
1327
|
+
this.valueEcho.clear();
|
|
1328
|
+
if (this.pendingSourceReconcile > 0) this.pendingSourceReconcile--;
|
|
1329
|
+
this.store.notifyIngestEdit();
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* WATCH ONE RE-PROJECTION for the selection it drops, and put it back.
|
|
1334
|
+
*
|
|
1335
|
+
* MEASURED 2026-08-30, on a scaffolded project's dom root: any HMR remount
|
|
1336
|
+
* of the project's source empties the edit tab's selection set ~100-150ms
|
|
1337
|
+
* after the update lands — including a remount triggered by APPENDING A
|
|
1338
|
+
* COMMENT to the file, with no inspector write anywhere in the picture. The
|
|
1339
|
+
* ids themselves are stable across the remount (the same oid resolves
|
|
1340
|
+
* before and after), so nothing about the selection was invalidated; it was
|
|
1341
|
+
* simply lost. The reported symptom — a second inspector write "degrading"
|
|
1342
|
+
* to `live-only (not saved)`, or throwing "No Inspector subject is active."
|
|
1343
|
+
* — is that loss arriving at the write door.
|
|
1344
|
+
*
|
|
1345
|
+
* Which line empties the set is NOT yet pinned: `select(null)`,
|
|
1346
|
+
* `selectMultiple([])` and `applySelectionBeforePresentation([])` were each
|
|
1347
|
+
* traced live through a reproduction and NONE of them fires. So this repairs
|
|
1348
|
+
* the observable rather than the cause, and says so out loud when it does —
|
|
1349
|
+
* a selection that vanishes under an author's cursor is not something to fix
|
|
1350
|
+
* quietly.
|
|
1351
|
+
*
|
|
1352
|
+
* It is deliberately NOT a write QUEUE. Queueing the second write behind the
|
|
1353
|
+
* re-projection would not help: the selection is already gone when that
|
|
1354
|
+
* write is issued, so a queued write resolves against the same empty
|
|
1355
|
+
* subject. The seam that needs repairing is the selection, not the ordering.
|
|
1356
|
+
*
|
|
1357
|
+
* SELF-LIMITING, three ways: one watcher per re-projection, a bounded window
|
|
1358
|
+
* (the measured loss lands inside it), and a repair that only restores ids
|
|
1359
|
+
* which STILL RESOLVE in the remounted tree — so it can never fabricate a
|
|
1360
|
+
* selection, and never fights a legitimate deselect that happened outside
|
|
1361
|
+
* the window.
|
|
1362
|
+
*/
|
|
1363
|
+
private scheduleSelectionRepair(): void {
|
|
1364
|
+
if (this.selectionRepairWatching) return;
|
|
1365
|
+
if (this.lastResolvedSelection.length === 0) return;
|
|
1366
|
+
this.selectionRepairWatching = true;
|
|
1367
|
+
let attempts = 0;
|
|
1368
|
+
let reported = false;
|
|
1369
|
+
const tick = (): void => {
|
|
1370
|
+
attempts++;
|
|
1371
|
+
// The watcher runs the WHOLE window rather than stopping at the first
|
|
1372
|
+
// repair: one edit can produce several update events, and a repair
|
|
1373
|
+
// followed by a second remount that drops it again is the shape the
|
|
1374
|
+
// measurement showed. It reports once per watch, so a repeat is a
|
|
1375
|
+
// restore, not a second wall of console.
|
|
1376
|
+
reported = this.repairSelectionAfterReprojection(reported) || reported;
|
|
1377
|
+
if (attempts >= SELECTION_REPAIR_ATTEMPTS) {
|
|
1378
|
+
this.selectionRepairWatching = false;
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
setTimeout(tick, SELECTION_REPAIR_INTERVAL_MS);
|
|
1382
|
+
};
|
|
1383
|
+
setTimeout(tick, SELECTION_REPAIR_INTERVAL_MS);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* One repair attempt. Returns whether it SAID something, so the watcher can
|
|
1388
|
+
* report once and restore as many times as the re-projection needs.
|
|
1389
|
+
*
|
|
1390
|
+
* The remembered set is NOT consumed: `selection.get` overwrites it with
|
|
1391
|
+
* every non-empty selection it serves, so it always names the last selection
|
|
1392
|
+
* the author actually had, and a second drop inside the same window is
|
|
1393
|
+
* repaired from the same truth.
|
|
1394
|
+
*/
|
|
1395
|
+
private repairSelectionAfterReprojection(alreadyReported: boolean): boolean {
|
|
1396
|
+
const remembered = this.lastResolvedSelection;
|
|
1397
|
+
if (remembered.length === 0) return false;
|
|
1398
|
+
if (this.store.selectedEntityIds.size > 0) return false; // not lost (yet)
|
|
1399
|
+
const tree = this.snapshot();
|
|
1400
|
+
const alive = remembered.filter((id) => tree.nodes.has(id));
|
|
1401
|
+
if (alive.length === 0) {
|
|
1402
|
+
if (!alreadyReported) {
|
|
1403
|
+
console.warn(
|
|
1404
|
+
'[ReactRootAuthoringAdapter] the re-projection dropped the selection ' +
|
|
1405
|
+
`(${remembered.join(', ')}) and none of those ids resolve in the remounted tree, so ` +
|
|
1406
|
+
'it cannot be restored. An inspector write issued now has no subject — re-select.',
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
this.lastResolvedSelection = [];
|
|
1410
|
+
return true;
|
|
1411
|
+
}
|
|
1412
|
+
if (!alreadyReported && !reportedSelectionRepair) {
|
|
1413
|
+
reportedSelectionRepair = true;
|
|
1414
|
+
console.warn(
|
|
1415
|
+
'[ReactRootAuthoringAdapter] OPEN DEFECT, repaired: an HMR re-projection drops this ' +
|
|
1416
|
+
"root's selection, and the editor is putting it back — restoring " +
|
|
1417
|
+
`${alive.length} of ${remembered.length} id(s) that still resolve in the remounted ` +
|
|
1418
|
+
`tree (${alive.join(', ')}). A write issued in that window has no subject. Named once ` +
|
|
1419
|
+
'per session; the repair runs on every re-projection.',
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
this.store.selectMultiple([...alive]);
|
|
1423
|
+
return true;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/** A4 — release the `vite:afterUpdate` reconcile subscription. Idempotent; safe when no
|
|
1427
|
+
* HMR context was present (the disposer is `undefined`). */
|
|
1428
|
+
disposeReactRootAdapter(): void {
|
|
1429
|
+
this.disposeReloadSignal?.();
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
private componentName(n: OidNode): string | null {
|
|
1433
|
+
const entry = this.oidIndex.get(n.oid);
|
|
1434
|
+
return entry?.component ?? getReactComponentName(n.el) ?? null;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
private elementLabel(n: OidNode, tree: OidTree): string {
|
|
1438
|
+
// Preserve the explicit escape hatch, but make ordinary DOM semantics
|
|
1439
|
+
// sufficient by default. Authored React should not need component splits
|
|
1440
|
+
// or `data-vgai-name` merely to produce a legible hierarchy.
|
|
1441
|
+
const editorLabel = normalizeElementText(n.el.getAttribute('data-vgai-name'));
|
|
1442
|
+
if (editorLabel) return editorLabel;
|
|
1443
|
+
|
|
1444
|
+
const labelledBy = n.el.getAttribute('aria-labelledby');
|
|
1445
|
+
if (labelledBy) {
|
|
1446
|
+
const labels = labelledBy
|
|
1447
|
+
.split(/\s+/)
|
|
1448
|
+
.map((id) =>
|
|
1449
|
+
Array.from(tree.nodes.values()).find(
|
|
1450
|
+
(candidate) => candidate.el.getAttribute('id') === id,
|
|
1451
|
+
),
|
|
1452
|
+
)
|
|
1453
|
+
.map((candidate) => normalizeElementText(candidate?.el.textContent))
|
|
1454
|
+
.filter((label): label is string => label !== null);
|
|
1455
|
+
const combinedLabel = normalizeElementText(labels.join(' '));
|
|
1456
|
+
if (combinedLabel) return combinedLabel;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const accessibleLabel = normalizeElementText(n.el.getAttribute('aria-label'));
|
|
1460
|
+
if (accessibleLabel) return accessibleLabel;
|
|
1461
|
+
|
|
1462
|
+
// A semantic section without explicit ARIA normally takes its identity
|
|
1463
|
+
// from its heading. Restrict this to direct children so a large nested
|
|
1464
|
+
// subtree cannot accidentally lend an unrelated heading to its parent.
|
|
1465
|
+
if (['section', 'article', 'aside', 'nav', 'main', 'header', 'footer'].includes(n.tag)) {
|
|
1466
|
+
const heading = n.childIds
|
|
1467
|
+
.map((id) => tree.nodes.get(id))
|
|
1468
|
+
.find((child) => child !== undefined && /^h[1-6]$/.test(child.tag));
|
|
1469
|
+
const headingText = normalizeElementText(heading?.el.textContent);
|
|
1470
|
+
if (headingText) return headingText;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Text-bearing HTML elements are already named by their content in the
|
|
1474
|
+
// browser/accessibility model. Use the same identity, with a short cap so
|
|
1475
|
+
// a paragraph cannot turn into an unbounded hierarchy row.
|
|
1476
|
+
if (
|
|
1477
|
+
/^(h[1-6]|p|span|label|button|a|time|output|dt|dd|li|legend|caption|summary)$/.test(n.tag)
|
|
1478
|
+
) {
|
|
1479
|
+
const text = normalizeElementText(n.el.textContent);
|
|
1480
|
+
if (text) return text;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
for (const attribute of ['alt', 'title', 'placeholder', 'name']) {
|
|
1484
|
+
const value = normalizeElementText(n.el.getAttribute(attribute));
|
|
1485
|
+
if (value) return value;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const id = n.el.getAttribute('id');
|
|
1489
|
+
if (id) return `#${id}`;
|
|
1490
|
+
// A styled anonymous element names itself by its first class — the same
|
|
1491
|
+
// identity a stylesheet addresses it by. `.reticle-dot` beats a fifth
|
|
1492
|
+
// bare "span" row (blind-walk beat 2: finding the crosshair's dot meant
|
|
1493
|
+
// clicking five identical rows and reading W/H off each).
|
|
1494
|
+
const firstClass = (n.el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean)[0];
|
|
1495
|
+
if (firstClass) return `${n.tag}.${firstClass}`;
|
|
1496
|
+
return n.tag;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
private toEditorNode(n: OidNode, tree: OidTree = this.snapshot()): EditorNode {
|
|
1500
|
+
// A component name marks only the boundary where that component enters the
|
|
1501
|
+
// native tree. Descendants owned by the same component use their own DOM
|
|
1502
|
+
// identity, avoiding the old `Game2048Page:div` repeated on every row.
|
|
1503
|
+
const component = this.componentName(n);
|
|
1504
|
+
const parent = n.parentId ? tree.nodes.get(n.parentId) : null;
|
|
1505
|
+
const parentComponent = parent ? this.componentName(parent) : null;
|
|
1506
|
+
const isComponentBoundary = component !== null && component !== parentComponent;
|
|
1507
|
+
const crossSurfaceId = n.el.getAttribute('data-vgai-hierarchy-id');
|
|
1508
|
+
const crossSurfaceParentId = n.el.getAttribute('data-vgai-hierarchy-parent-id');
|
|
1509
|
+
const crossSurfaceOrderAttribute = n.el.getAttribute('data-vgai-hierarchy-order');
|
|
1510
|
+
const crossSurfaceGroupLabel = n.el.getAttribute('data-vgai-hierarchy-group-label');
|
|
1511
|
+
const crossSurfaceOrder =
|
|
1512
|
+
crossSurfaceOrderAttribute === null ? undefined : Number(crossSurfaceOrderAttribute);
|
|
1513
|
+
const node: EditorNode = {
|
|
1514
|
+
id: n.id,
|
|
1515
|
+
label: isComponentBoundary ? component : this.elementLabel(n, tree),
|
|
1516
|
+
...(isComponentBoundary ? { secondaryLabel: `<${n.tag}>` } : {}),
|
|
1517
|
+
role: isComponentBoundary ? 'component' : 'element',
|
|
1518
|
+
kind: n.tag,
|
|
1519
|
+
parentId: n.parentId,
|
|
1520
|
+
childIds: n.childIds,
|
|
1521
|
+
...(crossSurfaceId === null ? {} : { crossSurfaceId }),
|
|
1522
|
+
...(crossSurfaceParentId === null ? {} : { crossSurfaceParentId }),
|
|
1523
|
+
...(crossSurfaceOrder === undefined || !Number.isInteger(crossSurfaceOrder)
|
|
1524
|
+
? {}
|
|
1525
|
+
: { crossSurfaceOrder }),
|
|
1526
|
+
...(crossSurfaceGroupLabel === null ? {} : { crossSurfaceGroupLabel }),
|
|
1527
|
+
};
|
|
1528
|
+
if (this.portableStories.length > 0 && node.parentId === null) {
|
|
1529
|
+
return { ...node, parentId: this.activeStoryId };
|
|
1530
|
+
}
|
|
1531
|
+
return node;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/** Exact DOM-native input signature for the composite's semantic join.
|
|
1535
|
+
* Shell notifications unrelated to this root leave it unchanged. The
|
|
1536
|
+
* nearest semantic ancestor and traversal order are included because
|
|
1537
|
+
* transparent DOM wrappers fold between explicitly identified rows. */
|
|
1538
|
+
private crossSurfaceStructureSignature(): string | null {
|
|
1539
|
+
const tree = this.hierarchySnapshot();
|
|
1540
|
+
const semanticIds = new Set<string>();
|
|
1541
|
+
for (const node of tree.nodes.values()) {
|
|
1542
|
+
if (
|
|
1543
|
+
node.el.getAttribute('data-vgai-hierarchy-id') !== null ||
|
|
1544
|
+
node.el.getAttribute('data-vgai-hierarchy-parent-id') !== null ||
|
|
1545
|
+
node.el.getAttribute('data-vgai-hierarchy-order') !== null ||
|
|
1546
|
+
node.el.getAttribute('data-vgai-hierarchy-group-label') !== null
|
|
1547
|
+
) {
|
|
1548
|
+
semanticIds.add(node.id);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
if (semanticIds.size === 0) return null;
|
|
1552
|
+
return JSON.stringify(
|
|
1553
|
+
[...tree.nodes.values()].flatMap((node) => {
|
|
1554
|
+
if (!semanticIds.has(node.id)) return [];
|
|
1555
|
+
let parentId = node.parentId;
|
|
1556
|
+
while (parentId !== null && !semanticIds.has(parentId)) {
|
|
1557
|
+
parentId = tree.nodes.get(parentId)?.parentId ?? null;
|
|
1558
|
+
}
|
|
1559
|
+
return [
|
|
1560
|
+
[
|
|
1561
|
+
node.id,
|
|
1562
|
+
parentId,
|
|
1563
|
+
node.el.getAttribute('data-vgai-hierarchy-id'),
|
|
1564
|
+
node.el.getAttribute('data-vgai-hierarchy-parent-id'),
|
|
1565
|
+
node.el.getAttribute('data-vgai-hierarchy-order'),
|
|
1566
|
+
node.el.getAttribute('data-vgai-hierarchy-group-label'),
|
|
1567
|
+
],
|
|
1568
|
+
];
|
|
1569
|
+
}),
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
private portableDocumentNode(document: (typeof this.portableDocuments)[number]): EditorNode {
|
|
1574
|
+
return {
|
|
1575
|
+
id: document.id,
|
|
1576
|
+
label: document.label,
|
|
1577
|
+
...(document.path ? { secondaryLabel: document.path } : {}),
|
|
1578
|
+
role: 'document',
|
|
1579
|
+
kind: 'tsx',
|
|
1580
|
+
parentId: null,
|
|
1581
|
+
childIds: [...document.storyIds],
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
private portableDocumentForStory(storyId: string) {
|
|
1586
|
+
return this.portableDocuments.find((document) => document.storyIds.includes(storyId)) ?? null;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
private portableHierarchyNode(id: string) {
|
|
1590
|
+
return this.portableHierarchy?.nodes.find((node) => node.id === id) ?? null;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
private portableStoriesForNode(nodeId: string): StoryRef[] {
|
|
1594
|
+
// The empty id is NOT a node — it is the world-level question
|
|
1595
|
+
// ({@link WORLD_SCOPE_NODE_ID}), and this provider's answer to it is its
|
|
1596
|
+
// whole list, because its WRITES are world-level: `active`/`apply` ignore
|
|
1597
|
+
// the node id, so applying any story remounts the whole react root. Left to
|
|
1598
|
+
// the per-node path below it fell into the "unrecognized node" branch and
|
|
1599
|
+
// answered whatever the ACTIVE story's component owns — a scope answer that
|
|
1600
|
+
// changed with UI state, so the shell's probe classified this adapter
|
|
1601
|
+
// differently depending on what was mounted.
|
|
1602
|
+
if (nodeId === WORLD_SCOPE_NODE_ID) return [...this.portableStories];
|
|
1603
|
+
if (!this.portableHierarchy) return [...this.portableStories];
|
|
1604
|
+
const hierarchyNode = this.portableHierarchyNode(nodeId);
|
|
1605
|
+
if (hierarchyNode && hierarchyNode.role !== 'component') return [];
|
|
1606
|
+
const componentId =
|
|
1607
|
+
hierarchyNode?.role === 'component'
|
|
1608
|
+
? hierarchyNode.id
|
|
1609
|
+
: (this.portableHierarchy.storyParentIds.get(nodeId) ??
|
|
1610
|
+
this.portableHierarchy.storyParentIds.get(this.activeStoryId ?? ''));
|
|
1611
|
+
if (!componentId) return [];
|
|
1612
|
+
const component = this.portableHierarchyNode(componentId);
|
|
1613
|
+
const storyIds = new Set(component?.childIds ?? []);
|
|
1614
|
+
return this.portableStories.filter((story) => storyIds.has(story.id));
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
private portableStoryNode(story: StoryRef, rootIds: string[]): EditorNode {
|
|
1618
|
+
const active = story.id === this.activeStoryId;
|
|
1619
|
+
return {
|
|
1620
|
+
id: story.id,
|
|
1621
|
+
label: story.label,
|
|
1622
|
+
role: 'story',
|
|
1623
|
+
kind: 'story',
|
|
1624
|
+
parentId:
|
|
1625
|
+
this.portableHierarchy?.storyParentIds.get(story.id) ??
|
|
1626
|
+
this.portableDocumentForStory(story.id)?.id ??
|
|
1627
|
+
PORTABLE_DOCUMENT_ID,
|
|
1628
|
+
childIds: active ? rootIds : [],
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
readonly hierarchy: HierarchyProvider = {
|
|
1633
|
+
crossSurfaceStructureSignature: () => this.crossSurfaceStructureSignature(),
|
|
1634
|
+
roots: () => {
|
|
1635
|
+
const tree = this.hierarchySnapshot();
|
|
1636
|
+
const { nodes, rootIds } = tree;
|
|
1637
|
+
if (this.portableStories.length > 0) {
|
|
1638
|
+
if (this.portableHierarchy) {
|
|
1639
|
+
return this.portableHierarchy.rootIds.flatMap((id) => {
|
|
1640
|
+
const node = this.portableHierarchy?.nodes.find((candidate) => candidate.id === id);
|
|
1641
|
+
return node ? [{ ...node, childIds: [...node.childIds] }] : [];
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
return this.portableDocuments.map((document) => this.portableDocumentNode(document));
|
|
1645
|
+
}
|
|
1646
|
+
return rootIds.map((id) => this.toEditorNode(nodes.get(id)!, tree));
|
|
1647
|
+
},
|
|
1648
|
+
node: (id) => {
|
|
1649
|
+
if (this.portableStories.length > 0) {
|
|
1650
|
+
const hierarchyNode = this.portableHierarchy?.nodes.find(
|
|
1651
|
+
(candidate) => candidate.id === id,
|
|
1652
|
+
);
|
|
1653
|
+
if (hierarchyNode) return { ...hierarchyNode, childIds: [...hierarchyNode.childIds] };
|
|
1654
|
+
const document = this.portableDocuments.find((candidate) => candidate.id === id);
|
|
1655
|
+
if (document) return this.portableDocumentNode(document);
|
|
1656
|
+
const story = this.portableStories.find((candidate) => candidate.id === id);
|
|
1657
|
+
if (story) return this.portableStoryNode(story, this.hierarchySnapshot().rootIds);
|
|
1658
|
+
}
|
|
1659
|
+
const tree = this.hierarchySnapshot();
|
|
1660
|
+
const n = tree.nodes.get(id);
|
|
1661
|
+
return n ? this.toEditorNode(n, tree) : null;
|
|
1662
|
+
},
|
|
1663
|
+
// No `object3D`/`idForObject3D`: react entities are DOM, not Object3D —
|
|
1664
|
+
// the concept does not apply here (no 3D gizmo binding).
|
|
1665
|
+
};
|
|
1666
|
+
|
|
1667
|
+
readonly selection: SelectionProvider = {
|
|
1668
|
+
get: () => {
|
|
1669
|
+
const ids = [...this.store.selectedEntityIds];
|
|
1670
|
+
// Remember the last NON-EMPTY selection so a re-projection that drops it
|
|
1671
|
+
// can be repaired — see `repairSelectionAfterReprojection`. Recorded on
|
|
1672
|
+
// the read because that is the one call every projection of the
|
|
1673
|
+
// selection already makes; the adapter has no store subscription of its
|
|
1674
|
+
// own and adding one to observe a value it is handed anyway would be a
|
|
1675
|
+
// second source of the same fact.
|
|
1676
|
+
if (ids.length > 0) this.lastResolvedSelection = ids;
|
|
1677
|
+
return ids;
|
|
1678
|
+
},
|
|
1679
|
+
set: (ids) => this.store.selectMultiple(ids),
|
|
1680
|
+
resolve: (rawId, options) => {
|
|
1681
|
+
const raw = this.hierarchy.node(rawId);
|
|
1682
|
+
if (!raw) return null;
|
|
1683
|
+
|
|
1684
|
+
const innerToOuter: EditorNode[] = [];
|
|
1685
|
+
let cursor: EditorNode | null = raw;
|
|
1686
|
+
let guard = 0;
|
|
1687
|
+
while (cursor && guard++ < 1000) {
|
|
1688
|
+
if (
|
|
1689
|
+
cursor.role === 'component' ||
|
|
1690
|
+
cursor.role === 'instance' ||
|
|
1691
|
+
cursor.role === 'boundary'
|
|
1692
|
+
) {
|
|
1693
|
+
innerToOuter.push(cursor);
|
|
1694
|
+
}
|
|
1695
|
+
cursor = cursor.parentId ? this.hierarchy.node(cursor.parentId) : null;
|
|
1696
|
+
}
|
|
1697
|
+
const chain = innerToOuter.reverse();
|
|
1698
|
+
if (chain.length === 0) {
|
|
1699
|
+
return { id: rawId };
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
const scopeIndex = options?.scopeId
|
|
1703
|
+
? chain.findIndex((candidate) => candidate.id === options.scopeId)
|
|
1704
|
+
: -1;
|
|
1705
|
+
let resolvedIndex = scopeIndex >= 0 ? scopeIndex + 1 : 0;
|
|
1706
|
+
if (options?.intent === 'deep') resolvedIndex += 1;
|
|
1707
|
+
|
|
1708
|
+
// Unlike the R3F projection, React keeps authored host elements in the
|
|
1709
|
+
// hierarchy. Once every component boundary on the route is open, the
|
|
1710
|
+
// exact DOM element becomes the honest selection subject.
|
|
1711
|
+
const subject = chain[resolvedIndex] ?? raw;
|
|
1712
|
+
return { id: subject.id };
|
|
1713
|
+
},
|
|
1714
|
+
};
|
|
1715
|
+
|
|
1716
|
+
readonly truth: TruthProvider = {
|
|
1717
|
+
resolve: (id): { site: NodeCreationSite; writeAnchorKind: WriteAnchorKind | undefined } => {
|
|
1718
|
+
const node = this.snapshot().nodes.get(id);
|
|
1719
|
+
if (!node) {
|
|
1720
|
+
return {
|
|
1721
|
+
site: { anchored: false, reason: 'This row is a design document, not a JSX element.' },
|
|
1722
|
+
writeAnchorKind: undefined,
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
const entry = this.oidIndex.get(node.oid);
|
|
1726
|
+
const writeAnchorKind = this.writeBackend ? 'source-prop' : 'live-only';
|
|
1727
|
+
if (!entry) {
|
|
1728
|
+
return {
|
|
1729
|
+
site: { anchored: false, reason: 'Source metadata is still loading or unavailable.' },
|
|
1730
|
+
writeAnchorKind,
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
const file = projectSourcePath(entry.file);
|
|
1734
|
+
return {
|
|
1735
|
+
site: {
|
|
1736
|
+
anchored: true,
|
|
1737
|
+
kind: 'source',
|
|
1738
|
+
file,
|
|
1739
|
+
line: entry.line,
|
|
1740
|
+
col: entry.col,
|
|
1741
|
+
display: `${file}:${entry.line}`,
|
|
1742
|
+
},
|
|
1743
|
+
writeAnchorKind,
|
|
1744
|
+
};
|
|
1745
|
+
},
|
|
1746
|
+
};
|
|
1747
|
+
|
|
1748
|
+
readonly related: RelatedSubjectsProvider = {
|
|
1749
|
+
links: (id) => {
|
|
1750
|
+
const node = this.hierarchy.node(id);
|
|
1751
|
+
if (node?.role !== 'component' || this.stories.storiesFor(id).length === 0) return [];
|
|
1752
|
+
return [
|
|
1753
|
+
{
|
|
1754
|
+
// `definition:` marks this as the subject's OPEN-FOR-EDIT target (the
|
|
1755
|
+
// component's own UI Components document), so the inspector surfaces it
|
|
1756
|
+
// as the labeled Edit button by kind, never by list position.
|
|
1757
|
+
id: `definition:${UI_COMPONENTS_DOCUMENT_ID}`,
|
|
1758
|
+
title: 'Open in UI Components',
|
|
1759
|
+
open: () => {
|
|
1760
|
+
void activateWorkspaceDocument(UI_COMPONENTS_DOCUMENT_ID);
|
|
1761
|
+
},
|
|
1762
|
+
},
|
|
1763
|
+
];
|
|
1764
|
+
},
|
|
1765
|
+
};
|
|
1766
|
+
|
|
1767
|
+
private instanceSource(id: string): {
|
|
1768
|
+
node: OidNode;
|
|
1769
|
+
callSiteOid: string;
|
|
1770
|
+
callSite: OidEntry;
|
|
1771
|
+
props: Record<string, string>;
|
|
1772
|
+
} | null {
|
|
1773
|
+
const node = this.snapshot().nodes.get(id);
|
|
1774
|
+
if (!node || this.hierarchy.node(id)?.role !== 'component') return null;
|
|
1775
|
+
const component = getComponentProps(node.el);
|
|
1776
|
+
if (!component) return null;
|
|
1777
|
+
const callSite = this.oidIndex.get(component.callSiteOid);
|
|
1778
|
+
if (!callSite || !/^[A-Z]/.test(callSite.tag)) return null;
|
|
1779
|
+
return {
|
|
1780
|
+
node,
|
|
1781
|
+
callSiteOid: component.callSiteOid,
|
|
1782
|
+
callSite,
|
|
1783
|
+
props: component.props,
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
private instanceProp(
|
|
1788
|
+
id: string,
|
|
1789
|
+
path: string,
|
|
1790
|
+
): {
|
|
1791
|
+
source: NonNullable<ReturnType<ReactRootAuthoringAdapter['instanceSource']>>;
|
|
1792
|
+
prop: string;
|
|
1793
|
+
spec: ComponentPropSpec;
|
|
1794
|
+
authored: NonNullable<OidEntry['authoredProps']>[number];
|
|
1795
|
+
} | null {
|
|
1796
|
+
if (!path.startsWith(PROP_PATH_PREFIX)) return null;
|
|
1797
|
+
const source = this.instanceSource(id);
|
|
1798
|
+
if (!source) return null;
|
|
1799
|
+
const prop = path.slice(PROP_PATH_PREFIX.length);
|
|
1800
|
+
const spec = source.callSite.props?.find((candidate) => candidate.name === prop);
|
|
1801
|
+
const authored = source.callSite.authoredProps?.find((candidate) => candidate.name === prop);
|
|
1802
|
+
return spec && authored ? { source, prop, spec, authored } : null;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
private affectedInstances(id: string, prop: string): number {
|
|
1806
|
+
const selected = this.instanceSource(id);
|
|
1807
|
+
if (!selected) return 0;
|
|
1808
|
+
let count = 0;
|
|
1809
|
+
for (const candidate of this.snapshot().nodes.values()) {
|
|
1810
|
+
const component = getComponentProps(candidate.el);
|
|
1811
|
+
if (!component) continue;
|
|
1812
|
+
const entry = this.oidIndex.get(component.callSiteOid);
|
|
1813
|
+
if (!entry || entry.tag !== selected.callSite.tag) continue;
|
|
1814
|
+
if (candidate.id === id || !entry.authoredProps?.some((item) => item.name === prop)) count++;
|
|
1815
|
+
}
|
|
1816
|
+
return Math.max(1, count);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
readonly instances: ComponentInstancesProvider = {
|
|
1820
|
+
openComponent: () => {
|
|
1821
|
+
void activateWorkspaceDocument(UI_COMPONENTS_DOCUMENT_ID);
|
|
1822
|
+
},
|
|
1823
|
+
describe: (id) => {
|
|
1824
|
+
const source = this.instanceSource(id);
|
|
1825
|
+
if (!source || this.stories.storiesFor(id).length === 0) return null;
|
|
1826
|
+
const overrides = Object.entries(source.props).flatMap(([prop, value]) => {
|
|
1827
|
+
const path = `${PROP_PATH_PREFIX}${prop}`;
|
|
1828
|
+
const detail = this.instanceProp(id, path);
|
|
1829
|
+
if (!detail) return [];
|
|
1830
|
+
const canApplyToComponent =
|
|
1831
|
+
detail.authored.literal &&
|
|
1832
|
+
detail.spec.defaultValue !== undefined &&
|
|
1833
|
+
source.node.oid !== source.callSiteOid &&
|
|
1834
|
+
!!this.writeBackend?.runGesture &&
|
|
1835
|
+
!!this.writeBackend.writeComponentDefault &&
|
|
1836
|
+
!!this.writeBackend.removeProp;
|
|
1837
|
+
return [
|
|
1838
|
+
{
|
|
1839
|
+
path,
|
|
1840
|
+
label: prop,
|
|
1841
|
+
value,
|
|
1842
|
+
...(detail.spec.defaultText === undefined
|
|
1843
|
+
? {}
|
|
1844
|
+
: { defaultText: detail.spec.defaultText }),
|
|
1845
|
+
canApplyToComponent,
|
|
1846
|
+
affectedInstanceCount: this.affectedInstances(id, prop),
|
|
1847
|
+
...(canApplyToComponent
|
|
1848
|
+
? {}
|
|
1849
|
+
: {
|
|
1850
|
+
applyUnavailableReason: !detail.authored.literal
|
|
1851
|
+
? 'The callsite value is a dynamic expression, so it cannot become a component literal.'
|
|
1852
|
+
: detail.spec.defaultValue === undefined
|
|
1853
|
+
? 'The component default is computed or absent, so source cannot be changed safely.'
|
|
1854
|
+
: 'This session cannot atomically update the component and its callsite.',
|
|
1855
|
+
}),
|
|
1856
|
+
},
|
|
1857
|
+
];
|
|
1858
|
+
});
|
|
1859
|
+
return {
|
|
1860
|
+
componentName: source.callSite.tag,
|
|
1861
|
+
sourcePath: projectSourcePath(source.callSite.file),
|
|
1862
|
+
overrides,
|
|
1863
|
+
};
|
|
1864
|
+
},
|
|
1865
|
+
revert: async (id, paths) => {
|
|
1866
|
+
const source = this.instanceSource(id);
|
|
1867
|
+
const backend = this.writeBackend;
|
|
1868
|
+
if (!source || !backend?.removeProp) return;
|
|
1869
|
+
const props = paths
|
|
1870
|
+
.map((path) => this.instanceProp(id, path)?.prop)
|
|
1871
|
+
.filter((prop): prop is string => !!prop);
|
|
1872
|
+
let changed = false;
|
|
1873
|
+
const remove = async (selected: SourceWriteBackend): Promise<void> => {
|
|
1874
|
+
for (const prop of props) {
|
|
1875
|
+
const result = await selected.removeProp?.(source.callSiteOid, prop);
|
|
1876
|
+
changed ||= result?.changed === true;
|
|
1877
|
+
}
|
|
1878
|
+
};
|
|
1879
|
+
if (props.length > 1 && backend.runGesture) {
|
|
1880
|
+
await backend.runGesture(`Revert ${props.length} Overrides`, remove);
|
|
1881
|
+
} else {
|
|
1882
|
+
await remove(backend);
|
|
1883
|
+
}
|
|
1884
|
+
if (!changed) return;
|
|
1885
|
+
this.dirty = true;
|
|
1886
|
+
this.pendingSourceReconcile++;
|
|
1887
|
+
await this.refreshOidIndex();
|
|
1888
|
+
return { destination: JSX_SOURCE_DESTINATION, persisted: true };
|
|
1889
|
+
},
|
|
1890
|
+
applyToComponent: async (id, path): Promise<ComponentInstanceApplyResult> => {
|
|
1891
|
+
const detail = this.instanceProp(id, path);
|
|
1892
|
+
const backend = this.writeBackend;
|
|
1893
|
+
if (
|
|
1894
|
+
!detail ||
|
|
1895
|
+
!detail.authored.literal ||
|
|
1896
|
+
detail.spec.defaultValue === undefined ||
|
|
1897
|
+
detail.source.node.oid === detail.source.callSiteOid ||
|
|
1898
|
+
!backend?.runGesture ||
|
|
1899
|
+
!backend.writeComponentDefault ||
|
|
1900
|
+
!backend.removeProp
|
|
1901
|
+
) {
|
|
1902
|
+
return {
|
|
1903
|
+
changed: false,
|
|
1904
|
+
message: 'Apply was refused because both literal source writes are not available.',
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
const value = detail.source.props[detail.prop];
|
|
1908
|
+
await backend.runGesture(
|
|
1909
|
+
`Apply ${detail.prop} to ${detail.source.callSite.tag}`,
|
|
1910
|
+
async (scoped) => {
|
|
1911
|
+
const applied = await scoped.writeComponentDefault?.(
|
|
1912
|
+
detail.source.node.oid,
|
|
1913
|
+
detail.prop,
|
|
1914
|
+
serializedComponentValue(value, detail.spec),
|
|
1915
|
+
);
|
|
1916
|
+
if (!applied?.changed) {
|
|
1917
|
+
throw new Error(applied?.error ?? 'The component default did not change.');
|
|
1918
|
+
}
|
|
1919
|
+
const reverted = await scoped.removeProp?.(detail.source.callSiteOid, detail.prop);
|
|
1920
|
+
if (!reverted?.changed) {
|
|
1921
|
+
throw new Error(reverted?.error ?? 'The callsite override was not removed.');
|
|
1922
|
+
}
|
|
1923
|
+
},
|
|
1924
|
+
);
|
|
1925
|
+
this.dirty = true;
|
|
1926
|
+
this.pendingSourceReconcile++;
|
|
1927
|
+
await this.refreshOidIndex();
|
|
1928
|
+
return {
|
|
1929
|
+
changed: true,
|
|
1930
|
+
message: `${detail.prop} now defaults to ${String(value)} in ${detail.source.callSite.tag}.`,
|
|
1931
|
+
write: { destination: JSX_SOURCE_DESTINATION, persisted: true },
|
|
1932
|
+
};
|
|
1933
|
+
},
|
|
1934
|
+
};
|
|
1935
|
+
|
|
1936
|
+
/**
|
|
1937
|
+
* D12 (B4) — GEOMETRIC rect hit-test over the live OID tree, NOT
|
|
1938
|
+
* `document.elementFromPoint` (which SKIPS a `pointer-events:none` wrapper —
|
|
1939
|
+
* this layer's resting CSS state at design time, `design-time-layers.ts`'s
|
|
1940
|
+
* `applySessionStyle`). Reuses `ui-editor/react-store.ts`'s established
|
|
1941
|
+
* `hitTestAttr` ranking rule (smallest-area element containing the point
|
|
1942
|
+
* wins — the deepest/most-specific node; ties broken by later tree-walk
|
|
1943
|
+
* order — the topmost) over each OID node's own `getBoundingClientRect()`,
|
|
1944
|
+
* inlined here rather than sharing that function directly since this walks
|
|
1945
|
+
* `OidNode`s already resolved by `walkOidTree`, not a live `querySelectorAll`
|
|
1946
|
+
* over a DOM attribute string.
|
|
1947
|
+
*
|
|
1948
|
+
* An element that EXPLICITLY opts out with inline `pointer-events:none` is
|
|
1949
|
+
* skipped. The design-time host itself is `pointer-events:none`, so checking
|
|
1950
|
+
* computed style would incorrectly inherit that host policy into every
|
|
1951
|
+
* authorable descendant. Reading the node's own declaration instead preserves
|
|
1952
|
+
* click-to-author descendants while making an authored full-viewport
|
|
1953
|
+
* click-through wrapper behave as click-through here too.
|
|
1954
|
+
*
|
|
1955
|
+
* The winning id is the SAME disambiguated id `hierarchy`/`selection`
|
|
1956
|
+
* already use (this IS `walkOidTree`'s own id space) — a hit routes
|
|
1957
|
+
* straight into `composite.selection.set([id])` with no translation. A
|
|
1958
|
+
* catalog node (`catalog:<key>`) has no DOM element behind it at all, so it
|
|
1959
|
+
* can never be a candidate here — only ordinary OID nodes are walked
|
|
1960
|
+
* (correct: nothing on screen corresponds to a bare catalog entry).
|
|
1961
|
+
*/
|
|
1962
|
+
readonly pickable: PickProvider = {
|
|
1963
|
+
pick: (clientX, clientY) => {
|
|
1964
|
+
const { nodes } = this.snapshot();
|
|
1965
|
+
let bestId: string | null = null;
|
|
1966
|
+
let bestArea = Number.POSITIVE_INFINITY;
|
|
1967
|
+
let bestOrder = -1;
|
|
1968
|
+
let order = -1;
|
|
1969
|
+
for (const node of nodes.values()) {
|
|
1970
|
+
order++;
|
|
1971
|
+
// D4.R1 — a locked node is SKIPPED, not returned: exactly the
|
|
1972
|
+
// `viewport-raycast.ts` first-party semantics ("skip locked
|
|
1973
|
+
// entities in viewport selection", falling through to whatever
|
|
1974
|
+
// unlocked node is behind/around it). See `lockedIds`'s doc
|
|
1975
|
+
// comment for why this Set, not a real inspector-backed field.
|
|
1976
|
+
if (this.lockedIds.has(node.id)) continue;
|
|
1977
|
+
if (styleProp(node.el.style, 'pointerEvents') === 'none') continue;
|
|
1978
|
+
const rect = node.el.getBoundingClientRect?.();
|
|
1979
|
+
if (!rect) continue; // no live rect (test fixture, or unmounted) — never a candidate
|
|
1980
|
+
if (
|
|
1981
|
+
clientX < rect.left ||
|
|
1982
|
+
clientX > rect.right ||
|
|
1983
|
+
clientY < rect.top ||
|
|
1984
|
+
clientY > rect.bottom
|
|
1985
|
+
) {
|
|
1986
|
+
continue;
|
|
1987
|
+
}
|
|
1988
|
+
const area = rect.width * rect.height;
|
|
1989
|
+
// smallest-area (deepest) wins; ties broken by later tree-order (topmost)
|
|
1990
|
+
if (area < bestArea || (area === bestArea && order > bestOrder)) {
|
|
1991
|
+
bestId = node.id;
|
|
1992
|
+
bestArea = area;
|
|
1993
|
+
bestOrder = order;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
return bestId === null ? null : this.resolvePlacementUnit(bestId);
|
|
1997
|
+
},
|
|
1998
|
+
};
|
|
1999
|
+
|
|
2000
|
+
/**
|
|
2001
|
+
* The pasteboard's PICK RULE: a hit anywhere inside an `<At>` subtree
|
|
2002
|
+
* selects the At — the placement unit whose `x`/`y` a drag writes — never
|
|
2003
|
+
* the helper's internals. Without this, the deepest-wins rule above picked
|
|
2004
|
+
* a Swatch's inner chip div (a STATIC node whose x/y drag drops by design),
|
|
2005
|
+
* and the drag gesture that followed re-picked it out from under the
|
|
2006
|
+
* selected At on pointerdown, so no pasteboard drag could ever commit
|
|
2007
|
+
* (measured on the moodboard acceptance run, 2026-08-30). Deeper selection
|
|
2008
|
+
* stays reachable through the hierarchy panel, which projects the full
|
|
2009
|
+
* subtree. Structural (`data-vgai-at`), so it costs nothing outside
|
|
2010
|
+
* pasteboard content.
|
|
2011
|
+
*/
|
|
2012
|
+
private resolvePlacementUnit(pickedId: string): string {
|
|
2013
|
+
const { nodes } = this.snapshot();
|
|
2014
|
+
const picked = nodes.get(pickedId);
|
|
2015
|
+
const el = picked?.el as unknown as
|
|
2016
|
+
| { closest?(selector: string): unknown; getAttribute?(name: string): string | null }
|
|
2017
|
+
| undefined;
|
|
2018
|
+
if (!el?.closest) return pickedId;
|
|
2019
|
+
if (el.getAttribute?.('data-vgai-at') === 'true') return pickedId;
|
|
2020
|
+
const at = el.closest('[data-vgai-at="true"]');
|
|
2021
|
+
if (!at) return pickedId;
|
|
2022
|
+
for (const [id, node] of nodes) {
|
|
2023
|
+
if ((node.el as unknown) === at) return id;
|
|
2024
|
+
}
|
|
2025
|
+
return pickedId;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
/** Host rect to subtract for {@link rects}' HOST-RELATIVE geometry — `this.root`
|
|
2029
|
+
* IS the world's own mounted DOM layer (`world.mounted.container` / the ingest
|
|
2030
|
+
* sibling's `layer`, see the constructor call sites), i.e. exactly the
|
|
2031
|
+
* `position:absolute; inset:0` per-world surface the overlay (Phase A3) is
|
|
2032
|
+
* itself hosted over. No new constructor option is needed — reusing the
|
|
2033
|
+
* proven `react-store.ts` `nodeRect` pattern (element rect minus stage/host
|
|
2034
|
+
* rect) against the field this adapter already holds. */
|
|
2035
|
+
private hostRect(): { left: number; top: number } {
|
|
2036
|
+
const r = this.root.getBoundingClientRect?.();
|
|
2037
|
+
return { left: r?.left ?? 0, top: r?.top ?? 0 };
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
private toHostRelative(r: {
|
|
2041
|
+
left: number;
|
|
2042
|
+
top: number;
|
|
2043
|
+
width: number;
|
|
2044
|
+
height: number;
|
|
2045
|
+
}): DOMRectLike {
|
|
2046
|
+
const host = this.hostRect();
|
|
2047
|
+
const zoom = getRootPan().zoom;
|
|
2048
|
+
return {
|
|
2049
|
+
x: (r.left - host.left) / zoom,
|
|
2050
|
+
y: (r.top - host.top) / zoom,
|
|
2051
|
+
width: r.width / zoom,
|
|
2052
|
+
height: r.height / zoom,
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
/**
|
|
2057
|
+
* T0 (spec 27 §2) — per-node screen geometry for the DOM visual editor's
|
|
2058
|
+
* overlay/snap/measure math. `rect`/`contextRects` return HOST-RELATIVE
|
|
2059
|
+
* coordinates (see {@link hostRect}'s doc comment) — the overlay this feeds
|
|
2060
|
+
* (Phase A3, `RootSelectionOverlay`) is itself mounted as a
|
|
2061
|
+
* `position:absolute; inset:0` layer over the same per-world DOM host, so a
|
|
2062
|
+
* host-relative rect is exactly what it can draw against with no further
|
|
2063
|
+
* translation (matching the established `ui-editor/react-store.ts`
|
|
2064
|
+
* `nodeRect` pattern this reuses).
|
|
2065
|
+
*/
|
|
2066
|
+
readonly rects: RectProvider = {
|
|
2067
|
+
rect: (id) => {
|
|
2068
|
+
const n = this.snapshot().nodes.get(id);
|
|
2069
|
+
const r = n?.el.getBoundingClientRect?.();
|
|
2070
|
+
return r ? this.toHostRelative(r) : null;
|
|
2071
|
+
},
|
|
2072
|
+
contextRects: (id) => {
|
|
2073
|
+
const { nodes } = this.snapshot();
|
|
2074
|
+
const n = nodes.get(id);
|
|
2075
|
+
if (!n) return {};
|
|
2076
|
+
const parentNode = n.parentId ? nodes.get(n.parentId) : undefined;
|
|
2077
|
+
const parentRect = parentNode?.el.getBoundingClientRect?.();
|
|
2078
|
+
const siblingIds = (parentNode ? parentNode.childIds : this.snapshot().rootIds).filter(
|
|
2079
|
+
(sid) => sid !== id,
|
|
2080
|
+
);
|
|
2081
|
+
const siblings = siblingIds
|
|
2082
|
+
.map((sid) => nodes.get(sid)?.el.getBoundingClientRect?.())
|
|
2083
|
+
.filter((r): r is NonNullable<typeof r> => r != null)
|
|
2084
|
+
.map((r) => this.toHostRelative(r));
|
|
2085
|
+
// Padding box (CSS box model, inside the border) — border widths read off
|
|
2086
|
+
// the same computed-style resolver the inspector uses; absent/unparsable
|
|
2087
|
+
// border widths degrade to 0 (padding box === border box), never thrown.
|
|
2088
|
+
const el = n.el;
|
|
2089
|
+
const rect = el.getBoundingClientRect?.();
|
|
2090
|
+
let paddingBox: DOMRectLike | undefined;
|
|
2091
|
+
if (rect) {
|
|
2092
|
+
const bt =
|
|
2093
|
+
numericStyleValue(getComputedStyleValue(el, 'borderTopWidth', this.computedStyle)) ?? 0;
|
|
2094
|
+
const br =
|
|
2095
|
+
numericStyleValue(getComputedStyleValue(el, 'borderRightWidth', this.computedStyle)) ?? 0;
|
|
2096
|
+
const bb =
|
|
2097
|
+
numericStyleValue(getComputedStyleValue(el, 'borderBottomWidth', this.computedStyle)) ??
|
|
2098
|
+
0;
|
|
2099
|
+
const bl =
|
|
2100
|
+
numericStyleValue(getComputedStyleValue(el, 'borderLeftWidth', this.computedStyle)) ?? 0;
|
|
2101
|
+
paddingBox = this.toHostRelative({
|
|
2102
|
+
left: rect.left + bl,
|
|
2103
|
+
top: rect.top + bt,
|
|
2104
|
+
width: Math.max(0, rect.width - bl - br),
|
|
2105
|
+
height: Math.max(0, rect.height - bt - bb),
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
// Persistent board guides (board-guides.ts), converted from client
|
|
2109
|
+
// space into the same host-relative units as every rect above.
|
|
2110
|
+
const guideClients = guideClientEdges();
|
|
2111
|
+
const host = this.hostRect();
|
|
2112
|
+
const zoom = getRootPan().zoom;
|
|
2113
|
+
const guideEdges =
|
|
2114
|
+
guideClients.x.length > 0 || guideClients.y.length > 0
|
|
2115
|
+
? {
|
|
2116
|
+
x: guideClients.x.map((cx) => (cx - host.left) / zoom),
|
|
2117
|
+
y: guideClients.y.map((cy) => (cy - host.top) / zoom),
|
|
2118
|
+
}
|
|
2119
|
+
: undefined;
|
|
2120
|
+
return {
|
|
2121
|
+
...(parentRect ? { parent: this.toHostRelative(parentRect) } : {}),
|
|
2122
|
+
...(siblings.length ? { siblings } : {}),
|
|
2123
|
+
...(paddingBox ? { paddingBox } : {}),
|
|
2124
|
+
...(guideEdges ? { guideEdges } : {}),
|
|
2125
|
+
};
|
|
2126
|
+
},
|
|
2127
|
+
// D3.c (spec 27 §6) — every currently-empty OID container: no visible
|
|
2128
|
+
// (OID or non-OID) child ELEMENT, no text, feeding the pure
|
|
2129
|
+
// `findEmptyContainers` math (`ui-source/inspect.ts:463`) unchanged. Rule
|
|
2130
|
+
// zero stays intact — the DOM READ happens HERE, in the adapter; the
|
|
2131
|
+
// overlay/shell only ever sees the already-filtered `{id, rect,
|
|
2132
|
+
// displayName}` result.
|
|
2133
|
+
emptyContainers: () => {
|
|
2134
|
+
const { nodes } = this.snapshot();
|
|
2135
|
+
const candidates: EmptyCandidate[] = [];
|
|
2136
|
+
for (const n of nodes.values()) {
|
|
2137
|
+
const r = n.el.getBoundingClientRect?.();
|
|
2138
|
+
if (!r || !canContainDomChildren(n.el, n.tag)) continue;
|
|
2139
|
+
candidates.push({
|
|
2140
|
+
oid: n.id,
|
|
2141
|
+
rect: this.toHostRelative(r),
|
|
2142
|
+
displayName: n.tag,
|
|
2143
|
+
hasVisibleChildren: n.el.children.length > 0,
|
|
2144
|
+
hasText: (n.el.textContent ?? '').trim().length > 0,
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
return findEmptyContainers(candidates).map((c) => ({
|
|
2148
|
+
id: c.oid,
|
|
2149
|
+
rect: c.rect,
|
|
2150
|
+
displayName: c.displayName,
|
|
2151
|
+
}));
|
|
2152
|
+
},
|
|
2153
|
+
};
|
|
2154
|
+
|
|
2155
|
+
/**
|
|
2156
|
+
* D3.d (spec 27 §6) — the eyedropper FALLBACK color-sample path: pick the
|
|
2157
|
+
* topmost OID node at the point (reusing this adapter's OWN `pickable.pick`,
|
|
2158
|
+
* never `elementFromPoint`), then walk its ancestor chain collecting each
|
|
2159
|
+
* node's RAW (un-normalized) computed `background-color` — hit-element
|
|
2160
|
+
* first, matching `effectiveColorFromChain`'s expected order. Raw, not
|
|
2161
|
+
* `cssColorToHex`-normalized, so a `transparent`/`rgba(0,0,0,0)` background
|
|
2162
|
+
* is recognizable as such by `isTransparentBackground` (the hex form would
|
|
2163
|
+
* lose that signal — see the contract's own doc comment on
|
|
2164
|
+
* `ColorSampleProvider`).
|
|
2165
|
+
*/
|
|
2166
|
+
readonly colorSample: ColorSampleProvider = {
|
|
2167
|
+
backgroundChainAt: (clientX, clientY) => {
|
|
2168
|
+
const hitId = this.pickable.pick(clientX, clientY);
|
|
2169
|
+
if (!hitId) return null;
|
|
2170
|
+
const { nodes } = this.snapshot();
|
|
2171
|
+
const chain: string[] = [];
|
|
2172
|
+
let cur: OidNode | undefined = nodes.get(hitId);
|
|
2173
|
+
while (cur) {
|
|
2174
|
+
chain.push(getComputedStyleValue(cur.el, 'backgroundColor', this.computedStyle));
|
|
2175
|
+
cur = cur.parentId ? nodes.get(cur.parentId) : undefined;
|
|
2176
|
+
}
|
|
2177
|
+
return chain;
|
|
2178
|
+
},
|
|
2179
|
+
};
|
|
2180
|
+
|
|
2181
|
+
/**
|
|
2182
|
+
* T0 (spec 27 §4, B1) — spatial drag-resize/move/spacing → source write, for
|
|
2183
|
+
* non-Object3D (DOM) nodes. `apply` is LIVE PREVIEW ONLY: it mutates the live
|
|
2184
|
+
* element's inline style directly, with ZERO backend traffic (acceptance:311 —
|
|
2185
|
+
* a resize drag must not spam the dev server with a write per frame). `end`
|
|
2186
|
+
* commits every touched prop ONCE through the existing {@link writeStyleEntry}
|
|
2187
|
+
* write pipeline (CSS-file routing + append-aware undo preserved unchanged),
|
|
2188
|
+
* composed into exactly ONE undo entry per gesture (acceptance:310) even when
|
|
2189
|
+
* the gesture touched multiple props (e.g. a corner-resize writes both `width`
|
|
2190
|
+
* and `height`). See {@link boxEditSession}'s doc comment for the
|
|
2191
|
+
* `priorInline` capture this depends on for a correct undo inverse.
|
|
2192
|
+
*/
|
|
2193
|
+
readonly boxEdit: BoxEditProvider = {
|
|
2194
|
+
begin: (id) => {
|
|
2195
|
+
// A stale, never-`end`ed session (caller bug) is simply replaced — its
|
|
2196
|
+
// preview mutations are already live on the DOM either way.
|
|
2197
|
+
this.boxEditSession = { id, touched: new Map(), priorInline: new Map() };
|
|
2198
|
+
},
|
|
2199
|
+
apply: (id, patch) => {
|
|
2200
|
+
const session = this.boxEditSession;
|
|
2201
|
+
if (!session || session.id !== id) return; // no open gesture for this id
|
|
2202
|
+
const n = this.snapshot().nodes.get(id);
|
|
2203
|
+
if (!n) return; // unresolved id — no-op (per contract)
|
|
2204
|
+
const pos = getComputedStyleValue(n.el, 'position', this.computedStyle);
|
|
2205
|
+
const isPositioned = pos === 'absolute' || pos === 'fixed';
|
|
2206
|
+
for (const [key, v] of Object.entries(patch)) {
|
|
2207
|
+
const mapped = mapBoxEditPatchKey(key, isPositioned);
|
|
2208
|
+
if (!mapped) {
|
|
2209
|
+
console.warn(
|
|
2210
|
+
`[ReactRootAuthoringAdapter] boxEdit: dropping patch key "${key}" for "${id}" — ` +
|
|
2211
|
+
(key === 'x' || key === 'y'
|
|
2212
|
+
? `node is not absolutely/fixed positioned (computed position: "${pos}"), ` +
|
|
2213
|
+
'no left/top to move'
|
|
2214
|
+
: 'unrecognized box-edit patch key'),
|
|
2215
|
+
);
|
|
2216
|
+
// SAY IT WHERE THE HAND IS. A drag on a flow-positioned element
|
|
2217
|
+
// writes nothing — the board moves only absolutely/fixed positioned
|
|
2218
|
+
// nodes (spec:322) — and the console line above was the only
|
|
2219
|
+
// account of it: "I can't move this" was a tester's verdict on the
|
|
2220
|
+
// health bar (runhuman pass 133). Once per gesture.
|
|
2221
|
+
if ((key === 'x' || key === 'y') && !session.hinted) {
|
|
2222
|
+
session.hinted = true;
|
|
2223
|
+
showTransientHint(
|
|
2224
|
+
`${n.tag} flows in its layout (position: ${pos}), so a drag ` +
|
|
2225
|
+
'cannot move it. Set Position to "abs" in the Inspector to place it freely; ' +
|
|
2226
|
+
'drag a handle to resize.',
|
|
2227
|
+
);
|
|
2228
|
+
}
|
|
2229
|
+
continue;
|
|
2230
|
+
}
|
|
2231
|
+
// Lazily snapshot the TRUE pre-gesture inline value the first time THIS
|
|
2232
|
+
// prop is touched in this gesture — before mutating it — see
|
|
2233
|
+
// `boxEditSession`'s doc comment for why this can't be re-derived later.
|
|
2234
|
+
if (!session.priorInline.has(mapped.prop)) {
|
|
2235
|
+
const prevRaw = styleProp(n.el.style, mapped.prop);
|
|
2236
|
+
session.priorInline.set(mapped.prop, prevRaw == null ? '' : String(prevRaw));
|
|
2237
|
+
}
|
|
2238
|
+
// x/y arrive HOST-RELATIVE (the rect provider's space), but CSS
|
|
2239
|
+
// left/top are OFFSET-PARENT-relative. They only coincide when the
|
|
2240
|
+
// offset parent sits at the host origin — which a story-frame child
|
|
2241
|
+
// never does, so a board drag used to write BOARD coordinates into
|
|
2242
|
+
// the element's source (left:'2968px' on a 1280-wide frame; found by
|
|
2243
|
+
// the blind walk, the element vanished outside its own frame).
|
|
2244
|
+
// parentOrigin = rect − offsetLeft is constant through the gesture
|
|
2245
|
+
// (both shift together as we mutate), and an element positioned via
|
|
2246
|
+
// translate() cancels exactly: rect includes the transform, so the
|
|
2247
|
+
// written left lands the VISUAL box at the requested position.
|
|
2248
|
+
let write = v;
|
|
2249
|
+
if ((key === 'x' || key === 'y') && typeof v === 'number') {
|
|
2250
|
+
const el = n.el as unknown as {
|
|
2251
|
+
getBoundingClientRect?: () => {
|
|
2252
|
+
left: number;
|
|
2253
|
+
top: number;
|
|
2254
|
+
width: number;
|
|
2255
|
+
height: number;
|
|
2256
|
+
};
|
|
2257
|
+
offsetLeft?: number;
|
|
2258
|
+
offsetTop?: number;
|
|
2259
|
+
};
|
|
2260
|
+
const rect = el.getBoundingClientRect?.();
|
|
2261
|
+
if (rect && typeof el.offsetLeft === 'number' && typeof el.offsetTop === 'number') {
|
|
2262
|
+
const hostRel = this.toHostRelative(rect);
|
|
2263
|
+
const parentOrigin = key === 'x' ? hostRel.x - el.offsetLeft : hostRel.y - el.offsetTop;
|
|
2264
|
+
// Whole-pixel, like every other gesture write (see the spacing
|
|
2265
|
+
// scrub's own doc comment): the patch is grid/edge-snapped, but
|
|
2266
|
+
// edge targets and this origin are MEASURED (rect ÷ zoom), so
|
|
2267
|
+
// without rounding a 65%-zoom drag authors `left:
|
|
2268
|
+
// '215.00005607057415px'` — measurement noise, not intent.
|
|
2269
|
+
write = Math.round(v - parentOrigin);
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
const cssValue = mapped.cssValue(write);
|
|
2273
|
+
if (n.el.style) (n.el.style as Record<string, unknown>)[mapped.prop] = cssValue;
|
|
2274
|
+
// Commit numerics as plain numbers (matching every other numeric style
|
|
2275
|
+
// write in this adapter — see `writeStyle`'s callers), transform as a
|
|
2276
|
+
// string — NOT the px-suffixed preview string, which is preview-only.
|
|
2277
|
+
session.touched.set(mapped.prop, mapped.prop === 'transform' ? cssValue : write);
|
|
2278
|
+
}
|
|
2279
|
+
},
|
|
2280
|
+
end: (id) => {
|
|
2281
|
+
const session = this.boxEditSession;
|
|
2282
|
+
this.boxEditSession = null;
|
|
2283
|
+
if (!session || session.id !== id || session.touched.size === 0) return;
|
|
2284
|
+
// Queue behind any still-running commit — see `boxEditTail`. A failed
|
|
2285
|
+
// commit must not poison the chain for every later gesture, so the tail
|
|
2286
|
+
// absorbs the rejection (commitBoxEdit reports its own failures).
|
|
2287
|
+
this.boxEditTail = this.boxEditTail.then(
|
|
2288
|
+
() => this.commitBoxEdit(id, session.touched, session.priorInline),
|
|
2289
|
+
() => this.commitBoxEdit(id, session.touched, session.priorInline),
|
|
2290
|
+
);
|
|
2291
|
+
void this.boxEditTail.catch((error) => {
|
|
2292
|
+
console.error('[ReactRootAuthoringAdapter] box edit failed and was not committed.', error);
|
|
2293
|
+
});
|
|
2294
|
+
},
|
|
2295
|
+
};
|
|
2296
|
+
|
|
2297
|
+
/**
|
|
2298
|
+
* T0 (spec 27 §4, B1) — commit every prop touched by one `boxEdit` gesture, each
|
|
2299
|
+
* through {@link writeStyleEntry} (so CSS-file routing / append-aware undo are
|
|
2300
|
+
* unchanged), then compose all resulting entries into exactly ONE undo entry
|
|
2301
|
+
* (acceptance:310) whose inverse runs in REVERSE order and whose redo runs in
|
|
2302
|
+
* gesture order — mirroring how a multi-statement edit undoes as one unit
|
|
2303
|
+
* elsewhere in this adapter.
|
|
2304
|
+
*/
|
|
2305
|
+
private async commitBoxEdit(
|
|
2306
|
+
id: string,
|
|
2307
|
+
touched: Map<string, string | number>,
|
|
2308
|
+
priorInline: Map<string, string>,
|
|
2309
|
+
): Promise<void> {
|
|
2310
|
+
const wasDirty = this.dirty;
|
|
2311
|
+
// A pasteboard `<At>` mount routes its position to the CALLSITE's `x`/`y`
|
|
2312
|
+
// JSX literals rather than inline `left`/`top` style — the drag's live
|
|
2313
|
+
// preview still moved `el.style.left/top` (the helper renders exactly that
|
|
2314
|
+
// from `x`/`y`), but the AUTHORED truth is the props, so that is what the
|
|
2315
|
+
// commit writes. Resolved from the fiber (the helper's own internal stamp
|
|
2316
|
+
// sits on the same element, so the element's `data-oid` alone would name
|
|
2317
|
+
// the helper file, not the callsite in the pasteboard file).
|
|
2318
|
+
const node = this.snapshot().nodes.get(id);
|
|
2319
|
+
const atCallSiteOid = node && isPasteboardAtElement(node.el) ? getCallSiteOid(node.el) : null;
|
|
2320
|
+
const atPropForStyle = (prop: string): 'x' | 'y' | null =>
|
|
2321
|
+
atCallSiteOid === null ? null : prop === 'left' ? 'x' : prop === 'top' ? 'y' : null;
|
|
2322
|
+
// A4 — echo every touched prop BEFORE any write, so the inspector field shows
|
|
2323
|
+
// the dragged value at once (same discipline as `inspector.set`). The echo holds
|
|
2324
|
+
// the BARE numeric (a `type:'number'` descriptor's `Inspector` field needs an
|
|
2325
|
+
// actual number — see `inspector.get`'s `numericStyleValue` note), NOT the
|
|
2326
|
+
// px-suffixed source form D1 writes below. An At placement echoes on the
|
|
2327
|
+
// PROP path instead (its Props section is where x/y render).
|
|
2328
|
+
for (const [prop, value] of touched) {
|
|
2329
|
+
const atProp = atPropForStyle(prop);
|
|
2330
|
+
if (atProp && typeof value === 'number') {
|
|
2331
|
+
this.setEcho(id, `${PROP_PATH_PREFIX}${atProp}`, String(Math.round(value)));
|
|
2332
|
+
} else {
|
|
2333
|
+
this.setEcho(id, `${STYLE_PATH_PREFIX}${prop}`, value);
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
// A GESTURE THAT WROTE NOTHING SAYS SO. Every refusal below only warned in
|
|
2337
|
+
// the browser console, so a drag whose commit was refused looked done —
|
|
2338
|
+
// the element sat where the hand left it (live preview), the history had
|
|
2339
|
+
// an "Edit 2 Styles" entry to undo, and undo reverted nothing because
|
|
2340
|
+
// nothing had been written; a normal reload then showed the element back
|
|
2341
|
+
// where it was (runhuman passes 135/137/138/140, all on Windows Chrome —
|
|
2342
|
+
// the macOS instrument writes and undoes the same gesture cleanly). The
|
|
2343
|
+
// refusal is now an editor-console ERROR and a hint, naming the reason.
|
|
2344
|
+
let applied = 0;
|
|
2345
|
+
this.lastStyleWriteRefusal = null;
|
|
2346
|
+
const commit = async (backend: SourceWriteBackend | undefined): Promise<void> => {
|
|
2347
|
+
for (const [prop, value] of touched) {
|
|
2348
|
+
const atProp = atPropForStyle(prop);
|
|
2349
|
+
if (atProp && typeof value === 'number') {
|
|
2350
|
+
await this.writeAtPlacement(id, atCallSiteOid as string, atProp, value, backend);
|
|
2351
|
+
applied += 1;
|
|
2352
|
+
continue;
|
|
2353
|
+
}
|
|
2354
|
+
// D1 (spec 27 §4 B2/B3 reload-safety) — a numeric length-prop value must
|
|
2355
|
+
// persist to JSX SOURCE in a form React honors on REMOUNT. React 19 DROPS a
|
|
2356
|
+
// bare UNITLESS numeric STRING (the writer quotes `String(152)` → `width:
|
|
2357
|
+
// '152'`) on reload — the element collapses to content size (verified in real
|
|
2358
|
+
// Chromium + React 19) — but HONORS a quoted CSS length (`width: '152px'`).
|
|
2359
|
+
// `boxEdit.apply` stores length props as bare NUMBERS and CSS-string props
|
|
2360
|
+
// (`transform` → `'rotate(90deg)'`) as strings, so a numeric value here is
|
|
2361
|
+
// EXACTLY the set of length props (width/height/left/top/margin*/padding*)
|
|
2362
|
+
// needing a `px` unit — suffix only those, leaving `transform` untouched.
|
|
2363
|
+
// (The live-preview `el.style` path already applied a px-suffixed string via
|
|
2364
|
+
// `mapBoxEditPatchKey.cssValue`; only the persisted-source form was unitless.)
|
|
2365
|
+
// Localized to this box-edit commit — the color/`inspector.set` write path is
|
|
2366
|
+
// deliberately NOT changed.
|
|
2367
|
+
const writeValue = typeof value === 'number' ? `${value}px` : value;
|
|
2368
|
+
if (await this.writeStyleEntry(id, prop, writeValue, priorInline.get(prop), backend)) {
|
|
2369
|
+
applied += 1;
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
if (applied === 0 && touched.size > 0) {
|
|
2373
|
+
const reason = this.lastStyleWriteRefusal ?? 'the write was refused without a reason';
|
|
2374
|
+
editorConsole.error(`The last edit on this element was NOT saved — ${reason}`, 'authoring');
|
|
2375
|
+
showTransientHint('Not saved: the edit could not be written to source — see the Console.');
|
|
2376
|
+
}
|
|
2377
|
+
};
|
|
2378
|
+
// The history-managed backend supplies an explicit scoped writer. Holding
|
|
2379
|
+
// the callback as one queue entry makes the gesture failure-atomic and
|
|
2380
|
+
// prevents unrelated inspector writes from being absorbed/interleaved.
|
|
2381
|
+
try {
|
|
2382
|
+
if (this.writeBackend?.runGesture) {
|
|
2383
|
+
await this.writeBackend.runGesture(`Edit ${touched.size} Styles`, commit);
|
|
2384
|
+
} else {
|
|
2385
|
+
await commit(this.writeBackend);
|
|
2386
|
+
}
|
|
2387
|
+
} catch (error) {
|
|
2388
|
+
const node = this.snapshot().nodes.get(id);
|
|
2389
|
+
for (const [prop, prior] of priorInline) {
|
|
2390
|
+
this.clearEcho(id, `${STYLE_PATH_PREFIX}${prop}`);
|
|
2391
|
+
if (node?.el.style) (node.el.style as Record<string, unknown>)[prop] = prior;
|
|
2392
|
+
}
|
|
2393
|
+
this.dirty = wasDirty;
|
|
2394
|
+
this.store.notifyIngestEdit();
|
|
2395
|
+
throw error;
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
/**
|
|
2400
|
+
* The pasteboard placement write: one `<At>` callsite prop (`x` or `y`) as a
|
|
2401
|
+
* JSX NUMBER LITERAL, `addIfMissing` because the helper defaults both to 0
|
|
2402
|
+
* and an author legitimately omits them until the first drag. Refusals
|
|
2403
|
+
* follow {@link writePropEdit}'s contract: a dynamic expression (`x={GRID *
|
|
2404
|
+
* 2}`) is authored truth the drag must not flatten — guarded and warned by
|
|
2405
|
+
* name, never overwritten. Runs inside {@link commitBoxEdit}'s gesture, so a
|
|
2406
|
+
* corner drag that writes both axes stays one undo entry.
|
|
2407
|
+
*/
|
|
2408
|
+
private async writeAtPlacement(
|
|
2409
|
+
id: string,
|
|
2410
|
+
callSiteOid: string,
|
|
2411
|
+
prop: 'x' | 'y',
|
|
2412
|
+
value: number,
|
|
2413
|
+
backend: SourceWriteBackend | undefined,
|
|
2414
|
+
): Promise<void> {
|
|
2415
|
+
const echoPath = `${PROP_PATH_PREFIX}${prop}`;
|
|
2416
|
+
const b = backend ?? this.writeBackend;
|
|
2417
|
+
if (!b?.writeProp) {
|
|
2418
|
+
this.clearEcho(id, echoPath);
|
|
2419
|
+
console.warn(
|
|
2420
|
+
`[ReactRootAuthoringAdapter] cannot write At placement "${prop}" on "${id}": no ` +
|
|
2421
|
+
'source-write backend with prop support in this session (hosted/no dev server).',
|
|
2422
|
+
);
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
const res = await b.writeProp(callSiteOid, prop, String(Math.round(value)), {
|
|
2426
|
+
addIfMissing: true,
|
|
2427
|
+
});
|
|
2428
|
+
if (!res.changed) {
|
|
2429
|
+
this.clearEcho(id, echoPath);
|
|
2430
|
+
if (res.dynamic) this.markGuarded(id, echoPath, DYNAMIC_EXPRESSION_GUARD);
|
|
2431
|
+
this.store.notifyIngestEdit();
|
|
2432
|
+
console.warn(
|
|
2433
|
+
`[ReactRootAuthoringAdapter] At placement write refused/no-op for oid ` +
|
|
2434
|
+
`"${callSiteOid}" prop "${prop}": ${
|
|
2435
|
+
res.dynamic
|
|
2436
|
+
? 'value is a dynamic expression (guarded) — authored truth the drag must not flatten'
|
|
2437
|
+
: (res.error ?? 'no change')
|
|
2438
|
+
}`,
|
|
2439
|
+
);
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
this.dirty = true;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
/**
|
|
2446
|
+
* T0 (spec 27 §2) — brings the react adapter's existing off-contract `editText`
|
|
2447
|
+
* (below) onto the contract. `get` is a best-effort CLIENT-side read: the live
|
|
2448
|
+
* DOM can only rule out "has child elements" (`el.children.length > 0`), not a
|
|
2449
|
+
* dynamic `{expression}` body — that guard is source-side and already enforced
|
|
2450
|
+
* at write time (`editText`'s `res.dynamic`, surfaced as a loud console warning
|
|
2451
|
+
* on refusal). `set` fires the existing undo-tracked write path unchanged.
|
|
2452
|
+
*/
|
|
2453
|
+
readonly text: TextProvider = {
|
|
2454
|
+
get: (id) => {
|
|
2455
|
+
// D3.R1 — a node already refused once as a dynamic-body text edit
|
|
2456
|
+
// (`markGuarded(id, TEXT_PATH)`, set from `editText`'s `res.dynamic`
|
|
2457
|
+
// refusal below) reports null from here on this session, mirroring
|
|
2458
|
+
// U4's style/prop after-touch marker — so a SECOND double-click reports
|
|
2459
|
+
// the refusal immediately instead of re-opening the textarea only to
|
|
2460
|
+
// have the source refuse it again.
|
|
2461
|
+
if (this.guardedPaths.has(`${id}|${TEXT_PATH}`)) return null;
|
|
2462
|
+
const n = this.snapshot().nodes.get(id);
|
|
2463
|
+
if (!n || n.el.children.length > 0) return null;
|
|
2464
|
+
const raw = n.el.textContent;
|
|
2465
|
+
if (raw == null) return null;
|
|
2466
|
+
const trimmed = raw.trim();
|
|
2467
|
+
return trimmed ? trimmed : null;
|
|
2468
|
+
},
|
|
2469
|
+
set: (id, text) => {
|
|
2470
|
+
void this.editText(id, text);
|
|
2471
|
+
},
|
|
2472
|
+
};
|
|
2473
|
+
|
|
2474
|
+
readonly inspector: InspectorProvider = {
|
|
2475
|
+
properties: (id: string): PropertyDescriptor[] => {
|
|
2476
|
+
const portableHierarchyNode = this.portableHierarchyNode(id);
|
|
2477
|
+
if (portableHierarchyNode) {
|
|
2478
|
+
return portableHierarchyNode.secondaryLabel
|
|
2479
|
+
? [
|
|
2480
|
+
{
|
|
2481
|
+
path: 'document.path',
|
|
2482
|
+
label: 'Source',
|
|
2483
|
+
type: 'string',
|
|
2484
|
+
readonly: true,
|
|
2485
|
+
group: 'Document',
|
|
2486
|
+
},
|
|
2487
|
+
]
|
|
2488
|
+
: [];
|
|
2489
|
+
}
|
|
2490
|
+
const portableDocument = this.portableDocuments.find((document) => document.id === id);
|
|
2491
|
+
if (portableDocument) {
|
|
2492
|
+
return portableDocument.path
|
|
2493
|
+
? [
|
|
2494
|
+
{
|
|
2495
|
+
path: 'document.path',
|
|
2496
|
+
label: 'Source',
|
|
2497
|
+
type: 'string',
|
|
2498
|
+
readonly: true,
|
|
2499
|
+
group: 'Document',
|
|
2500
|
+
},
|
|
2501
|
+
]
|
|
2502
|
+
: [];
|
|
2503
|
+
}
|
|
2504
|
+
if (this.portableStories.some((story) => story.id === id))
|
|
2505
|
+
return this.portableStoryArgDescriptors(id);
|
|
2506
|
+
const style = STYLE_PROPERTIES.map(({ prop, label, type, group, options }) => ({
|
|
2507
|
+
path: `${STYLE_PATH_PREFIX}${prop}`,
|
|
2508
|
+
label,
|
|
2509
|
+
type,
|
|
2510
|
+
group,
|
|
2511
|
+
...(options ? { options } : {}),
|
|
2512
|
+
}));
|
|
2513
|
+
// Cap 4 (React visual-edit parity): append a "Props" section for this node's component
|
|
2514
|
+
// call site — its editable primitive props, read live off the React fiber
|
|
2515
|
+
// (`getComponentProps`). A write is guarded server-side (a dynamic prop is refused),
|
|
2516
|
+
// so surfacing every primitive prop here is safe. Cap 7 appends a read-only "Tokens"
|
|
2517
|
+
// section — the document's design tokens (`:root` custom properties).
|
|
2518
|
+
const all = [...style, ...this.propDescriptors(id), ...this.tokenDescriptors()];
|
|
2519
|
+
// U4 — a path already refused once by a source GUARD (`markGuarded`, set
|
|
2520
|
+
// from `writeStyleEntry`/`writePropEdit`'s refusal) reports
|
|
2521
|
+
// `readonly: true` from here on, carrying that guard's OWN sentence, so
|
|
2522
|
+
// the widget disables itself instead of re-offering an edit the source
|
|
2523
|
+
// will refuse again. See `guardedPaths`'s doc comment for why this is
|
|
2524
|
+
// after-touch, not a pre-touch predictor.
|
|
2525
|
+
if (this.guardedPaths.size === 0) return all;
|
|
2526
|
+
return all.map((p) => {
|
|
2527
|
+
const reason = this.guardedPaths.get(`${id}|${p.path}`);
|
|
2528
|
+
return reason ? { ...p, readonly: true, readonlyReason: reason } : p;
|
|
2529
|
+
});
|
|
2530
|
+
},
|
|
2531
|
+
get: (id, path) => {
|
|
2532
|
+
const portableHierarchyNode = this.portableHierarchyNode(id);
|
|
2533
|
+
if (portableHierarchyNode) {
|
|
2534
|
+
return path === 'document.path' ? portableHierarchyNode.secondaryLabel : undefined;
|
|
2535
|
+
}
|
|
2536
|
+
const portableDocument = this.portableDocuments.find((document) => document.id === id);
|
|
2537
|
+
if (portableDocument) {
|
|
2538
|
+
return path === 'document.path' ? portableDocument.path : undefined;
|
|
2539
|
+
}
|
|
2540
|
+
if (this.portableStories.some((story) => story.id === id)) {
|
|
2541
|
+
if (path === 'story.active') return id === this.activeStoryId;
|
|
2542
|
+
if (path.startsWith(PORTABLE_STORY_ARG_PATH_PREFIX)) {
|
|
2543
|
+
return this.portableStoryArgs.get(id)?.[
|
|
2544
|
+
path.slice(PORTABLE_STORY_ARG_PATH_PREFIX.length)
|
|
2545
|
+
];
|
|
2546
|
+
}
|
|
2547
|
+
return undefined;
|
|
2548
|
+
}
|
|
2549
|
+
// A4 — read THROUGH the optimistic echo: a value just set (style/prop) wins over the
|
|
2550
|
+
// live-DOM walk until the source-write + HMR land, so the field never snaps back.
|
|
2551
|
+
const echoed = this.readEcho(id, path);
|
|
2552
|
+
if (echoed.hit) return echoed.value;
|
|
2553
|
+
// D4 (spec27 §6 D4, layer-tree visibility/lock) — the two reserved
|
|
2554
|
+
// paths GameHierarchy's row reads generically off ANY adapter's
|
|
2555
|
+
// `inspector`. `locked` is session-local (see `lockedIds`'s doc
|
|
2556
|
+
// comment — no source-backed equivalent). `visible` IS source-backed:
|
|
2557
|
+
// sugar for the `style.visibility` prop (read THROUGH that same prop's
|
|
2558
|
+
// own optimistic echo, so the eye icon never snap-backs while the
|
|
2559
|
+
// async write is in flight — same A4 discipline every other style
|
|
2560
|
+
// path gets).
|
|
2561
|
+
if (path === 'locked') return this.lockedIds.has(id);
|
|
2562
|
+
if (path === 'visible') {
|
|
2563
|
+
const echoedStyle = this.readEcho(id, `${STYLE_PATH_PREFIX}visibility`);
|
|
2564
|
+
if (echoedStyle.hit) return echoedStyle.value !== 'hidden';
|
|
2565
|
+
const n = this.snapshot().nodes.get(id);
|
|
2566
|
+
if (!n) return undefined;
|
|
2567
|
+
const raw = getComputedStyleValue(n.el, 'visibility', this.computedStyle);
|
|
2568
|
+
return raw !== 'hidden';
|
|
2569
|
+
}
|
|
2570
|
+
if (path.startsWith(TOKEN_PATH_PREFIX)) {
|
|
2571
|
+
const name = path.slice(TOKEN_PATH_PREFIX.length);
|
|
2572
|
+
return this.designTokenValue(name);
|
|
2573
|
+
}
|
|
2574
|
+
if (path.startsWith(PROP_PATH_PREFIX)) {
|
|
2575
|
+
const n = this.snapshot().nodes.get(id);
|
|
2576
|
+
const cp = n ? getComponentProps(n.el) : null;
|
|
2577
|
+
return cp?.props[path.slice(PROP_PATH_PREFIX.length)];
|
|
2578
|
+
}
|
|
2579
|
+
if (!path.startsWith(STYLE_PATH_PREFIX)) return undefined;
|
|
2580
|
+
const prop = path.slice(STYLE_PATH_PREFIX.length);
|
|
2581
|
+
const n = this.snapshot().nodes.get(id);
|
|
2582
|
+
if (!n) return undefined;
|
|
2583
|
+
// Cap 1 (React visual-edit parity): read the COMPUTED value via the resolver, so a
|
|
2584
|
+
// property styled through a className utility (F5's class routing) OR a CSS file
|
|
2585
|
+
// resolves too — fixing the "class-styled props show blank" read-back gap. Under
|
|
2586
|
+
// vitest's `node` env the default resolver falls back to the element's inline
|
|
2587
|
+
// `.style`, so headless fixtures keep working unchanged.
|
|
2588
|
+
// THE AUTHORED VALUE WINS THE FIELD when the element carries one inline:
|
|
2589
|
+
// an author who typed 257 into W and reads back 247.262 — the computed
|
|
2590
|
+
// width of an inline element that ignores `width`, or of a flex item
|
|
2591
|
+
// its parent shrank — concludes the edit "reverted" (runhuman pass 137,
|
|
2592
|
+
// twice). The field shows what the source says; the computed value
|
|
2593
|
+
// remains the fallback for class- and stylesheet-styled props, which is
|
|
2594
|
+
// what the resolver read below exists for.
|
|
2595
|
+
const authored = styleProp(n.el.style, prop);
|
|
2596
|
+
const raw =
|
|
2597
|
+
typeof authored === 'string' && authored !== ''
|
|
2598
|
+
? authored
|
|
2599
|
+
: typeof authored === 'number'
|
|
2600
|
+
? String(authored)
|
|
2601
|
+
: getComputedStyleValue(n.el, prop, this.computedStyle);
|
|
2602
|
+
const declaredType = STYLE_PROPERTY_TYPE.get(prop);
|
|
2603
|
+
// A `type: 'number'` descriptor (fontSize/width/height/padding/margin/
|
|
2604
|
+
// borderRadius/gap) needs an actual number for the generic numeric
|
|
2605
|
+
// input to render/edit correctly — a REAL `CSSStyleDeclaration` always
|
|
2606
|
+
// hands these back as a unit-suffixed STRING (e.g. `"16px"`, never a
|
|
2607
|
+
// bare `16`), which `Inspector.tsx`'s `typeof v === 'number'` check
|
|
2608
|
+
// would otherwise silently read as `0` (this repo's own
|
|
2609
|
+
// `react-world-authoring-adapter.test.ts` fixtures never caught this —
|
|
2610
|
+
// a hand-built plain-object `style: {}` can hold a bare JS number
|
|
2611
|
+
// directly, which a real DOM element's `style` never does). A
|
|
2612
|
+
// `type: 'color'` descriptor needs `#rrggbb` for the same reason —
|
|
2613
|
+
// see `cssColorToHex`'s doc comment.
|
|
2614
|
+
if (declaredType === 'number') return numericStyleValue(raw);
|
|
2615
|
+
if (declaredType === 'color') return cssColorToHex(raw);
|
|
2616
|
+
return raw || undefined; // an unset computed value reads '' — surface as blank
|
|
2617
|
+
},
|
|
2618
|
+
set: (id, path, value) => {
|
|
2619
|
+
if (this.portableHierarchyNode(id)) return;
|
|
2620
|
+
if (this.portableDocuments.some((document) => document.id === id)) return;
|
|
2621
|
+
if (this.portableStories.some((story) => story.id === id)) {
|
|
2622
|
+
if (!path.startsWith(PORTABLE_STORY_ARG_PATH_PREFIX)) return;
|
|
2623
|
+
const name = path.slice(PORTABLE_STORY_ARG_PATH_PREFIX.length);
|
|
2624
|
+
const current = this.portableStoryArgs.get(id);
|
|
2625
|
+
if (!current || !(name in current)) return;
|
|
2626
|
+
const next = { ...current, [name]: value };
|
|
2627
|
+
this.portableStoryArgs.set(id, next);
|
|
2628
|
+
this.onPortableStoryArgsChanged?.(id, { ...next });
|
|
2629
|
+
this.store.notifyIngestEdit();
|
|
2630
|
+
return;
|
|
2631
|
+
}
|
|
2632
|
+
// D4 — see the matching `get` branch's doc comment.
|
|
2633
|
+
if (path === 'locked') {
|
|
2634
|
+
if (value) this.lockedIds.add(id);
|
|
2635
|
+
else this.lockedIds.delete(id);
|
|
2636
|
+
this.store.notifyIngestEdit();
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
if (path === 'visible') {
|
|
2640
|
+
const cssValue = value ? 'visible' : 'hidden';
|
|
2641
|
+
this.setEcho(id, `${STYLE_PATH_PREFIX}visibility`, cssValue);
|
|
2642
|
+
return this.pipedSourceWrite(() => this.writeStyle(id, 'visibility', cssValue));
|
|
2643
|
+
}
|
|
2644
|
+
if (path.startsWith(PROP_PATH_PREFIX)) {
|
|
2645
|
+
// A4 — echo BEFORE the async write so `inspector.get` shows the new value at once.
|
|
2646
|
+
this.setEcho(id, path, value);
|
|
2647
|
+
return this.pipedSourceWrite(() =>
|
|
2648
|
+
this.writePropEdit(id, path.slice(PROP_PATH_PREFIX.length), String(value)),
|
|
2649
|
+
);
|
|
2650
|
+
}
|
|
2651
|
+
if (path.startsWith(TOKEN_PATH_PREFIX)) {
|
|
2652
|
+
// Tokens-as-noun: a token declared by a project stylesheet is EDITED
|
|
2653
|
+
// AT ITS DECLARATION (`writeCss` on the traced rule) — the one write
|
|
2654
|
+
// that moves every binding at once. Undeclared tokens (script-set)
|
|
2655
|
+
// refuse with the pointer; their descriptor is read-only anyway.
|
|
2656
|
+
this.setEcho(id, path, value);
|
|
2657
|
+
return this.pipedSourceWrite(() =>
|
|
2658
|
+
this.writeTokenValue(id, path.slice(TOKEN_PATH_PREFIX.length), String(value)),
|
|
2659
|
+
);
|
|
2660
|
+
}
|
|
2661
|
+
if (!path.startsWith(STYLE_PATH_PREFIX)) return;
|
|
2662
|
+
const prop = path.slice(STYLE_PATH_PREFIX.length);
|
|
2663
|
+
// A4 — echo BEFORE the async write (the desync fix): the field reflects the commit
|
|
2664
|
+
// within one frame; the write path clears this echo if the write is refused, and HMR
|
|
2665
|
+
// clears it once the re-render lands.
|
|
2666
|
+
this.setEcho(id, path, value);
|
|
2667
|
+
return this.pipedSourceWrite(() => this.writeStyle(id, prop, value as string | number));
|
|
2668
|
+
},
|
|
2669
|
+
remove: (id, path) => {
|
|
2670
|
+
// U2 — remove a stale CSS longhand override so its shorthand actually
|
|
2671
|
+
// wins (a uniform border/radius edit clears the per-side/per-corner
|
|
2672
|
+
// longhands a prior non-uniform edit wrote; without this the longhand
|
|
2673
|
+
// silently overrides the shorthand on reload — the D1 defect). Style
|
|
2674
|
+
// paths only; a `prop.`/`token.` path has no removable-override
|
|
2675
|
+
// meaning here (no-op). `removeStyle` is itself a source no-op when the
|
|
2676
|
+
// longhand isn't authored, so calling this unconditionally for all four
|
|
2677
|
+
// corners / twelve side-props is safe and never pollutes clean source.
|
|
2678
|
+
if (!path.startsWith(STYLE_PATH_PREFIX)) return;
|
|
2679
|
+
const prop = path.slice(STYLE_PATH_PREFIX.length);
|
|
2680
|
+
// Point the removed longhand's echo at the value it actually renders at
|
|
2681
|
+
// once the override is gone — the shorthand's current (just-committed)
|
|
2682
|
+
// value — so `inspector.get(longhand)` reports the resolved corner value,
|
|
2683
|
+
// not a stale override or an empty computed read, and stays consistent
|
|
2684
|
+
// after HMR clears the echo (the shorthand then cascades to it).
|
|
2685
|
+
const shorthand = LONGHAND_TO_SHORTHAND[prop];
|
|
2686
|
+
if (shorthand)
|
|
2687
|
+
this.setEcho(id, path, this.inspector.get(id, `${STYLE_PATH_PREFIX}${shorthand}`));
|
|
2688
|
+
else this.clearEcho(id, path);
|
|
2689
|
+
// Through the SAME pipe `set` uses, so a removal answers for itself with
|
|
2690
|
+
// an awaited per-edit ack instead of being fired into the void. It is the
|
|
2691
|
+
// only door that can express byte-ABSENCE — `set` writes a value, and a
|
|
2692
|
+
// longhand set back to the shorthand's value is still a longhand in the
|
|
2693
|
+
// file.
|
|
2694
|
+
return this.pipedSourceWrite(() => this.removeStyleProp(id, prop));
|
|
2695
|
+
},
|
|
2696
|
+
};
|
|
2697
|
+
|
|
2698
|
+
readonly assetSubject: AssetSubjectProvider = {
|
|
2699
|
+
get: (id) => this.assetSubjectForNode(this.snapshot(), id),
|
|
2700
|
+
entries: () => {
|
|
2701
|
+
const tree = walkOidTree(this.assetRoot);
|
|
2702
|
+
return [...tree.nodes.keys()].flatMap((id) => {
|
|
2703
|
+
const subject = this.assetSubjectForNode(tree, id);
|
|
2704
|
+
return subject ? [{ id, subject }] : [];
|
|
2705
|
+
});
|
|
2706
|
+
},
|
|
2707
|
+
};
|
|
2708
|
+
|
|
2709
|
+
private assetSubjectForNode(tree: OidTree, id: string): AuthoringAssetSubject | null {
|
|
2710
|
+
const node = tree.nodes.get(id);
|
|
2711
|
+
if (!node || node.tag !== 'svg' || !node.el.outerHTML) return null;
|
|
2712
|
+
const source = this.oidIndex.get(node.oid);
|
|
2713
|
+
return {
|
|
2714
|
+
kind: 'image',
|
|
2715
|
+
name: this.componentName(node) ?? this.elementLabel(node, tree),
|
|
2716
|
+
mediaType: 'image/svg+xml',
|
|
2717
|
+
text: node.el.outerHTML,
|
|
2718
|
+
...(source?.file ? { sourcePath: projectSourcePath(source.file) } : {}),
|
|
2719
|
+
};
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
/** Storybook Controls descriptors for an ordinary CSF story node. Functions
|
|
2723
|
+
* and other non-serializable implementation values stay out of the form;
|
|
2724
|
+
* they remain present in the complete args object passed to the composed
|
|
2725
|
+
* story. The rule itself lives in `stories/story-arg-descriptors.ts` — the
|
|
2726
|
+
* three-story gallery's per-story document describes its args through the
|
|
2727
|
+
* same function. */
|
|
2728
|
+
private portableStoryArgDescriptors(storyId: string): PropertyDescriptor[] {
|
|
2729
|
+
return storyArgPropertyDescriptors(
|
|
2730
|
+
this.portableStoryArgs.get(storyId) ?? {},
|
|
2731
|
+
PORTABLE_STORY_ARG_PATH_PREFIX,
|
|
2732
|
+
);
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
/** Current complete args for a portable story, including callbacks omitted
|
|
2736
|
+
* from the visual Controls list. Returned as a copy so inspector UI cannot
|
|
2737
|
+
* mutate adapter state behind the provider contract. */
|
|
2738
|
+
portableArgs(storyId: string): Record<string, unknown> | null {
|
|
2739
|
+
const args = this.portableStoryArgs.get(storyId);
|
|
2740
|
+
return args ? { ...args } : null;
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
/**
|
|
2744
|
+
* The CSF WRITE half (design ledger: stories were composed and framed
|
|
2745
|
+
* everywhere, written nowhere). Three verbs over the story's OWN module
|
|
2746
|
+
* through the csf-story door; every planner refusal surfaces as the
|
|
2747
|
+
* returned error string, loudly, never a silent no-op. `saveStoryAs`
|
|
2748
|
+
* writes the story's CURRENT SESSION ARGS — the "save what I am looking
|
|
2749
|
+
* at" gesture the board's arg controls set up.
|
|
2750
|
+
*/
|
|
2751
|
+
/**
|
|
2752
|
+
* The element's classes as NAMED-STYLE facts (design ledger: named styles,
|
|
2753
|
+
* Webflow prior art): each class token the element wears, how many elements
|
|
2754
|
+
* in its own game-CSS scope share it (Webflow's "N elements share this
|
|
2755
|
+
* class" feedback loop), and — when a first-party stylesheet declares a
|
|
2756
|
+
* single-class rule for it — the stylesheet it lives in (which is what makes
|
|
2757
|
+
* it an editable named style rather than a utility/generated class).
|
|
2758
|
+
*/
|
|
2759
|
+
elementClasses(id: string): { name: string; count: number; styledIn: string | null }[] {
|
|
2760
|
+
const n = this.snapshot().nodes.get(id);
|
|
2761
|
+
const el = n?.el as unknown as Element | undefined;
|
|
2762
|
+
if (!el || typeof el.getAttribute !== 'function') return [];
|
|
2763
|
+
const tokens = (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
|
|
2764
|
+
// Shared-count scope: the element's own game-CSS scope root — the same
|
|
2765
|
+
// boundary the scoped stylesheet paints — falling back to the document
|
|
2766
|
+
// for a fixture element outside any scope.
|
|
2767
|
+
const scope =
|
|
2768
|
+
(typeof el.closest === 'function' ? el.closest('[data-vgai-game-styles]') : null) ??
|
|
2769
|
+
el.ownerDocument;
|
|
2770
|
+
return tokens.map((name) => {
|
|
2771
|
+
let count = 0;
|
|
2772
|
+
try {
|
|
2773
|
+
count = scope?.querySelectorAll?.(`.${CSS.escape(name)}`).length ?? 0;
|
|
2774
|
+
} catch {
|
|
2775
|
+
// A fixture scope without querySelectorAll — count stays 0.
|
|
2776
|
+
}
|
|
2777
|
+
return { name, count, styledIn: findClassRuleSource(name)?.sourceFile ?? null };
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
/**
|
|
2782
|
+
* The named-style write: APPLY when any first-party stylesheet already
|
|
2783
|
+
* declares `.className` (the class is an existing style — adding the token
|
|
2784
|
+
* is the whole gesture), else CREATE — mint the rule in the element's own
|
|
2785
|
+
* stylesheet (the last matched first-party rule's file, else the first
|
|
2786
|
+
* first-party stylesheet in the document), moving the element's literal
|
|
2787
|
+
* inline declarations into it. `remove` strips the token. Refusals are
|
|
2788
|
+
* returned AND logged, so the calling UI can show the sentence.
|
|
2789
|
+
*/
|
|
2790
|
+
async writeNamedStyle(
|
|
2791
|
+
id: string,
|
|
2792
|
+
op: { kind: 'set' | 'remove'; className: string },
|
|
2793
|
+
): Promise<{ changed: boolean; created?: boolean; error?: string }> {
|
|
2794
|
+
const n = this.snapshot().nodes.get(id);
|
|
2795
|
+
if (!n) return { changed: false, error: `"${id}" no longer resolves in this root` };
|
|
2796
|
+
const refuse = (error: string): { changed: false; error: string } => {
|
|
2797
|
+
console.warn(`[ReactRootAuthoringAdapter] named-style ${op.kind} refused: ${error}`);
|
|
2798
|
+
return { changed: false, error };
|
|
2799
|
+
};
|
|
2800
|
+
if (op.kind === 'remove') {
|
|
2801
|
+
const res = await writeNamedStyle({ op: 'remove', oid: n.oid, className: op.className });
|
|
2802
|
+
if (!res.changed) return refuse(res.error ?? 'no change');
|
|
2803
|
+
this.dirty = true;
|
|
2804
|
+
this.store.notifyIngestEdit();
|
|
2805
|
+
return { changed: true };
|
|
2806
|
+
}
|
|
2807
|
+
const existing = findClassRuleSource(op.className);
|
|
2808
|
+
if (existing) {
|
|
2809
|
+
const res = await writeNamedStyle({ op: 'apply', oid: n.oid, className: op.className });
|
|
2810
|
+
if (!res.changed) return refuse(res.error ?? 'the element already wears this class');
|
|
2811
|
+
this.dirty = true;
|
|
2812
|
+
this.store.notifyIngestEdit();
|
|
2813
|
+
return { changed: true };
|
|
2814
|
+
}
|
|
2815
|
+
// The rule's home: the last matched first-party rule's own file, else any
|
|
2816
|
+
// first-party stylesheet in the document — and with NEITHER, omit `file`
|
|
2817
|
+
// and the server MINTS a stylesheet beside the element's module (adding
|
|
2818
|
+
// the bare css import), so the first named style in a css-less project
|
|
2819
|
+
// works instead of refusing.
|
|
2820
|
+
// Candidate homes are the GAME's stylesheets. The dev-id enumeration also
|
|
2821
|
+
// sees the editor's own vite-served CSS, and offering one of those as the
|
|
2822
|
+
// home would (rightly) bounce off the server's scope guard — so mirror the
|
|
2823
|
+
// guard's exclusions here and let a css-less project fall through to the
|
|
2824
|
+
// server's minting path instead.
|
|
2825
|
+
const gameCss = (f: string | undefined): boolean =>
|
|
2826
|
+
f !== undefined &&
|
|
2827
|
+
!f.includes('/packages/editor/') &&
|
|
2828
|
+
!f.includes('/node_modules/') &&
|
|
2829
|
+
!f.includes('/vendor/');
|
|
2830
|
+
const matched = this.matchedCssRules(n.el).filter((rule) => gameCss(rule.sourceFile));
|
|
2831
|
+
const cssFile =
|
|
2832
|
+
matched[matched.length - 1]?.sourceFile ?? firstPartyStylesheetFiles().find(gameCss);
|
|
2833
|
+
const res = await writeNamedStyle({
|
|
2834
|
+
op: 'create',
|
|
2835
|
+
oid: n.oid,
|
|
2836
|
+
className: op.className,
|
|
2837
|
+
...(cssFile !== undefined ? { file: cssFile } : {}),
|
|
2838
|
+
});
|
|
2839
|
+
if (!res.changed) return refuse(res.error ?? 'no change');
|
|
2840
|
+
this.dirty = true;
|
|
2841
|
+
this.store.notifyIngestEdit();
|
|
2842
|
+
return { changed: true, created: true };
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
async writeStory(
|
|
2846
|
+
storyId: string,
|
|
2847
|
+
op:
|
|
2848
|
+
| { kind: 'save-as'; name: string }
|
|
2849
|
+
| { kind: 'rename'; newName: string }
|
|
2850
|
+
| { kind: 'delete' },
|
|
2851
|
+
): Promise<{ changed: boolean; error?: string }> {
|
|
2852
|
+
const story = this.portableStories.find((candidate) => candidate.id === storyId);
|
|
2853
|
+
if (!story) return { changed: false, error: `unknown portable story '${storyId}'` };
|
|
2854
|
+
if (!story.name || !story.modulePath) {
|
|
2855
|
+
return {
|
|
2856
|
+
changed: false,
|
|
2857
|
+
error: `story '${story.label}' carries no source address (name/modulePath) — it is not writable from this session`,
|
|
2858
|
+
};
|
|
2859
|
+
}
|
|
2860
|
+
const request =
|
|
2861
|
+
op.kind === 'save-as'
|
|
2862
|
+
? {
|
|
2863
|
+
op: 'save' as const,
|
|
2864
|
+
file: story.modulePath,
|
|
2865
|
+
name: op.name,
|
|
2866
|
+
args: this.portableStoryArgs.get(storyId) ?? {},
|
|
2867
|
+
}
|
|
2868
|
+
: op.kind === 'rename'
|
|
2869
|
+
? { op: 'rename' as const, file: story.modulePath, name: story.name, newName: op.newName }
|
|
2870
|
+
: { op: 'delete' as const, file: story.modulePath, name: story.name };
|
|
2871
|
+
const result = await writeCsfStory(request);
|
|
2872
|
+
if (!result.changed) {
|
|
2873
|
+
console.warn(
|
|
2874
|
+
`[ReactRootAuthoringAdapter] csf-story ${op.kind} refused for '${story.label}': ` +
|
|
2875
|
+
`${result.error ?? 'no change'}`,
|
|
2876
|
+
);
|
|
2877
|
+
return { changed: false, ...(result.error !== undefined ? { error: result.error } : {}) };
|
|
2878
|
+
}
|
|
2879
|
+
// Keep the IN-MEMORY story list truthful about the file it just rewrote
|
|
2880
|
+
// (blind-walk finding: rename left the old identity live, so the very
|
|
2881
|
+
// next delete refused with "no story export named '<old>'" until a full
|
|
2882
|
+
// page reload). Rename updates the entry it addressed; delete drops it.
|
|
2883
|
+
// Save-as visibility still needs the discovery refresh — recorded, not
|
|
2884
|
+
// silently claimed.
|
|
2885
|
+
if (op.kind === 'rename') {
|
|
2886
|
+
story.name = op.newName;
|
|
2887
|
+
story.label = op.newName.replace(/([a-z])([A-Z])/g, '$1 $2');
|
|
2888
|
+
} else if (op.kind === 'delete') {
|
|
2889
|
+
const index = this.portableStories.indexOf(story);
|
|
2890
|
+
if (index >= 0) this.portableStories.splice(index, 1);
|
|
2891
|
+
if (this.activeStoryId === storyId) this.activeStoryId = null;
|
|
2892
|
+
}
|
|
2893
|
+
this.dirty = true;
|
|
2894
|
+
this.store.notifyIngestEdit();
|
|
2895
|
+
return { changed: true };
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
/** Restore the selected story's composed CSF defaults. */
|
|
2899
|
+
resetPortableArgs(storyId: string): void {
|
|
2900
|
+
const story = this.portableStories.find((candidate) => candidate.id === storyId);
|
|
2901
|
+
if (!story) return;
|
|
2902
|
+
const next = { ...(story.args ?? {}) };
|
|
2903
|
+
this.portableStoryArgs.set(storyId, next);
|
|
2904
|
+
this.onPortableStoryArgsChanged?.(storyId, { ...next });
|
|
2905
|
+
this.store.notifyIngestEdit();
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
/** Cap 4: PropertyDescriptors for a node's editable component props (read off the fiber). */
|
|
2909
|
+
private propDescriptors(id: string): PropertyDescriptor[] {
|
|
2910
|
+
const n = this.snapshot().nodes.get(id);
|
|
2911
|
+
const cp = n ? getComponentProps(n.el) : null;
|
|
2912
|
+
if (!cp) return [];
|
|
2913
|
+
return Object.keys(cp.props).map((name) => ({
|
|
2914
|
+
path: `${PROP_PATH_PREFIX}${name}`,
|
|
2915
|
+
label: name,
|
|
2916
|
+
type: 'string' as const,
|
|
2917
|
+
group: 'Props',
|
|
2918
|
+
}));
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
/** Cap 7: read-only PropertyDescriptors for the document's design tokens (`:root` custom
|
|
2922
|
+
* properties, incl. Tailwind v4 @theme/oklch), shown under a "Tokens" section. */
|
|
2923
|
+
private tokenDescriptors(): PropertyDescriptor[] {
|
|
2924
|
+
// Tokens-as-noun: a token whose `:root` declaration traces to a project
|
|
2925
|
+
// stylesheet is WRITABLE (the edit lands on that declaration through the
|
|
2926
|
+
// css door and every binding moves at once); one set by script — a theme
|
|
2927
|
+
// data module mirroring itself onto `:root`, or inherited host style —
|
|
2928
|
+
// stays read-only, because its truth lives in that source, not in a rule
|
|
2929
|
+
// the css writer can reach.
|
|
2930
|
+
return this.designTokens().map((t) => ({
|
|
2931
|
+
path: `${TOKEN_PATH_PREFIX}${t.name}`,
|
|
2932
|
+
label: t.name,
|
|
2933
|
+
type: 'string' as const,
|
|
2934
|
+
group: 'Tokens',
|
|
2935
|
+
readonly: !t.declaredIn,
|
|
2936
|
+
}));
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
/**
|
|
2940
|
+
* The token-edit write: `writeCss` on the token's TRACED `:root`
|
|
2941
|
+
* declaration. Refusals are loud and name the remedy — an untraced token's
|
|
2942
|
+
* edit door is its declaring source (plain data), not this path.
|
|
2943
|
+
*/
|
|
2944
|
+
private async writeTokenValue(id: string, name: string, value: string): Promise<boolean> {
|
|
2945
|
+
const echoPath = `${TOKEN_PATH_PREFIX}${name}`;
|
|
2946
|
+
const token = this.designTokens().find((t) => t.name === name);
|
|
2947
|
+
if (!token) {
|
|
2948
|
+
this.clearEcho(id, echoPath);
|
|
2949
|
+
console.warn(`[ReactRootAuthoringAdapter] unknown design token "${name}".`);
|
|
2950
|
+
return false;
|
|
2951
|
+
}
|
|
2952
|
+
if (!token.declaredIn) {
|
|
2953
|
+
this.clearEcho(id, echoPath);
|
|
2954
|
+
console.warn(
|
|
2955
|
+
`[ReactRootAuthoringAdapter] token "${name}" has no stylesheet declaration to edit — ` +
|
|
2956
|
+
'it is set by script (a theme data module mirroring itself onto :root, or host ' +
|
|
2957
|
+
'style). Edit that source; it is plain data.',
|
|
2958
|
+
);
|
|
2959
|
+
return false;
|
|
2960
|
+
}
|
|
2961
|
+
const backend = this.writeBackend;
|
|
2962
|
+
if (!backend?.writeCss) {
|
|
2963
|
+
this.clearEcho(id, echoPath);
|
|
2964
|
+
console.warn(
|
|
2965
|
+
`[ReactRootAuthoringAdapter] cannot write token "${name}": no css-capable ` +
|
|
2966
|
+
'source-write backend in this session (hosted/no dev server).',
|
|
2967
|
+
);
|
|
2968
|
+
return false;
|
|
2969
|
+
}
|
|
2970
|
+
const res = await backend.writeCss(
|
|
2971
|
+
token.declaredIn.sourceFile,
|
|
2972
|
+
token.declaredIn.selector,
|
|
2973
|
+
name,
|
|
2974
|
+
value,
|
|
2975
|
+
);
|
|
2976
|
+
if (!res.changed) {
|
|
2977
|
+
this.clearEcho(id, echoPath);
|
|
2978
|
+
console.warn(
|
|
2979
|
+
`[ReactRootAuthoringAdapter] token write refused/no-op for "${name}" at ` +
|
|
2980
|
+
`${token.declaredIn.sourceFile} (${token.declaredIn.selector}): ${res.error ?? 'no change'}`,
|
|
2981
|
+
);
|
|
2982
|
+
return false;
|
|
2983
|
+
}
|
|
2984
|
+
this.dirty = true;
|
|
2985
|
+
this.store.notifyIngestEdit();
|
|
2986
|
+
return true;
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
/**
|
|
2990
|
+
* Portable Storybook CSF stories. `active`/`apply` are world-level because
|
|
2991
|
+
* applying a story remounts the whole React root. `storiesFor` narrows
|
|
2992
|
+
* presentation to the stories of the node's own component
|
|
2993
|
+
* (`portableStoriesForNode`) — but the world question, `WORLD_SCOPE_NODE_ID`,
|
|
2994
|
+
* is still answered with the whole list, because that is the id the shell
|
|
2995
|
+
* probes this provider's SCOPE with and the scope is world-level.
|
|
2996
|
+
* `apply` validates the id, updates `activeStoryId`, asks the design-time
|
|
2997
|
+
* layer to render that composed CSF story, and notifies the shell.
|
|
2998
|
+
*/
|
|
2999
|
+
readonly stories: StoriesProvider = {
|
|
3000
|
+
storiesFor: (nodeId): StoryRef[] =>
|
|
3001
|
+
this.portableStories.length > 0 ? this.portableStoriesForNode(nodeId) : [],
|
|
3002
|
+
active: (_nodeId) => this.activeStoryId,
|
|
3003
|
+
apply: (_nodeId, storyId) => {
|
|
3004
|
+
if (storyId !== null && !this.portableStories.some((story) => story.id === storyId)) {
|
|
3005
|
+
console.warn(
|
|
3006
|
+
`[ReactRootAuthoringAdapter] stories.apply: unknown portable CSF story id "${storyId}" — ignoring.`,
|
|
3007
|
+
);
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
3010
|
+
// Portable CSF always renders a design state. Clearing the picker means
|
|
3011
|
+
// "restore the document's default", not "orphan the live DOM roots
|
|
3012
|
+
// outside every story row".
|
|
3013
|
+
this.activeStoryId = storyId ?? this.portableDefaultStoryId;
|
|
3014
|
+
this.onPortableStoryApplied?.(storyId);
|
|
3015
|
+
this.store.notifyIngestEdit();
|
|
3016
|
+
},
|
|
3017
|
+
// This provider's states ARE the project's portable CSF, so it is the one
|
|
3018
|
+
// that can say whether discovery ran at all. Without this the section that
|
|
3019
|
+
// draws the empty list had to reach into the CSF registry itself, which is
|
|
3020
|
+
// one particular provider's private business.
|
|
3021
|
+
unavailable: () => storyDiscoveryUnavailable(),
|
|
3022
|
+
};
|
|
3023
|
+
|
|
3024
|
+
readonly structure: StructureProvider = {
|
|
3025
|
+
// Every structural op journals complete next-file text. Undo never needs to
|
|
3026
|
+
// address a moved or deleted element by an OID that may no longer exist.
|
|
3027
|
+
// The new element's OID is minted by the server on the next index, so the
|
|
3028
|
+
// id half is '' and the ack half is the write itself (`StructuralIdWrite`).
|
|
3029
|
+
create: (kind, parentId) => ({
|
|
3030
|
+
id: '',
|
|
3031
|
+
ack: this.structOp(parentId ?? '', 'create', { wrapperTag: kind }),
|
|
3032
|
+
}),
|
|
3033
|
+
// Returns `removeElement`'s own `Promise<void>` (not `void this....`) so
|
|
3034
|
+
// `deleteSelection` (`editor-hotkeys.ts`) can `await` each id's write
|
|
3035
|
+
// before firing the next — see the CORRECTNESS INVARIANT comment below
|
|
3036
|
+
// for why that serialization (plus deletion ORDER) is what makes the
|
|
3037
|
+
// per-id fallback loop sound for an adapter WITHOUT `removeMany` (any
|
|
3038
|
+
// adapter override lacking `structure.removeMany` — `deleteSelection`
|
|
3039
|
+
// always prefers a batched `removeMany` when present, see below).
|
|
3040
|
+
remove: (id) => this.removeElement(id),
|
|
3041
|
+
// delete-order-residual fix (bug-panel follow-up to the multi-delete
|
|
3042
|
+
// corruption fix, 50f90a6d) — SOUND batched delete, ONE undo entry for
|
|
3043
|
+
// the whole selection. D4.R2 originally investigated and REJECTED
|
|
3044
|
+
// `removeMany` here as unsound because the OBVIOUS implementation is N
|
|
3045
|
+
// separate `structOp`-style calls, each targeting its OID's `{file,
|
|
3046
|
+
// line, col}` from the SERVER's `OidStore.index`
|
|
3047
|
+
// (`vite-plugin-ui-oid.ts`'s `handleStruct`) — exactly the per-id
|
|
3048
|
+
// `remove` loop's own stale-offset hazard (see the CORRECTNESS INVARIANT
|
|
3049
|
+
// comment below), just without the ordering discipline that loop needs
|
|
3050
|
+
// to stay sound. This implementation is NOT that: `removeManyElements`
|
|
3051
|
+
// posts every id's raw oid in ONE request to `/__ui-source/struct-many`
|
|
3052
|
+
// (`handleStructMany`, `vite-plugin-ui-oid.ts`), which resolves every
|
|
3053
|
+
// oid's offset against a SINGLE shared `readFileSync` snapshot (never a
|
|
3054
|
+
// per-id re-read, so no write-to-write staleness is even possible) and
|
|
3055
|
+
// applies them highest-offset-first in one pass, one write. That is also
|
|
3056
|
+
// why this is the fix for the DOM-reordered-vs-source residual the
|
|
3057
|
+
// per-id fallback below still carries: this batch never consults
|
|
3058
|
+
// `collectAllNodeIds`/hierarchy-walk order at all, so it is correct
|
|
3059
|
+
// regardless of whether the live DOM's child order matches the .tsx
|
|
3060
|
+
// source order. (Explicitly NOT a live re-transform/re-scan per id
|
|
3061
|
+
// either — see `deleteElements`'s doc comment in `writer.ts` for why
|
|
3062
|
+
// that would reopen a DIFFERENT unsoundness: occurrence-index-based oid
|
|
3063
|
+
// identity churns when same-tag siblings are removed mid-batch.)
|
|
3064
|
+
// Returns `removeManyElements`'s own `Promise<void>` (not `void
|
|
3065
|
+
// this....`) so `deleteSelection` can `await` the whole batch's write
|
|
3066
|
+
// landing before it returns, same reason `remove` above does.
|
|
3067
|
+
removeMany: (ids) => this.removeManyElements(ids),
|
|
3068
|
+
//
|
|
3069
|
+
// CORRECTNESS INVARIANT for the per-id `remove` loop (bug-panel reopen,
|
|
3070
|
+
// post-D4.R2) — `deleteSelection` (`editor-hotkeys.ts`) falls back to
|
|
3071
|
+
// this loop only when `removeMany` is ABSENT (a different adapter
|
|
3072
|
+
// override); for THIS adapter `removeMany` above is always preferred, so
|
|
3073
|
+
// this loop is dead code for react-world today, kept sound and
|
|
3074
|
+
// documented for any future override without a batched delete. It is
|
|
3075
|
+
// safe ONLY under TWO conditions `deleteSelection` enforces together —
|
|
3076
|
+
// neither held before the 50f90a6d fix (an unsorted, unawaited loop was
|
|
3077
|
+
// a real source-corruption hazard, reachable by an ordinary top-first
|
|
3078
|
+
// marquee/multi-select delete) — AND both assume the caller's walk order
|
|
3079
|
+
// (`collectAllNodeIds`, DOM order for this adapter) matches true SOURCE
|
|
3080
|
+
// order (the DOM-reordered-vs-source residual this file's `removeMany`
|
|
3081
|
+
// fixes for THIS adapter specifically):
|
|
3082
|
+
// 1. REVERSE (walk-)DOCUMENT ORDER — delete the bottom-most element
|
|
3083
|
+
// first, per the caller's own hierarchy walk. Every element's
|
|
3084
|
+
// `{line, col}` in `OidStore.index` is a snapshot from the LAST
|
|
3085
|
+
// real parse and is never refreshed between same-file writes (only
|
|
3086
|
+
// by a real Vite `transform()` re-run — the async HMR round trip
|
|
3087
|
+
// `pendingSourceReconcile` already tracks). Deleting bottom-up
|
|
3088
|
+
// means every write only ever removes source that sits BELOW every
|
|
3089
|
+
// id still queued — nothing ABOVE a queued id's own offset ever
|
|
3090
|
+
// shifts, so that offset stays valid no matter how many of its
|
|
3091
|
+
// later-walked siblings/descendants have already been removed. A
|
|
3092
|
+
// descendant is later in DFS pre-order than its ancestor, so this
|
|
3093
|
+
// same reverse-pre-order rule also deletes a selected descendant
|
|
3094
|
+
// before a selected ancestor — required, since deleting the
|
|
3095
|
+
// ancestor first would remove the descendant's own source out from
|
|
3096
|
+
// under it before its turn. This is sound ONLY when walk order ==
|
|
3097
|
+
// source order (true for ordinary JSX; false when a component's
|
|
3098
|
+
// live DOM child order is a runtime permutation of its JSX source,
|
|
3099
|
+
// e.g. `{[...els].reverse()}` over an array of DISTINCT element
|
|
3100
|
+
// values).
|
|
3101
|
+
// 2. SERIALIZED WRITES — `deleteSelection` `await`s each `remove(id)`
|
|
3102
|
+
// (this method returns `removeElement`'s promise instead of firing
|
|
3103
|
+
// it `void`) before starting the next. `writeStruct` is a real
|
|
3104
|
+
// network POST in production (`source-write-backend.ts`); two
|
|
3105
|
+
// in-flight, un-awaited requests for the SAME file can each read it
|
|
3106
|
+
// BEFORE either has written back, so whichever write lands second
|
|
3107
|
+
// silently clobbers (loses) the first — a lost-update race
|
|
3108
|
+
// independent of ordering. Awaiting each id in turn guarantees
|
|
3109
|
+
// request N only starts once request N-1's write has landed.
|
|
3110
|
+
// Together, every write in the sequence reads a file that already
|
|
3111
|
+
// reflects every prior delete in the sequence, and targets an offset
|
|
3112
|
+
// still valid against that file — the "safe" claim this comment used to
|
|
3113
|
+
// make unconditionally, which was FALSE for the raw (unsorted, top-first)
|
|
3114
|
+
// selection order `deleteSelection` used to iterate in
|
|
3115
|
+
// (the structural-undo cases' own "offset-staleness
|
|
3116
|
+
// hazard" case, reachable through the real `deleteSelection` path, not
|
|
3117
|
+
// just a hand-picked unsafe direct-adapter call).
|
|
3118
|
+
// Hands the write's promise back for the same reason `remove` does (the
|
|
3119
|
+
// CORRECTNESS INVARIANT above): `duplicateSelection` awaits each id so two
|
|
3120
|
+
// same-file writes can never be in flight together.
|
|
3121
|
+
duplicate: (id) => ({ id, ack: this.structOp(id, 'duplicate') }),
|
|
3122
|
+
reparent: (id, newParentId) => {
|
|
3123
|
+
const parentOid = newParentId ? this.oidOf(newParentId) : undefined;
|
|
3124
|
+
if (!parentOid) {
|
|
3125
|
+
return this.structRefusal(
|
|
3126
|
+
`reparent refused: "${newParentId ?? 'the document root'}" has no authored source element to move into.`,
|
|
3127
|
+
);
|
|
3128
|
+
}
|
|
3129
|
+
return this.structOp(id, 'reparent', { parentOid });
|
|
3130
|
+
},
|
|
3131
|
+
// T0 (spec 27 §2): bring the pre-existing off-contract `reorder(id, beforeId,
|
|
3132
|
+
// parentId)` method (below) onto the contract. D2.b (spec 27 §6) fix: a
|
|
3133
|
+
// `null` `beforeSiblingId` means "move to the end of id's OWN current
|
|
3134
|
+
// parent" (this contract method's own doc comment) — that needs a REAL
|
|
3135
|
+
// `parentOid` to target (`reorder`'s own `parentStart != null` end-of-list
|
|
3136
|
+
// branch), so resolve `id`'s CURRENT parent from the live snapshot for
|
|
3137
|
+
// that case. A non-null `beforeSiblingId` already fully determines the
|
|
3138
|
+
// target position via `targetOid` alone, so `parentId` stays `null` there
|
|
3139
|
+
// (matching this method's pre-D2 behavior — no other caller of this
|
|
3140
|
+
// contract method existed before D2.b's canvas drag-to-reorder, which is
|
|
3141
|
+
// the first to actually exercise the null/"move to end" case).
|
|
3142
|
+
reorder: (id, beforeSiblingId) => {
|
|
3143
|
+
const parentId =
|
|
3144
|
+
beforeSiblingId === null ? (this.snapshot().nodes.get(id)?.parentId ?? null) : null;
|
|
3145
|
+
return this.reorder(id, beforeSiblingId, parentId);
|
|
3146
|
+
},
|
|
3147
|
+
// D3.e (spec 27 §2 T0 leftover) — the generic HTML kinds `create`
|
|
3148
|
+
// genuinely supports (see `CREATABLE_KINDS`'s doc comment below).
|
|
3149
|
+
// `parentId` is ignored: every OID node accepts any of these as a plain
|
|
3150
|
+
// child, mirroring `ui-authoring-adapter.ts`'s identically-parentId-
|
|
3151
|
+
// agnostic `creatableKinds`.
|
|
3152
|
+
creatableKinds: () => [...CREATABLE_KINDS],
|
|
3153
|
+
// D3.a (spec 27 §6) — bring the pre-existing off-contract `wrap`/`unwrap`
|
|
3154
|
+
// methods (below) onto the contract so the canvas context menu can reach
|
|
3155
|
+
// them through the adapter interface alone (rule zero).
|
|
3156
|
+
wrap: (id, wrapperTag) => this.wrap(id, wrapperTag),
|
|
3157
|
+
unwrap: (id) => this.unwrap(id),
|
|
3158
|
+
};
|
|
3159
|
+
|
|
3160
|
+
readonly assetDrop: AssetDropProvider = {
|
|
3161
|
+
accepts: (nodeId, assetPath, context) => this.assetDropPlan(nodeId, assetPath, context).ok,
|
|
3162
|
+
drop: (nodeId, assetPath, context) => {
|
|
3163
|
+
const plan = this.assetDropPlan(nodeId, assetPath, context);
|
|
3164
|
+
if (!plan.ok) return this.structRefusal(`asset drop refused — ${plan.reason}`);
|
|
3165
|
+
return this.structOp(plan.parentId, 'create', {
|
|
3166
|
+
snippet: plan.snippet,
|
|
3167
|
+
...(plan.ensureImport ? { ensureImport: plan.ensureImport } : {}),
|
|
3168
|
+
});
|
|
3169
|
+
},
|
|
3170
|
+
};
|
|
3171
|
+
|
|
3172
|
+
private assetDropPlan(
|
|
3173
|
+
nodeId: string,
|
|
3174
|
+
assetPath: string,
|
|
3175
|
+
context?: AssetDropContext,
|
|
3176
|
+
): DomAssetDropPlan {
|
|
3177
|
+
if (!this.writeBackend) {
|
|
3178
|
+
return { ok: false, reason: 'This session has no source writer, so nothing can be added.' };
|
|
3179
|
+
}
|
|
3180
|
+
const node = this.snapshot().nodes.get(nodeId);
|
|
3181
|
+
if (!node) return { ok: false, reason: 'That row has no authored JSX element.' };
|
|
3182
|
+
if (!canContainDomChildren(node.el, node.tag)) {
|
|
3183
|
+
return { ok: false, reason: `<${node.tag}> cannot contain child elements.` };
|
|
3184
|
+
}
|
|
3185
|
+
const component = context?.item?.kind === 'component' ? context.item : null;
|
|
3186
|
+
if (component) {
|
|
3187
|
+
if (component.surface !== 'dom') {
|
|
3188
|
+
return {
|
|
3189
|
+
ok: false,
|
|
3190
|
+
reason: `${component.name} is a ${component.surface} component, not a React UI component.`,
|
|
3191
|
+
};
|
|
3192
|
+
}
|
|
3193
|
+
const from = this.sourceLocation(nodeId)?.file;
|
|
3194
|
+
if (!from) {
|
|
3195
|
+
return { ok: false, reason: 'The target source location has not loaded yet.' };
|
|
3196
|
+
}
|
|
3197
|
+
return {
|
|
3198
|
+
ok: true,
|
|
3199
|
+
parentId: nodeId,
|
|
3200
|
+
snippet: `<${component.name} />`,
|
|
3201
|
+
ensureImport: {
|
|
3202
|
+
name: component.name,
|
|
3203
|
+
module: this.relativeProjectModule(from, component.sourcePath),
|
|
3204
|
+
kind: component.exportKind,
|
|
3205
|
+
},
|
|
3206
|
+
};
|
|
3207
|
+
}
|
|
3208
|
+
if (!IMAGE_ASSET_RE.test(assetPath)) {
|
|
3209
|
+
return {
|
|
3210
|
+
ok: false,
|
|
3211
|
+
reason: `${assetPath} is not a browser image asset and has no DOM element to become.`,
|
|
3212
|
+
};
|
|
3213
|
+
}
|
|
3214
|
+
return {
|
|
3215
|
+
ok: true,
|
|
3216
|
+
parentId: nodeId,
|
|
3217
|
+
snippet: `<img src=${JSON.stringify(assetPath)} alt="" />`,
|
|
3218
|
+
};
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
private relativeProjectModule(fromFile: string, targetFile: string): string {
|
|
3222
|
+
return relativeImportSpecifier(projectSourcePath(fromFile), projectSourcePath(targetFile));
|
|
3223
|
+
}
|
|
3224
|
+
/** Wrap the element in a new container (Cap 5). */
|
|
3225
|
+
wrap(id: string, wrapperTag = 'div'): Promise<WriteAck> {
|
|
3226
|
+
return this.structOp(id, 'wrap', { wrapperTag });
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
/** Replace the element with its children (Cap 5). */
|
|
3230
|
+
unwrap(id: string): Promise<WriteAck> {
|
|
3231
|
+
return this.structOp(id, 'unwrap');
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
/** Reorder the element before a sibling (`beforeId`), or to the end of `parentId` when
|
|
3235
|
+
* `beforeId` is null (Cap 5, layer-tree drag-to-reorder). */
|
|
3236
|
+
reorder(id: string, beforeId: string | null, parentId: string | null): Promise<WriteAck> {
|
|
3237
|
+
const opts: { targetOid?: string; parentOid?: string } = {};
|
|
3238
|
+
const targetOid = beforeId ? this.oidOf(beforeId) : undefined;
|
|
3239
|
+
const parentOid = parentId ? this.oidOf(parentId) : undefined;
|
|
3240
|
+
if (targetOid) opts.targetOid = targetOid;
|
|
3241
|
+
if (parentOid) opts.parentOid = parentOid;
|
|
3242
|
+
return this.structOp(id, 'reorder', opts);
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
/** The underlying (component-global) OID for an entity id (strips the `#n` repeat tag). */
|
|
3246
|
+
private oidOf(id: string): string | undefined {
|
|
3247
|
+
return this.snapshot().nodes.get(id)?.oid;
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
/**
|
|
3251
|
+
* C4 (spec §9): map an entity id to its component/source location, when available —
|
|
3252
|
+
* the read side of `oidIndex` (populated from `SourceWriteBackend.index()`, same as
|
|
3253
|
+
* `toEditorNode`'s label lookup). Returns `undefined` when the id has no OID (e.g. a
|
|
3254
|
+
* catalog node) or the index hasn't resolved (no dev-server backend, or the index
|
|
3255
|
+
* fetch hasn't landed yet) — an honest "unavailable", never a guess. The editor UI
|
|
3256
|
+
* (`@volter/editor-game/react/react-inspector-section.tsx`'s `LaneDisclosure`) uses this to offer copy-path /
|
|
3257
|
+
* go-to-line without ever exposing a write surface.
|
|
3258
|
+
*/
|
|
3259
|
+
sourceLocation(id: string): OidEntry | undefined {
|
|
3260
|
+
const oid = this.oidOf(id);
|
|
3261
|
+
if (!oid) return undefined;
|
|
3262
|
+
return this.oidIndex.get(oid);
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
/** Run a structural op through the persistence pipe (loud degradation without
|
|
3266
|
+
* a writer). */
|
|
3267
|
+
private structOp(id: string, op: string, opts?: StructOpOptions): Promise<WriteAck> {
|
|
3268
|
+
return this.structPipe.structOp(this.oidOf(id), id, op, opts, JSX_SOURCE_DESTINATION);
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
/**
|
|
3272
|
+
* T0 (spec 27 §4, B1) — the write body extracted from {@link writeStyle}, returning
|
|
3273
|
+
* whether source changed so a multi-property box gesture can reconcile once.
|
|
3274
|
+
* `priorInlineOverride`, when given, is used as the captured
|
|
3275
|
+
* PRE-gesture inline value instead of re-reading `n.el.style` — needed because
|
|
3276
|
+
* `boxEdit.apply` already mutated the live inline style for live preview before
|
|
3277
|
+
* `end` gets here, so a fresh DOM read would see the LAST previewed value, not the
|
|
3278
|
+
* true original (see {@link boxEditSession}'s doc comment). Unused by the plain CSS
|
|
3279
|
+
* cascade branch below (`pickCssRuleTarget`'s own `prevValue` comes from the matched
|
|
3280
|
+
* rule's text, not inline style, and is unaffected either way).
|
|
3281
|
+
*/
|
|
3282
|
+
/** The last style write this adapter refused, for the gesture that ends
|
|
3283
|
+
* with nothing written to say WHY where the author is looking. */
|
|
3284
|
+
private lastStyleWriteRefusal: string | null = null;
|
|
3285
|
+
private refuseStyleWrite(message: string): void {
|
|
3286
|
+
this.lastStyleWriteRefusal = message;
|
|
3287
|
+
console.warn(message);
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
private async writeStyleEntry(
|
|
3291
|
+
id: string,
|
|
3292
|
+
prop: string,
|
|
3293
|
+
value: string | number,
|
|
3294
|
+
priorInlineOverride?: string,
|
|
3295
|
+
backend: SourceWriteBackend | undefined = this.writeBackend,
|
|
3296
|
+
): Promise<boolean> {
|
|
3297
|
+
// A4 — the echo key `inspector.set` populated for this write; cleared on any failure
|
|
3298
|
+
// return below so a refused/unbacked write can't leave the field stuck on a value the
|
|
3299
|
+
// source never took.
|
|
3300
|
+
const echoPath = `${STYLE_PATH_PREFIX}${prop}`;
|
|
3301
|
+
const n = this.snapshot().nodes.get(id);
|
|
3302
|
+
if (!n) {
|
|
3303
|
+
this.clearEcho(id, echoPath);
|
|
3304
|
+
// THE ONE SILENT LOSS on this path, and the one the write-race report is
|
|
3305
|
+
// made of: the pipe turns a `false` here into an ack of
|
|
3306
|
+
// `live-only (not saved)` / `persisted: false`, which reads exactly like
|
|
3307
|
+
// the honest live-only floor while in fact NOTHING was attempted. Every
|
|
3308
|
+
// other refusal below names its gate; this one must too.
|
|
3309
|
+
this.refuseStyleWrite(
|
|
3310
|
+
`[ReactRootAuthoringAdapter] cannot write "${prop}": "${id}" no longer resolves in this ` +
|
|
3311
|
+
'root (the tree was re-projected — an HMR remount — between the selection and the ' +
|
|
3312
|
+
'write). Nothing was written. Re-select and retry.',
|
|
3313
|
+
);
|
|
3314
|
+
return false;
|
|
3315
|
+
}
|
|
3316
|
+
if (!backend) {
|
|
3317
|
+
this.clearEcho(id, echoPath);
|
|
3318
|
+
this.refuseStyleWrite(
|
|
3319
|
+
`[ReactRootAuthoringAdapter] cannot write "${prop}" on "${id}": no source-write ` +
|
|
3320
|
+
'backend in this session (hosted/no dev server) — selection/inspection still work.',
|
|
3321
|
+
);
|
|
3322
|
+
return false;
|
|
3323
|
+
}
|
|
3324
|
+
// A per-side longhand cannot share a style object with the shorthand that
|
|
3325
|
+
// owns it — React raises its own conflicting-property error and the winner
|
|
3326
|
+
// becomes key-order dependent. Expand the shorthand first, so the element
|
|
3327
|
+
// is authored in ONE vocabulary. A source no-op on a clean element.
|
|
3328
|
+
await this.expandShorthandsFor(id, prop, backend);
|
|
3329
|
+
// Cap 2 (React visual-edit parity): if a first-party CSS RULE declares this property,
|
|
3330
|
+
// edit that CSS FILE (cascade-correct: the last matched rule wins) instead of writing
|
|
3331
|
+
// inline/class. A `generated: true` response means the selector isn't in source
|
|
3332
|
+
// (Tailwind/styled-components) — fall through to the inline/class path below.
|
|
3333
|
+
// BREAKPOINT MODE: with an active breakpoint the edit lands in
|
|
3334
|
+
// `@media <bp>` for the element's NAMED STYLE — the only honest carrier
|
|
3335
|
+
// (an inline style cannot be responsive). No class, no css door ⇒ a
|
|
3336
|
+
// guarded refusal naming the remedy, never a base write that
|
|
3337
|
+
// misrepresents the gesture.
|
|
3338
|
+
const breakpoint = activeBreakpoint();
|
|
3339
|
+
if (breakpoint !== null) {
|
|
3340
|
+
const named = namedStyleRuleFor(this.matchedCssRules(n.el), classTokensOf(n.el));
|
|
3341
|
+
const guard = !backend.writeCss
|
|
3342
|
+
? 'breakpoint edits need the dev-server css door (a hosted session cannot write @media).'
|
|
3343
|
+
: named
|
|
3344
|
+
? null
|
|
3345
|
+
: 'no style class carries this element — create one in the Style class row first ' +
|
|
3346
|
+
'(inline styles cannot be responsive, so a breakpoint edit needs a class rule).';
|
|
3347
|
+
if (guard !== null || !named || !backend.writeCss) {
|
|
3348
|
+
this.clearEcho(id, echoPath);
|
|
3349
|
+
if (guard) {
|
|
3350
|
+
this.markGuarded(id, echoPath, guard);
|
|
3351
|
+
// The refusal must be SEEN where the gesture happened (blind-walk
|
|
3352
|
+
// finding: the guarded-readonly state alone read as a silent no-op
|
|
3353
|
+
// — the value just snapped back with no visible reason).
|
|
3354
|
+
showTransientHint(guard);
|
|
3355
|
+
}
|
|
3356
|
+
this.store.notifyIngestEdit();
|
|
3357
|
+
this.refuseStyleWrite(`[ReactRootAuthoringAdapter] breakpoint edit refused: ${guard}`);
|
|
3358
|
+
return false;
|
|
3359
|
+
}
|
|
3360
|
+
const res = await backend.writeCss(
|
|
3361
|
+
named.sourceFile,
|
|
3362
|
+
named.selectorText,
|
|
3363
|
+
prop,
|
|
3364
|
+
cssTextForStyleValue(prop, value),
|
|
3365
|
+
breakpoint,
|
|
3366
|
+
);
|
|
3367
|
+
if (!res.changed) {
|
|
3368
|
+
this.clearEcho(id, echoPath);
|
|
3369
|
+
this.refuseStyleWrite(
|
|
3370
|
+
`[ReactRootAuthoringAdapter] breakpoint css write refused/no-op for ` +
|
|
3371
|
+
`"${named.selectorText}" ${prop} at ${breakpoint}: ${res.error ?? 'no change'}`,
|
|
3372
|
+
);
|
|
3373
|
+
return false;
|
|
3374
|
+
}
|
|
3375
|
+
this.dirty = true;
|
|
3376
|
+
this.store.notifyIngestEdit();
|
|
3377
|
+
return true;
|
|
3378
|
+
}
|
|
3379
|
+
if (backend.writeCss) {
|
|
3380
|
+
const matchedRules = this.matchedCssRules(n.el);
|
|
3381
|
+
const cssTarget = pickCssRuleTarget(matchedRules, prop);
|
|
3382
|
+
// Named-style routing (design ledger: named styles, Webflow prior art).
|
|
3383
|
+
// No matched rule declares this property yet — but the element WEARS a
|
|
3384
|
+
// named style (a first-party single-class rule), and it has no live
|
|
3385
|
+
// inline declaration of the property (inline would beat the class in
|
|
3386
|
+
// cascade, making the write a dead declaration). Land the NEW
|
|
3387
|
+
// declaration in the class rule (`surgicalCssEdit` appends absent
|
|
3388
|
+
// properties), so the named style stays the element's one style home
|
|
3389
|
+
// instead of every later edit forking back to inline.
|
|
3390
|
+
const namedTarget =
|
|
3391
|
+
!cssTarget && !liveInlineDeclares(n.el, prop)
|
|
3392
|
+
? namedStyleRuleFor(matchedRules, classTokensOf(n.el))
|
|
3393
|
+
: null;
|
|
3394
|
+
const routedRule = cssTarget?.rule ?? namedTarget;
|
|
3395
|
+
if (routedRule) {
|
|
3396
|
+
const cssRoutedTarget = { rule: routedRule };
|
|
3397
|
+
const { rule } = cssRoutedTarget;
|
|
3398
|
+
// A CSS FILE declaration is CSS TEXT, so a numeric value needs its unit
|
|
3399
|
+
// spelled here (`300` -> `300px`) — unlike the JSX object below, where
|
|
3400
|
+
// React does the px-ifying. `css-numeric-style.ts` owns that rule once.
|
|
3401
|
+
const res = await backend.writeCss(
|
|
3402
|
+
rule.sourceFile,
|
|
3403
|
+
rule.selectorText,
|
|
3404
|
+
prop,
|
|
3405
|
+
cssTextForStyleValue(prop, value),
|
|
3406
|
+
);
|
|
3407
|
+
if (res.changed) {
|
|
3408
|
+
this.dirty = true;
|
|
3409
|
+
this.store.notifyIngestEdit();
|
|
3410
|
+
return true;
|
|
3411
|
+
}
|
|
3412
|
+
if (!res.generated) {
|
|
3413
|
+
this.clearEcho(id, echoPath);
|
|
3414
|
+
this.refuseStyleWrite(
|
|
3415
|
+
`[ReactRootAuthoringAdapter] CSS write refused/no-op for selector ` +
|
|
3416
|
+
`"${rule.selectorText}" prop "${prop}": ${res.error ?? 'no change'}`,
|
|
3417
|
+
);
|
|
3418
|
+
return false;
|
|
3419
|
+
}
|
|
3420
|
+
// res.generated ⇒ selector is generated CSS — fall through to inline/class routing.
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
value = preserveNumericStyleUnit(value, styleProp(n.el.style, prop));
|
|
3424
|
+
// NOT `String(value)`. A pixel-valued numeric descriptor must reach the writer as a
|
|
3425
|
+
// NUMBER so the JSX carries a bare `300`, which React px-ifies at render;
|
|
3426
|
+
// the quoted `'300'` this used to write is passed through verbatim by React
|
|
3427
|
+
// and REJECTED by the CSSOM, which keeps the previous value — a source
|
|
3428
|
+
// write with no rendered effect, acked `persisted: true`.
|
|
3429
|
+
const res = await backend.writeStyle(n.oid, prop, value);
|
|
3430
|
+
if (!res.changed) {
|
|
3431
|
+
this.clearEcho(id, echoPath);
|
|
3432
|
+
// Two guards, two sentences. A dynamic `{expression}` and a
|
|
3433
|
+
// `var(--token)` reference are both literals the writer will not
|
|
3434
|
+
// overwrite, and completely different facts to the author — so the
|
|
3435
|
+
// refusal that reaches the field carries the guard's own words.
|
|
3436
|
+
const guard = res.tokenRef
|
|
3437
|
+
? tokenReferenceGuardText(prop, res.tokenRef)
|
|
3438
|
+
: res.dynamic
|
|
3439
|
+
? DYNAMIC_EXPRESSION_GUARD
|
|
3440
|
+
: null;
|
|
3441
|
+
if (guard) this.markGuarded(id, echoPath, guard);
|
|
3442
|
+
// D2 — notify so the refusal actually RE-RENDERS: the cleared echo (field
|
|
3443
|
+
// snaps back off the stale value) and, for a guarded refusal, the now
|
|
3444
|
+
// `readonly: true` descriptor only reach the UI on a store tick. Without
|
|
3445
|
+
// this the widget keeps showing the refused value, enabled, until some
|
|
3446
|
+
// unrelated event happens to re-render.
|
|
3447
|
+
this.store.notifyIngestEdit();
|
|
3448
|
+
this.refuseStyleWrite(
|
|
3449
|
+
`[ReactRootAuthoringAdapter] style write refused/no-op for oid "${n.oid}" ` +
|
|
3450
|
+
`prop "${prop}": ${guard ?? res.error ?? 'no change'}`,
|
|
3451
|
+
);
|
|
3452
|
+
return false;
|
|
3453
|
+
}
|
|
3454
|
+
this.dirty = true;
|
|
3455
|
+
// D-A4: if this write took the writer's APPEND branch (no prior
|
|
3456
|
+
// literal for `prop`, e.g. spread-derived `style={{ ...vars }}`), undo must
|
|
3457
|
+
// REMOVE the appended prop rather than replay the write with `prev` — replaying
|
|
3458
|
+
// would find the NOW-appended literal and REPLACE it with a hardcoded runtime
|
|
3459
|
+
// value, baking a literal into a spot the source never had one. Redo is a plain
|
|
3460
|
+
// write either way (a re-append is just a write). B1-parity live preview for
|
|
3461
|
+
// single-prop inspector writes. The `boxEdit` gesture patches `n.el.style` live
|
|
3462
|
+
// during the drag (see `boxEdit.apply`), but a plain single-prop write (a color
|
|
3463
|
+
// / any inspector field) only wrote SOURCE — so the live element didn't repaint
|
|
3464
|
+
// until HMR/reload, which never lands in a headless harness (a color edit's
|
|
3465
|
+
// element stays the old color forever). Optimistically apply the
|
|
3466
|
+
// committed value to the live inline style here — mirroring
|
|
3467
|
+
// `dom-authoring-adapter`'s own `applyStyleToElement` — so the edit is
|
|
3468
|
+
// visible immediately; HMR then converges on the same value from source and the
|
|
3469
|
+
// A4 echo re-syncs. Skipped for the box-edit caller (`priorInlineOverride`
|
|
3470
|
+
// set), which already applied its own live preview and whose committed `value`
|
|
3471
|
+
// can differ from the inline CSS (e.g. a unitless length vs the `px` string it
|
|
3472
|
+
// painted). INLINE branch only — the CSS-cascade branch above returns early;
|
|
3473
|
+
// patching inline there would shadow the rule and stick past later edits.
|
|
3474
|
+
// The inline patch is CSS TEXT (a live `CSSStyleDeclaration`), so the unit
|
|
3475
|
+
// is spelled HERE while the source above keeps the bare number: assigning
|
|
3476
|
+
// `'300'` to `el.style.width` is rejected by the CSSOM exactly the way the
|
|
3477
|
+
// source string was, and the screen would not move until HMR landed.
|
|
3478
|
+
if (priorInlineOverride === undefined && n.el.style) {
|
|
3479
|
+
(n.el.style as Record<string, unknown>)[prop] = cssTextForStyleValue(prop, value);
|
|
3480
|
+
}
|
|
3481
|
+
this.store.notifyIngestEdit();
|
|
3482
|
+
return true;
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
private async writeStyle(id: string, prop: string, value: string | number): Promise<boolean> {
|
|
3486
|
+
return this.writeStyleEntry(id, prop, value);
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
/**
|
|
3490
|
+
* Make room for a per-side write by EXPANDING every shorthand that owns it.
|
|
3491
|
+
*
|
|
3492
|
+
* `SemanticSpacing` offers eight per-side rows on an element authored
|
|
3493
|
+
* `padding: 16`, and writing one of them used to leave BOTH in the style
|
|
3494
|
+
* object — React's own "don't mix shorthand and non-shorthand properties for
|
|
3495
|
+
* the same value" error, with an order-dependent result.
|
|
3496
|
+
*
|
|
3497
|
+
* The direction the combo widgets already handle is the mirror of this one:
|
|
3498
|
+
* going UNIFORM, `RadiusRow`/`ComboRow` remove the longhands so the shorthand
|
|
3499
|
+
* wins cleanly (`inspector.remove`). Going PER-SIDE, this removes the
|
|
3500
|
+
* shorthand and writes its resolved value into each sibling longhand first,
|
|
3501
|
+
* so nothing moves on screen and the element ends up authored in exactly one
|
|
3502
|
+
* vocabulary. Refusing instead was the alternative and it is strictly worse:
|
|
3503
|
+
* the per-side rows are offered, so refusing them would make the widget lie.
|
|
3504
|
+
*
|
|
3505
|
+
* Outermost shorthand first, because `border` owns `borderWidth` which owns
|
|
3506
|
+
* `borderTopWidth` — expanding one level authors the next, which the next
|
|
3507
|
+
* pass then expands. `removeStyleProp` is a source no-op when the shorthand
|
|
3508
|
+
* is not authored on this element, which is what gates the whole thing: a
|
|
3509
|
+
* clean element pays one no-op removal and keeps its source untouched.
|
|
3510
|
+
*
|
|
3511
|
+
* Values come from the element's COMPUTED style, the same read
|
|
3512
|
+
* `inspector.get` uses — the resolved value is what the author sees, and the
|
|
3513
|
+
* siblings are written to exactly what they already render at.
|
|
3514
|
+
*/
|
|
3515
|
+
private async expandShorthandsFor(
|
|
3516
|
+
id: string,
|
|
3517
|
+
prop: string,
|
|
3518
|
+
backend: SourceWriteBackend | undefined,
|
|
3519
|
+
): Promise<void> {
|
|
3520
|
+
const chain = shorthandChain(prop);
|
|
3521
|
+
if (chain.length === 0) return;
|
|
3522
|
+
const n = this.snapshot().nodes.get(id);
|
|
3523
|
+
if (!n || !backend) return;
|
|
3524
|
+
for (const shorthand of chain) {
|
|
3525
|
+
const longhands = SHORTHAND_LONGHANDS[shorthand] ?? [];
|
|
3526
|
+
// Read BEFORE the removal — the removal drops the live inline override,
|
|
3527
|
+
// so a read afterwards would see the cascade without it.
|
|
3528
|
+
const resolved = longhands.map((longhand) =>
|
|
3529
|
+
getComputedStyleValue(n.el, longhand, this.computedStyle),
|
|
3530
|
+
);
|
|
3531
|
+
// The SAME backend the caller writes through — a box-edit commit hands
|
|
3532
|
+
// in a scoped writer for atomicity, and an expansion that reached around
|
|
3533
|
+
// it would split one gesture across two transactions.
|
|
3534
|
+
if (!(await this.removeStyleProp(id, shorthand, backend))) continue; // not authored here
|
|
3535
|
+
for (const [index, longhand] of longhands.entries()) {
|
|
3536
|
+
if (longhand === prop) continue; // the caller writes this one itself
|
|
3537
|
+
const value = resolved[index];
|
|
3538
|
+
if (!value) continue;
|
|
3539
|
+
await this.writeStyleEntry(id, longhand, value, undefined, backend);
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
}
|
|
3543
|
+
|
|
3544
|
+
/**
|
|
3545
|
+
* U2 (spec 27 §5 C2) — surgically REMOVE a style property's source override
|
|
3546
|
+
* (`inspector.remove`'s style path). Captures the prior source literal first
|
|
3547
|
+
* so the removal is undoable (undo re-writes it, redo re-removes); a source
|
|
3548
|
+
* no-op (`changed: false`, e.g. the prop wasn't authored) pushes NO undo
|
|
3549
|
+
* entry and touches nothing — which is what makes it safe to call for every
|
|
3550
|
+
* corner/side unconditionally on a uniform edit. Also drops the live inline
|
|
3551
|
+
* override so the element re-cascades to the shorthand immediately (mirrors
|
|
3552
|
+
* `writeStyleEntry`'s inline live-preview, inverse direction).
|
|
3553
|
+
*/
|
|
3554
|
+
private async removeStyleProp(
|
|
3555
|
+
id: string,
|
|
3556
|
+
prop: string,
|
|
3557
|
+
backend: SourceWriteBackend | undefined = this.writeBackend,
|
|
3558
|
+
): Promise<boolean> {
|
|
3559
|
+
const n = this.snapshot().nodes.get(id);
|
|
3560
|
+
if (!n || !backend) return false;
|
|
3561
|
+
const res = await backend.removeStyle(n.oid, prop);
|
|
3562
|
+
if (!res.changed) return false; // longhand wasn't authored — nothing removed, no undo
|
|
3563
|
+
this.dirty = true;
|
|
3564
|
+
if (n.el.style) delete (n.el.style as Record<string, unknown>)[prop];
|
|
3565
|
+
this.store.notifyIngestEdit();
|
|
3566
|
+
return true;
|
|
3567
|
+
}
|
|
3568
|
+
|
|
3569
|
+
/**
|
|
3570
|
+
* Cap 3 (React visual-edit parity): replace a leaf element's pure-text content in source
|
|
3571
|
+
* (double-click-to-edit). Refused (guarded) when the body has an expression or child
|
|
3572
|
+
* elements. Undoable: the prior text (returned by the backend) is written back on undo.
|
|
3573
|
+
*/
|
|
3574
|
+
async editText(id: string, newText: string): Promise<void> {
|
|
3575
|
+
const n = this.snapshot().nodes.get(id);
|
|
3576
|
+
if (!n) return;
|
|
3577
|
+
if (!this.writeBackend?.writeText) {
|
|
3578
|
+
console.warn(
|
|
3579
|
+
`[ReactRootAuthoringAdapter] cannot edit text on "${id}": no source-write backend ` +
|
|
3580
|
+
'with text support in this session (hosted/no dev server).',
|
|
3581
|
+
);
|
|
3582
|
+
return;
|
|
3583
|
+
}
|
|
3584
|
+
const res = await this.writeBackend.writeText(n.oid, newText);
|
|
3585
|
+
if (!res.changed) {
|
|
3586
|
+
// D3.R1 (reopen fix) — a dynamic-body refusal marks this id readonly for
|
|
3587
|
+
// text edits (U4's session-scoped `guardedPaths`, read by `text.get`
|
|
3588
|
+
// above) and — mirroring D2's style/prop precedent — notifies the store
|
|
3589
|
+
// UNCONDITIONALLY so the refusal (and the overlay's refused indicator,
|
|
3590
|
+
// which polls `text.get` off this same notify) renders at refusal time
|
|
3591
|
+
// instead of silently vanishing until an unrelated event ticks the store.
|
|
3592
|
+
if (res.dynamic) {
|
|
3593
|
+
this.markGuarded(
|
|
3594
|
+
id,
|
|
3595
|
+
TEXT_PATH,
|
|
3596
|
+
'The body has an expression or child elements, so replacing it with text is guarded.',
|
|
3597
|
+
);
|
|
3598
|
+
}
|
|
3599
|
+
this.store.notifyIngestEdit();
|
|
3600
|
+
console.warn(
|
|
3601
|
+
`[ReactRootAuthoringAdapter] text edit refused/no-op for oid "${n.oid}": ` +
|
|
3602
|
+
`${res.dynamic ? 'body has an expression/children (guarded)' : (res.error ?? 'no change')}`,
|
|
3603
|
+
);
|
|
3604
|
+
return;
|
|
3605
|
+
}
|
|
3606
|
+
this.dirty = true;
|
|
3607
|
+
// D3.R5 (reopen fix) — a successful text write flips `hasText`, itself a
|
|
3608
|
+
// `findEmptyContainers` hint-eligibility criterion (`ui-source/inspect.ts`), yet
|
|
3609
|
+
// populates no `valueEcho` (echo is only ever set by the inspector style/prop
|
|
3610
|
+
// paths) — same pre-HMR-notify timing gap as a structural op (see
|
|
3611
|
+
// `pendingSourceReconcile`'s doc comment): queue a pending reconcile.
|
|
3612
|
+
this.pendingSourceReconcile++;
|
|
3613
|
+
this.store.notifyIngestEdit();
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
/**
|
|
3617
|
+
* Cap 4 (React visual-edit parity): write a component prop at its CALL SITE (the
|
|
3618
|
+
* `<Component …>` tag), resolved from the live fiber (`getComponentProps` → callSiteOid).
|
|
3619
|
+
* Refused (guarded) when the prop is a dynamic expression. Undoable via the prior fiber
|
|
3620
|
+
* value.
|
|
3621
|
+
*/
|
|
3622
|
+
private async writePropEdit(id: string, prop: string, value: string): Promise<boolean> {
|
|
3623
|
+
// A4 — the echo key `inspector.set` populated for this prop write; cleared on any
|
|
3624
|
+
// failure return so a refused write can't leave the field stuck.
|
|
3625
|
+
const echoPath = `${PROP_PATH_PREFIX}${prop}`;
|
|
3626
|
+
const n = this.snapshot().nodes.get(id);
|
|
3627
|
+
if (!n) {
|
|
3628
|
+
this.clearEcho(id, echoPath);
|
|
3629
|
+
return false;
|
|
3630
|
+
}
|
|
3631
|
+
if (!this.writeBackend?.writeProp) {
|
|
3632
|
+
this.clearEcho(id, echoPath);
|
|
3633
|
+
console.warn(
|
|
3634
|
+
`[ReactRootAuthoringAdapter] cannot write prop "${prop}" on "${id}": no source-write ` +
|
|
3635
|
+
'backend with prop support in this session (hosted/no dev server).',
|
|
3636
|
+
);
|
|
3637
|
+
return false;
|
|
3638
|
+
}
|
|
3639
|
+
const cp = getComponentProps(n.el);
|
|
3640
|
+
if (!cp) {
|
|
3641
|
+
this.clearEcho(id, echoPath);
|
|
3642
|
+
console.warn(
|
|
3643
|
+
`[ReactRootAuthoringAdapter] "${id}" is not a component call site with props — ` +
|
|
3644
|
+
`cannot write prop "${prop}".`,
|
|
3645
|
+
);
|
|
3646
|
+
return false;
|
|
3647
|
+
}
|
|
3648
|
+
const res = await this.writeBackend.writeProp(cp.callSiteOid, prop, value);
|
|
3649
|
+
if (!res.changed) {
|
|
3650
|
+
this.clearEcho(id, echoPath);
|
|
3651
|
+
if (res.dynamic) this.markGuarded(id, echoPath, DYNAMIC_EXPRESSION_GUARD);
|
|
3652
|
+
// D2 — notify so the cleared echo + (dynamic) new `readonly: true`
|
|
3653
|
+
// descriptor render at refusal time, not on the next unrelated event.
|
|
3654
|
+
this.store.notifyIngestEdit();
|
|
3655
|
+
console.warn(
|
|
3656
|
+
`[ReactRootAuthoringAdapter] prop write refused/no-op for oid "${cp.callSiteOid}" ` +
|
|
3657
|
+
`prop "${prop}": ${res.dynamic ? 'value is a dynamic expression (guarded)' : (res.error ?? 'no change')}`,
|
|
3658
|
+
);
|
|
3659
|
+
return false;
|
|
3660
|
+
}
|
|
3661
|
+
this.dirty = true;
|
|
3662
|
+
this.store.notifyIngestEdit();
|
|
3663
|
+
return true;
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
private removeElement(id: string): Promise<WriteAck> {
|
|
3667
|
+
const n = this.snapshot().nodes.get(id);
|
|
3668
|
+
if (!n) return this.structRefusal(`cannot delete "${id}": no such node in this world.`);
|
|
3669
|
+
// Whole-file source history restores deletion without fabricating an inverse
|
|
3670
|
+
// element insertion from an OID that no longer exists.
|
|
3671
|
+
return this.structPipe.structOp(n.oid, id, 'delete', undefined, JSX_SOURCE_DESTINATION);
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
/**
|
|
3675
|
+
* delete-order-residual fix — the `structure.removeMany` backing. Resolves every
|
|
3676
|
+
* `id` to its raw (non-disambiguated) oid, dedupes (a repeated-OID `.map` list's
|
|
3677
|
+
* `#n` siblings share ONE raw oid — deleting it once is correct, per
|
|
3678
|
+
* `walkOidTree`'s own disambiguation comment), and posts them ALL in ONE
|
|
3679
|
+
* `writeStructMany` call — see that method's doc comment on `SourceWriteBackend`
|
|
3680
|
+
* (`source-write-backend.ts`) and `handleStructMany`'s (`vite-plugin-ui-oid.ts`)
|
|
3681
|
+
* for the soundness argument (one shared file snapshot, highest-offset-first,
|
|
3682
|
+
* caller-order-independent). Degrades to a loud no-op — never a silent partial
|
|
3683
|
+
* delete — when this session's backend hasn't implemented `writeStructMany` (a
|
|
3684
|
+
* hosted/no-dev-server session, or an incomplete backend): `deleteSelection`
|
|
3685
|
+
* (`editor-hotkeys.ts`) only reaches this method because `structure.removeMany`
|
|
3686
|
+
* is present at all, so there is no further per-id fallback to drop into here.
|
|
3687
|
+
*/
|
|
3688
|
+
private removeManyElements(ids: readonly string[]): Promise<WriteAck> {
|
|
3689
|
+
const snapshot = this.snapshot();
|
|
3690
|
+
const oids = [
|
|
3691
|
+
...new Set(
|
|
3692
|
+
ids.map((id) => snapshot.nodes.get(id)?.oid).filter((oid): oid is string => !!oid),
|
|
3693
|
+
),
|
|
3694
|
+
];
|
|
3695
|
+
if (oids.length === 0) {
|
|
3696
|
+
return this.structRefusal('batch delete reached no source-addressable element.');
|
|
3697
|
+
}
|
|
3698
|
+
// One prepared batch source write becomes one history transaction.
|
|
3699
|
+
return this.structPipe.structMany(oids, 'delete', undefined, JSX_SOURCE_DESTINATION);
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
subscribe(listener: () => void): () => void {
|
|
3703
|
+
return this.store.subscribe(listener);
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
// A class GETTER, not a field initializer — see `ui-authoring-adapter.ts`/
|
|
3707
|
+
// `vgai-scene-authoring-adapter.ts` for why (field initializers run before the
|
|
3708
|
+
// constructor body assigns `this.writeBackend`).
|
|
3709
|
+
get persistence(): PersistenceProvider {
|
|
3710
|
+
const backend = this.writeBackend;
|
|
3711
|
+
return {
|
|
3712
|
+
isDirty: () => this.dirty,
|
|
3713
|
+
save: async () => {
|
|
3714
|
+
// Immediate-write architecture (same as UIAuthoringAdapter/
|
|
3715
|
+
// SourceWriteBackend's doc comment) — every edit already landed on disk
|
|
3716
|
+
// the instant it was made; nothing is pending to flush.
|
|
3717
|
+
},
|
|
3718
|
+
destination: backend ? JSX_SOURCE_DESTINATION : NO_BACKEND_DESTINATION,
|
|
3719
|
+
};
|
|
3720
|
+
}
|
|
3721
|
+
|
|
3722
|
+
/**
|
|
3723
|
+
* EVERY SOURCE WRITE this adapter performs, through the persistence pipe.
|
|
3724
|
+
*
|
|
3725
|
+
* One dialect here (the JSX/CSS source writer behind `/__ui-source`), so the
|
|
3726
|
+
* resolution is one question — is a writer bound in this session — and the
|
|
3727
|
+
* writer's own per-edit refusals (a dynamic expression, an unauthored prop, a
|
|
3728
|
+
* generated selector) come back as `false` with their reason on the console.
|
|
3729
|
+
* That is why they are a WRITE outcome rather than a resolution: the echo is
|
|
3730
|
+
* cleared and the field re-reads source, so the honest ack is the live-only
|
|
3731
|
+
* floor and the pipe supplies it.
|
|
3732
|
+
*
|
|
3733
|
+
* `record` does nothing because the source-write backend already carries the
|
|
3734
|
+
* whole-file history transaction; journaling here would double the undo.
|
|
3735
|
+
*/
|
|
3736
|
+
private pipedSourceWrite(write: () => Promise<boolean>): Promise<WriteAck> {
|
|
3737
|
+
return runWritePipe({
|
|
3738
|
+
resolve: (): WriteResolution =>
|
|
3739
|
+
this.writeBackend
|
|
3740
|
+
? {
|
|
3741
|
+
reaches: 'writer',
|
|
3742
|
+
anchorKind: 'source-prop',
|
|
3743
|
+
destination: JSX_SOURCE_DESTINATION,
|
|
3744
|
+
write,
|
|
3745
|
+
}
|
|
3746
|
+
: resolvesLiveOnly(NO_BACKEND_DESTINATION),
|
|
3747
|
+
record: () => undefined,
|
|
3748
|
+
// A session with no writer degrades LOUDLY — the same warning the write
|
|
3749
|
+
// helpers raised when they were the ones discovering it. Resolution moved
|
|
3750
|
+
// that discovery earlier; the sentence has to move with it or the lane
|
|
3751
|
+
// goes quiet for a whole class of edits.
|
|
3752
|
+
report: (reason) =>
|
|
3753
|
+
console.warn(`[ReactRootAuthoringAdapter] this edit stays live-only — ${reason}.`),
|
|
3754
|
+
});
|
|
3755
|
+
}
|
|
3756
|
+
|
|
3757
|
+
/**
|
|
3758
|
+
* EVERY STRUCTURAL SOURCE WRITE this adapter performs, through the pipe.
|
|
3759
|
+
*
|
|
3760
|
+
* A second plug point beside {@link pipedSourceWrite} rather than a reuse of
|
|
3761
|
+
* it, because a structural edit resolves on a DIFFERENT DOOR: `writeStruct` /
|
|
3762
|
+
* `writeStructMany`, not `writeStyle`/`writeProp`. That is why it acks
|
|
3763
|
+
* `source-structure` and not the value lane's `source-prop` — resolving a
|
|
3764
|
+
* delete on the attribute writer's presence, or naming the attribute lane in
|
|
3765
|
+
* its ack, is the classifier/writer split the pipe exists to make impossible.
|
|
3766
|
+
*
|
|
3767
|
+
* `record` does nothing: the backend is wrapped in `withProjectSourceHistory`
|
|
3768
|
+
* (`:957`), so the sha-guarded whole-file transaction that carries the bytes
|
|
3769
|
+
* IS the undo entry.
|
|
3770
|
+
*/
|
|
3771
|
+
/**
|
|
3772
|
+
* THE STRUCT DIALECT, one producer (`struct-write-pipe.ts`) shared with the
|
|
3773
|
+
* r3f and canvas source lanes. This lane's on-changed hook carries the
|
|
3774
|
+
* D3.R4 reconcile: the immediate notify's `storeVersion` still reflects the
|
|
3775
|
+
* PRE-HMR DOM (see `pendingSourceReconcile`'s doc comment), so a pending
|
|
3776
|
+
* reconcile is queued for the upcoming `vite:afterUpdate` to notify AGAIN
|
|
3777
|
+
* once the remount has actually happened.
|
|
3778
|
+
*/
|
|
3779
|
+
private readonly structPipe = createStructWritePipe({
|
|
3780
|
+
// biome-ignore lint/suspicious/noConsole: a refused source write must be visible
|
|
3781
|
+
report: (message) => console.warn(`[ReactRootAuthoringAdapter] ${message}`),
|
|
3782
|
+
noWriterReason: NO_BACKEND_DESTINATION,
|
|
3783
|
+
backend: () => this.writeBackend,
|
|
3784
|
+
onChanged: () => {
|
|
3785
|
+
this.dirty = true;
|
|
3786
|
+
this.pendingSourceReconcile++;
|
|
3787
|
+
this.store.notifyIngestEdit();
|
|
3788
|
+
},
|
|
3789
|
+
});
|
|
3790
|
+
|
|
3791
|
+
/** The shared pipe's live-only floor, under this lane's own label. */
|
|
3792
|
+
private structRefusal(reason: string): Promise<WriteAck> {
|
|
3793
|
+
return this.structPipe.structRefusal(reason);
|
|
3794
|
+
}
|
|
3795
|
+
}
|