@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,2496 @@
|
|
|
1
|
+
import { setAuthoringSelection } from '@volter/editor-core/authoring/consumer-actions';
|
|
2
|
+
import { faBookBookmark, faCode, faPalette } from '@fortawesome/free-solid-svg-icons';
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Checkbox,
|
|
6
|
+
DraftTextInput,
|
|
7
|
+
Select,
|
|
8
|
+
TextArea,
|
|
9
|
+
TextInput,
|
|
10
|
+
} from '@volter/editor-sdk/widgets';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* React-world inspector section (C2, spec 27 §5 C2) — the "wire the widgets"
|
|
14
|
+
* half of the widget story. `components/inspector-widgets/` is a PURE widget kit
|
|
15
|
+
* with no wiring; this module is the adapter-module contribution that plugs it
|
|
16
|
+
* into the ONE inspector shell (`components/Inspector.tsx`) via
|
|
17
|
+
* `inspector-section-registry.ts`, the SAME seam
|
|
18
|
+
* `authoring/first-party-inspector/register.tsx` (three) and
|
|
19
|
+
* `authoring/ui-inspector-sections.tsx` (scene-UI) already use — see those two
|
|
20
|
+
* files for the sanctioned pattern this one follows.
|
|
21
|
+
*
|
|
22
|
+
* Matches any node owned by a react/DOM adapter — `ReactRootAuthoringAdapter`
|
|
23
|
+
* (Lane B, `data-oid` component-source writes, T6.2) or
|
|
24
|
+
* `DomAuthoringAdapter` (the ingested/overlay DOM adapter) —
|
|
25
|
+
* whether the active adapter IS one of those directly (a bare single-world
|
|
26
|
+
* session) or is a `CompositeAuthoringAdapter` routing to one as `id`'s
|
|
27
|
+
* owner (multi-world edit mode). Renders every `PropertyDescriptor` the
|
|
28
|
+
* adapter's `InspectorProvider.properties(id)` reports, GROUPED by
|
|
29
|
+
* `PropertyDescriptor.group` (falling back to one flat "Style" group for an
|
|
30
|
+
* adapter, like `DomAuthoringAdapter`, whose descriptors declare none),
|
|
31
|
+
* mapping each descriptor to the right C1 widget instead of a generic
|
|
32
|
+
* `<TextInput>` — see `widgetKindFor`'s doc comment for the exact mapping table.
|
|
33
|
+
* It CLAIMS `PROPERTIES_SECTION_ID`, so it stands in for the shell's generic
|
|
34
|
+
* property grid on a matched node (Rule zero: this section still talks to the
|
|
35
|
+
* node ONLY through `adapter.inspector`/`adapter.hierarchy`, never the
|
|
36
|
+
* DOM/fiber the adapter itself holds).
|
|
37
|
+
*
|
|
38
|
+
* `onChange` (every interaction tick) and `onChangeEnd` (the commit) both
|
|
39
|
+
* route through the SAME `adapter.inspector.set` call — unlike
|
|
40
|
+
* `BoxEditProvider`'s begin/apply/end split (a spatial-gesture concern),
|
|
41
|
+
* `ReactRootAuthoringAdapter.inspector.set` has exactly ONE write
|
|
42
|
+
* path (T0 §2/A4): it echoes the value into its optimistic cache
|
|
43
|
+
* synchronously (so the field never snaps back) and fires the async
|
|
44
|
+
* source-write itself. There is no separate "preview without writing" mode
|
|
45
|
+
* to route `onChange` through, so both handlers call `commit`, mirroring the
|
|
46
|
+
* shell's own pre-existing `setProp` helper (`components/Inspector.tsx`)
|
|
47
|
+
* which already does the same on every keystroke of the generic grid this
|
|
48
|
+
* section replaces.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import {
|
|
52
|
+
activeBreakpoint,
|
|
53
|
+
setActiveBreakpoint,
|
|
54
|
+
subscribeBreakpoint,
|
|
55
|
+
} from '@volter/editor-core/authoring/breakpoint-state';
|
|
56
|
+
import { CompositeAuthoringAdapter } from '@volter/editor-core/authoring/composite-authoring-adapter';
|
|
57
|
+
import { numericStyleValue, UNITLESS_NUMBER_PROPS } from '@volter/editor-core/authoring/css-numeric-style';
|
|
58
|
+
import { beginEyedropperSession } from '@volter/editor-core/authoring/eyedropper-session';
|
|
59
|
+
import { groupProperties } from '@volter/editor-core/components/inspector-property-grouping';
|
|
60
|
+
import { useEditorStore } from '@volter/editor-core/editor-runtime';
|
|
61
|
+
import { createHmrRegistrationGroup } from '@volter/editor-core/hmr-registration-group';
|
|
62
|
+
import { groupIcon } from '@volter/editor-core/inspection/compose';
|
|
63
|
+
import {
|
|
64
|
+
GROUP_SECTION_ORDER,
|
|
65
|
+
groupSectionId,
|
|
66
|
+
type InspectionSection,
|
|
67
|
+
PROPERTIES_SECTION_ID,
|
|
68
|
+
PROPERTIES_SECTION_ORDER,
|
|
69
|
+
} from '@volter/editor-core/inspection/model';
|
|
70
|
+
import { registerInspectorSections } from '@volter/editor-core/inspector-section-registry';
|
|
71
|
+
import { effectiveColorFromChain, getAvailableFonts } from '@volter/editor-core/ui-source/inspect';
|
|
72
|
+
import type { OidEntry } from '@volter/editor-core/ui-source/oid-transform';
|
|
73
|
+
import type { AuthoringAdapter, EditorNode, PropertyDescriptor } from '@volter/editor-project/adapter';
|
|
74
|
+
import {
|
|
75
|
+
AlignmentGrid,
|
|
76
|
+
type AlignmentValue,
|
|
77
|
+
BorderEditor,
|
|
78
|
+
BorderRadiusEditor,
|
|
79
|
+
type BorderRadiusValue,
|
|
80
|
+
type BorderSide,
|
|
81
|
+
type BorderValue,
|
|
82
|
+
ColorSwatch,
|
|
83
|
+
composeFilter,
|
|
84
|
+
composeGradient,
|
|
85
|
+
composeShadows,
|
|
86
|
+
FilterEditor,
|
|
87
|
+
type FilterFunc,
|
|
88
|
+
FONT_MONO,
|
|
89
|
+
FontPicker,
|
|
90
|
+
fontSizeVar,
|
|
91
|
+
GradientEditor,
|
|
92
|
+
type GradientValue,
|
|
93
|
+
isUniformBorder,
|
|
94
|
+
isUniformRadius,
|
|
95
|
+
LABEL_COL_WIDTH,
|
|
96
|
+
lineHeightVar,
|
|
97
|
+
parseFilterFunctions,
|
|
98
|
+
parseGradient,
|
|
99
|
+
parseShadows,
|
|
100
|
+
radius,
|
|
101
|
+
rowStyle,
|
|
102
|
+
ScrubbableInput,
|
|
103
|
+
ShadowEditor,
|
|
104
|
+
spaceVar,
|
|
105
|
+
TextShadowEditor,
|
|
106
|
+
THEME,
|
|
107
|
+
} from '@volter/editor-sdk/widgets';
|
|
108
|
+
import { createElement, useMemo, useState, useSyncExternalStore } from 'react';
|
|
109
|
+
import { DomAuthoringAdapter } from './dom-authoring-adapter';
|
|
110
|
+
import { cssColorToHex, ReactRootAuthoringAdapter } from './react-world-authoring-adapter';
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The child adapter that actually owns `node.id` — a bare react/DOM adapter
|
|
114
|
+
* itself, or (via a `CompositeAuthoringAdapter`) whichever child's
|
|
115
|
+
* `ownerOf`/`childAdapters()` claims the id (the SAME public T0 query
|
|
116
|
+
* `components/Inspector.tsx`'s `worldGroupChildAdapter` uses — never decode a
|
|
117
|
+
* reserved id here). `null` for anything not owned by a
|
|
118
|
+
* react/DOM adapter (a three/scene-UI node, or an unresolved id).
|
|
119
|
+
*/
|
|
120
|
+
export function resolveReactOwner(
|
|
121
|
+
node: EditorNode | null,
|
|
122
|
+
adapter: AuthoringAdapter,
|
|
123
|
+
): ReactRootAuthoringAdapter | DomAuthoringAdapter | null {
|
|
124
|
+
if (!node) return null;
|
|
125
|
+
const target: AuthoringAdapter | null = (() => {
|
|
126
|
+
if (!(adapter instanceof CompositeAuthoringAdapter)) return adapter;
|
|
127
|
+
const owner = adapter.ownerOf(node.id);
|
|
128
|
+
if (owner === null) return null;
|
|
129
|
+
return adapter.childAdapters().find((c) => c.worldId === owner)?.adapter ?? null;
|
|
130
|
+
})();
|
|
131
|
+
if (target instanceof ReactRootAuthoringAdapter) return target;
|
|
132
|
+
if (target instanceof DomAuthoringAdapter) return target;
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The registry `match`: any real selection owned by a react/DOM adapter. */
|
|
137
|
+
export const isDomSelection = (node: EditorNode | null, adapter: AuthoringAdapter): boolean =>
|
|
138
|
+
resolveReactOwner(node, adapter) !== null;
|
|
139
|
+
|
|
140
|
+
/** The FACETS this adapter contributes, on the model's shared id/order scales
|
|
141
|
+
* (`inspection/model.ts`). A section id names a facet of the inspected thing
|
|
142
|
+
* — never the lane or adapter that happens to render it; what the object IS
|
|
143
|
+
* belongs to the subject's identity row. They lead: what the element IS comes
|
|
144
|
+
* before what it looks like, in the column and in the card's tab strip alike. */
|
|
145
|
+
export const REACT_ELEMENT_SECTION_ID = 'element';
|
|
146
|
+
const REACT_ELEMENT_SECTION_ORDER = 1500;
|
|
147
|
+
export const REACT_STORY_SECTION_ID = 'story';
|
|
148
|
+
const REACT_STORY_SECTION_ORDER = 1600;
|
|
149
|
+
|
|
150
|
+
// --- widget mapping (the C2 "map PropertyDescriptor.type/group -> widget" table) ---
|
|
151
|
+
|
|
152
|
+
export type ReactPropertyWidgetKind =
|
|
153
|
+
| 'color'
|
|
154
|
+
| 'font'
|
|
155
|
+
| 'gradient'
|
|
156
|
+
| 'shadow'
|
|
157
|
+
| 'text-shadow'
|
|
158
|
+
| 'filter'
|
|
159
|
+
| 'backdrop-filter'
|
|
160
|
+
| 'radius'
|
|
161
|
+
| 'transform'
|
|
162
|
+
| 'scrub'
|
|
163
|
+
| 'select'
|
|
164
|
+
| 'checkbox'
|
|
165
|
+
| 'json'
|
|
166
|
+
| 'text';
|
|
167
|
+
|
|
168
|
+
function propNameFromPath(path: string): string {
|
|
169
|
+
const idx = path.indexOf('.');
|
|
170
|
+
return idx === -1 ? path : path.slice(idx + 1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A single `PropertyDescriptor` -> the C1 widget that renders it. Structured
|
|
175
|
+
* CSS values (gradient/shadow/filter/radius) are matched by their well-known
|
|
176
|
+
* style-prop NAME (the react adapters' `STYLE_PROPERTIES` tables are the
|
|
177
|
+
* only source of these paths today — `PropertyDescriptor.type` alone can't
|
|
178
|
+
* distinguish a plain string from a composable CSS function list); every
|
|
179
|
+
* other descriptor maps off its plain `type`.
|
|
180
|
+
*/
|
|
181
|
+
export function widgetKindFor(prop: PropertyDescriptor): ReactPropertyWidgetKind {
|
|
182
|
+
const name = propNameFromPath(prop.path);
|
|
183
|
+
if (prop.type === 'color') return 'color';
|
|
184
|
+
if (/^fontFamily$/i.test(name)) return 'font';
|
|
185
|
+
if (name === 'background' || name === 'backgroundImage') return 'gradient';
|
|
186
|
+
if (name === 'boxShadow') return 'shadow';
|
|
187
|
+
if (name === 'textShadow') return 'text-shadow';
|
|
188
|
+
if (name === 'filter') return 'filter';
|
|
189
|
+
if (name === 'backdropFilter') return 'backdrop-filter';
|
|
190
|
+
if (name === 'borderRadius') return 'radius';
|
|
191
|
+
// Scoped to `type: 'string'` (the react-world `style.transform` CSS-string
|
|
192
|
+
// descriptor) so an unrelated `transform` name of a DIFFERENT type — e.g. a
|
|
193
|
+
// three `vec3` position/transform prop — still falls through to its own
|
|
194
|
+
// generic widget instead of being misread as a CSS transform string.
|
|
195
|
+
if (prop.type === 'string' && name === 'transform') return 'transform';
|
|
196
|
+
if (prop.type === 'boolean') return 'checkbox';
|
|
197
|
+
if (prop.type === 'enum') return 'select';
|
|
198
|
+
if (prop.type === 'json') return 'json';
|
|
199
|
+
if (prop.type === 'number') return 'scrub';
|
|
200
|
+
// 'string' / 'vec3' / 'asset' — no dedicated widget in this task's mapping;
|
|
201
|
+
// falls through to a plain text input (see the report's "no widget" list).
|
|
202
|
+
return 'text';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A composed row: either one property, or a "combo" of several sibling
|
|
206
|
+
* properties this section renders as ONE richer widget (per-side border,
|
|
207
|
+
* 3x3 flex alignment) instead of N separate fields. */
|
|
208
|
+
export type PropertyRow =
|
|
209
|
+
| {
|
|
210
|
+
kind: 'border';
|
|
211
|
+
width: PropertyDescriptor;
|
|
212
|
+
style: PropertyDescriptor;
|
|
213
|
+
color: PropertyDescriptor;
|
|
214
|
+
}
|
|
215
|
+
| { kind: 'align'; justify: PropertyDescriptor; align: PropertyDescriptor }
|
|
216
|
+
| { kind: 'single'; prop: PropertyDescriptor };
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Groups the react-world adapter's separate `borderWidth`/`borderStyle`/
|
|
220
|
+
* `borderColor` scalars into one `BorderEditor` row, and `justifyContent`/
|
|
221
|
+
* `alignItems` into one `AlignmentGrid` row — order-preserving single pass
|
|
222
|
+
* (a combo is emitted at the position of its FIRST member; already-consumed
|
|
223
|
+
* members are skipped later in the pass). Any property list missing one or
|
|
224
|
+
* more combo members (e.g. `DomAuthoringAdapter`'s flatter list) simply
|
|
225
|
+
* never forms that combo — every member renders as its own `single` row.
|
|
226
|
+
*/
|
|
227
|
+
/** Detects the `borderWidth`/`borderStyle`/`borderColor` trio starting at
|
|
228
|
+
* `p` and, if all three are present, pushes ONE `border` combo row. Split
|
|
229
|
+
* out of {@link buildPropertyRows} to keep its own cognitive complexity
|
|
230
|
+
* under biome's ceiling (same discipline as `GradientEditor.tsx`'s helper
|
|
231
|
+
* extraction). Returns `true` only when a combo row was actually formed (so
|
|
232
|
+
* the caller can `continue` past all three consumed members); `false` when
|
|
233
|
+
* `p` isn't a border-trio member, or is one but its siblings aren't present
|
|
234
|
+
* (falls through to an ordinary `single` row instead). */
|
|
235
|
+
function tryBorderCombo(
|
|
236
|
+
p: PropertyDescriptor,
|
|
237
|
+
byPath: ReadonlyMap<string, PropertyDescriptor>,
|
|
238
|
+
consumed: Set<string>,
|
|
239
|
+
rows: PropertyRow[],
|
|
240
|
+
): boolean {
|
|
241
|
+
const isBorderProp =
|
|
242
|
+
p.path === 'style.borderWidth' ||
|
|
243
|
+
p.path === 'style.borderStyle' ||
|
|
244
|
+
p.path === 'style.borderColor';
|
|
245
|
+
if (!isBorderProp) return false;
|
|
246
|
+
const width = byPath.get('style.borderWidth');
|
|
247
|
+
const style = byPath.get('style.borderStyle');
|
|
248
|
+
const color = byPath.get('style.borderColor');
|
|
249
|
+
if (!width || !style || !color) return false;
|
|
250
|
+
rows.push({ kind: 'border', width, style, color });
|
|
251
|
+
consumed.add(width.path);
|
|
252
|
+
consumed.add(style.path);
|
|
253
|
+
consumed.add(color.path);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Detects the `justifyContent`/`alignItems` pair starting at `p` and, if
|
|
258
|
+
* both are present, pushes ONE `align` combo row. See {@link tryBorderCombo}'s
|
|
259
|
+
* doc comment for why this is its own function. */
|
|
260
|
+
function tryAlignCombo(
|
|
261
|
+
p: PropertyDescriptor,
|
|
262
|
+
byPath: ReadonlyMap<string, PropertyDescriptor>,
|
|
263
|
+
consumed: Set<string>,
|
|
264
|
+
rows: PropertyRow[],
|
|
265
|
+
): boolean {
|
|
266
|
+
const isAlignProp = p.path === 'style.justifyContent' || p.path === 'style.alignItems';
|
|
267
|
+
if (!isAlignProp) return false;
|
|
268
|
+
const justify = byPath.get('style.justifyContent');
|
|
269
|
+
const align = byPath.get('style.alignItems');
|
|
270
|
+
if (!justify || !align) return false;
|
|
271
|
+
rows.push({ kind: 'align', justify, align });
|
|
272
|
+
consumed.add(justify.path);
|
|
273
|
+
consumed.add(align.path);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function buildPropertyRows(props: readonly PropertyDescriptor[]): PropertyRow[] {
|
|
278
|
+
const byPath = new Map(props.map((p) => [p.path, p] as const));
|
|
279
|
+
const consumed = new Set<string>();
|
|
280
|
+
const rows: PropertyRow[] = [];
|
|
281
|
+
for (const p of props) {
|
|
282
|
+
if (consumed.has(p.path)) continue;
|
|
283
|
+
if (tryBorderCombo(p, byPath, consumed, rows)) continue;
|
|
284
|
+
if (tryAlignCombo(p, byPath, consumed, rows)) continue;
|
|
285
|
+
rows.push({ kind: 'single', prop: p });
|
|
286
|
+
}
|
|
287
|
+
return rows;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function numericValue(v: unknown): number {
|
|
291
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function testIdFor(path: string): string {
|
|
295
|
+
return `react-prop-${path.replace(/\./g, '-')}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const DEFAULT_GRADIENT: GradientValue = {
|
|
299
|
+
type: 'linear',
|
|
300
|
+
angle: 180,
|
|
301
|
+
stops: [
|
|
302
|
+
{ color: '#ffffff', position: 0 },
|
|
303
|
+
{ color: '#000000', position: 1 },
|
|
304
|
+
],
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/** A labeled row shell, matching `components/Inspector.tsx`'s own generic
|
|
308
|
+
* property row layout (`NUM_STYLE`'s spacing/label conventions, ported to
|
|
309
|
+
* this kit's `THEME`/`rowStyle`) so a react section slots in visually next
|
|
310
|
+
* to the sections it replaces. The wrapper's own test id is the row's
|
|
311
|
+
* `${testId}-row` — DISTINCT from the interactive control inside it (which
|
|
312
|
+
* carries the bare `testId`), so a select/text/json control and its row
|
|
313
|
+
* wrapper never collide (spec 27 §5 C2, S1). */
|
|
314
|
+
function Field({
|
|
315
|
+
label,
|
|
316
|
+
testId,
|
|
317
|
+
children,
|
|
318
|
+
}: {
|
|
319
|
+
label: string;
|
|
320
|
+
testId?: string | undefined;
|
|
321
|
+
children: React.ReactNode;
|
|
322
|
+
}): React.ReactElement {
|
|
323
|
+
return (
|
|
324
|
+
<div
|
|
325
|
+
data-testid={testId ? `${testId}-row` : undefined}
|
|
326
|
+
style={{ ...rowStyle, padding: `${spaceVar[2]} ${spaceVar[4]}` }}
|
|
327
|
+
>
|
|
328
|
+
<span
|
|
329
|
+
style={{
|
|
330
|
+
width: LABEL_COL_WIDTH,
|
|
331
|
+
flexShrink: 0,
|
|
332
|
+
fontSize: fontSizeVar.base,
|
|
333
|
+
color: THEME.textMuted,
|
|
334
|
+
}}
|
|
335
|
+
>
|
|
336
|
+
{label}
|
|
337
|
+
</span>
|
|
338
|
+
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
|
|
339
|
+
</div>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** {@link PropertyRow}'s two combo variants — excludes `single` so
|
|
344
|
+
* {@link ComboRow} (only ever called for a combo row, see `GroupBlock`)
|
|
345
|
+
* narrows on `row.kind` without TS widening back to the full union. */
|
|
346
|
+
type ComboPropertyRow = Extract<PropertyRow, { kind: 'border' } | { kind: 'align' }>;
|
|
347
|
+
|
|
348
|
+
interface ComboRowProps {
|
|
349
|
+
adapter: AuthoringAdapter;
|
|
350
|
+
id: string;
|
|
351
|
+
row: ComboPropertyRow;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Every per-kind row renderer's shared inputs — `raw` is the CURRENT value
|
|
355
|
+
* read off `adapter.inspector.get`, `commit` writes a (possibly different)
|
|
356
|
+
* path through the SAME `adapter.inspector.set` + `notifyIngestEdit` pair
|
|
357
|
+
* (see the file doc comment) so a combo write-back (border/align, above)
|
|
358
|
+
* and a plain single-prop write share one code path. */
|
|
359
|
+
interface KindRowProps {
|
|
360
|
+
prop: PropertyDescriptor;
|
|
361
|
+
raw: unknown;
|
|
362
|
+
disabled: boolean | undefined;
|
|
363
|
+
testId: string;
|
|
364
|
+
commit: (path: string, value: unknown) => void;
|
|
365
|
+
fonts: readonly string[];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function ColorRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
|
|
369
|
+
return (
|
|
370
|
+
<Field label={prop.label} testId={testId}>
|
|
371
|
+
<ColorSwatch
|
|
372
|
+
testId={testId}
|
|
373
|
+
value={String(raw ?? '#ffffff')}
|
|
374
|
+
disabled={disabled}
|
|
375
|
+
onChange={(c) => commit(prop.path, c)}
|
|
376
|
+
onChangeEnd={(c) => commit(prop.path, c)}
|
|
377
|
+
eyedropperFallback={() => effectiveColorFromChain([String(raw ?? '')])}
|
|
378
|
+
// D3.d (spec 27 §6) — no native EyeDropper: begin an interactive
|
|
379
|
+
// canvas-pick session (`RootSelectionOverlay` renders the
|
|
380
|
+
// cursor-following swatch and resolves it) instead of the static
|
|
381
|
+
// synchronous fallback above, which this session-based path replaces
|
|
382
|
+
// whenever the host (this section, mounted inside the real editor)
|
|
383
|
+
// is present to wire it.
|
|
384
|
+
onEyedropperPick={(apply) => beginEyedropperSession(apply)}
|
|
385
|
+
/>
|
|
386
|
+
</Field>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function FontRow({ prop, raw, disabled, fonts, testId, commit }: KindRowProps): React.ReactElement {
|
|
391
|
+
return (
|
|
392
|
+
<Field label={prop.label} testId={testId}>
|
|
393
|
+
<FontPicker
|
|
394
|
+
testId={testId}
|
|
395
|
+
value={String(raw ?? '')}
|
|
396
|
+
fonts={fonts}
|
|
397
|
+
disabled={disabled}
|
|
398
|
+
onChange={(f) => commit(prop.path, f)}
|
|
399
|
+
/>
|
|
400
|
+
</Field>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function GradientRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
|
|
405
|
+
const parsed = parseGradient(String(raw ?? 'none'));
|
|
406
|
+
// S2 (anti-shim, CLAUDE.md "adapters never fabricate first-party data"): when
|
|
407
|
+
// the element has NO real gradient (`parseGradient` -> null), render an honest
|
|
408
|
+
// empty state — NOT a fabricated white→black `DEFAULT_GRADIENT` preview that
|
|
409
|
+
// misrepresents the source as already having a gradient. `DEFAULT_GRADIENT` is
|
|
410
|
+
// still the SEED for an explicit user "add" (a real, source-writing action),
|
|
411
|
+
// never a passive display value.
|
|
412
|
+
if (!parsed) {
|
|
413
|
+
return (
|
|
414
|
+
<Field label={prop.label} testId={testId}>
|
|
415
|
+
<Button
|
|
416
|
+
type="button"
|
|
417
|
+
variant="secondary"
|
|
418
|
+
className="vgai-field-trigger"
|
|
419
|
+
data-testid={testId}
|
|
420
|
+
disabled={disabled}
|
|
421
|
+
onClick={() => commit(prop.path, composeGradient(DEFAULT_GRADIENT))}
|
|
422
|
+
>
|
|
423
|
+
No gradient — click to add
|
|
424
|
+
</Button>
|
|
425
|
+
</Field>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
return (
|
|
429
|
+
<Field label={prop.label} testId={testId}>
|
|
430
|
+
<GradientEditor
|
|
431
|
+
testId={testId}
|
|
432
|
+
value={parsed}
|
|
433
|
+
disabled={disabled}
|
|
434
|
+
onChange={(g) => commit(prop.path, composeGradient(g))}
|
|
435
|
+
onChangeEnd={(g) => commit(prop.path, composeGradient(g))}
|
|
436
|
+
/>
|
|
437
|
+
</Field>
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function ShadowRow({
|
|
442
|
+
prop,
|
|
443
|
+
raw,
|
|
444
|
+
disabled,
|
|
445
|
+
testId,
|
|
446
|
+
commit,
|
|
447
|
+
withSpreadInset,
|
|
448
|
+
}: KindRowProps & { withSpreadInset: boolean }): React.ReactElement {
|
|
449
|
+
const parsed = parseShadows(String(raw ?? 'none'), withSpreadInset);
|
|
450
|
+
const Widget = withSpreadInset ? ShadowEditor : TextShadowEditor;
|
|
451
|
+
return (
|
|
452
|
+
<Field label={prop.label} testId={testId}>
|
|
453
|
+
<Widget
|
|
454
|
+
value={parsed}
|
|
455
|
+
disabled={disabled}
|
|
456
|
+
onChange={(list) => commit(prop.path, composeShadows(list, withSpreadInset))}
|
|
457
|
+
onChangeEnd={(list) => commit(prop.path, composeShadows(list, withSpreadInset))}
|
|
458
|
+
/>
|
|
459
|
+
</Field>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function FilterRow({
|
|
464
|
+
prop,
|
|
465
|
+
raw,
|
|
466
|
+
disabled,
|
|
467
|
+
testId,
|
|
468
|
+
commit,
|
|
469
|
+
label,
|
|
470
|
+
}: KindRowProps & { label: string }): React.ReactElement {
|
|
471
|
+
const parsed = parseFilterFunctions(String(raw ?? 'none'));
|
|
472
|
+
return (
|
|
473
|
+
<Field label={prop.label} testId={testId}>
|
|
474
|
+
<FilterEditor
|
|
475
|
+
testId={testId}
|
|
476
|
+
label={label}
|
|
477
|
+
value={parsed}
|
|
478
|
+
disabled={disabled}
|
|
479
|
+
onChange={(f: FilterFunc[]) => commit(prop.path, composeFilter(f))}
|
|
480
|
+
onChangeEnd={(f: FilterFunc[]) => commit(prop.path, composeFilter(f))}
|
|
481
|
+
/>
|
|
482
|
+
</Field>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Corner suffixes (`borderTopLeftRadius`, …) shared by {@link RadiusRow}'s
|
|
487
|
+
* read + write-back — the per-corner longhand CSS the shorthand `borderRadius`
|
|
488
|
+
* descriptor (`prop`/`raw`/`commit` above) never had a slot for. */
|
|
489
|
+
const RADIUS_CORNERS: ReadonlyArray<{ key: keyof BorderRadiusValue; suffix: string }> = [
|
|
490
|
+
{ key: 'topLeft', suffix: 'TopLeft' },
|
|
491
|
+
{ key: 'topRight', suffix: 'TopRight' },
|
|
492
|
+
{ key: 'bottomLeft', suffix: 'BottomLeft' },
|
|
493
|
+
{ key: 'bottomRight', suffix: 'BottomRight' },
|
|
494
|
+
];
|
|
495
|
+
|
|
496
|
+
/** U2 (spec 27 §5 "Effects: radius per-corner") — true per-corner write-back.
|
|
497
|
+
* Reads each corner's own `style.border<Corner>Radius` longhand through
|
|
498
|
+
* `adapter.inspector.get` (falling back to the shorthand `raw` when a
|
|
499
|
+
* corner's own computed value is unset/unparseable — the vitest `node` env
|
|
500
|
+
* resolver falls back to inline `.style`, which has no implicit per-corner
|
|
501
|
+
* values the way a REAL computed style always does) so the widget reflects
|
|
502
|
+
* a PRIOR non-uniform edit instead of collapsing back to uniform on every
|
|
503
|
+
* re-render. Write-back keeps the uniform shorthand fast-path (`commit`
|
|
504
|
+
* the same `borderRadius` path this row already used) but writes the four
|
|
505
|
+
* longhand corners when the widget reports a non-uniform value — removing
|
|
506
|
+
* the documented `:397-409` limitation (per-corner UI, uniform-only write). */
|
|
507
|
+
function RadiusRow({
|
|
508
|
+
adapter,
|
|
509
|
+
id,
|
|
510
|
+
prop,
|
|
511
|
+
raw,
|
|
512
|
+
disabled,
|
|
513
|
+
testId,
|
|
514
|
+
commit,
|
|
515
|
+
removeStyle,
|
|
516
|
+
}: KindRowProps & {
|
|
517
|
+
adapter: AuthoringAdapter;
|
|
518
|
+
id: string;
|
|
519
|
+
removeStyle: (path: string) => void;
|
|
520
|
+
}): React.ReactElement {
|
|
521
|
+
const fallback = numericValue(raw);
|
|
522
|
+
const value: BorderRadiusValue = RADIUS_CORNERS.reduce((acc, { key, suffix }) => {
|
|
523
|
+
const cornerRaw = adapter.inspector?.get(id, `style.border${suffix}Radius`);
|
|
524
|
+
acc[key] = numericStyleValue(cornerRaw) ?? fallback;
|
|
525
|
+
return acc;
|
|
526
|
+
}, {} as BorderRadiusValue);
|
|
527
|
+
const writeBack = (v: BorderRadiusValue): void => {
|
|
528
|
+
if (isUniformRadius(v)) {
|
|
529
|
+
commit(prop.path, v.topLeft);
|
|
530
|
+
// D1 — a uniform edit must also CLEAR any per-corner longhands a prior
|
|
531
|
+
// non-uniform edit wrote, or the stale longhand silently overrides the
|
|
532
|
+
// shorthand on reload. `removeStyle` no-ops when the longhand isn't
|
|
533
|
+
// authored, so this is safe on a clean element (never pollutes source).
|
|
534
|
+
for (const { suffix } of RADIUS_CORNERS) {
|
|
535
|
+
removeStyle(`style.border${suffix}Radius`);
|
|
536
|
+
}
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
for (const { key, suffix } of RADIUS_CORNERS) {
|
|
540
|
+
commit(`style.border${suffix}Radius`, v[key]);
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
return (
|
|
544
|
+
<Field label={prop.label} testId={testId}>
|
|
545
|
+
<BorderRadiusEditor
|
|
546
|
+
value={value}
|
|
547
|
+
disabled={disabled}
|
|
548
|
+
onChange={writeBack}
|
|
549
|
+
onChangeEnd={writeBack}
|
|
550
|
+
/>
|
|
551
|
+
</Field>
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function ScrubRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
|
|
556
|
+
const unit = UNITLESS_NUMBER_PROPS.has(propNameFromPath(prop.path)) ? '' : 'px';
|
|
557
|
+
return (
|
|
558
|
+
<ScrubbableInput
|
|
559
|
+
testId={testId}
|
|
560
|
+
label={prop.label}
|
|
561
|
+
value={numericValue(raw)}
|
|
562
|
+
unit={unit}
|
|
563
|
+
disabled={disabled}
|
|
564
|
+
onChange={(v) => commit(prop.path, v)}
|
|
565
|
+
onChangeEnd={(v) => commit(prop.path, v)}
|
|
566
|
+
style={{ padding: `${spaceVar[2]} ${spaceVar[4]}` }}
|
|
567
|
+
/>
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function SelectRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
|
|
572
|
+
return (
|
|
573
|
+
<Field label={prop.label} testId={testId}>
|
|
574
|
+
<Select
|
|
575
|
+
data-testid={testId}
|
|
576
|
+
value={String(raw ?? '')}
|
|
577
|
+
disabled={disabled}
|
|
578
|
+
data-dynamic={disabled || undefined}
|
|
579
|
+
onChange={(e) => commit(prop.path, e.target.value)}
|
|
580
|
+
style={{ width: '100%' }}
|
|
581
|
+
>
|
|
582
|
+
{(prop.options ?? []).map((o) => (
|
|
583
|
+
<option key={String(o)} value={String(o)}>
|
|
584
|
+
{String(o)}
|
|
585
|
+
</option>
|
|
586
|
+
))}
|
|
587
|
+
</Select>
|
|
588
|
+
</Field>
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function CheckboxRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
|
|
593
|
+
return (
|
|
594
|
+
<label
|
|
595
|
+
style={{
|
|
596
|
+
display: 'flex',
|
|
597
|
+
gap: spaceVar[3],
|
|
598
|
+
alignItems: 'center',
|
|
599
|
+
padding: `${spaceVar[3]} ${spaceVar[4]}`,
|
|
600
|
+
fontSize: fontSizeVar.md,
|
|
601
|
+
}}
|
|
602
|
+
>
|
|
603
|
+
<Checkbox
|
|
604
|
+
className="vgai-checkbox"
|
|
605
|
+
data-testid={testId}
|
|
606
|
+
checked={raw !== false}
|
|
607
|
+
disabled={disabled}
|
|
608
|
+
onChange={(e) => commit(prop.path, e.target.checked)}
|
|
609
|
+
/>
|
|
610
|
+
{prop.label}
|
|
611
|
+
</label>
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function JsonRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
|
|
616
|
+
return (
|
|
617
|
+
<Field label={prop.label} testId={testId}>
|
|
618
|
+
<TextArea
|
|
619
|
+
data-testid={testId}
|
|
620
|
+
defaultValue={JSON.stringify(raw ?? null)}
|
|
621
|
+
disabled={disabled}
|
|
622
|
+
data-dynamic={disabled || undefined}
|
|
623
|
+
data-variant="code"
|
|
624
|
+
style={{ width: '100%', minHeight: 40 }}
|
|
625
|
+
onBlur={(e) => {
|
|
626
|
+
try {
|
|
627
|
+
commit(prop.path, JSON.parse(e.target.value));
|
|
628
|
+
} catch {
|
|
629
|
+
// Invalid JSON — leave the field as typed, don't write garbage.
|
|
630
|
+
}
|
|
631
|
+
}}
|
|
632
|
+
/>
|
|
633
|
+
</Field>
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function TextRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
|
|
638
|
+
// Commit once, on Enter/blur — a per-keystroke write re-projects the board
|
|
639
|
+
// under the typist (see `ScrubbableInput`'s draft for the measurement).
|
|
640
|
+
return (
|
|
641
|
+
<Field label={prop.label} testId={testId}>
|
|
642
|
+
<DraftTextInput
|
|
643
|
+
type="text"
|
|
644
|
+
data-testid={testId}
|
|
645
|
+
readOnly={disabled}
|
|
646
|
+
data-dynamic={disabled || undefined}
|
|
647
|
+
value={String(raw ?? '')}
|
|
648
|
+
onCommit={(next) => commit(prop.path, next)}
|
|
649
|
+
style={{ width: '100%' }}
|
|
650
|
+
/>
|
|
651
|
+
</Field>
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** U3 (spec 27 §5 "Transform: rotate/scale") — a structured `{rotateDeg, scale}`
|
|
656
|
+
* pair, when `style.transform` is losslessly representable that way. */
|
|
657
|
+
export interface TransformValue {
|
|
658
|
+
rotateDeg: number;
|
|
659
|
+
scale: number;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const TRANSFORM_ROTATE_SCALE_RE = /^rotate\(\s*(-?[\d.]+)deg\s*\)\s*scale\(\s*(-?[\d.]+)\s*\)$/i;
|
|
663
|
+
const TRANSFORM_SCALE_ROTATE_RE = /^scale\(\s*(-?[\d.]+)\s*\)\s*rotate\(\s*(-?[\d.]+)deg\s*\)$/i;
|
|
664
|
+
// S3 — single-function literals (a JSX-authored `rotate(45deg)` or `scale(1.5)`
|
|
665
|
+
// alone, missing axis defaulted): computed style is always a matrix in the real
|
|
666
|
+
// browser, but these are exactly what an author hand-writes, so give them the
|
|
667
|
+
// structured row too rather than the raw TextRow.
|
|
668
|
+
const TRANSFORM_ROTATE_ONLY_RE = /^rotate\(\s*(-?[\d.]+)deg\s*\)$/i;
|
|
669
|
+
const TRANSFORM_SCALE_ONLY_RE = /^scale\(\s*(-?[\d.]+)\s*\)$/i;
|
|
670
|
+
const TRANSFORM_MATRIX_RE =
|
|
671
|
+
/^matrix\(\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*\)$/;
|
|
672
|
+
const TRANSFORM_EPS = 1e-4;
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Parse a `style.transform` value (either the author's literal
|
|
676
|
+
* `rotate(<deg>deg) scale(<n>)` — the exact form this row writes — or a
|
|
677
|
+
* REAL browser's computed 2x2 `matrix(a, b, c, d, e, f)`, since Chromium
|
|
678
|
+
* always normalizes an authored transform to matrix form, unlike this
|
|
679
|
+
* repo's headless/vitest fallback which echoes the literal string straight
|
|
680
|
+
* from inline style) into a structured `{rotateDeg, scale}` pair, or `null`
|
|
681
|
+
* when the value can't be represented that way LOSSLESSLY — a nonzero
|
|
682
|
+
* translate (`e`/`f`), a skew/non-uniform scale (`c !== -b` or `d !== a`),
|
|
683
|
+
* `matrix3d(...)`, or any other transform function list. `null` is the
|
|
684
|
+
* signal {@link TransformRow} uses to fall back to the plain `TextRow` —
|
|
685
|
+
* every value this doesn't understand stays editable as raw text, so no
|
|
686
|
+
* expressiveness is ever silently dropped.
|
|
687
|
+
*/
|
|
688
|
+
export function parseTransform(raw: string): TransformValue | null {
|
|
689
|
+
const v = raw.trim();
|
|
690
|
+
if (v === '' || v === 'none') return { rotateDeg: 0, scale: 1 };
|
|
691
|
+
const lit = TRANSFORM_ROTATE_SCALE_RE.exec(v);
|
|
692
|
+
if (lit) return { rotateDeg: Number(lit[1]), scale: Number(lit[2]) };
|
|
693
|
+
const swapped = TRANSFORM_SCALE_ROTATE_RE.exec(v);
|
|
694
|
+
if (swapped) return { rotateDeg: Number(swapped[2]), scale: Number(swapped[1]) };
|
|
695
|
+
const rotOnly = TRANSFORM_ROTATE_ONLY_RE.exec(v);
|
|
696
|
+
if (rotOnly) return { rotateDeg: Number(rotOnly[1]), scale: 1 }; // S3 — missing scale defaults 1
|
|
697
|
+
const scaleOnly = TRANSFORM_SCALE_ONLY_RE.exec(v);
|
|
698
|
+
if (scaleOnly) return { rotateDeg: 0, scale: Number(scaleOnly[1]) }; // S3 — missing rotate defaults 0
|
|
699
|
+
const m = TRANSFORM_MATRIX_RE.exec(v);
|
|
700
|
+
if (!m) return null; // matrix3d / translate / skew / any other function list
|
|
701
|
+
const [a, b, c, d, e, f] = m.slice(1, 7).map(Number) as [
|
|
702
|
+
number,
|
|
703
|
+
number,
|
|
704
|
+
number,
|
|
705
|
+
number,
|
|
706
|
+
number,
|
|
707
|
+
number,
|
|
708
|
+
];
|
|
709
|
+
if (Math.abs(e) > TRANSFORM_EPS || Math.abs(f) > TRANSFORM_EPS) return null; // has a translate
|
|
710
|
+
if (Math.abs(c + b) > TRANSFORM_EPS || Math.abs(d - a) > TRANSFORM_EPS) return null; // skew / non-uniform scale
|
|
711
|
+
const scale = Math.hypot(a, b);
|
|
712
|
+
if (scale < TRANSFORM_EPS) return null; // degenerate (zero scale)
|
|
713
|
+
const rotateDeg = (Math.atan2(b, a) * 180) / Math.PI;
|
|
714
|
+
return {
|
|
715
|
+
rotateDeg: Math.round(rotateDeg * 100) / 100,
|
|
716
|
+
scale: Math.round(scale * 1000) / 1000,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** The inverse of {@link parseTransform}'s literal fast-path — always
|
|
721
|
+
* composes the readable `rotate()/scale()` form, never a matrix, so the
|
|
722
|
+
* written source stays human-editable. */
|
|
723
|
+
export function composeTransform(v: TransformValue): string {
|
|
724
|
+
return `rotate(${v.rotateDeg}deg) scale(${v.scale})`;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function TransformRow(props: KindRowProps): React.ReactElement {
|
|
728
|
+
const { prop, raw, disabled, testId, commit } = props;
|
|
729
|
+
const parsed = parseTransform(String(raw ?? 'none'));
|
|
730
|
+
if (!parsed) return <TextRow {...props} />; // safety net — no expressiveness lost
|
|
731
|
+
const writeBack = (next: TransformValue): void => commit(prop.path, composeTransform(next));
|
|
732
|
+
return (
|
|
733
|
+
<Field label={prop.label} testId={testId}>
|
|
734
|
+
<div style={{ display: 'flex', gap: spaceVar[3] }}>
|
|
735
|
+
<ScrubbableInput
|
|
736
|
+
testId={`${testId}-rotate`}
|
|
737
|
+
label="Rotate"
|
|
738
|
+
value={parsed.rotateDeg}
|
|
739
|
+
unit="deg"
|
|
740
|
+
disabled={disabled}
|
|
741
|
+
onChange={(v) => writeBack({ ...parsed, rotateDeg: v })}
|
|
742
|
+
onChangeEnd={(v) => writeBack({ ...parsed, rotateDeg: v })}
|
|
743
|
+
style={{ flex: 1 }}
|
|
744
|
+
/>
|
|
745
|
+
<ScrubbableInput
|
|
746
|
+
testId={`${testId}-scale`}
|
|
747
|
+
label="Scale"
|
|
748
|
+
value={parsed.scale}
|
|
749
|
+
unit="x"
|
|
750
|
+
step={0.1}
|
|
751
|
+
disabled={disabled}
|
|
752
|
+
onChange={(v) => writeBack({ ...parsed, scale: v })}
|
|
753
|
+
onChangeEnd={(v) => writeBack({ ...parsed, scale: v })}
|
|
754
|
+
style={{ flex: 1 }}
|
|
755
|
+
/>
|
|
756
|
+
</div>
|
|
757
|
+
</Field>
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** One `single`-kind row: reads the current value, picks the widget via
|
|
762
|
+
* {@link widgetKindFor}, and delegates to the matching `*Row` component
|
|
763
|
+
* above — each extracted so this dispatcher's own branching stays well
|
|
764
|
+
* under biome's complexity ceiling (same discipline `GradientEditor.tsx`/
|
|
765
|
+
* `ShadowEditor.tsx` document for their own helper splits). Both
|
|
766
|
+
* `onChange`/`onChangeEnd` route through the SAME `commit` (file doc
|
|
767
|
+
* comment: this adapter has one write path, not a separate preview mode). */
|
|
768
|
+
function SingleRow({
|
|
769
|
+
adapter,
|
|
770
|
+
id,
|
|
771
|
+
prop,
|
|
772
|
+
fonts,
|
|
773
|
+
}: {
|
|
774
|
+
adapter: AuthoringAdapter;
|
|
775
|
+
id: string;
|
|
776
|
+
prop: PropertyDescriptor;
|
|
777
|
+
fonts: readonly string[];
|
|
778
|
+
}): React.ReactElement {
|
|
779
|
+
const store = useEditorStore();
|
|
780
|
+
const commit = (path: string, value: unknown): void => {
|
|
781
|
+
adapter.inspector?.set(id, path, value);
|
|
782
|
+
store.notifyIngestEdit();
|
|
783
|
+
};
|
|
784
|
+
// D1 — remove a stale CSS longhand override (no-op when unauthored); optional
|
|
785
|
+
// on the inspector, so a non-react adapter simply skips the cleanup.
|
|
786
|
+
const removeStyle = (path: string): void => {
|
|
787
|
+
adapter.inspector?.remove?.(id, path);
|
|
788
|
+
store.notifyIngestEdit();
|
|
789
|
+
};
|
|
790
|
+
const rowProps: KindRowProps = {
|
|
791
|
+
prop,
|
|
792
|
+
raw: adapter.inspector?.get(id, prop.path),
|
|
793
|
+
disabled: prop.readonly,
|
|
794
|
+
testId: testIdFor(prop.path),
|
|
795
|
+
commit,
|
|
796
|
+
fonts,
|
|
797
|
+
};
|
|
798
|
+
switch (widgetKindFor(prop)) {
|
|
799
|
+
case 'color':
|
|
800
|
+
return <ColorRow {...rowProps} />;
|
|
801
|
+
case 'font':
|
|
802
|
+
return <FontRow {...rowProps} />;
|
|
803
|
+
case 'gradient':
|
|
804
|
+
return <GradientRow {...rowProps} />;
|
|
805
|
+
case 'shadow':
|
|
806
|
+
return <ShadowRow {...rowProps} withSpreadInset />;
|
|
807
|
+
case 'text-shadow':
|
|
808
|
+
return <ShadowRow {...rowProps} withSpreadInset={false} />;
|
|
809
|
+
case 'filter':
|
|
810
|
+
return <FilterRow {...rowProps} label="Filter" />;
|
|
811
|
+
case 'backdrop-filter':
|
|
812
|
+
return <FilterRow {...rowProps} label="Backdrop Filter" />;
|
|
813
|
+
case 'radius':
|
|
814
|
+
return <RadiusRow {...rowProps} adapter={adapter} id={id} removeStyle={removeStyle} />;
|
|
815
|
+
case 'transform':
|
|
816
|
+
return <TransformRow {...rowProps} />;
|
|
817
|
+
case 'scrub':
|
|
818
|
+
return <ScrubRow {...rowProps} />;
|
|
819
|
+
case 'select':
|
|
820
|
+
return <SelectRow {...rowProps} />;
|
|
821
|
+
case 'checkbox':
|
|
822
|
+
return <CheckboxRow {...rowProps} />;
|
|
823
|
+
case 'json':
|
|
824
|
+
return <JsonRow {...rowProps} />;
|
|
825
|
+
default:
|
|
826
|
+
return <TextRow {...rowProps} />;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** A `border`/`align` combo row — reads all its member props, composes the
|
|
831
|
+
* widget's structured value, and writes every member back on change. */
|
|
832
|
+
function ComboRow({ adapter, id, row }: ComboRowProps): React.ReactElement | null {
|
|
833
|
+
const store = useEditorStore();
|
|
834
|
+
const commit = (path: string, value: unknown): void => {
|
|
835
|
+
adapter.inspector?.set(id, path, value);
|
|
836
|
+
store.notifyIngestEdit();
|
|
837
|
+
};
|
|
838
|
+
const removeStyle = (path: string): void => {
|
|
839
|
+
adapter.inspector?.remove?.(id, path);
|
|
840
|
+
store.notifyIngestEdit();
|
|
841
|
+
};
|
|
842
|
+
if (row.kind === 'border') {
|
|
843
|
+
// U2 (spec 27 §5 "Stroke: per-side border") — the shorthand descriptors
|
|
844
|
+
// (`row.width`/`row.style`/`row.color`) are the uniform fallback; each
|
|
845
|
+
// side's OWN longhand (`borderTopWidth`, …) is read through the same
|
|
846
|
+
// `adapter.inspector.get` and wins when present, so a prior per-side
|
|
847
|
+
// edit survives the next re-render instead of collapsing back to
|
|
848
|
+
// uniform (mirrors `RadiusRow`'s corner read, same fallback rationale:
|
|
849
|
+
// the vitest `node` env resolver has no implicit per-side computed
|
|
850
|
+
// values the way a real `CSSStyleDeclaration` always does).
|
|
851
|
+
const fallback: BorderSide = {
|
|
852
|
+
width: numericValue(adapter.inspector?.get(id, row.width.path)),
|
|
853
|
+
style: String(adapter.inspector?.get(id, row.style.path) ?? 'solid'),
|
|
854
|
+
color: String(adapter.inspector?.get(id, row.color.path) ?? '#000000'),
|
|
855
|
+
};
|
|
856
|
+
const readSide = (suffix: 'Top' | 'Right' | 'Bottom' | 'Left'): BorderSide => {
|
|
857
|
+
const w = numericStyleValue(adapter.inspector?.get(id, `style.border${suffix}Width`));
|
|
858
|
+
const st = adapter.inspector?.get(id, `style.border${suffix}Style`);
|
|
859
|
+
const c = adapter.inspector?.get(id, `style.border${suffix}Color`);
|
|
860
|
+
// D1 — normalize the per-side color longhand through `cssColorToHex`: it's
|
|
861
|
+
// not in `STYLE_PROPERTY_TYPE`, so `inspector.get` hands back the RAW
|
|
862
|
+
// computed value (`rgb(…)`), and letting that flow into a later uniform
|
|
863
|
+
// commit would rewrite the author's hex literal as an `rgb()` string.
|
|
864
|
+
const cHex = typeof c === 'string' ? cssColorToHex(c) : undefined;
|
|
865
|
+
return {
|
|
866
|
+
width: w ?? fallback.width,
|
|
867
|
+
style: typeof st === 'string' && st ? st : fallback.style,
|
|
868
|
+
color: cHex ?? fallback.color,
|
|
869
|
+
};
|
|
870
|
+
};
|
|
871
|
+
const value: BorderValue = {
|
|
872
|
+
top: readSide('Top'),
|
|
873
|
+
right: readSide('Right'),
|
|
874
|
+
bottom: readSide('Bottom'),
|
|
875
|
+
left: readSide('Left'),
|
|
876
|
+
};
|
|
877
|
+
// Uniform edits keep the pre-existing shorthand fast-path (3 commits to
|
|
878
|
+
// the `borderWidth`/`borderStyle`/`borderColor` descriptors this row
|
|
879
|
+
// always had); a non-uniform widget value now writes the 4 per-side
|
|
880
|
+
// CSS longhands instead of collapsing to `.top` — removing the
|
|
881
|
+
// documented limitation this file used to note here.
|
|
882
|
+
const sideSuffixes = ['Top', 'Right', 'Bottom', 'Left'] as const;
|
|
883
|
+
const writeBack = (v: BorderValue): void => {
|
|
884
|
+
if (isUniformBorder(v)) {
|
|
885
|
+
commit(row.width.path, v.top.width);
|
|
886
|
+
commit(row.style.path, v.top.style);
|
|
887
|
+
commit(row.color.path, v.top.color);
|
|
888
|
+
// D1 — clear any per-side longhands a prior non-uniform edit wrote, so
|
|
889
|
+
// the uniform shorthand actually wins on reload (removeStyle no-ops
|
|
890
|
+
// when a longhand isn't authored — safe on a clean element).
|
|
891
|
+
for (const suffix of sideSuffixes) {
|
|
892
|
+
removeStyle(`style.border${suffix}Width`);
|
|
893
|
+
removeStyle(`style.border${suffix}Style`);
|
|
894
|
+
removeStyle(`style.border${suffix}Color`);
|
|
895
|
+
}
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
const sides: ReadonlyArray<{
|
|
899
|
+
suffix: 'Top' | 'Right' | 'Bottom' | 'Left';
|
|
900
|
+
side: BorderSide;
|
|
901
|
+
}> = sideSuffixes.map((suffix) => ({
|
|
902
|
+
suffix,
|
|
903
|
+
side: v[suffix.toLowerCase() as 'top' | 'right' | 'bottom' | 'left'],
|
|
904
|
+
}));
|
|
905
|
+
for (const { suffix, side } of sides) {
|
|
906
|
+
commit(`style.border${suffix}Width`, side.width);
|
|
907
|
+
commit(`style.border${suffix}Style`, side.style);
|
|
908
|
+
commit(`style.border${suffix}Color`, side.color);
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
return (
|
|
912
|
+
<Field label="Border" testId="react-prop-border">
|
|
913
|
+
<BorderEditor value={value} onChange={writeBack} onChangeEnd={writeBack} />
|
|
914
|
+
</Field>
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
const value: AlignmentValue = {
|
|
918
|
+
justify:
|
|
919
|
+
(adapter.inspector?.get(id, row.justify.path) as AlignmentValue['justify']) ?? 'flex-start',
|
|
920
|
+
align: (adapter.inspector?.get(id, row.align.path) as AlignmentValue['align']) ?? 'flex-start',
|
|
921
|
+
};
|
|
922
|
+
return (
|
|
923
|
+
<Field label="Align" testId="react-prop-align">
|
|
924
|
+
<AlignmentGrid
|
|
925
|
+
value={value}
|
|
926
|
+
onChange={(v) => {
|
|
927
|
+
commit(row.justify.path, v.justify);
|
|
928
|
+
commit(row.align.path, v.align);
|
|
929
|
+
}}
|
|
930
|
+
/>
|
|
931
|
+
</Field>
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function rowKey(row: PropertyRow): string {
|
|
936
|
+
if (row.kind === 'single') return row.prop.path;
|
|
937
|
+
if (row.kind === 'border') return `border:${row.width.path}`;
|
|
938
|
+
return `align:${row.justify.path}`;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function compactScrub(
|
|
942
|
+
adapter: AuthoringAdapter,
|
|
943
|
+
id: string,
|
|
944
|
+
prop: PropertyDescriptor | undefined,
|
|
945
|
+
label: string,
|
|
946
|
+
commit: (path: string, value: unknown) => void,
|
|
947
|
+
): React.ReactElement | null {
|
|
948
|
+
if (!prop) return null;
|
|
949
|
+
const unit = UNITLESS_NUMBER_PROPS.has(propNameFromPath(prop.path)) ? '' : 'px';
|
|
950
|
+
const testId = testIdFor(prop.path);
|
|
951
|
+
return (
|
|
952
|
+
<ScrubbableInput
|
|
953
|
+
key={prop.path}
|
|
954
|
+
testId={testId}
|
|
955
|
+
label={label}
|
|
956
|
+
value={numericValue(adapter.inspector?.get(id, prop.path))}
|
|
957
|
+
unit={unit}
|
|
958
|
+
disabled={prop.readonly}
|
|
959
|
+
onChange={(value) => commit(prop.path, value)}
|
|
960
|
+
onChangeEnd={(value) => commit(prop.path, value)}
|
|
961
|
+
style={{ flex: 1, minWidth: 0 }}
|
|
962
|
+
/>
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function SemanticLayout({
|
|
967
|
+
adapter,
|
|
968
|
+
id,
|
|
969
|
+
props,
|
|
970
|
+
fonts,
|
|
971
|
+
showConstraints,
|
|
972
|
+
onToggleConstraints,
|
|
973
|
+
}: {
|
|
974
|
+
adapter: AuthoringAdapter;
|
|
975
|
+
id: string;
|
|
976
|
+
props: readonly PropertyDescriptor[];
|
|
977
|
+
fonts: readonly string[];
|
|
978
|
+
showConstraints: boolean;
|
|
979
|
+
onToggleConstraints: () => void;
|
|
980
|
+
}): React.ReactElement {
|
|
981
|
+
const store = useEditorStore();
|
|
982
|
+
const byName = new Map(props.map((prop) => [propNameFromPath(prop.path), prop] as const));
|
|
983
|
+
const commit = (path: string, value: unknown): void => {
|
|
984
|
+
adapter.inspector?.set(id, path, value);
|
|
985
|
+
store.notifyIngestEdit();
|
|
986
|
+
};
|
|
987
|
+
const display = String(
|
|
988
|
+
adapter.inspector?.get(id, byName.get('display')?.path ?? 'style.display') ?? '',
|
|
989
|
+
);
|
|
990
|
+
const isFlex = display === 'flex' || display === 'inline-flex';
|
|
991
|
+
const isGrid = display === 'grid' || display === 'inline-grid';
|
|
992
|
+
const displayProp = byName.get('display');
|
|
993
|
+
const gapProp = byName.get('gap');
|
|
994
|
+
const alignRows = buildPropertyRows(
|
|
995
|
+
props.filter((prop) => ['justifyContent', 'alignItems'].includes(propNameFromPath(prop.path))),
|
|
996
|
+
);
|
|
997
|
+
const ordinary = ['flexDirection', 'flexWrap', 'overflow']
|
|
998
|
+
.map((name) => byName.get(name))
|
|
999
|
+
.filter((prop): prop is PropertyDescriptor => !!prop);
|
|
1000
|
+
return (
|
|
1001
|
+
<div style={{ padding: '2px 8px 7px' }}>
|
|
1002
|
+
<div style={{ display: 'flex', gap: spaceVar[3] }}>
|
|
1003
|
+
{compactScrub(adapter, id, byName.get('width'), 'W', commit)}
|
|
1004
|
+
{compactScrub(adapter, id, byName.get('height'), 'H', commit)}
|
|
1005
|
+
</div>
|
|
1006
|
+
{showConstraints && (
|
|
1007
|
+
<div data-testid="react-layout-constraints" style={{ marginTop: 5 }}>
|
|
1008
|
+
<div style={{ display: 'flex', gap: spaceVar[3] }}>
|
|
1009
|
+
{compactScrub(adapter, id, byName.get('minWidth'), 'Min W', commit)}
|
|
1010
|
+
{compactScrub(adapter, id, byName.get('minHeight'), 'Min H', commit)}
|
|
1011
|
+
</div>
|
|
1012
|
+
<div style={{ display: 'flex', gap: spaceVar[3], marginTop: spaceVar[2] }}>
|
|
1013
|
+
{compactScrub(adapter, id, byName.get('maxWidth'), 'Max W', commit)}
|
|
1014
|
+
{compactScrub(adapter, id, byName.get('maxHeight'), 'Max H', commit)}
|
|
1015
|
+
</div>
|
|
1016
|
+
</div>
|
|
1017
|
+
)}
|
|
1018
|
+
<Button
|
|
1019
|
+
type="button"
|
|
1020
|
+
variant="ghost"
|
|
1021
|
+
size="compact"
|
|
1022
|
+
data-testid="react-layout-toggle-constraints"
|
|
1023
|
+
onClick={onToggleConstraints}
|
|
1024
|
+
style={{ display: 'flex', margin: '2px auto 5px' }}
|
|
1025
|
+
>
|
|
1026
|
+
{showConstraints ? '− constraints' : '··· constraints'}
|
|
1027
|
+
</Button>
|
|
1028
|
+
<div
|
|
1029
|
+
style={{
|
|
1030
|
+
display: 'grid',
|
|
1031
|
+
gridTemplateColumns: 'minmax(0, 1.35fr) minmax(0, 1fr)',
|
|
1032
|
+
gap: spaceVar[3],
|
|
1033
|
+
}}
|
|
1034
|
+
>
|
|
1035
|
+
{displayProp && (
|
|
1036
|
+
<Select
|
|
1037
|
+
data-testid={testIdFor(displayProp.path)}
|
|
1038
|
+
value={display}
|
|
1039
|
+
disabled={displayProp.readonly}
|
|
1040
|
+
data-dynamic={displayProp.readonly || undefined}
|
|
1041
|
+
onChange={(event) => commit(displayProp.path, event.target.value)}
|
|
1042
|
+
style={{ width: '100%' }}
|
|
1043
|
+
>
|
|
1044
|
+
{(displayProp.options ?? []).map((option) => (
|
|
1045
|
+
<option key={String(option)} value={String(option)}>
|
|
1046
|
+
{String(option)}
|
|
1047
|
+
</option>
|
|
1048
|
+
))}
|
|
1049
|
+
</Select>
|
|
1050
|
+
)}
|
|
1051
|
+
{compactScrub(adapter, id, gapProp, 'Gap', commit)}
|
|
1052
|
+
</div>
|
|
1053
|
+
{isFlex && (
|
|
1054
|
+
<div data-testid="react-layout-flex-controls" style={{ marginTop: 5 }}>
|
|
1055
|
+
{ordinary
|
|
1056
|
+
.filter((prop) => propNameFromPath(prop.path) !== 'overflow')
|
|
1057
|
+
.map((prop) => (
|
|
1058
|
+
<SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
|
|
1059
|
+
))}
|
|
1060
|
+
{alignRows.map((row) =>
|
|
1061
|
+
row.kind === 'single' ? (
|
|
1062
|
+
<SingleRow
|
|
1063
|
+
key={rowKey(row)}
|
|
1064
|
+
adapter={adapter}
|
|
1065
|
+
id={id}
|
|
1066
|
+
prop={row.prop}
|
|
1067
|
+
fonts={fonts}
|
|
1068
|
+
/>
|
|
1069
|
+
) : (
|
|
1070
|
+
<ComboRow key={rowKey(row)} adapter={adapter} id={id} row={row} />
|
|
1071
|
+
),
|
|
1072
|
+
)}
|
|
1073
|
+
</div>
|
|
1074
|
+
)}
|
|
1075
|
+
{isGrid && (
|
|
1076
|
+
<div data-testid="react-layout-grid-controls" style={{ marginTop: 5 }}>
|
|
1077
|
+
<div style={{ display: 'flex', gap: spaceVar[3] }}>
|
|
1078
|
+
{gridCountScrub(adapter, id, byName.get('gridTemplateColumns'), 'Cols', commit)}
|
|
1079
|
+
{gridCountScrub(adapter, id, byName.get('gridTemplateRows'), 'Rows', commit)}
|
|
1080
|
+
</div>
|
|
1081
|
+
{[byName.get('gridTemplateColumns'), byName.get('gridTemplateRows')]
|
|
1082
|
+
.filter((prop): prop is PropertyDescriptor => !!prop)
|
|
1083
|
+
.map((prop) => (
|
|
1084
|
+
<SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
|
|
1085
|
+
))}
|
|
1086
|
+
<div style={{ display: 'flex', gap: spaceVar[3], marginTop: spaceVar[1] }}>
|
|
1087
|
+
{compactScrub(adapter, id, byName.get('rowGap'), 'Row Gap', commit)}
|
|
1088
|
+
{compactScrub(adapter, id, byName.get('columnGap'), 'Col Gap', commit)}
|
|
1089
|
+
</div>
|
|
1090
|
+
{byName.get('gridAutoFlow') && (
|
|
1091
|
+
<SingleRow
|
|
1092
|
+
adapter={adapter}
|
|
1093
|
+
id={id}
|
|
1094
|
+
prop={byName.get('gridAutoFlow') as PropertyDescriptor}
|
|
1095
|
+
fonts={fonts}
|
|
1096
|
+
/>
|
|
1097
|
+
)}
|
|
1098
|
+
</div>
|
|
1099
|
+
)}
|
|
1100
|
+
{ordinary
|
|
1101
|
+
.filter((prop) => propNameFromPath(prop.path) === 'overflow')
|
|
1102
|
+
.map((prop) => (
|
|
1103
|
+
<SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
|
|
1104
|
+
))}
|
|
1105
|
+
<GridItemDisclosure adapter={adapter} id={id} byName={byName} fonts={fonts} />
|
|
1106
|
+
</div>
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** The number of tracks in a `grid-template-*` value (`none` ⇒ 0). A COMPUTED
|
|
1111
|
+
* template is a resolved list, but the optimistic echo hands back the
|
|
1112
|
+
* AUTHORED string, so integer `repeat(N, tracks)` must expand (measured:
|
|
1113
|
+
* without it, the echoed `repeat(3, 1fr)` counted as ONE track and the next
|
|
1114
|
+
* commit collapsed the template back to `repeat(1, 1fr)`). `auto-fit`/
|
|
1115
|
+
* `auto-fill` repeats count as one segment — their track count is genuinely
|
|
1116
|
+
* not knowable from the text. */
|
|
1117
|
+
export function gridTrackCount(template: string): number {
|
|
1118
|
+
const t = template.trim();
|
|
1119
|
+
if (!t || t === 'none') return 0;
|
|
1120
|
+
const segments: string[] = [];
|
|
1121
|
+
let depth = 0;
|
|
1122
|
+
let start = 0;
|
|
1123
|
+
for (let i = 0; i < t.length; i++) {
|
|
1124
|
+
const ch = t[i];
|
|
1125
|
+
if (ch === '(' || ch === '[') depth++;
|
|
1126
|
+
else if (ch === ')' || ch === ']') depth--;
|
|
1127
|
+
else if (ch === ' ' && depth === 0) {
|
|
1128
|
+
if (i > start) segments.push(t.slice(start, i));
|
|
1129
|
+
start = i + 1;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
if (start < t.length) segments.push(t.slice(start));
|
|
1133
|
+
let count = 0;
|
|
1134
|
+
for (const segment of segments) {
|
|
1135
|
+
const rep = /^repeat\(\s*(\d+)\s*,([\s\S]*)\)$/.exec(segment);
|
|
1136
|
+
count += rep?.[1] && rep[2] !== undefined ? Number(rep[1]) * gridTrackCount(rep[2]) : 1;
|
|
1137
|
+
}
|
|
1138
|
+
return count;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** Figma's grid COUNT control on a CSS template: scrub/type the track count
|
|
1142
|
+
* and the template becomes `repeat(N, 1fr)` — exactly what Figma's
|
|
1143
|
+
* rows/columns numbers mean. A custom template (minmax, named lines) stays
|
|
1144
|
+
* authorable through the template text row below; bumping the count
|
|
1145
|
+
* deliberately rewrites to N equal tracks, same as Figma. */
|
|
1146
|
+
function gridCountScrub(
|
|
1147
|
+
adapter: AuthoringAdapter,
|
|
1148
|
+
id: string,
|
|
1149
|
+
prop: PropertyDescriptor | undefined,
|
|
1150
|
+
label: string,
|
|
1151
|
+
commit: (path: string, value: unknown) => void,
|
|
1152
|
+
): React.ReactElement | null {
|
|
1153
|
+
if (!prop) return null;
|
|
1154
|
+
// Dedupe instead of end-only (measured by the blind walk): with a noop
|
|
1155
|
+
// onChange the CONTROLLED input never echoed a keystroke — typing was dead
|
|
1156
|
+
// and only the raw template field worked. onChange must reach commit for
|
|
1157
|
+
// the echo to render; the duplicate-write warn the end-only shape avoided
|
|
1158
|
+
// is handled by skipping writes of the template just written.
|
|
1159
|
+
let lastWritten: string | null = null;
|
|
1160
|
+
const write = (value: number): void => {
|
|
1161
|
+
const n = Math.max(1, Math.round(value));
|
|
1162
|
+
const template = `repeat(${n}, 1fr)`;
|
|
1163
|
+
if (template === lastWritten) return;
|
|
1164
|
+
lastWritten = template;
|
|
1165
|
+
commit(prop.path, template);
|
|
1166
|
+
};
|
|
1167
|
+
return (
|
|
1168
|
+
<ScrubbableInput
|
|
1169
|
+
key={`${prop.path}-count`}
|
|
1170
|
+
testId={`${testIdFor(prop.path)}-count`}
|
|
1171
|
+
label={label}
|
|
1172
|
+
value={gridTrackCount(String(adapter.inspector?.get(id, prop.path) ?? ''))}
|
|
1173
|
+
min={1}
|
|
1174
|
+
disabled={prop.readonly}
|
|
1175
|
+
onChange={write}
|
|
1176
|
+
onChangeEnd={write}
|
|
1177
|
+
style={{ flex: 1, minWidth: 0 }}
|
|
1178
|
+
/>
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/** The grid-ITEM half (placement lives on the child, not the container):
|
|
1183
|
+
* `grid-column` / `grid-row` / `justify-self` behind a ghost disclosure —
|
|
1184
|
+
* same idiom as the constraints toggle, because for most elements these
|
|
1185
|
+
* are noise, and the inspector cannot cheaply know whether the PARENT is
|
|
1186
|
+
* a grid (its reads are node-scoped). */
|
|
1187
|
+
function GridItemDisclosure({
|
|
1188
|
+
adapter,
|
|
1189
|
+
id,
|
|
1190
|
+
byName,
|
|
1191
|
+
fonts,
|
|
1192
|
+
}: {
|
|
1193
|
+
adapter: AuthoringAdapter;
|
|
1194
|
+
id: string;
|
|
1195
|
+
byName: ReadonlyMap<string, PropertyDescriptor>;
|
|
1196
|
+
fonts: readonly string[];
|
|
1197
|
+
}): React.ReactElement | null {
|
|
1198
|
+
const [open, setOpen] = useState(false);
|
|
1199
|
+
const props = ['gridColumn', 'gridRow', 'justifySelf']
|
|
1200
|
+
.map((name) => byName.get(name))
|
|
1201
|
+
.filter((prop): prop is PropertyDescriptor => !!prop);
|
|
1202
|
+
if (props.length === 0) return null;
|
|
1203
|
+
return (
|
|
1204
|
+
<>
|
|
1205
|
+
<Button
|
|
1206
|
+
type="button"
|
|
1207
|
+
variant="ghost"
|
|
1208
|
+
size="compact"
|
|
1209
|
+
data-testid="react-layout-toggle-grid-item"
|
|
1210
|
+
onClick={() => setOpen((value) => !value)}
|
|
1211
|
+
style={{ display: 'flex', margin: '2px auto 0' }}
|
|
1212
|
+
>
|
|
1213
|
+
{open ? '− grid item' : '··· grid item'}
|
|
1214
|
+
</Button>
|
|
1215
|
+
{open && (
|
|
1216
|
+
<div data-testid="react-layout-grid-item">
|
|
1217
|
+
{props.map((prop) => (
|
|
1218
|
+
<SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
|
|
1219
|
+
))}
|
|
1220
|
+
</div>
|
|
1221
|
+
)}
|
|
1222
|
+
</>
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/** Split a `background-image` value into its top-level comma layers (CSS
|
|
1227
|
+
* paints the FIRST layer on top). `none`/empty ⇒ []. */
|
|
1228
|
+
export function splitFillLayers(value: string): string[] {
|
|
1229
|
+
const t = value.trim();
|
|
1230
|
+
if (!t || t === 'none') return [];
|
|
1231
|
+
const layers: string[] = [];
|
|
1232
|
+
let depth = 0;
|
|
1233
|
+
let start = 0;
|
|
1234
|
+
for (let i = 0; i < t.length; i++) {
|
|
1235
|
+
const ch = t[i];
|
|
1236
|
+
if (ch === '(') depth++;
|
|
1237
|
+
else if (ch === ')') depth--;
|
|
1238
|
+
else if (ch === ',' && depth === 0) {
|
|
1239
|
+
layers.push(t.slice(start, i).trim());
|
|
1240
|
+
start = i + 1;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
layers.push(t.slice(start).trim());
|
|
1244
|
+
return layers.filter(Boolean);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** One fill layer's URL, when the layer is an image paint. */
|
|
1248
|
+
function fillLayerUrl(layer: string): string | null {
|
|
1249
|
+
const m = /^url\((['"]?)([\s\S]*)\1\)$/.exec(layer.trim());
|
|
1250
|
+
return m?.[2] ?? null;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* FILLS AS A LIST (design ledger: fill-as-list; the scoreboard's measured
|
|
1255
|
+
* gap was "exactly ONE colour and ONE gradient per element"). Figma's fill
|
|
1256
|
+
* list maps onto the web's own layer model: `background-image` is a
|
|
1257
|
+
* comma-separated stack of paints (first = topmost), and `background-color`
|
|
1258
|
+
* is the one solid underneath them all. Each layer row previews itself and
|
|
1259
|
+
* expands to its own editor (gradient editor / image url); add, remove and
|
|
1260
|
+
* reorder rewrite the list. Honest deltas, stated: CSS has no per-layer
|
|
1261
|
+
* visibility toggle (Figma's eye) — hiding is removing — and no per-layer
|
|
1262
|
+
* opacity knob for image paints (a gradient layer carries opacity in its
|
|
1263
|
+
* own color stops).
|
|
1264
|
+
*/
|
|
1265
|
+
function SemanticFill({
|
|
1266
|
+
adapter,
|
|
1267
|
+
id,
|
|
1268
|
+
props,
|
|
1269
|
+
fonts,
|
|
1270
|
+
}: {
|
|
1271
|
+
adapter: AuthoringAdapter;
|
|
1272
|
+
id: string;
|
|
1273
|
+
props: readonly PropertyDescriptor[];
|
|
1274
|
+
fonts: readonly string[];
|
|
1275
|
+
}): React.ReactElement {
|
|
1276
|
+
const store = useEditorStore();
|
|
1277
|
+
const byName = new Map(props.map((prop) => [propNameFromPath(prop.path), prop] as const));
|
|
1278
|
+
const commit = (path: string, value: unknown): void => {
|
|
1279
|
+
adapter.inspector?.set(id, path, value);
|
|
1280
|
+
store.notifyIngestEdit();
|
|
1281
|
+
};
|
|
1282
|
+
const imageProp = byName.get('backgroundImage');
|
|
1283
|
+
const colorProp = byName.get('backgroundColor');
|
|
1284
|
+
const [expanded, setExpanded] = useState<number | null>(null);
|
|
1285
|
+
const [imageDraft, setImageDraft] = useState<string | null>(null);
|
|
1286
|
+
const raw = String(imageProp ? (adapter.inspector?.get(id, imageProp.path) ?? '') : '');
|
|
1287
|
+
const layers = splitFillLayers(raw);
|
|
1288
|
+
const write = (next: string[]): void => {
|
|
1289
|
+
if (!imageProp) return;
|
|
1290
|
+
if (next.length > 0) {
|
|
1291
|
+
commit(imageProp.path, next.join(', '));
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
// An emptied list REMOVES the member — `backgroundImage: 'none'` parked
|
|
1295
|
+
// in source is authored noise the element never had.
|
|
1296
|
+
adapter.inspector?.remove?.(id, imageProp.path);
|
|
1297
|
+
store.notifyIngestEdit();
|
|
1298
|
+
};
|
|
1299
|
+
const replaceLayer = (index: number, layer: string): void =>
|
|
1300
|
+
write(layers.map((l, j) => (j === index ? layer : l)));
|
|
1301
|
+
const removeLayer = (index: number): void => {
|
|
1302
|
+
setExpanded(null);
|
|
1303
|
+
write(layers.filter((_, j) => j !== index));
|
|
1304
|
+
};
|
|
1305
|
+
const moveLayer = (index: number, delta: number): void => {
|
|
1306
|
+
const next = [...layers];
|
|
1307
|
+
const swap = next[index + delta];
|
|
1308
|
+
if (swap === undefined || next[index] === undefined) return;
|
|
1309
|
+
next[index + delta] = next[index] as string;
|
|
1310
|
+
next[index] = swap;
|
|
1311
|
+
setExpanded(null);
|
|
1312
|
+
write(next);
|
|
1313
|
+
};
|
|
1314
|
+
// Image layers compose with DOUBLE-quoted urls: the style writer stores
|
|
1315
|
+
// members single-quoted, and an escaped inner quote fails its literal
|
|
1316
|
+
// grammar (LITERAL_RE) — the member becomes uneditable AND unremovable
|
|
1317
|
+
// (measured: a url('…') layer could not be removed through this list).
|
|
1318
|
+
const layerButton: React.CSSProperties = {
|
|
1319
|
+
background: 'none',
|
|
1320
|
+
border: 'none',
|
|
1321
|
+
color: THEME.textMuted,
|
|
1322
|
+
cursor: 'pointer',
|
|
1323
|
+
fontSize: fontSizeVar.base,
|
|
1324
|
+
// Real hit targets (blind-walk friction: the reorder/remove buttons were
|
|
1325
|
+
// a couple of pixels wide).
|
|
1326
|
+
padding: `${spaceVar[2]} ${spaceVar[3]}`,
|
|
1327
|
+
lineHeight: lineHeightVar.tight,
|
|
1328
|
+
};
|
|
1329
|
+
return (
|
|
1330
|
+
<div data-testid="react-fill-list" style={{ padding: '2px 8px 7px' }}>
|
|
1331
|
+
{layers.map((layer, index) => {
|
|
1332
|
+
const gradient = parseGradient(layer);
|
|
1333
|
+
const url = fillLayerUrl(layer);
|
|
1334
|
+
const label = gradient
|
|
1335
|
+
? `${gradient.type} gradient`
|
|
1336
|
+
: url !== null
|
|
1337
|
+
? (url.split('/').pop() ?? 'image')
|
|
1338
|
+
: layer.slice(0, 22);
|
|
1339
|
+
return (
|
|
1340
|
+
<div key={`${index}-${layer.slice(0, 24)}`} style={{ marginBottom: 3 }}>
|
|
1341
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
|
1342
|
+
<button
|
|
1343
|
+
type="button"
|
|
1344
|
+
data-testid={`react-fill-layer-${index}`}
|
|
1345
|
+
title={layer}
|
|
1346
|
+
onClick={() => setExpanded(expanded === index ? null : index)}
|
|
1347
|
+
style={{
|
|
1348
|
+
display: 'flex',
|
|
1349
|
+
alignItems: 'center',
|
|
1350
|
+
gap: spaceVar[3],
|
|
1351
|
+
flex: 1,
|
|
1352
|
+
minWidth: 0,
|
|
1353
|
+
background: 'none',
|
|
1354
|
+
border: 'none',
|
|
1355
|
+
cursor: 'pointer',
|
|
1356
|
+
padding: `${spaceVar[1]} 0`,
|
|
1357
|
+
color: THEME.text,
|
|
1358
|
+
fontSize: fontSizeVar.base,
|
|
1359
|
+
textAlign: 'left',
|
|
1360
|
+
}}
|
|
1361
|
+
>
|
|
1362
|
+
<span
|
|
1363
|
+
style={{
|
|
1364
|
+
width: 16,
|
|
1365
|
+
height: 16,
|
|
1366
|
+
flexShrink: 0,
|
|
1367
|
+
borderRadius: radius.sm,
|
|
1368
|
+
border: `1px solid ${THEME.border}`,
|
|
1369
|
+
background: layer,
|
|
1370
|
+
backgroundSize: 'cover',
|
|
1371
|
+
}}
|
|
1372
|
+
/>
|
|
1373
|
+
<span
|
|
1374
|
+
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
|
1375
|
+
>
|
|
1376
|
+
{label}
|
|
1377
|
+
</span>
|
|
1378
|
+
</button>
|
|
1379
|
+
{index > 0 && (
|
|
1380
|
+
<button
|
|
1381
|
+
type="button"
|
|
1382
|
+
data-testid={`react-fill-up-${index}`}
|
|
1383
|
+
title="Raise this fill"
|
|
1384
|
+
onClick={() => moveLayer(index, -1)}
|
|
1385
|
+
style={layerButton}
|
|
1386
|
+
>
|
|
1387
|
+
↑
|
|
1388
|
+
</button>
|
|
1389
|
+
)}
|
|
1390
|
+
{index < layers.length - 1 && (
|
|
1391
|
+
<button
|
|
1392
|
+
type="button"
|
|
1393
|
+
data-testid={`react-fill-down-${index}`}
|
|
1394
|
+
title="Lower this fill"
|
|
1395
|
+
onClick={() => moveLayer(index, 1)}
|
|
1396
|
+
style={layerButton}
|
|
1397
|
+
>
|
|
1398
|
+
↓
|
|
1399
|
+
</button>
|
|
1400
|
+
)}
|
|
1401
|
+
<button
|
|
1402
|
+
type="button"
|
|
1403
|
+
data-testid={`react-fill-remove-${index}`}
|
|
1404
|
+
title="Remove this fill (CSS has no hidden-layer state — removing is Figma's eye)"
|
|
1405
|
+
onClick={() => removeLayer(index)}
|
|
1406
|
+
style={layerButton}
|
|
1407
|
+
>
|
|
1408
|
+
✕
|
|
1409
|
+
</button>
|
|
1410
|
+
</div>
|
|
1411
|
+
{expanded === index && gradient && (
|
|
1412
|
+
<GradientEditor
|
|
1413
|
+
testId={`react-fill-gradient-${index}`}
|
|
1414
|
+
value={gradient}
|
|
1415
|
+
onChange={(g) => replaceLayer(index, composeGradient(g))}
|
|
1416
|
+
onChangeEnd={(g) => replaceLayer(index, composeGradient(g))}
|
|
1417
|
+
/>
|
|
1418
|
+
)}
|
|
1419
|
+
{expanded === index && url !== null && (
|
|
1420
|
+
<TextInput
|
|
1421
|
+
data-testid={`react-fill-url-${index}`}
|
|
1422
|
+
defaultValue={url}
|
|
1423
|
+
placeholder="image url"
|
|
1424
|
+
onBlur={(event) => replaceLayer(index, `url("${event.target.value.trim()}")`)}
|
|
1425
|
+
onKeyDown={(event) => {
|
|
1426
|
+
if (event.key === 'Enter')
|
|
1427
|
+
replaceLayer(index, `url("${event.currentTarget.value.trim()}")`);
|
|
1428
|
+
}}
|
|
1429
|
+
style={{ width: '100%', marginTop: spaceVar[1] }}
|
|
1430
|
+
/>
|
|
1431
|
+
)}
|
|
1432
|
+
</div>
|
|
1433
|
+
);
|
|
1434
|
+
})}
|
|
1435
|
+
{imageDraft !== null && (
|
|
1436
|
+
<TextInput
|
|
1437
|
+
data-testid="react-fill-image-draft"
|
|
1438
|
+
value={imageDraft}
|
|
1439
|
+
placeholder="image url — Enter to add"
|
|
1440
|
+
autoFocus
|
|
1441
|
+
onChange={(event) => setImageDraft(event.target.value)}
|
|
1442
|
+
onKeyDown={(event) => {
|
|
1443
|
+
if (event.key === 'Enter' && imageDraft.trim()) {
|
|
1444
|
+
write([`url("${imageDraft.trim()}")`, ...layers]);
|
|
1445
|
+
setImageDraft(null);
|
|
1446
|
+
}
|
|
1447
|
+
if (event.key === 'Escape') setImageDraft(null);
|
|
1448
|
+
}}
|
|
1449
|
+
style={{ width: '100%', marginBottom: 3 }}
|
|
1450
|
+
/>
|
|
1451
|
+
)}
|
|
1452
|
+
<div style={{ display: 'flex', gap: spaceVar[2] }}>
|
|
1453
|
+
<Button
|
|
1454
|
+
type="button"
|
|
1455
|
+
variant="ghost"
|
|
1456
|
+
size="compact"
|
|
1457
|
+
data-testid="react-fill-add-gradient"
|
|
1458
|
+
onClick={() => {
|
|
1459
|
+
write([composeGradient(DEFAULT_GRADIENT), ...layers]);
|
|
1460
|
+
setExpanded(0);
|
|
1461
|
+
}}
|
|
1462
|
+
>
|
|
1463
|
+
+ gradient
|
|
1464
|
+
</Button>
|
|
1465
|
+
<Button
|
|
1466
|
+
type="button"
|
|
1467
|
+
variant="ghost"
|
|
1468
|
+
size="compact"
|
|
1469
|
+
data-testid="react-fill-add-image"
|
|
1470
|
+
onClick={() => setImageDraft('')}
|
|
1471
|
+
>
|
|
1472
|
+
+ image
|
|
1473
|
+
</Button>
|
|
1474
|
+
</div>
|
|
1475
|
+
{layers.some((layer) => fillLayerUrl(layer) !== null) &&
|
|
1476
|
+
['backgroundSize', 'backgroundPosition', 'backgroundRepeat']
|
|
1477
|
+
.map((name) => byName.get(name))
|
|
1478
|
+
.filter((prop): prop is PropertyDescriptor => !!prop)
|
|
1479
|
+
.map((prop) => (
|
|
1480
|
+
<SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
|
|
1481
|
+
))}
|
|
1482
|
+
{colorProp && <SingleRow adapter={adapter} id={id} prop={colorProp} fonts={fonts} />}
|
|
1483
|
+
</div>
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function SemanticPosition({
|
|
1488
|
+
adapter,
|
|
1489
|
+
id,
|
|
1490
|
+
props,
|
|
1491
|
+
fonts,
|
|
1492
|
+
}: {
|
|
1493
|
+
adapter: AuthoringAdapter;
|
|
1494
|
+
id: string;
|
|
1495
|
+
props: readonly PropertyDescriptor[];
|
|
1496
|
+
fonts: readonly string[];
|
|
1497
|
+
}): React.ReactElement {
|
|
1498
|
+
const store = useEditorStore();
|
|
1499
|
+
const byName = new Map(props.map((prop) => [propNameFromPath(prop.path), prop] as const));
|
|
1500
|
+
const position = byName.get('position');
|
|
1501
|
+
const positionValue = String(
|
|
1502
|
+
position ? (adapter.inspector?.get(id, position.path) ?? 'static') : 'static',
|
|
1503
|
+
);
|
|
1504
|
+
const commit = (path: string, value: unknown): void => {
|
|
1505
|
+
adapter.inspector?.set(id, path, value);
|
|
1506
|
+
store.notifyIngestEdit();
|
|
1507
|
+
};
|
|
1508
|
+
const offsets = ['top', 'right', 'bottom', 'left'];
|
|
1509
|
+
const itemProps = ['zIndex', 'flexGrow', 'flexShrink', 'flexBasis', 'alignSelf']
|
|
1510
|
+
.map((name) => byName.get(name))
|
|
1511
|
+
.filter((prop): prop is PropertyDescriptor => !!prop);
|
|
1512
|
+
return (
|
|
1513
|
+
<div style={{ padding: '2px 8px 7px' }}>
|
|
1514
|
+
{position && (
|
|
1515
|
+
<div
|
|
1516
|
+
data-testid={testIdFor(position.path)}
|
|
1517
|
+
style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: spaceVar[1] }}
|
|
1518
|
+
>
|
|
1519
|
+
{(position.options ?? []).map((option) => {
|
|
1520
|
+
const value = String(option);
|
|
1521
|
+
const active = value === positionValue;
|
|
1522
|
+
return (
|
|
1523
|
+
<Button
|
|
1524
|
+
key={value}
|
|
1525
|
+
type="button"
|
|
1526
|
+
variant={active ? 'primary' : 'secondary'}
|
|
1527
|
+
size="compact"
|
|
1528
|
+
aria-pressed={active}
|
|
1529
|
+
title={value}
|
|
1530
|
+
disabled={position.readonly}
|
|
1531
|
+
onClick={() => commit(position.path, value)}
|
|
1532
|
+
>
|
|
1533
|
+
{value.slice(0, 3)}
|
|
1534
|
+
</Button>
|
|
1535
|
+
);
|
|
1536
|
+
})}
|
|
1537
|
+
</div>
|
|
1538
|
+
)}
|
|
1539
|
+
{positionValue !== 'static' && (
|
|
1540
|
+
<div
|
|
1541
|
+
data-testid="react-position-offsets"
|
|
1542
|
+
style={{
|
|
1543
|
+
display: 'grid',
|
|
1544
|
+
gridTemplateColumns: '1fr 1fr',
|
|
1545
|
+
gap: spaceVar[2],
|
|
1546
|
+
marginTop: spaceVar[3],
|
|
1547
|
+
}}
|
|
1548
|
+
>
|
|
1549
|
+
{offsets.map((name) =>
|
|
1550
|
+
compactScrub(adapter, id, byName.get(name), name[0]!.toUpperCase(), commit),
|
|
1551
|
+
)}
|
|
1552
|
+
</div>
|
|
1553
|
+
)}
|
|
1554
|
+
{itemProps.length > 0 && (
|
|
1555
|
+
<div style={{ borderTop: `1px solid ${THEME.border}`, marginTop: 7, paddingTop: 3 }}>
|
|
1556
|
+
<div style={{ color: THEME.textMuted, fontSize: fontSizeVar.xs, padding: '2px 0 1px' }}>
|
|
1557
|
+
STACKING & FLEX ITEM
|
|
1558
|
+
</div>
|
|
1559
|
+
{itemProps.map((prop) => (
|
|
1560
|
+
<SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
|
|
1561
|
+
))}
|
|
1562
|
+
</div>
|
|
1563
|
+
)}
|
|
1564
|
+
</div>
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function SemanticSpacing({
|
|
1569
|
+
adapter,
|
|
1570
|
+
id,
|
|
1571
|
+
props,
|
|
1572
|
+
}: {
|
|
1573
|
+
adapter: AuthoringAdapter;
|
|
1574
|
+
id: string;
|
|
1575
|
+
props: readonly PropertyDescriptor[];
|
|
1576
|
+
}): React.ReactElement {
|
|
1577
|
+
const store = useEditorStore();
|
|
1578
|
+
const byName = new Map(props.map((prop) => [propNameFromPath(prop.path), prop] as const));
|
|
1579
|
+
const commit = (path: string, value: unknown): void => {
|
|
1580
|
+
adapter.inspector?.set(id, path, value);
|
|
1581
|
+
store.notifyIngestEdit();
|
|
1582
|
+
};
|
|
1583
|
+
// The BOX-MODEL diagram (owner feedback, 2026-08-31: the labeled M T/M R
|
|
1584
|
+
// grid "is not how figma works"). The value's PLACE on the nested box says
|
|
1585
|
+
// which side it is — margin the outer ring, padding the inner — the way
|
|
1586
|
+
// design tools draw CSS spacing (Webflow's spacing control, devtools'
|
|
1587
|
+
// box model; Figma's own H/V padding pair exists only because Figma has
|
|
1588
|
+
// no margin, so it cannot be transcribed onto CSS whole).
|
|
1589
|
+
const slot = (name: string): React.ReactElement => {
|
|
1590
|
+
const prop = byName.get(name);
|
|
1591
|
+
if (!prop) return <span key={name} />;
|
|
1592
|
+
return (
|
|
1593
|
+
<ScrubbableInput
|
|
1594
|
+
key={prop.path}
|
|
1595
|
+
testId={testIdFor(prop.path)}
|
|
1596
|
+
label=""
|
|
1597
|
+
value={numericValue(adapter.inspector?.get(id, prop.path))}
|
|
1598
|
+
unit="px"
|
|
1599
|
+
disabled={prop.readonly}
|
|
1600
|
+
onChange={(value) => commit(prop.path, value)}
|
|
1601
|
+
onChangeEnd={(value) => commit(prop.path, value)}
|
|
1602
|
+
style={{ width: 46, minWidth: 0, flex: 'none' }}
|
|
1603
|
+
/>
|
|
1604
|
+
);
|
|
1605
|
+
};
|
|
1606
|
+
const ringLabel = (text: string): React.ReactElement => (
|
|
1607
|
+
<span
|
|
1608
|
+
style={{
|
|
1609
|
+
position: 'absolute',
|
|
1610
|
+
top: 2,
|
|
1611
|
+
left: 6,
|
|
1612
|
+
fontSize: 8,
|
|
1613
|
+
letterSpacing: '.08em',
|
|
1614
|
+
color: THEME.textMuted,
|
|
1615
|
+
pointerEvents: 'none',
|
|
1616
|
+
}}
|
|
1617
|
+
>
|
|
1618
|
+
{text}
|
|
1619
|
+
</span>
|
|
1620
|
+
);
|
|
1621
|
+
const centered: React.CSSProperties = {
|
|
1622
|
+
display: 'flex',
|
|
1623
|
+
alignItems: 'center',
|
|
1624
|
+
justifyContent: 'center',
|
|
1625
|
+
};
|
|
1626
|
+
return (
|
|
1627
|
+
<div
|
|
1628
|
+
style={{ padding: `${spaceVar[1]} ${spaceVar[4]} ${spaceVar[4]}` }}
|
|
1629
|
+
data-testid="react-spacing-box"
|
|
1630
|
+
>
|
|
1631
|
+
<div
|
|
1632
|
+
style={{
|
|
1633
|
+
position: 'relative',
|
|
1634
|
+
border: `1px solid ${THEME.border}`,
|
|
1635
|
+
borderRadius: 4,
|
|
1636
|
+
padding: '11px 4px 3px',
|
|
1637
|
+
}}
|
|
1638
|
+
>
|
|
1639
|
+
{ringLabel('MARGIN')}
|
|
1640
|
+
<div style={centered}>{slot('marginTop')}</div>
|
|
1641
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: spaceVar[1] }}>
|
|
1642
|
+
{slot('marginLeft')}
|
|
1643
|
+
<div
|
|
1644
|
+
style={{
|
|
1645
|
+
position: 'relative',
|
|
1646
|
+
flex: 1,
|
|
1647
|
+
minWidth: 0,
|
|
1648
|
+
border: `1px dashed ${THEME.border}`,
|
|
1649
|
+
borderRadius: 4,
|
|
1650
|
+
padding: '11px 4px 3px',
|
|
1651
|
+
}}
|
|
1652
|
+
>
|
|
1653
|
+
{ringLabel('PADDING')}
|
|
1654
|
+
<div style={centered}>{slot('paddingTop')}</div>
|
|
1655
|
+
<div
|
|
1656
|
+
style={{
|
|
1657
|
+
display: 'flex',
|
|
1658
|
+
alignItems: 'center',
|
|
1659
|
+
justifyContent: 'space-between',
|
|
1660
|
+
gap: spaceVar[1],
|
|
1661
|
+
}}
|
|
1662
|
+
>
|
|
1663
|
+
{slot('paddingLeft')}
|
|
1664
|
+
{slot('paddingRight')}
|
|
1665
|
+
</div>
|
|
1666
|
+
<div style={centered}>{slot('paddingBottom')}</div>
|
|
1667
|
+
</div>
|
|
1668
|
+
{slot('marginRight')}
|
|
1669
|
+
</div>
|
|
1670
|
+
<div style={centered}>{slot('marginBottom')}</div>
|
|
1671
|
+
</div>
|
|
1672
|
+
</div>
|
|
1673
|
+
);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/**
|
|
1677
|
+
* ONE group's body — the widget-mapped rows for one descriptor group, with no
|
|
1678
|
+
* chrome of its own: the group IS an identified `InspectionSection` now
|
|
1679
|
+
* (see `reactInspectorSections`), so its title, icon, order and collapse
|
|
1680
|
+
* affordance belong to the projection rendering it, and repeating them here
|
|
1681
|
+
* would title the same block twice.
|
|
1682
|
+
*/
|
|
1683
|
+
function GroupBody({
|
|
1684
|
+
name,
|
|
1685
|
+
groupId,
|
|
1686
|
+
props,
|
|
1687
|
+
adapter,
|
|
1688
|
+
id,
|
|
1689
|
+
fonts,
|
|
1690
|
+
}: {
|
|
1691
|
+
name: string;
|
|
1692
|
+
groupId: string;
|
|
1693
|
+
props: readonly PropertyDescriptor[];
|
|
1694
|
+
adapter: AuthoringAdapter;
|
|
1695
|
+
id: string;
|
|
1696
|
+
fonts: readonly string[];
|
|
1697
|
+
}): React.ReactElement {
|
|
1698
|
+
const [showConstraints, setShowConstraints] = useState(false);
|
|
1699
|
+
const rows = buildPropertyRows(props);
|
|
1700
|
+
return (
|
|
1701
|
+
<div data-testid={`react-inspector-group-${groupId}`}>
|
|
1702
|
+
{name === 'Layout' && (
|
|
1703
|
+
<SemanticLayout
|
|
1704
|
+
adapter={adapter}
|
|
1705
|
+
id={id}
|
|
1706
|
+
props={props}
|
|
1707
|
+
fonts={fonts}
|
|
1708
|
+
showConstraints={showConstraints}
|
|
1709
|
+
onToggleConstraints={() => setShowConstraints((value) => !value)}
|
|
1710
|
+
/>
|
|
1711
|
+
)}
|
|
1712
|
+
{name === 'Position' && (
|
|
1713
|
+
<SemanticPosition adapter={adapter} id={id} props={props} fonts={fonts} />
|
|
1714
|
+
)}
|
|
1715
|
+
{name === 'Spacing' && <SemanticSpacing adapter={adapter} id={id} props={props} />}
|
|
1716
|
+
{name === 'Fill' && <SemanticFill adapter={adapter} id={id} props={props} fonts={fonts} />}
|
|
1717
|
+
{name !== 'Layout' &&
|
|
1718
|
+
name !== 'Position' &&
|
|
1719
|
+
name !== 'Spacing' &&
|
|
1720
|
+
name !== 'Fill' &&
|
|
1721
|
+
rows.map((row) =>
|
|
1722
|
+
row.kind === 'single' ? (
|
|
1723
|
+
<SingleRow key={rowKey(row)} adapter={adapter} id={id} prop={row.prop} fonts={fonts} />
|
|
1724
|
+
) : (
|
|
1725
|
+
<ComboRow key={rowKey(row)} adapter={adapter} id={id} row={row} />
|
|
1726
|
+
),
|
|
1727
|
+
)}
|
|
1728
|
+
</div>
|
|
1729
|
+
);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function BreadcrumbBar({
|
|
1733
|
+
adapter,
|
|
1734
|
+
node,
|
|
1735
|
+
}: {
|
|
1736
|
+
adapter: AuthoringAdapter;
|
|
1737
|
+
node: EditorNode;
|
|
1738
|
+
}): React.ReactElement {
|
|
1739
|
+
const chain: EditorNode[] = [];
|
|
1740
|
+
let current: EditorNode | null = node;
|
|
1741
|
+
let guard = 0;
|
|
1742
|
+
while (current && guard++ < 100) {
|
|
1743
|
+
chain.unshift(current);
|
|
1744
|
+
current = current.parentId ? adapter.hierarchy.node(current.parentId) : null;
|
|
1745
|
+
}
|
|
1746
|
+
return (
|
|
1747
|
+
<nav
|
|
1748
|
+
aria-label="Selection ancestry"
|
|
1749
|
+
data-testid="react-inspector-breadcrumbs"
|
|
1750
|
+
style={{
|
|
1751
|
+
display: 'flex',
|
|
1752
|
+
alignItems: 'center',
|
|
1753
|
+
gap: 3,
|
|
1754
|
+
padding: `${spaceVar[3]} ${spaceVar[4]}`,
|
|
1755
|
+
overflowX: 'auto',
|
|
1756
|
+
borderBottom: `1px solid ${THEME.border}`,
|
|
1757
|
+
whiteSpace: 'nowrap',
|
|
1758
|
+
}}
|
|
1759
|
+
>
|
|
1760
|
+
{chain.map((entry, index) => (
|
|
1761
|
+
<span key={entry.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
|
1762
|
+
{index > 0 && <span style={{ color: THEME.textMuted }}>/</span>}
|
|
1763
|
+
<Button
|
|
1764
|
+
type="button"
|
|
1765
|
+
variant="ghost"
|
|
1766
|
+
size="compact"
|
|
1767
|
+
aria-current={entry.id === node.id ? 'page' : undefined}
|
|
1768
|
+
title={entry.secondaryLabel ?? entry.label}
|
|
1769
|
+
onClick={() => setAuthoringSelection(adapter, [entry.id])}
|
|
1770
|
+
>
|
|
1771
|
+
{entry.label}
|
|
1772
|
+
</Button>
|
|
1773
|
+
</span>
|
|
1774
|
+
))}
|
|
1775
|
+
</nav>
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
function StoryActions({
|
|
1780
|
+
owner,
|
|
1781
|
+
node,
|
|
1782
|
+
}: {
|
|
1783
|
+
owner: ReactRootAuthoringAdapter;
|
|
1784
|
+
node: EditorNode;
|
|
1785
|
+
}): React.ReactElement {
|
|
1786
|
+
const args = owner.portableArgs(node.id);
|
|
1787
|
+
// The CSF WRITE half: save-the-current-args as a new story, rename, delete
|
|
1788
|
+
// — inline inputs, never window.prompt (banned), refusals rendered where
|
|
1789
|
+
// the gesture happened.
|
|
1790
|
+
const [mode, setMode] = useState<'idle' | 'save-as' | 'rename' | 'confirm-delete'>('idle');
|
|
1791
|
+
const [draftName, setDraftName] = useState('');
|
|
1792
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
1793
|
+
const run = async (op: Parameters<ReactRootAuthoringAdapter['writeStory']>[1]): Promise<void> => {
|
|
1794
|
+
const result = await owner.writeStory(node.id, op);
|
|
1795
|
+
setNotice(result.changed ? null : (result.error ?? 'refused'));
|
|
1796
|
+
if (result.changed) {
|
|
1797
|
+
setMode('idle');
|
|
1798
|
+
setDraftName('');
|
|
1799
|
+
}
|
|
1800
|
+
};
|
|
1801
|
+
return (
|
|
1802
|
+
<div
|
|
1803
|
+
data-testid="react-story-controls-header"
|
|
1804
|
+
style={{ borderBottom: `1px solid ${THEME.border}` }}
|
|
1805
|
+
>
|
|
1806
|
+
<div
|
|
1807
|
+
style={{
|
|
1808
|
+
display: 'flex',
|
|
1809
|
+
alignItems: 'center',
|
|
1810
|
+
justifyContent: 'space-between',
|
|
1811
|
+
gap: spaceVar[4],
|
|
1812
|
+
padding: '7px 8px',
|
|
1813
|
+
}}
|
|
1814
|
+
>
|
|
1815
|
+
<div style={{ minWidth: 0 }}>
|
|
1816
|
+
<div style={{ color: THEME.text, fontSize: fontSizeVar.md, fontWeight: 600 }}>
|
|
1817
|
+
{node.label}
|
|
1818
|
+
</div>
|
|
1819
|
+
<div style={{ color: THEME.textMuted, fontSize: fontSizeVar.sm }}>
|
|
1820
|
+
{args ? `${Object.keys(args).length} args · session controls` : 'Portable CSF story'}
|
|
1821
|
+
</div>
|
|
1822
|
+
</div>
|
|
1823
|
+
<Button
|
|
1824
|
+
type="button"
|
|
1825
|
+
variant="secondary"
|
|
1826
|
+
size="compact"
|
|
1827
|
+
data-testid="react-story-reset-args"
|
|
1828
|
+
onClick={() => owner.resetPortableArgs(node.id)}
|
|
1829
|
+
>
|
|
1830
|
+
Reset
|
|
1831
|
+
</Button>
|
|
1832
|
+
</div>
|
|
1833
|
+
<div
|
|
1834
|
+
style={{
|
|
1835
|
+
display: 'flex',
|
|
1836
|
+
gap: spaceVar[2],
|
|
1837
|
+
padding: `0 ${spaceVar[4]} ${spaceVar[3]}`,
|
|
1838
|
+
flexWrap: 'wrap',
|
|
1839
|
+
}}
|
|
1840
|
+
>
|
|
1841
|
+
<Button
|
|
1842
|
+
type="button"
|
|
1843
|
+
variant="ghost"
|
|
1844
|
+
size="compact"
|
|
1845
|
+
data-testid="react-story-save-as"
|
|
1846
|
+
title="Write the CURRENT session args as a new story export in this file"
|
|
1847
|
+
onClick={() => {
|
|
1848
|
+
setNotice(null);
|
|
1849
|
+
setDraftName('');
|
|
1850
|
+
setMode(mode === 'save-as' ? 'idle' : 'save-as');
|
|
1851
|
+
}}
|
|
1852
|
+
>
|
|
1853
|
+
Save as…
|
|
1854
|
+
</Button>
|
|
1855
|
+
<Button
|
|
1856
|
+
type="button"
|
|
1857
|
+
variant="ghost"
|
|
1858
|
+
size="compact"
|
|
1859
|
+
data-testid="react-story-rename"
|
|
1860
|
+
onClick={() => {
|
|
1861
|
+
setNotice(null);
|
|
1862
|
+
setDraftName('');
|
|
1863
|
+
setMode(mode === 'rename' ? 'idle' : 'rename');
|
|
1864
|
+
}}
|
|
1865
|
+
>
|
|
1866
|
+
Rename…
|
|
1867
|
+
</Button>
|
|
1868
|
+
{mode === 'confirm-delete' ? (
|
|
1869
|
+
<Button
|
|
1870
|
+
type="button"
|
|
1871
|
+
variant="secondary"
|
|
1872
|
+
size="compact"
|
|
1873
|
+
data-testid="react-story-delete-confirm"
|
|
1874
|
+
onClick={() => void run({ kind: 'delete' })}
|
|
1875
|
+
>
|
|
1876
|
+
Delete story?
|
|
1877
|
+
</Button>
|
|
1878
|
+
) : (
|
|
1879
|
+
<Button
|
|
1880
|
+
type="button"
|
|
1881
|
+
variant="ghost"
|
|
1882
|
+
size="compact"
|
|
1883
|
+
data-testid="react-story-delete"
|
|
1884
|
+
onClick={() => {
|
|
1885
|
+
setNotice(null);
|
|
1886
|
+
setMode('confirm-delete');
|
|
1887
|
+
}}
|
|
1888
|
+
>
|
|
1889
|
+
Delete
|
|
1890
|
+
</Button>
|
|
1891
|
+
)}
|
|
1892
|
+
</div>
|
|
1893
|
+
{(mode === 'save-as' || mode === 'rename') && (
|
|
1894
|
+
<div
|
|
1895
|
+
style={{
|
|
1896
|
+
display: 'flex',
|
|
1897
|
+
gap: spaceVar[2],
|
|
1898
|
+
padding: `0 ${spaceVar[4]} ${spaceVar[4]}`,
|
|
1899
|
+
alignItems: 'center',
|
|
1900
|
+
}}
|
|
1901
|
+
>
|
|
1902
|
+
<input
|
|
1903
|
+
data-testid="react-story-name-input"
|
|
1904
|
+
// Focus follows the gesture (blind re-walk nit: typing right
|
|
1905
|
+
// after "Save as…" went nowhere until a second click).
|
|
1906
|
+
// biome-ignore lint/a11y/noAutofocus: the input exists only because the user just asked for it.
|
|
1907
|
+
autoFocus
|
|
1908
|
+
value={draftName}
|
|
1909
|
+
placeholder={mode === 'save-as' ? 'NewStoryName' : `Rename ${node.label}`}
|
|
1910
|
+
onChange={(event) => setDraftName(event.target.value)}
|
|
1911
|
+
onKeyDown={(event) => {
|
|
1912
|
+
if (event.key === 'Enter' && draftName.trim()) {
|
|
1913
|
+
void run(
|
|
1914
|
+
mode === 'save-as'
|
|
1915
|
+
? { kind: 'save-as', name: draftName.trim() }
|
|
1916
|
+
: { kind: 'rename', newName: draftName.trim() },
|
|
1917
|
+
);
|
|
1918
|
+
}
|
|
1919
|
+
if (event.key === 'Escape') setMode('idle');
|
|
1920
|
+
}}
|
|
1921
|
+
style={{
|
|
1922
|
+
flex: 1,
|
|
1923
|
+
minWidth: 0,
|
|
1924
|
+
background: THEME.neutralHover,
|
|
1925
|
+
border: `1px solid ${THEME.border}`,
|
|
1926
|
+
borderRadius: 4,
|
|
1927
|
+
color: THEME.text,
|
|
1928
|
+
fontSize: fontSizeVar.base,
|
|
1929
|
+
padding: '3px 6px',
|
|
1930
|
+
}}
|
|
1931
|
+
/>
|
|
1932
|
+
<Button
|
|
1933
|
+
type="button"
|
|
1934
|
+
variant="secondary"
|
|
1935
|
+
size="compact"
|
|
1936
|
+
data-testid="react-story-name-commit"
|
|
1937
|
+
onClick={() =>
|
|
1938
|
+
void run(
|
|
1939
|
+
mode === 'save-as'
|
|
1940
|
+
? { kind: 'save-as', name: draftName.trim() }
|
|
1941
|
+
: { kind: 'rename', newName: draftName.trim() },
|
|
1942
|
+
)
|
|
1943
|
+
}
|
|
1944
|
+
>
|
|
1945
|
+
{mode === 'save-as' ? 'Save' : 'Rename'}
|
|
1946
|
+
</Button>
|
|
1947
|
+
</div>
|
|
1948
|
+
)}
|
|
1949
|
+
{notice && (
|
|
1950
|
+
<div
|
|
1951
|
+
data-testid="react-story-write-notice"
|
|
1952
|
+
style={{
|
|
1953
|
+
padding: `0 ${spaceVar[4]} ${spaceVar[4]}`,
|
|
1954
|
+
color: themeDanger(),
|
|
1955
|
+
fontSize: fontSizeVar.sm,
|
|
1956
|
+
lineHeight: lineHeightVar.normal,
|
|
1957
|
+
}}
|
|
1958
|
+
>
|
|
1959
|
+
{notice}
|
|
1960
|
+
</div>
|
|
1961
|
+
)}
|
|
1962
|
+
</div>
|
|
1963
|
+
);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
/**
|
|
1967
|
+
* NAMED STYLES on the element (design ledger; Webflow prior art): the class
|
|
1968
|
+
* tokens the element wears, each with Webflow's "N elements share this class"
|
|
1969
|
+
* count and a remove affordance, plus one input that APPLIES an existing
|
|
1970
|
+
* first-party class or CREATES a new one from the element's literal inline
|
|
1971
|
+
* styles (the adapter decides which — `writeNamedStyle`). A chip whose class
|
|
1972
|
+
* has a first-party single-class rule is an editable named style; one without
|
|
1973
|
+
* (a utility/generated class) renders muted, because there is no rule of ours
|
|
1974
|
+
* behind it to edit.
|
|
1975
|
+
*/
|
|
1976
|
+
function ClassesBlock({
|
|
1977
|
+
owner,
|
|
1978
|
+
nodeId,
|
|
1979
|
+
}: {
|
|
1980
|
+
owner: ReactRootAuthoringAdapter;
|
|
1981
|
+
nodeId: string;
|
|
1982
|
+
}): React.ReactElement {
|
|
1983
|
+
const classes = owner.elementClasses(nodeId);
|
|
1984
|
+
const [draft, setDraft] = useState('');
|
|
1985
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
1986
|
+
const commit = async (): Promise<void> => {
|
|
1987
|
+
const className = draft.trim().replace(/^\./, '');
|
|
1988
|
+
if (!className) return;
|
|
1989
|
+
const result = await owner.writeNamedStyle(nodeId, { kind: 'set', className });
|
|
1990
|
+
setNotice(result.changed ? null : (result.error ?? 'refused'));
|
|
1991
|
+
if (result.changed) setDraft('');
|
|
1992
|
+
};
|
|
1993
|
+
return (
|
|
1994
|
+
<div
|
|
1995
|
+
data-testid="react-element-classes"
|
|
1996
|
+
style={{
|
|
1997
|
+
padding: `${spaceVar[3]} ${spaceVar[4]}`,
|
|
1998
|
+
borderBottom: `1px solid ${THEME.border}`,
|
|
1999
|
+
}}
|
|
2000
|
+
>
|
|
2001
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: spaceVar[2], alignItems: 'center' }}>
|
|
2002
|
+
<span
|
|
2003
|
+
style={{ fontSize: fontSizeVar.sm, color: THEME.textMuted, marginRight: spaceVar[1] }}
|
|
2004
|
+
>
|
|
2005
|
+
Style class
|
|
2006
|
+
</span>
|
|
2007
|
+
{classes.map((cls) => (
|
|
2008
|
+
<span
|
|
2009
|
+
key={cls.name}
|
|
2010
|
+
data-testid={`react-class-chip-${cls.name}`}
|
|
2011
|
+
title={
|
|
2012
|
+
cls.styledIn
|
|
2013
|
+
? `.${cls.name} — ${cls.count} element${cls.count === 1 ? '' : 's'} in this game ` +
|
|
2014
|
+
`share this style (${cls.styledIn.split('/').pop() ?? cls.styledIn})`
|
|
2015
|
+
: `.${cls.name} — utility/generated class (no first-party rule to edit)`
|
|
2016
|
+
}
|
|
2017
|
+
style={{
|
|
2018
|
+
display: 'inline-flex',
|
|
2019
|
+
alignItems: 'center',
|
|
2020
|
+
gap: 3,
|
|
2021
|
+
fontFamily: FONT_MONO,
|
|
2022
|
+
fontSize: fontSizeVar.sm,
|
|
2023
|
+
color: cls.styledIn ? THEME.text : THEME.textMuted,
|
|
2024
|
+
background: THEME.neutralHover,
|
|
2025
|
+
border: `1px solid ${THEME.border}`,
|
|
2026
|
+
borderRadius: 4,
|
|
2027
|
+
padding: '1px 4px',
|
|
2028
|
+
}}
|
|
2029
|
+
>
|
|
2030
|
+
.{cls.name}
|
|
2031
|
+
{cls.count > 1 && (
|
|
2032
|
+
<span style={{ color: THEME.textMuted, fontSize: fontSizeVar.xs }}>×{cls.count}</span>
|
|
2033
|
+
)}
|
|
2034
|
+
<button
|
|
2035
|
+
type="button"
|
|
2036
|
+
data-testid={`react-class-remove-${cls.name}`}
|
|
2037
|
+
title={`Remove .${cls.name} from this element`}
|
|
2038
|
+
onClick={() =>
|
|
2039
|
+
void owner
|
|
2040
|
+
.writeNamedStyle(nodeId, { kind: 'remove', className: cls.name })
|
|
2041
|
+
.then((result) => setNotice(result.changed ? null : (result.error ?? 'refused')))
|
|
2042
|
+
}
|
|
2043
|
+
style={{
|
|
2044
|
+
background: 'none',
|
|
2045
|
+
border: 'none',
|
|
2046
|
+
color: THEME.textMuted,
|
|
2047
|
+
cursor: 'pointer',
|
|
2048
|
+
fontSize: fontSizeVar.base,
|
|
2049
|
+
// A real hit target (blind-walk friction: the ✕ was ~6px).
|
|
2050
|
+
padding: '3px 5px',
|
|
2051
|
+
margin: '-3px -4px -3px -1px',
|
|
2052
|
+
lineHeight: lineHeightVar.tight,
|
|
2053
|
+
}}
|
|
2054
|
+
>
|
|
2055
|
+
✕
|
|
2056
|
+
</button>
|
|
2057
|
+
</span>
|
|
2058
|
+
))}
|
|
2059
|
+
<input
|
|
2060
|
+
data-testid="react-class-input"
|
|
2061
|
+
value={draft}
|
|
2062
|
+
placeholder={classes.length === 0 ? 'create style…' : 'add class…'}
|
|
2063
|
+
title="Type a class name — an existing first-party class is applied; a new name creates a style from this element's inline declarations"
|
|
2064
|
+
onChange={(event) => {
|
|
2065
|
+
setDraft(event.target.value);
|
|
2066
|
+
if (notice) setNotice(null);
|
|
2067
|
+
}}
|
|
2068
|
+
onKeyDown={(event) => {
|
|
2069
|
+
if (event.key === 'Enter') void commit();
|
|
2070
|
+
if (event.key === 'Escape') setDraft('');
|
|
2071
|
+
}}
|
|
2072
|
+
style={{
|
|
2073
|
+
flex: 1,
|
|
2074
|
+
minWidth: 72,
|
|
2075
|
+
background: 'transparent',
|
|
2076
|
+
border: `1px dashed ${THEME.border}`,
|
|
2077
|
+
borderRadius: 4,
|
|
2078
|
+
color: THEME.text,
|
|
2079
|
+
fontSize: fontSizeVar.sm,
|
|
2080
|
+
fontFamily: FONT_MONO,
|
|
2081
|
+
padding: '2px 5px',
|
|
2082
|
+
}}
|
|
2083
|
+
/>
|
|
2084
|
+
</div>
|
|
2085
|
+
{notice && (
|
|
2086
|
+
<div
|
|
2087
|
+
data-testid="react-class-notice"
|
|
2088
|
+
style={{
|
|
2089
|
+
paddingTop: spaceVar[2],
|
|
2090
|
+
color: themeDanger(),
|
|
2091
|
+
fontSize: fontSizeVar.sm,
|
|
2092
|
+
lineHeight: lineHeightVar.normal,
|
|
2093
|
+
}}
|
|
2094
|
+
>
|
|
2095
|
+
{notice}
|
|
2096
|
+
</div>
|
|
2097
|
+
)}
|
|
2098
|
+
<BreakpointRow />
|
|
2099
|
+
</div>
|
|
2100
|
+
);
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
/** The breakpoint selector — module session state (`breakpoint-state.ts`),
|
|
2104
|
+
* because the authoring adapters are recreated across design-time re-renders
|
|
2105
|
+
* and instance state silently reset to base between the select gesture and
|
|
2106
|
+
* the next edit (measured). */
|
|
2107
|
+
function BreakpointRow(): React.ReactElement {
|
|
2108
|
+
const bp = useSyncExternalStore(subscribeBreakpoint, activeBreakpoint);
|
|
2109
|
+
return (
|
|
2110
|
+
<>
|
|
2111
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 5, paddingTop: 5 }}>
|
|
2112
|
+
<span style={{ fontSize: fontSizeVar.sm, color: THEME.textMuted }}>Breakpoint</span>
|
|
2113
|
+
<Select
|
|
2114
|
+
data-testid="react-breakpoint-select"
|
|
2115
|
+
value={bp ?? ''}
|
|
2116
|
+
onChange={(event) => setActiveBreakpoint(event.target.value || null)}
|
|
2117
|
+
style={{
|
|
2118
|
+
fontSize: fontSizeVar.sm,
|
|
2119
|
+
flex: 1,
|
|
2120
|
+
...(bp ? { color: THEME.accent, borderColor: THEME.accent } : {}),
|
|
2121
|
+
}}
|
|
2122
|
+
>
|
|
2123
|
+
{BREAKPOINT_OPTIONS.map((option) => (
|
|
2124
|
+
<option key={option.label} value={option.media ?? ''}>
|
|
2125
|
+
{option.label}
|
|
2126
|
+
</option>
|
|
2127
|
+
))}
|
|
2128
|
+
</Select>
|
|
2129
|
+
</div>
|
|
2130
|
+
{bp && (
|
|
2131
|
+
<div
|
|
2132
|
+
data-testid="react-breakpoint-note"
|
|
2133
|
+
style={{
|
|
2134
|
+
paddingTop: 3,
|
|
2135
|
+
color: THEME.textMuted,
|
|
2136
|
+
fontSize: fontSizeVar.xs,
|
|
2137
|
+
lineHeight: lineHeightVar.normal,
|
|
2138
|
+
}}
|
|
2139
|
+
>
|
|
2140
|
+
Style edits write @media {bp} — previewed live by frame width.
|
|
2141
|
+
</div>
|
|
2142
|
+
)}
|
|
2143
|
+
</>
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
/** Webflow's cascade tiers as `@media` conditions. Base is the unscoped rule;
|
|
2148
|
+
* each tier is max-width, so narrower inherits from wider — the same
|
|
2149
|
+
* downward cascade Webflow teaches. A project's own additional conditions
|
|
2150
|
+
* are honored wherever they already exist in its css; this list is only
|
|
2151
|
+
* what the SELECT offers. */
|
|
2152
|
+
const BREAKPOINT_OPTIONS: ReadonlyArray<{ label: string; media: string | null }> = [
|
|
2153
|
+
{ label: 'Base', media: null },
|
|
2154
|
+
{ label: 'Tablet ≤991', media: '(max-width: 991px)' },
|
|
2155
|
+
{ label: 'Phone L ≤767', media: '(max-width: 767px)' },
|
|
2156
|
+
{ label: 'Phone ≤479', media: '(max-width: 479px)' },
|
|
2157
|
+
];
|
|
2158
|
+
|
|
2159
|
+
/** The danger tone for a story-write refusal, resolved lazily so this module
|
|
2160
|
+
* keeps a single THEME import surface. */
|
|
2161
|
+
function themeDanger(): string {
|
|
2162
|
+
return '#e06c75';
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
/** The Lane-B ("edits component source -> every instance") disclosure —
|
|
2166
|
+
* ports `ui-editor/source-edit-panel.tsx`'s header COPY, not the component
|
|
2167
|
+
* itself (that one is bound to `UIEditStore`/an ad hoc `/__ui-source/index`
|
|
2168
|
+
* fetch — Rule zero bans importing `UIEditStore` into this shell-registered
|
|
2169
|
+
* section). Shown only for a `ReactRootAuthoringAdapter` (real Lane B,
|
|
2170
|
+
* `data-oid` component-source writes); a `DomAuthoringAdapter`
|
|
2171
|
+
* (ingested, overlay-only — never touches source) gets its own honest note
|
|
2172
|
+
* instead, so neither lane's write model is ever misrepresented. */
|
|
2173
|
+
/**
|
|
2174
|
+
* C4 (spec §9): "the editor offers open/copy-path/go-to-line operations" for a DOM
|
|
2175
|
+
* element's mapped source location. Read-only — this ONLY surfaces where the source is
|
|
2176
|
+
* and copies a `file:line` locator; it has no write path (that stays SourceWriteBackend's
|
|
2177
|
+
* bounded style/className/text/struct ops, wired elsewhere). `open` degrades honestly to
|
|
2178
|
+
* copy-path when no editor-launch protocol is configured (documented fallback, not a
|
|
2179
|
+
* silent no-op): most terminals/OS shells don't register a handler for arbitrary custom
|
|
2180
|
+
* URI schemes, so we copy a `vscode://file/<abs>:<line>` link users can paste, rather than
|
|
2181
|
+
* assume a scheme is registered and fail silently if it isn't.
|
|
2182
|
+
*/
|
|
2183
|
+
function SourceLocationRow({ entry }: { entry: OidEntry }): React.ReactElement {
|
|
2184
|
+
const [copied, setCopied] = useState(false);
|
|
2185
|
+
const filePath = `${entry.file}:${entry.line}`;
|
|
2186
|
+
const short = `${entry.file.split('/').pop() ?? entry.file}:${entry.line}`;
|
|
2187
|
+
const copy = async (): Promise<void> => {
|
|
2188
|
+
try {
|
|
2189
|
+
await navigator.clipboard.writeText(filePath);
|
|
2190
|
+
setCopied(true);
|
|
2191
|
+
setTimeout(() => setCopied(false), 1500);
|
|
2192
|
+
} catch {
|
|
2193
|
+
// Clipboard API unavailable — the row still shows the path as text.
|
|
2194
|
+
}
|
|
2195
|
+
};
|
|
2196
|
+
return (
|
|
2197
|
+
<button
|
|
2198
|
+
type="button"
|
|
2199
|
+
data-testid="react-source-location"
|
|
2200
|
+
title={`${filePath} — click to copy the path`}
|
|
2201
|
+
onClick={() => void copy()}
|
|
2202
|
+
style={{
|
|
2203
|
+
fontFamily: FONT_MONO,
|
|
2204
|
+
fontSize: 'inherit',
|
|
2205
|
+
color: THEME.textMuted,
|
|
2206
|
+
cursor: 'pointer',
|
|
2207
|
+
overflow: 'hidden',
|
|
2208
|
+
textOverflow: 'ellipsis',
|
|
2209
|
+
whiteSpace: 'nowrap',
|
|
2210
|
+
minWidth: 0,
|
|
2211
|
+
background: 'none',
|
|
2212
|
+
border: 'none',
|
|
2213
|
+
padding: 0,
|
|
2214
|
+
textAlign: 'left',
|
|
2215
|
+
}}
|
|
2216
|
+
>
|
|
2217
|
+
{copied ? 'Copied' : short}
|
|
2218
|
+
</button>
|
|
2219
|
+
);
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
function LaneDisclosure({
|
|
2223
|
+
owner,
|
|
2224
|
+
node,
|
|
2225
|
+
nodeId,
|
|
2226
|
+
}: {
|
|
2227
|
+
owner: ReactRootAuthoringAdapter | DomAuthoringAdapter;
|
|
2228
|
+
node: EditorNode | null;
|
|
2229
|
+
nodeId: string;
|
|
2230
|
+
}): React.ReactElement {
|
|
2231
|
+
const isLaneB = owner instanceof ReactRootAuthoringAdapter;
|
|
2232
|
+
// C4: only Lane B (the OID/JSX source-writer path) has a source-location mapping —
|
|
2233
|
+
// Lane A (`DomAuthoringAdapter`, an ingested/overlay session) never stamps
|
|
2234
|
+
// `data-oid` or has an OID index (see its file doc comment), so it has nothing to
|
|
2235
|
+
// resolve here; that's the intended DOM-only degradation, not a bug.
|
|
2236
|
+
const sourceEntry = isLaneB ? owner.sourceLocation(nodeId) : undefined;
|
|
2237
|
+
// ONE quiet line, Figma-shaped (owner feedback, 2026-08-30): the inspector
|
|
2238
|
+
// leads with design controls, so provenance is a small muted row — the
|
|
2239
|
+
// short label + the file:line — with the full explanation living in the
|
|
2240
|
+
// tooltip instead of a callout paragraph.
|
|
2241
|
+
const short =
|
|
2242
|
+
node?.role === 'story'
|
|
2243
|
+
? `Story · ${node.label}`
|
|
2244
|
+
: node?.role === 'document'
|
|
2245
|
+
? `CSF · ${node.secondaryLabel ?? node.label}`
|
|
2246
|
+
: isLaneB
|
|
2247
|
+
? 'Source'
|
|
2248
|
+
: 'Session overlay — not written to source';
|
|
2249
|
+
const explain =
|
|
2250
|
+
node?.role === 'story'
|
|
2251
|
+
? `${node.label} is the active portable CSF design state.`
|
|
2252
|
+
: node?.role === 'document'
|
|
2253
|
+
? `${node.secondaryLabel ?? node.label} owns this root's design states.`
|
|
2254
|
+
: isLaneB
|
|
2255
|
+
? `Edits change ${node?.label ?? 'this component'}'s source — every rendered ` +
|
|
2256
|
+
'instance updates.'
|
|
2257
|
+
: 'This is an ingested game: edits persist as a per-project overlay only, never ' +
|
|
2258
|
+
'written to source.';
|
|
2259
|
+
return (
|
|
2260
|
+
<div
|
|
2261
|
+
data-testid={isLaneB ? 'react-lane-b-disclosure' : 'react-overlay-disclosure'}
|
|
2262
|
+
title={explain}
|
|
2263
|
+
style={{
|
|
2264
|
+
display: 'flex',
|
|
2265
|
+
alignItems: 'center',
|
|
2266
|
+
gap: spaceVar[3],
|
|
2267
|
+
padding: `${spaceVar[1]} ${spaceVar[4]} ${spaceVar[3]}`,
|
|
2268
|
+
borderBottom: `1px solid ${THEME.border}`,
|
|
2269
|
+
color: THEME.textMuted,
|
|
2270
|
+
fontSize: fontSizeVar.sm,
|
|
2271
|
+
lineHeight: lineHeightVar.normal,
|
|
2272
|
+
minWidth: 0,
|
|
2273
|
+
}}
|
|
2274
|
+
>
|
|
2275
|
+
<span style={{ flexShrink: 0, ...(isLaneB ? {} : { color: THEME.accent }) }}>{short}</span>
|
|
2276
|
+
{sourceEntry && <SourceLocationRow entry={sourceEntry} />}
|
|
2277
|
+
</div>
|
|
2278
|
+
);
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
/** The `element` facet's body: where it sits (ancestry), which lane owns it,
|
|
2282
|
+
* and where its source is. The subject's own name and type live on the
|
|
2283
|
+
* shell's identity row, not here. */
|
|
2284
|
+
function ElementSection({
|
|
2285
|
+
adapter,
|
|
2286
|
+
node,
|
|
2287
|
+
nodeId,
|
|
2288
|
+
owner,
|
|
2289
|
+
}: {
|
|
2290
|
+
adapter: AuthoringAdapter;
|
|
2291
|
+
node: EditorNode;
|
|
2292
|
+
nodeId: string;
|
|
2293
|
+
owner: ReactRootAuthoringAdapter | DomAuthoringAdapter;
|
|
2294
|
+
}): React.ReactElement {
|
|
2295
|
+
return (
|
|
2296
|
+
<div data-testid="react-inspector-section">
|
|
2297
|
+
<BreadcrumbBar adapter={adapter} node={node} />
|
|
2298
|
+
{owner instanceof ReactRootAuthoringAdapter && <ClassesBlock owner={owner} nodeId={nodeId} />}
|
|
2299
|
+
<LaneDisclosure owner={owner} node={node} nodeId={nodeId} />
|
|
2300
|
+
</div>
|
|
2301
|
+
);
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
/** The Style/Layout/Position/Spacing/component-prop group bodies re-render on
|
|
2305
|
+
* the shell's own subscription: every `SingleRow`/`ComboRow` calls
|
|
2306
|
+
* `store.notifyIngestEdit()` on commit (its own `useEditorStore()`), which
|
|
2307
|
+
* re-renders the inspector tree including these sections. */
|
|
2308
|
+
function GroupSectionBody({
|
|
2309
|
+
adapter,
|
|
2310
|
+
id,
|
|
2311
|
+
name,
|
|
2312
|
+
groupId,
|
|
2313
|
+
props,
|
|
2314
|
+
}: {
|
|
2315
|
+
adapter: AuthoringAdapter;
|
|
2316
|
+
id: string;
|
|
2317
|
+
name: string;
|
|
2318
|
+
groupId: string;
|
|
2319
|
+
props: readonly PropertyDescriptor[];
|
|
2320
|
+
}): React.ReactElement {
|
|
2321
|
+
const fonts = useMemo(() => getAvailableFonts(), []);
|
|
2322
|
+
return (
|
|
2323
|
+
<GroupBody
|
|
2324
|
+
name={name}
|
|
2325
|
+
groupId={groupId}
|
|
2326
|
+
props={props}
|
|
2327
|
+
adapter={adapter}
|
|
2328
|
+
id={id}
|
|
2329
|
+
fonts={fonts}
|
|
2330
|
+
/>
|
|
2331
|
+
);
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
/**
|
|
2335
|
+
* The react/DOM adapter as a PRODUCER of subject data (owner, 2026-08-06 —
|
|
2336
|
+
* "every section its own identified tab; no lumps").
|
|
2337
|
+
*
|
|
2338
|
+
* This adapter's inspector is not one thing: it is the element's identity,
|
|
2339
|
+
* its loose style declarations, and one block per descriptor GROUP (Layout,
|
|
2340
|
+
* Position, Spacing, a component's own props, the document's tokens…). Which
|
|
2341
|
+
* groups exist is a property of the NODE, so they can only be produced from
|
|
2342
|
+
* it — which is exactly what a producer registration is for. Each block comes
|
|
2343
|
+
* back as its own identified `InspectionSection`, so the compact card gives
|
|
2344
|
+
* it its own icon tab and the column its own titled block, with no projection
|
|
2345
|
+
* learning anything about React.
|
|
2346
|
+
*
|
|
2347
|
+
* The ids are the model's own (`properties` for the loose declarations,
|
|
2348
|
+
* `group:<id>` per group), so each block REPLACES the generic built-in of the
|
|
2349
|
+
* same id rather than appearing beside it.
|
|
2350
|
+
*/
|
|
2351
|
+
export function reactInspectorSections(
|
|
2352
|
+
node: EditorNode | null,
|
|
2353
|
+
adapter: AuthoringAdapter,
|
|
2354
|
+
): readonly InspectionSection[] {
|
|
2355
|
+
const owner = resolveReactOwner(node, adapter);
|
|
2356
|
+
if (!owner || !node) return [];
|
|
2357
|
+
const nodeId = node.id;
|
|
2358
|
+
const props = (adapter.inspector?.properties(nodeId) ?? []).filter((p) => p.path !== 'name');
|
|
2359
|
+
const { ungrouped, groups } = groupProperties(props);
|
|
2360
|
+
const sections: InspectionSection[] = [
|
|
2361
|
+
{
|
|
2362
|
+
id: REACT_ELEMENT_SECTION_ID,
|
|
2363
|
+
// The facet's name, not the object's: what KIND of element this is
|
|
2364
|
+
// surfaces on the subject's identity row. This adapter sets no
|
|
2365
|
+
// `typeLabel`, so the row's kind label falls back to `node.kind` (the tag
|
|
2366
|
+
// name) — the `typeLabel ?? kind` chain in `compose.ts`'s `kindLabel`.
|
|
2367
|
+
title: 'Element',
|
|
2368
|
+
icon: faCode,
|
|
2369
|
+
order: REACT_ELEMENT_SECTION_ORDER,
|
|
2370
|
+
body: {
|
|
2371
|
+
kind: 'custom',
|
|
2372
|
+
render: () => createElement(ElementSection, { adapter, node, nodeId, owner }),
|
|
2373
|
+
// The AGENT half of the Element section (P18 + the Transform-section
|
|
2374
|
+
// editable precedent): the class chips and active breakpoint the
|
|
2375
|
+
// rendered chrome shows, published as data — and the breakpoint as a
|
|
2376
|
+
// writable field through the SAME `setActiveBreakpoint` the select
|
|
2377
|
+
// calls, so `editor.inspect()` can read the named-styles surface and
|
|
2378
|
+
// `editor.setField('element.breakpoint', …)` can arm it. Class
|
|
2379
|
+
// add/remove stays a structural gesture (writeNamedStyle), not a
|
|
2380
|
+
// field — a class list is not a value.
|
|
2381
|
+
...(owner instanceof ReactRootAuthoringAdapter
|
|
2382
|
+
? {
|
|
2383
|
+
data: {
|
|
2384
|
+
classes: owner.elementClasses(nodeId),
|
|
2385
|
+
breakpoint: activeBreakpoint(),
|
|
2386
|
+
},
|
|
2387
|
+
editable: {
|
|
2388
|
+
fields: [
|
|
2389
|
+
{
|
|
2390
|
+
path: 'element.breakpoint',
|
|
2391
|
+
label: 'Breakpoint',
|
|
2392
|
+
type: 'enum' as const,
|
|
2393
|
+
options: BREAKPOINT_OPTIONS.map((option) => option.media ?? ''),
|
|
2394
|
+
},
|
|
2395
|
+
],
|
|
2396
|
+
io: {
|
|
2397
|
+
get: (path: string) =>
|
|
2398
|
+
path === 'element.breakpoint' ? (activeBreakpoint() ?? '') : undefined,
|
|
2399
|
+
set: (path: string, value: unknown) => {
|
|
2400
|
+
if (path === 'element.breakpoint') {
|
|
2401
|
+
setActiveBreakpoint(typeof value === 'string' && value ? value : null);
|
|
2402
|
+
}
|
|
2403
|
+
},
|
|
2404
|
+
},
|
|
2405
|
+
},
|
|
2406
|
+
}
|
|
2407
|
+
: {}),
|
|
2408
|
+
},
|
|
2409
|
+
},
|
|
2410
|
+
];
|
|
2411
|
+
if (node.role === 'story' && owner instanceof ReactRootAuthoringAdapter) {
|
|
2412
|
+
sections.push({
|
|
2413
|
+
id: REACT_STORY_SECTION_ID,
|
|
2414
|
+
title: 'Story',
|
|
2415
|
+
// The single selected story's own controls — a DIFFERENT facet from the
|
|
2416
|
+
// built-in `stories` collection/switcher (`faBookOpen`), and both can
|
|
2417
|
+
// compose on a story node. The compact card tells tabs apart by glyph
|
|
2418
|
+
// alone, so this one wears a distinct single-story glyph.
|
|
2419
|
+
icon: faBookBookmark,
|
|
2420
|
+
order: REACT_STORY_SECTION_ORDER,
|
|
2421
|
+
body: { kind: 'custom', render: () => createElement(StoryActions, { owner, node }) },
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
if (ungrouped.length > 0) {
|
|
2425
|
+
sections.push({
|
|
2426
|
+
id: PROPERTIES_SECTION_ID,
|
|
2427
|
+
title: 'Style',
|
|
2428
|
+
icon: faPalette,
|
|
2429
|
+
order: PROPERTIES_SECTION_ORDER,
|
|
2430
|
+
defaultOpen: true,
|
|
2431
|
+
body: {
|
|
2432
|
+
kind: 'custom',
|
|
2433
|
+
render: () =>
|
|
2434
|
+
createElement(GroupSectionBody, {
|
|
2435
|
+
adapter,
|
|
2436
|
+
id: nodeId,
|
|
2437
|
+
name: 'Style',
|
|
2438
|
+
groupId: 'style',
|
|
2439
|
+
props: ungrouped,
|
|
2440
|
+
}),
|
|
2441
|
+
},
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
groups.forEach((group, index) => {
|
|
2445
|
+
sections.push({
|
|
2446
|
+
id: groupSectionId(group.groupId),
|
|
2447
|
+
title: group.name,
|
|
2448
|
+
icon: groupIcon(group.name),
|
|
2449
|
+
order: GROUP_SECTION_ORDER + index,
|
|
2450
|
+
defaultOpen: true,
|
|
2451
|
+
body: {
|
|
2452
|
+
kind: 'custom',
|
|
2453
|
+
render: () =>
|
|
2454
|
+
createElement(GroupSectionBody, {
|
|
2455
|
+
adapter,
|
|
2456
|
+
id: nodeId,
|
|
2457
|
+
name: group.name,
|
|
2458
|
+
groupId: group.groupId,
|
|
2459
|
+
props: group.properties,
|
|
2460
|
+
}),
|
|
2461
|
+
},
|
|
2462
|
+
});
|
|
2463
|
+
});
|
|
2464
|
+
return sections;
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
const registrationGroup = createHmrRegistrationGroup(import.meta.hot, 'react-inspector-section');
|
|
2468
|
+
|
|
2469
|
+
/**
|
|
2470
|
+
* Idempotent one-time registration of this surface's Inspector section, called
|
|
2471
|
+
* by `@volter/editor-game/contributions/react/react-inspector.service.ts`.
|
|
2472
|
+
*
|
|
2473
|
+
* It used to run at module scope, reached by a bare `import
|
|
2474
|
+
* './authoring/react-inspector-section';` on line 33 of the host's `main.tsx`
|
|
2475
|
+
* — which is how the whole React/DOM design-time surface, 28 files, entered
|
|
2476
|
+
* every editor boot including a `models` build that authors no DOM. The
|
|
2477
|
+
* `ensure`/`track` shape is `@volter/editor-game`'s story contributions', for the same
|
|
2478
|
+
* reason recorded there: importing this module (in a test, or to reach
|
|
2479
|
+
* {@link resolveReactOwner}) must not mutate a registry.
|
|
2480
|
+
*/
|
|
2481
|
+
export function ensureReactInspectorSectionRegistered(): void {
|
|
2482
|
+
registrationGroup.ensure((track) => {
|
|
2483
|
+
track(
|
|
2484
|
+
registerInspectorSections({
|
|
2485
|
+
match: isDomSelection,
|
|
2486
|
+
sections: reactInspectorSections,
|
|
2487
|
+
// The descriptor channel is this adapter's own: its blocks ARE the node's
|
|
2488
|
+
// properties, mapped to the C1 widgets instead of the generic grid. Claimed
|
|
2489
|
+
// even for a node with no loose declarations, so the thin generic grid
|
|
2490
|
+
// never appears beside the rich blocks. (A react/DOM node has no transform
|
|
2491
|
+
// provider at all, so no Transform block is ever composed for one.)
|
|
2492
|
+
claims: [PROPERTIES_SECTION_ID],
|
|
2493
|
+
}),
|
|
2494
|
+
);
|
|
2495
|
+
});
|
|
2496
|
+
}
|