@volter/editor-game 0.5.66 → 0.5.68

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.
Files changed (239) hide show
  1. package/contributions/audio-unlock.service.ts +2 -2
  2. package/contributions/autoplay.service.ts +1 -1
  3. package/contributions/bridge.command.ts +3 -3
  4. package/contributions/build-player.document.tsx +95 -0
  5. package/contributions/canvas/component-board.service.ts +16 -0
  6. package/contributions/canvas/design-time-mount.service.ts +45 -0
  7. package/contributions/canvas-story-capture.service.ts +12 -0
  8. package/contributions/edit-mode-audio.service.ts +2 -2
  9. package/contributions/edit-mode-networking.service.ts +1 -1
  10. package/contributions/gameplay.command.ts +4 -4
  11. package/contributions/generation.service.ts +1 -1
  12. package/contributions/godot.style.ts +26 -6
  13. package/contributions/godot.view.ts +6 -0
  14. package/contributions/ingest.service.ts +2 -2
  15. package/contributions/instances.command.ts +1 -1
  16. package/contributions/navmesh.menu.ts +1 -1
  17. package/contributions/network-observer.service.ts +14 -0
  18. package/contributions/play.command.ts +10 -4
  19. package/contributions/react/component-board.service.ts +1 -1
  20. package/contributions/react/design-time-mount.service.ts +1 -1
  21. package/contributions/scene-document.service.ts +3 -3
  22. package/contributions/state-watch.menu.ts +1 -1
  23. package/contributions/state-watch.utility.tsx +1 -1
  24. package/contributions/team-playtest.service.ts +4 -4
  25. package/contributions/three/component-board.service.ts +1 -1
  26. package/contributions/three/component-verbs.command.ts +6 -6
  27. package/contributions/three/three-authoring.service.ts +8 -5
  28. package/contributions/three-story-capture.service.ts +12 -0
  29. package/contributions/unity.style.ts +17 -7
  30. package/contributions/unity.view.ts +6 -0
  31. package/contributions/unreal.style.ts +10 -2
  32. package/contributions/unreal.view.ts +5 -0
  33. package/package.json +21 -10
  34. package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
  35. package/src/asset-budget/asset-budget-model.ts +1 -1
  36. package/src/audio/AudioDebuggerPanel.tsx +1 -1
  37. package/src/bridge/dispatch.ts +14 -14
  38. package/src/bridge/live-frames.ts +1 -1
  39. package/src/bridge/screenshot.ts +3 -3
  40. package/src/build/BuildProfilesPanel.tsx +14 -3
  41. package/src/build/build-session.ts +35 -0
  42. package/src/canvas/canvas-board/CanvasBoardDocument.tsx +749 -0
  43. package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
  44. package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
  45. package/src/canvas/canvas-design-mount.ts +524 -0
  46. package/src/canvas/design-time-canvas-mount.ts +79 -0
  47. package/src/coverage/live-authoring-surface.ts +5 -5
  48. package/src/coverage/live-project-verbs.ts +1 -1
  49. package/src/coverage/native-system-coverage.ts +5 -5
  50. package/src/coverage/root-coverage.ts +1 -1
  51. package/src/coverage/session-coverage.ts +3 -3
  52. package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
  53. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
  54. package/src/edit-mode/edit-mode-audio.ts +4 -4
  55. package/src/edit-mode/edit-mode-networking.ts +2 -2
  56. package/src/game-document/GameCaptureFrameButton.tsx +1 -1
  57. package/src/game-document/GameDocument.tsx +2 -2
  58. package/src/game-document/GamePanel.tsx +4 -4
  59. package/src/game-document/InstanceInspectorPicker.tsx +2 -2
  60. package/src/game-document/crowd-debug.ts +1 -1
  61. package/src/game-document/device-preview.ts +10 -11
  62. package/src/game-document/physics-debug.ts +1 -1
  63. package/src/generation/GenerationActivity.tsx +3 -3
  64. package/src/generation/generation-documents.tsx +5 -5
  65. package/src/generation/generation-jobs.ts +1 -1
  66. package/src/host/adapter-runtime-bindings.ts +96 -5
  67. package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
  68. package/src/host/authoring/gesture-persist.ts +1 -1
  69. package/src/host/authoring/ingest-data-writer.ts +1 -1
  70. package/src/host/authoring/ingest-source-persistence.ts +4 -4
  71. package/src/host/authoring/mounted-authoring.ts +4 -4
  72. package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
  73. package/src/host/authoring/pixi-authoring-adapter.ts +178 -31
  74. package/src/host/authoring/pixi-creatable-kinds.ts +62 -0
  75. package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
  76. package/src/host/authoring/pixi-live-write-target.ts +4 -4
  77. package/src/host/authoring/pixi-source-identity.ts +3 -3
  78. package/src/host/authoring/pixi-source-write-target.ts +1709 -0
  79. package/src/host/authoring/pixi-still-presentation.ts +1 -1
  80. package/src/host/authoring/pixi-structure-history.ts +2 -2
  81. package/src/host/authoring/pixi-transform-channels.ts +16 -14
  82. package/src/host/authoring/source-persistence-backend.ts +3 -3
  83. package/src/host/authoring/struct-write-pipe.ts +1 -1
  84. package/src/host/binding-resolver.ts +8 -9
  85. package/src/host/browser-transpile.ts +1 -1
  86. package/src/host/canvas-entry-runtime.ts +58 -47
  87. package/src/host/canvas-preview-frames.ts +482 -0
  88. package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
  89. package/src/host/components/HeaderTelemetry.tsx +4 -4
  90. package/src/host/components/PixiIsolationSceneContent.tsx +11 -11
  91. package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
  92. package/src/host/components/frame-debugger-model.ts +2 -2
  93. package/src/host/components/header-telemetry-model.ts +2 -2
  94. package/src/host/components/scene-document.tsx +14 -14
  95. package/src/host/components/utility-view-state.ts +1 -1
  96. package/src/host/components/world-root-stage-binding.tsx +12 -12
  97. package/src/host/components/world-root-stage.ts +70 -49
  98. package/src/host/coverage/system-adapter-coverage.ts +3 -4
  99. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
  100. package/src/host/document-preview-three.ts +1 -1
  101. package/src/host/entry-adjudication.ts +6 -6
  102. package/src/host/game-css-scope-transform.ts +4 -0
  103. package/src/host/game-realm-page.ts +1 -1
  104. package/src/host/gameplay-export.ts +25 -14
  105. package/src/host/gameplay-recording.ts +5 -5
  106. package/src/host/gated-globals.ts +2 -2
  107. package/src/host/history/json-history-resource.ts +1 -1
  108. package/src/host/projection/pixi.ts +24 -2
  109. package/src/host/r3f-entry-runtime.ts +65 -34
  110. package/src/host/react-mount-runtime.ts +7 -48
  111. package/src/host/realm-services.ts +1 -1
  112. package/src/host/roots/canvas-root.tsx +373 -0
  113. package/src/host/roots/r3f-root.tsx +473 -0
  114. package/src/host/roots/react-root.ts +9 -43
  115. package/src/host/served-bundle-runtime-modules.ts +3 -19
  116. package/src/host/server-log-bridge.ts +2 -2
  117. package/src/host/stories/mounted-story-viewport-source.ts +1 -1
  118. package/src/host/stories/pixi-story-model.ts +30 -0
  119. package/src/host/stories/story-media-captures.ts +46 -0
  120. package/src/host/stories/story-media-presence.ts +3 -3
  121. package/src/host/stories/story-pixi-preview.ts +408 -0
  122. package/src/host/stories/story-three-preview.ts +806 -0
  123. package/src/host/stories/three-story-captures.ts +35 -0
  124. package/src/host/story-three-preview-runtime.ts +56 -0
  125. package/src/host/use-active-performance-source.ts +2 -2
  126. package/src/host/viewport-pose-memory.ts +1 -1
  127. package/src/host/viewport-root-presentation.ts +6 -5
  128. package/src/ingest/active-ingest.ts +1 -1
  129. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
  130. package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
  131. package/src/ingest/deferred-ingest-play.ts +6 -6
  132. package/src/ingest/discovery-public-ingest.ts +2 -2
  133. package/src/ingest/ingest-boot-viewport.ts +2 -2
  134. package/src/ingest/ingest-canvas-scene-document.tsx +9 -9
  135. package/src/ingest/ingest-canvas-scene.ts +3 -3
  136. package/src/ingest/ingest-evidence-hook.ts +1 -1
  137. package/src/ingest/ingest-frame-snapshot.ts +1 -1
  138. package/src/ingest/ingest-play-control.ts +1 -1
  139. package/src/ingest/ingest-render-debug.ts +10 -10
  140. package/src/ingest/ingest-siblings.ts +13 -25
  141. package/src/ingest/module-mode.ts +14 -14
  142. package/src/ingest/mount-canvas-ingest-root.ts +21 -21
  143. package/src/ingest/mount-coverage.ts +2 -2
  144. package/src/ingest/mount-dom-ingest-root.ts +11 -11
  145. package/src/ingest/mount-ingest-root.ts +8 -8
  146. package/src/ingest/mount-three-ingest-root.ts +8 -8
  147. package/src/ingest/resolve-canvas.ts +1 -1
  148. package/src/ingest/served-html-boot.ts +1 -1
  149. package/src/ingest/surface-canvas.ts +1 -1
  150. package/src/ingest/unmount-ingest-root.ts +5 -5
  151. package/src/navmesh/navmesh-handler.ts +24 -16
  152. package/src/network/NetworkInspectorPanel.tsx +939 -37
  153. package/src/network/network-inspector-model.ts +20 -2
  154. package/src/play/play-log-events.ts +1 -1
  155. package/src/play/play-mode.ts +76 -133
  156. package/src/play/play-recording.ts +1 -1
  157. package/src/play/react-play-live-authoring.ts +3 -3
  158. package/src/play/run-selection.ts +93 -0
  159. package/src/play-bar/PlayBar.tsx +20 -39
  160. package/src/profiler/FrameDebuggerPanel.tsx +1 -1
  161. package/src/profiler/PerformancePanel.tsx +3 -3
  162. package/src/react/design-time-react-mount.ts +23 -65
  163. package/src/react/dom-authoring-adapter.ts +9 -9
  164. package/src/react/react-inspector-section.tsx +5 -5
  165. package/src/react/react-world-authoring-adapter.ts +11 -11
  166. package/src/react/story-documents/story-documents.tsx +9 -9
  167. package/src/react/ui-board-document.tsx +7 -7
  168. package/src/react/ui-component-board.ts +2 -2
  169. package/src/runtime/adapter/audio-meter.ts +21 -0
  170. package/src/runtime/adapter/first-party-audio-system.ts +230 -0
  171. package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
  172. package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
  173. package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
  174. package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
  175. package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
  176. package/src/runtime/adapter/native-debug-module.ts +498 -0
  177. package/src/runtime/audio/bus-mixer.ts +161 -0
  178. package/src/runtime/audio/pose-guard.ts +80 -0
  179. package/src/runtime/core/frame-pacing.ts +126 -0
  180. package/src/runtime/core/game-loop.ts +225 -0
  181. package/src/runtime/core/game-scoped-slot.ts +28 -0
  182. package/src/runtime/core/seeded-random.ts +162 -0
  183. package/src/runtime/core/sim-clock.ts +391 -0
  184. package/src/runtime/core/system-runner.ts +269 -0
  185. package/src/runtime/core/types.ts +104 -0
  186. package/src/runtime/create-runtime.ts +1128 -0
  187. package/src/runtime/debug-bridge.ts +570 -0
  188. package/src/runtime/debug-registry.ts +899 -0
  189. package/src/runtime/dev/chrome-trace.ts +153 -0
  190. package/src/runtime/dev/instruments.ts +403 -0
  191. package/src/runtime/dev/logger.ts +119 -0
  192. package/src/runtime/dev/performance-profiler.ts +367 -0
  193. package/src/runtime/dev/register-render-vitals.ts +276 -0
  194. package/src/runtime/dev/render-census.ts +354 -0
  195. package/src/runtime/dev/render-debug-adapter.ts +218 -0
  196. package/src/runtime/dev/render-memory.ts +226 -0
  197. package/src/runtime/dev/render-vitals.ts +338 -0
  198. package/src/runtime/dev/static-batch-advisor.ts +188 -0
  199. package/src/runtime/dev/webgl-frame-capture.ts +366 -0
  200. package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
  201. package/src/runtime/dev-build.ts +47 -0
  202. package/src/runtime/game.ts +1636 -0
  203. package/src/runtime/gameplay-rng-trap.ts +135 -0
  204. package/src/runtime/host-context.ts +64 -0
  205. package/src/runtime/input-router.ts +182 -0
  206. package/src/runtime/mount-manifest.ts +480 -0
  207. package/src/runtime/pixi/authoring.ts +706 -0
  208. package/src/runtime/pixi/ingest.ts +116 -0
  209. package/src/runtime/pixi/physics-registry.ts +49 -0
  210. package/src/runtime/pixi/render-pass-bracket.ts +117 -0
  211. package/src/runtime/pixi/scene-capture.ts +179 -0
  212. package/src/runtime/pixi/system-adapters.ts +69 -0
  213. package/src/runtime/playtest.ts +22 -0
  214. package/src/runtime/presentation.ts +141 -0
  215. package/src/runtime/render-control.ts +642 -0
  216. package/src/runtime/render-seed.ts +77 -0
  217. package/src/runtime/run-ticks-settled.ts +73 -0
  218. package/src/runtime/setup/setup-audio.ts +72 -0
  219. package/src/services/audio-pose-guard.ts +2 -2
  220. package/src/services/game-audio.ts +152 -0
  221. package/src/services/game-network.ts +767 -0
  222. package/src/services/game-physics.ts +334 -0
  223. package/src/state-watch/StateWatchPanel.tsx +1 -1
  224. package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
  225. package/src/three/authoring/constraint-inspector-section.tsx +6 -5
  226. package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
  227. package/src/three/authoring/oid-source-persistence.ts +7 -7
  228. package/src/three/authoring/r3f-design-session.ts +58 -54
  229. package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
  230. package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
  231. package/src/three/authoring/three-authoring-adapter.ts +29 -29
  232. package/src/three/component-verbs/extract-menu.ts +7 -6
  233. package/src/three/component-verbs/fork-menu.ts +7 -6
  234. package/src/three/component-verbs/internals-menu.ts +2 -2
  235. package/src/three/story-documents/three-story-documents.tsx +13 -13
  236. package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
  237. package/src/three/three-board/board-scene.ts +6 -6
  238. package/src/three/three-board/three-component-board.ts +2 -2
  239. package/src/services/game-audio-unlock.ts +0 -48
@@ -0,0 +1,1709 @@
1
+ /**
2
+ * The TSX-SOURCE half of the canvas surface's persistence axis
3
+ * ({@link CanvasWriteTarget}) — a first-party `@pixi/react` world, whose
4
+ * document IS its source file.
5
+ *
6
+ * Model, and it is the three lane's ({@link
7
+ * ../../three/authoring/r3f-source-authoring-adapter}): the live display tree is truth
8
+ * for STRUCTURE and TRANSFORMS (projected directly, no fabricated document);
9
+ * the project's `.tsx` is truth for PERSISTENCE. A row whose container carries
10
+ * an oid is SOURCE-ADDRESSABLE — its JSX props read from the real source text
11
+ * and write back through the SAME `/__ui-source/*` seam the dom and three
12
+ * lanes use, wrapped in `withProjectSourceHistory` so every write is a
13
+ * checksum-guarded undo/redo entry for free.
14
+ *
15
+ * The literal-vs-dynamic guard is sacred: an expression-bound prop
16
+ * (`x={grunt.x}` — the survivor example's actors are positioned by their sim
17
+ * every tick) is surfaced READ-ONLY and never written, and a gizmo edit on
18
+ * such a channel is refused AND the live object reverts to its pre-drag
19
+ * snapshot. Never a silent two-truths divergence.
20
+ *
21
+ * Local component callsites are projected as native prefab instances. The
22
+ * source transform stamps their callsite identity onto the first Pixi host
23
+ * element, while that host keeps its own definition-side oid. The pair is the
24
+ * same source-derived contract the Three lane uses: callsite props are the
25
+ * instance overrides, declared props are the defaults, and no override sidecar
26
+ * exists.
27
+ */
28
+
29
+ import type {
30
+ CanvasWriteContext,
31
+ CanvasWriteTarget,
32
+ } from './pixi-authoring-adapter';
33
+ import {
34
+ authoringOidForContainer,
35
+ isCanvasComponentInstanceRoot,
36
+ oidOfPixiId,
37
+ readContainerOid,
38
+ } from './pixi-source-identity';
39
+ import {
40
+ formatSourceNumber,
41
+ planChannelWrite,
42
+ transform2DChanged,
43
+ } from './pixi-transform-channels';
44
+ import { createStructWritePipe, type StructOpOptions } from './struct-write-pipe';
45
+ import { PIXI_CREATABLE_KINDS } from './pixi-creatable-kinds';
46
+ import {
47
+ clipboardOutcome,
48
+ LIVE_ONLY_ACK,
49
+ resolvesLiveOnly,
50
+ runWritePipe,
51
+ type WriteAck,
52
+ type WriteResolution,
53
+ } from '@volter/editor-sdk/kit/write-pipe';
54
+ import type { CanvasPixiNamespace } from '../canvas-entry-runtime';
55
+ import { editorConsole } from '@volter/editor-sdk/kit/editor-console';
56
+ import { withProjectSourceHistory } from '@volter/editor-sdk/kit/history/source-history-backend';
57
+ import { showTransientHint } from '@volter/editor-sdk/kit/transient-hint';
58
+ import {
59
+ type ComponentPropSpec,
60
+ lineColToOffset,
61
+ type OidEntry,
62
+ } from '@volter/editor-react/source/oid-transform';
63
+ import { relativeImportSpecifier } from '@volter/editor-react/source/relative-import-specifier';
64
+ import type { SourceWriteBackend } from '@volter/editor-sdk/kit/ui-source/source-write-backend';
65
+ import {
66
+ analyzeJsxAttributes,
67
+ findElementEnd,
68
+ findTagEnd,
69
+ type JsxAttrInfo,
70
+ } from '@volter/editor-react/source/writer';
71
+ import { CANVAS_COMPONENTS_DOCUMENT_ID } from '@volter/editor-sdk/kit/workspace-document-ids';
72
+ import { activateWorkspaceDocument } from '@volter/editor-sdk/kit/workspace-document-registry';
73
+ import type {
74
+ AssetDropProvider,
75
+ ComponentInstanceApplyResult,
76
+ ComponentInstanceDescription,
77
+ ComponentInstancesProvider,
78
+ NodeCreationSite,
79
+ PersistenceProvider,
80
+ PropertyDescriptor,
81
+ StructureProvider,
82
+ TransformChannel,
83
+ TransformEditability,
84
+ } from '@volter/editor-project/adapter';
85
+ import type { AuthoringAdapter2D, Transform2DValue } from '../../runtime/pixi/authoring';
86
+ import { getComponentPreviewStories } from '@volter/editor-sdk/kit/stories/story-registry';
87
+ import type { Container, Matrix } from 'pixi.js';
88
+ import * as shellPixi from 'pixi.js';
89
+
90
+ /** Why a write here resolves live-only — the ONE spelling for this lane. */
91
+ const NO_SOURCE_WRITER_REASON = 'this session has no source-write backend';
92
+
93
+ /** Props the JSX section never lists: the editor's own stamp, the wrapper's
94
+ * structural props, and the ones the panels above already own. */
95
+ const HIDDEN_PROPS = new Set(['key', 'ref', 'data-oid', 'children', 'style', 'className']);
96
+ /** Owned by the Transform section and the header's Name field. */
97
+ const PANEL_OWNED_PROPS = new Set([
98
+ 'x',
99
+ 'y',
100
+ 'rotation',
101
+ 'scale',
102
+ 'scale-x',
103
+ 'scale-y',
104
+ 'label',
105
+ 'visible',
106
+ 'skew',
107
+ ]);
108
+ /** Props the drop snippet writes itself. */
109
+ const DROP_SNIPPET_PROPS = new Set(['x', 'y', 'name', 'children']);
110
+
111
+ const NUMBER_RE = /^-?\d+(\.\d+)?$/;
112
+ const CREATE_SNIPPETS: Readonly<Record<string, string>> = {
113
+ container: '<pixiContainer label="Container" />',
114
+ sprite: '<pixiSprite label="Sprite" />',
115
+ text: '<pixiText label="Text" text="Text" />',
116
+ graphics: '<pixiGraphics label="Graphics" />',
117
+ animatedSprite: '<pixiAnimatedSprite label="AnimatedSprite" textures={[Texture.WHITE]} width={64} height={64} />',
118
+ tilingSprite: '<pixiTilingSprite label="TilingSprite" texture={Texture.WHITE} width={100} height={100} />',
119
+ nineSliceSprite: '<pixiNineSliceSprite label="NineSliceSprite" texture={Texture.WHITE} width={100} height={100} />',
120
+ bitmapText: '<pixiBitmapText label="BitmapText" text="BitmapText" />',
121
+ htmlText: '<pixiHTMLText label="HTMLText" text="HTMLText" />',
122
+ };
123
+ /** A kind whose snippet names Pixi's `Texture`, which the file must then import. */
124
+ const TEXTURE_SNIPPET_KINDS = new Set(['animatedSprite', 'tilingSprite', 'nineSliceSprite']);
125
+
126
+ function isHiddenProp(name: string): boolean {
127
+ return HIDDEN_PROPS.has(name) || /^on[A-Z]/.test(name) || name.startsWith('__vgai');
128
+ }
129
+
130
+ /** Present a source identifier the way native engine inspectors present
131
+ * property names, while keeping the JSX name as source truth. */
132
+ function humanizeIdentifier(value: string): string {
133
+ const spaced = value
134
+ .replace(/[_-]+/g, ' ')
135
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
136
+ .trim();
137
+ return spaced ? spaced[0]!.toUpperCase() + spaced.slice(1) : value;
138
+ }
139
+
140
+ export interface SourceCanvasWriteTargetOptions {
141
+ /** Manifest root id — the world half of every id this lane mints. */
142
+ readonly worldId: string;
143
+ /** Project-relative entry path, for the console messages a refusal writes. */
144
+ readonly entryPath: string;
145
+ /** The RAW backend; wrapped in `withProjectSourceHistory` here (the same
146
+ * recipe `R3fSourceAuthoringAdapter` uses). Absent ⇒ every write refuses in
147
+ * a sentence rather than pretending. */
148
+ readonly writeBackend?: SourceWriteBackend | undefined;
149
+ /**
150
+ * THE namespace of the world this target writes for — the mount site's, the
151
+ * same one its `PixiAuthoringAdapter` holds
152
+ * (`../../vite-plugin-module-doorways.ts`).
153
+ *
154
+ * Nothing built here today ESCAPES into the world: `Matrix`/`Transform` are
155
+ * used to decompose a reparent into numbers, which is pure value math that
156
+ * measured correct across instances. It is a parameter anyway, because the
157
+ * lane's rule is that a canvas surface holds ONE namespace with no exceptions
158
+ * a reader has to hold in their head — and because the next value constructed
159
+ * in this file may well be a display object, where the same static import
160
+ * would be a silent bug rather than a harmless one.
161
+ */
162
+ readonly pixi?: CanvasPixiNamespace | undefined;
163
+ }
164
+
165
+ interface CanvasStructureClipboard {
166
+ readonly sourceFile: string;
167
+ readonly text: string;
168
+ readonly count: number;
169
+ }
170
+
171
+ interface CanvasClipboardElement {
172
+ readonly file: string;
173
+ readonly offset: number;
174
+ readonly text: string;
175
+ }
176
+
177
+ function dedentJsxElement(source: string, offset: number, end: number): string {
178
+ const indent = source.slice(source.lastIndexOf('\n', offset - 1) + 1, offset);
179
+ const raw = source.slice(offset, end);
180
+ if (!/^\s*$/.test(indent)) return raw;
181
+ return raw
182
+ .split('\n')
183
+ .map((line, index) => (index > 0 && line.startsWith(indent) ? line.slice(indent.length) : line))
184
+ .join('\n');
185
+ }
186
+
187
+ function highestSelectedIds(
188
+ ids: readonly string[],
189
+ parentOf: (id: string) => string | null,
190
+ ): string[] {
191
+ const selected = new Set(ids);
192
+ return [...selected].filter((id) => {
193
+ let parentId = parentOf(id);
194
+ while (parentId) {
195
+ if (selected.has(parentId)) return false;
196
+ parentId = parentOf(parentId);
197
+ }
198
+ return true;
199
+ });
200
+ }
201
+
202
+ export function createSourceCanvasWriteTarget(
203
+ options: SourceCanvasWriteTargetOptions,
204
+ ): CanvasWriteTarget {
205
+ const { worldId, entryPath } = options;
206
+ const pixi = options.pixi ?? shellPixi;
207
+ let a2d: AuthoringAdapter2D;
208
+ let notify: () => void = () => {};
209
+ let backend: SourceWriteBackend | undefined;
210
+ let boundStore: CanvasWriteContext['store'] | undefined;
211
+ let structureClipboard: CanvasStructureClipboard | null = null;
212
+
213
+ /** OID → {file,line,col} — fetched from `/__ui-source/index`. */
214
+ let oidIndex = new Map<string, OidEntry>();
215
+ /** file → current source text (for client-side attr analysis). */
216
+ let sources = new Map<string, string>();
217
+ /** Optimistic write echo (id|path → value) until the source re-read lands. */
218
+ const valueEcho = new Map<string, unknown>();
219
+ /** id|channel refused as dynamic this session — rendered read-only. */
220
+ const dynamicPaths = new Set<string>();
221
+ /** Pre-gesture 2D poses, one per id currently under a drag. */
222
+ const editStarts = new Map<string, Transform2DValue>();
223
+ const pendingOrigins = new Map<
224
+ string,
225
+ { kind: 'pivot' | 'anchor'; value: readonly [number, number]; before: readonly [number, number] }
226
+ >();
227
+ let commitQueue = Promise.resolve();
228
+
229
+ const oidOf = (id: string): string | null => {
230
+ // The LIVE object is the authority (an id can outlive a remount), with the
231
+ // id's own encoding as the fallback for a row the walk no longer holds.
232
+ const object = a2d.displayObject(id);
233
+ return (object ? authoringOidForContainer(object) : undefined) ?? oidOfPixiId(worldId, id);
234
+ };
235
+
236
+ const liveOids = (): Set<string> => {
237
+ const oids = new Set<string>();
238
+ const visit = (id: string): void => {
239
+ // BOTH addresses. `attrsOf`/`oidOf` resolve a row by its CALLSITE oid,
240
+ // which for a component-instance root lives in the PARENT's file — a
241
+ // file that may contribute no host element of its own (`src/world.tsx`,
242
+ // which only renders `<SurvivorScene />`). A set built from the
243
+ // element's own oid alone never named it, so its source was never
244
+ // fetched and every row addressed there — including anything a drop
245
+ // wrote into it — read "Source metadata is still loading or
246
+ // unavailable" for good (runhuman passes 130/132; traced on a local
247
+ // hosted tier, 2026-09-03). `onReindex`'s staleness probe shares this.
248
+ const object = a2d.displayObject(id);
249
+ const own = readContainerOid(object);
250
+ if (own !== undefined) oids.add(own);
251
+ const callsite = object ? authoringOidForContainer(object) : undefined;
252
+ if (callsite !== undefined) oids.add(callsite);
253
+ for (const childId of a2d.node(id)?.childIds ?? []) visit(childId);
254
+ };
255
+ for (const root of a2d.roots()) visit(root.id);
256
+ return oids;
257
+ };
258
+
259
+ /** Refetch the OID index + the source text of every file it references. */
260
+ const refreshSourceState = async (): Promise<void> => {
261
+ if (!backend?.index || !backend.readSource) return;
262
+ try {
263
+ const index = await backend.index();
264
+ oidIndex = new Map(Object.entries(index));
265
+ const files = new Set<string>();
266
+ for (const oid of liveOids()) {
267
+ const entry = oidIndex.get(oid);
268
+ if (entry) files.add(entry.file);
269
+ }
270
+ const next = new Map<string, string>();
271
+ await Promise.all(
272
+ [...files].map(async (file) => {
273
+ const res = await backend!.readSource!(file);
274
+ next.set(file, res.source);
275
+ }),
276
+ );
277
+ sources = next;
278
+ notify();
279
+ } catch {
280
+ // Honest degradation: rows stay live-projected (read-only) when the
281
+ // index/source cannot be fetched.
282
+ }
283
+ };
284
+
285
+ /** The node's JSX attributes, analyzed from the CURRENT source text (null
286
+ * when the node isn't source-addressable or the source isn't cached yet). */
287
+ const attrsOf = (id: string): JsxAttrInfo[] | null => {
288
+ const oid = oidOf(id);
289
+ if (!oid) return null;
290
+ const entry = oidIndex.get(oid);
291
+ if (!entry) return null;
292
+ const source = sources.get(entry.file);
293
+ if (source === undefined) return null;
294
+ const start = lineColToOffset(source, entry.line, entry.col);
295
+ if (source[start] !== '<') return null; // stale index vs source — refuse honestly
296
+ const tagEnd = findTagEnd(source, start);
297
+ if (tagEnd < 0) return null;
298
+ return analyzeJsxAttributes(source, start, tagEnd);
299
+ };
300
+
301
+ const projectRelativeSourceFile = (file: string): string => {
302
+ const normalized = file.replaceAll('\\', '/');
303
+ const normalizedEntry = entryPath.replaceAll('\\', '/').replace(/^\.?\//, '');
304
+ if (!normalized.startsWith('/') && !/^[A-Za-z]:\//.test(normalized)) return normalized;
305
+ for (const entry of oidIndex.values()) {
306
+ const indexed = entry.file.replaceAll('\\', '/');
307
+ if (indexed !== normalizedEntry && !indexed.endsWith(`/${normalizedEntry}`)) continue;
308
+ const projectPrefix = indexed.slice(0, -normalizedEntry.length);
309
+ if (normalized.startsWith(projectPrefix)) return normalized.slice(projectPrefix.length);
310
+ }
311
+ return normalizedEntry;
312
+ };
313
+
314
+ const declaredPropsOf = (id: string): readonly ComponentPropSpec[] => {
315
+ const oid = oidOf(id);
316
+ return (oid ? oidIndex.get(oid)?.props : undefined) ?? [];
317
+ };
318
+
319
+ const definitionOidOf = (id: string): string | null => {
320
+ const object = a2d.displayObject(id);
321
+ if (!object || !isCanvasComponentInstanceRoot(object)) return null;
322
+ const own = readContainerOid(object);
323
+ const callsite = authoringOidForContainer(object);
324
+ return own && own !== callsite ? own : null;
325
+ };
326
+
327
+ const componentIdentityOf = (id: string): { name: string; sourcePath?: string } | null => {
328
+ const object = a2d.displayObject(id);
329
+ if (!object || !isCanvasComponentInstanceRoot(object)) return null;
330
+ const definitionOid = definitionOidOf(id);
331
+ const definition = definitionOid ? oidIndex.get(definitionOid) : undefined;
332
+ const callsiteOid = oidOf(id);
333
+ const callsite = callsiteOid ? oidIndex.get(callsiteOid) : undefined;
334
+ const name = definition?.component ?? callsite?.tag;
335
+ if (!name) return null;
336
+ return {
337
+ name,
338
+ ...(definition?.file ? { sourcePath: projectRelativeSourceFile(definition.file) } : {}),
339
+ };
340
+ };
341
+
342
+ const creationSiteOf = (id: string): NodeCreationSite => {
343
+ const oid = oidOf(id);
344
+ if (!oid) {
345
+ return {
346
+ anchored: false,
347
+ reason: 'This rendered Canvas object has no authored source identity.',
348
+ };
349
+ }
350
+ const entry = oidIndex.get(oid);
351
+ if (!entry) {
352
+ return { anchored: false, reason: 'Source metadata is still loading or unavailable.' };
353
+ }
354
+ const file = projectRelativeSourceFile(entry.file);
355
+ return {
356
+ anchored: true,
357
+ kind: 'source',
358
+ file,
359
+ line: entry.line,
360
+ col: entry.col,
361
+ display: `${file}:${entry.line}`,
362
+ };
363
+ };
364
+
365
+ const persistence: PersistenceProvider | undefined = options.writeBackend
366
+ ? {
367
+ isDirty: () => false,
368
+ save: async () => undefined,
369
+ destination: entryPath,
370
+ lastError: () => {
371
+ const error = boundStore?.shell.projectHistory?.getSnapshot().lastError;
372
+ if (!error) return null;
373
+ return error.code === 'apply-failed' ||
374
+ error.code === 'compensation-failed' ||
375
+ error.code === 'history-limit'
376
+ ? error.message
377
+ : null;
378
+ },
379
+ }
380
+ : undefined;
381
+
382
+ const read2D = (id: string): Transform2DValue =>
383
+ a2d.getTransform(id) ?? { position: [0, 0], rotation: 0, scale: [1, 1] };
384
+
385
+ /**
386
+ * A channel a COMPONENT INSTANCE's own code owns: the write would need a
387
+ * prop the component neither authors at the callsite nor declares, so the
388
+ * byte lands in the file and never reaches the running object. The same
389
+ * question the drop asks at creation (`declaredSpecsOf`) and the
390
+ * gesture asks at commit — asked by `transformEditability` too, so the
391
+ * field is disabled with the real reason instead of accepting a drag it is
392
+ * going to revert.
393
+ */
394
+ const componentOwnedChannel = (
395
+ id: string,
396
+ channel: TransformChannel,
397
+ attrs: readonly JsxAttrInfo[],
398
+ value: Transform2DValue,
399
+ ): boolean => {
400
+ if (!componentIdentityOf(id)) return false;
401
+ const plan = planChannelWrite(channel, value, attrs);
402
+ if (!plan.writable) return false; // its own refusal names the reason
403
+ const declared = declaredPropsOf(id);
404
+ return plan.writes.some(
405
+ (write) =>
406
+ !attrs.some((attr) => attr.name === write.prop) &&
407
+ !declared.some((spec) => spec.name === write.prop),
408
+ );
409
+ };
410
+
411
+ const literalValueOf = (attr: JsxAttrInfo): unknown => {
412
+ if (!attr.isLiteral) return `{${attr.rawValue}}`;
413
+ const raw = attr.rawValue;
414
+ if (!attr.isExpression) return raw;
415
+ if (raw === 'true') return true;
416
+ if (raw === 'false') return false;
417
+ if (NUMBER_RE.test(raw)) return Number(raw);
418
+ return raw.replace(/^['"]|['"]$/g, '');
419
+ };
420
+
421
+ const descriptorType = (attr: JsxAttrInfo): PropertyDescriptor['type'] => {
422
+ if (!attr.isExpression) return 'string';
423
+ if (attr.rawValue === 'true' || attr.rawValue === 'false') return 'boolean';
424
+ if (NUMBER_RE.test(attr.rawValue)) return 'number';
425
+ return 'string';
426
+ };
427
+
428
+ const serializeValue = (value: unknown): string => {
429
+ if (typeof value === 'number') return formatSourceNumber(value);
430
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
431
+ return String(value);
432
+ };
433
+
434
+ const propValue = (
435
+ attrs: readonly JsxAttrInfo[] | null,
436
+ declared: readonly ComponentPropSpec[],
437
+ prop: string,
438
+ ): unknown => {
439
+ const attr = attrs?.find((candidate) => candidate.name === prop);
440
+ if (attr) return literalValueOf(attr);
441
+ const spec = declared.find((candidate) => candidate.name === prop);
442
+ return spec ? (spec.defaultValue ?? spec.defaultText) : undefined;
443
+ };
444
+
445
+ const isOverride = (
446
+ id: string,
447
+ attr: JsxAttrInfo | undefined,
448
+ spec: ComponentPropSpec | undefined,
449
+ ): boolean =>
450
+ Boolean(
451
+ attr?.isLiteral &&
452
+ spec?.optional &&
453
+ backend?.removeProp &&
454
+ !dynamicPaths.has(`${id}|jsx.${attr.name}`),
455
+ );
456
+
457
+ /**
458
+ * Gesture end → one source write per CHANGED channel.
459
+ *
460
+ * A refused channel (an expression-bound prop, a shape this lane cannot
461
+ * express, no backend) reverts the live object to its pre-gesture snapshot:
462
+ * source stays truth, and the viewport never shows a value the file does not
463
+ * have. A multi-channel gesture goes through `runGesture` so one Ctrl+Z
464
+ * restores the whole thing.
465
+ */
466
+ /** One closed gesture. Answers whether ANY byte reached the entry TSX — a
467
+ * gesture every channel of which was refused is `false`, and the live
468
+ * object has been reverted to match. */
469
+ const commitTransform = async (id: string, before: Transform2DValue): Promise<boolean> => {
470
+ const after = read2D(id);
471
+ const channels = (['position', 'rotation', 'scale'] as const).filter((channel) =>
472
+ transform2DChanged(channel, before, after),
473
+ );
474
+ // A moved origin (pivot or anchor) is written with the position that compensates it.
475
+ const origin = pendingOrigins.get(id);
476
+ pendingOrigins.delete(id);
477
+ if (channels.length === 0 && !origin) return false;
478
+
479
+ const revert = (channel: TransformChannel | 'origin'): void => {
480
+ if (channel === 'origin') {
481
+ const display = a2d.displayObject(id) as (Container & Record<string, unknown>) | null;
482
+ if (display && origin) display[origin.kind] = { x: origin.before[0], y: origin.before[1] };
483
+ } else if (channel === 'position') a2d.setTransform(id, { position: before.position });
484
+ else if (channel === 'rotation') a2d.setTransform(id, { rotation: before.rotation });
485
+ else a2d.setTransform(id, { scale: before.scale });
486
+ notify();
487
+ };
488
+
489
+ const oid = oidOf(id);
490
+ if (!oid || !backend?.writeProp) {
491
+ // biome-ignore lint/suspicious/noConsole: a lost write must stay diagnosable
492
+ console.warn(
493
+ `[canvas-source ${entryPath}] transform on "${id}" is not persistable ` +
494
+ `(${oid ? 'no source-write backend' : 'node has no source stamp'}) — reverting.`,
495
+ );
496
+ for (const channel of channels) revert(channel);
497
+ return false;
498
+ }
499
+
500
+ const attrs = attrsOf(id) ?? [];
501
+ // A COMPONENT INSTANCE's transform reaches the game only through props the
502
+ // component DECLARES — the same declared-prop gate the three lane's
503
+ // `writeJsxProp` applies to addIfMissing. Appending x/y to a callsite whose
504
+ // component takes no props writes dead schema while the component's own
505
+ // code keeps driving the live object — the tester-visible "it resets on
506
+ // its own" (runhuman pass 63, Survivor's sim-driven HeroActor). An attr
507
+ // the tag ALREADY authors stays editable: that shape is the author's own.
508
+ const instance = componentIdentityOf(id);
509
+ const channelBlocked = (channel: TransformChannel): boolean =>
510
+ componentOwnedChannel(id, channel, attrs, after);
511
+ const writes: Array<{ channel: TransformChannel | 'origin'; prop: string; value: string }> = [];
512
+ for (const channel of channels) {
513
+ if (channelBlocked(channel)) {
514
+ dynamicPaths.add(`${id}|${channel}`);
515
+ // editorConsole, not console: the user watching the value snap back
516
+ // must see WHY in the editor's own Console (the three lane's
517
+ // writeTypedThree narrates its refusals the same way).
518
+ // The Console alone does not reach a user watching the viewport: pass 65
519
+ // read the snap-back as unexplained with the refusal already printed
520
+ // three lines away. The hint says it where they are looking; the
521
+ // Console keeps the full reason.
522
+ showTransientHint(
523
+ `${channel} is owned by <${instance?.name ?? 'this component'}>'s own code — see Console`,
524
+ );
525
+ editorConsole.warn(
526
+ `${channel} on <${instance?.name ?? 'this component'}> is not writable — the ` +
527
+ `component declares no prop for it, so a written value would never reach the running ` +
528
+ `game (the component's own code owns that channel). Edit ` +
529
+ `${instance?.sourcePath ?? 'the component definition'} instead. Reverting.`,
530
+ 'authoring',
531
+ );
532
+ revert(channel);
533
+ continue;
534
+ }
535
+ const plan = planChannelWrite(channel, after, attrs);
536
+ if (!plan.writable) {
537
+ dynamicPaths.add(`${id}|${channel}`);
538
+ // biome-ignore lint/suspicious/noConsole: the refusal reason is the whole point
539
+ console.warn(
540
+ `[canvas-source ${entryPath}] ${channel} on "${id}" is not writable — ${plan.reason} ` +
541
+ 'Reverting the live object.',
542
+ );
543
+ revert(channel);
544
+ continue;
545
+ }
546
+ for (const write of plan.writes) writes.push({ channel, ...write });
547
+ }
548
+ if (origin) {
549
+ if (instance && channelBlocked('position')) revert('origin');
550
+ else {
551
+ writes.push({
552
+ channel: 'origin',
553
+ prop: origin.kind,
554
+ value: `{ x: ${formatSourceNumber(origin.value[0])}, y: ${formatSourceNumber(origin.value[1])} }`,
555
+ });
556
+ }
557
+ }
558
+ if (writes.length === 0) return false;
559
+
560
+ let persisted = false;
561
+ const run = async (selected: SourceWriteBackend): Promise<void> => {
562
+ for (const write of writes) {
563
+ const res = await selected.writeProp!(oid, write.prop, write.value, {
564
+ // Editing a channel the tag doesn't author yet APPENDS it — placing
565
+ // an object that has never been placed is ordinary authoring, and it
566
+ // must not need a trip to the text editor.
567
+ addIfMissing: true,
568
+ // `scale={1.5}` becomes `scale={{ x, y }}` when a gesture makes it non-uniform.
569
+ // The same for an origin authored as one number (`pivot={60}`).
570
+ ...(write.prop === 'scale' || write.channel === 'origin' ? { allowShapeUpgrade: true } : {}),
571
+ });
572
+ if (res.changed) {
573
+ persisted = true;
574
+ continue;
575
+ }
576
+ // A channel can span several props. During a revert, for example,
577
+ // x may already be the requested literal while y is the byte that
578
+ // actually changes. That first result is satisfied, not refused: only
579
+ // a dynamic expression or a named backend error closes the channel.
580
+ if (!res.dynamic && !res.error) continue;
581
+ if (res.dynamic) dynamicPaths.add(`${id}|${write.channel}`);
582
+ // biome-ignore lint/suspicious/noConsole: a refused write must say why
583
+ console.warn(
584
+ `[canvas-source ${entryPath}] ${write.prop} write refused for oid "${oid}": ` +
585
+ `${res.dynamic ? 'dynamic expression (guarded)' : (res.error ?? 'no change')} — reverting.`,
586
+ );
587
+ revert(write.channel);
588
+ }
589
+ };
590
+ if (writes.length > 1 && backend.runGesture) {
591
+ await backend.runGesture('Transform', (scoped) => run(scoped));
592
+ } else {
593
+ await run(backend);
594
+ }
595
+ // The file changed under us; re-read it so the next gesture plans against
596
+ // what is now on disk rather than the pre-write text.
597
+ await refreshSourceState();
598
+ notify();
599
+ return persisted;
600
+ };
601
+
602
+ /** Mirror a written prop onto the live object where Pixi owns a field of
603
+ * that name. Deliberately narrow: a redraw courtesy, not a second write
604
+ * path — anything Pixi does not carry simply waits for the remount. */
605
+ /** The live object's current value for a prop `applyLiveProp` can mirror —
606
+ * what a refused write must be restored to. */
607
+ const readLiveProp = (id: string, prop: string): unknown => {
608
+ const display = a2d.displayObject(id) as (Container & Record<string, unknown>) | null;
609
+ if (!display) return undefined;
610
+ return prop === 'label' ? display.label : prop in display ? display[prop] : undefined;
611
+ };
612
+
613
+ const applyLiveProp = (id: string, prop: string, value: unknown): void => {
614
+ const display = a2d.displayObject(id) as (Container & Record<string, unknown>) | null;
615
+ if (!display) return;
616
+ if (prop === 'label') display.label = String(value);
617
+ else if (prop in display) display[prop] = value;
618
+ };
619
+
620
+ /** One prop edit. Answers whether a byte reached the entry TSX; every
621
+ * refusal has already named itself on the console before it returns. */
622
+ /** Godot's Node2D Skew, one axis in degrees: Pixi's `skew` point in radians, written whole. */
623
+ const writeSkew = async (id: string, axis: 'x' | 'y', degrees: number): Promise<boolean> => {
624
+ const display = a2d.displayObject(id);
625
+ const oid = oidOf(id);
626
+ if (!display || !oid || !backend?.writeProp || !Number.isFinite(degrees)) return false;
627
+ const before = { x: display.skew.x, y: display.skew.y };
628
+ const radians = (degrees * Math.PI) / 180;
629
+ const next = { x: axis === 'x' ? radians : before.x, y: axis === 'y' ? radians : before.y };
630
+ display.skew.set(next.x, next.y);
631
+ notify();
632
+ const res = await backend.writeProp(
633
+ oid,
634
+ 'skew',
635
+ // Radians to six places, so a typed angle reads back as typed (four places lose 0.003°).
636
+ `{ x: ${String(Number(next.x.toFixed(6)))}, y: ${String(Number(next.y.toFixed(6)))} }`,
637
+ { addIfMissing: true, allowShapeUpgrade: true },
638
+ );
639
+ if (!res.changed && (res.dynamic || res.error)) {
640
+ display.skew.set(before.x, before.y);
641
+ notify();
642
+ // biome-ignore lint/suspicious/noConsole: a refused write must say why
643
+ console.warn(
644
+ `[canvas-source ${entryPath}] skew write refused for oid "${oid}": ` +
645
+ `${res.dynamic ? 'dynamic expression (guarded)' : res.error} — reverting.`,
646
+ );
647
+ return false;
648
+ }
649
+ await refreshSourceState();
650
+ notify();
651
+ return res.changed;
652
+ };
653
+
654
+ const writeJsxProp = async (
655
+ id: string,
656
+ path: string,
657
+ prop: string,
658
+ value: unknown,
659
+ ): Promise<boolean> => {
660
+ const oid = oidOf(id);
661
+ if (!oid || !backend?.writeProp) {
662
+ // biome-ignore lint/suspicious/noConsole: a refused write must say why
663
+ console.warn(
664
+ `[canvas-source ${entryPath}] cannot write "${prop}" on "${id}": ` +
665
+ `${oid ? 'no source-write backend in this session' : 'node has no source stamp'}.`,
666
+ );
667
+ return false;
668
+ }
669
+ const echoKey = `${id}|${path}`;
670
+ const liveBefore = readLiveProp(id, prop);
671
+ valueEcho.set(echoKey, value);
672
+ // Optimistic on the LIVE object too, so the paused design frame redraws
673
+ // with the new value instead of waiting for a remount.
674
+ applyLiveProp(id, prop, value);
675
+ notify();
676
+ let res: Awaited<ReturnType<NonNullable<SourceWriteBackend['writeProp']>>>;
677
+ try {
678
+ res = await backend.writeProp(oid, prop, serializeValue(value), { addIfMissing: true });
679
+ } catch (error) {
680
+ valueEcho.delete(echoKey);
681
+ notify();
682
+ // biome-ignore lint/suspicious/noConsole: a failed write must stay diagnosable
683
+ console.warn(
684
+ `[canvas-source ${entryPath}] prop write failed for oid "${oid}" prop "${prop}": ${String(error)}`,
685
+ );
686
+ return false;
687
+ }
688
+ if (!res.changed) {
689
+ valueEcho.delete(echoKey);
690
+ if (res.dynamic) dynamicPaths.add(echoKey);
691
+ // REVERT + NARRATE IN THE PRODUCT. A raw console.warn is invisible: a
692
+ // tester renamed a component instance, watched the name stay put with no
693
+ // explanation anywhere they could see, and reported the editor as unable
694
+ // to change the game (runhuman pass 72). Same contract the transform path
695
+ // above and the three lane's typed writes already keep.
696
+ if (liveBefore !== undefined) applyLiveProp(id, prop, liveBefore);
697
+ notify();
698
+ const subject = componentIdentityOf(id)?.name ?? a2d.displayObject(id)?.label ?? id;
699
+ const reason = res.dynamic
700
+ ? `it is bound to an expression in <${subject}>'s own source, so a direct edit cannot land`
701
+ : `the component declares no ${JSON.stringify(prop)} prop to write` +
702
+ `${res.error ? ` (${res.error})` : ''}`;
703
+ showTransientHint(`${prop} on <${subject}> is not writable — see Console`);
704
+ editorConsole.warn(
705
+ `${JSON.stringify(prop)} on <${subject}> is not writable — ${reason}. ` +
706
+ 'Edit the component definition (right-click the row → Open Component Source), or the ' +
707
+ 'prop that drives it. Reverting.',
708
+ 'authoring',
709
+ );
710
+ return false;
711
+ }
712
+ await refreshSourceState();
713
+ return true;
714
+ };
715
+
716
+ /** Where THIS node's bytes land — its creation-site file, not the world
717
+ * entry. `writeProp` addresses an OID that may live in a scene/prefab the
718
+ * entry only imports; naming `entryPath` here is how a Floor edit in
719
+ * `SurvivorScene.tsx` acked `src/world.tsx`. */
720
+ const destinationOf = (id: string): string => {
721
+ const site = creationSiteOf(id);
722
+ return site.anchored ? site.file : entryPath;
723
+ };
724
+
725
+ /**
726
+ * THIS TARGET'S PLUG INTO THE ONE PERSISTENCE PIPE
727
+ * (`resolve(anchor) → write(dialect) → record(recorder)`, `write-pipe.ts`).
728
+ *
729
+ * The dialect is one — `SourceWriteBackend.writeProp` against the node's
730
+ * own source file — so the resolution is one question: is a writer bound
731
+ * in this session. The target's own per-edit refusals (an expression-bound
732
+ * attribute, an unstamped node, a plan this channel cannot express) are
733
+ * WRITE outcomes rather than resolutions: they revert the live object and
734
+ * name themselves on the console, so the edit's honest ack is the live-only
735
+ * floor and the pipe supplies it.
736
+ *
737
+ * `record` is a no-op because the source-write backend is wrapped in the
738
+ * project's own source history — the transaction that carries the bytes IS
739
+ * the history entry, and journaling here would make one gesture two undos.
740
+ */
741
+ const piped = (
742
+ write: () => Promise<boolean>,
743
+ /** Which backend verb THIS edit's dialect needs bound. A removal is the
744
+ * same dialect and the same anchor as a prop write, but it needs
745
+ * `removeProp`; resolving on the wrong verb acks `source-prop` for a door
746
+ * the session does not have. */
747
+ bound: boolean = backend?.writeProp !== undefined,
748
+ destination: string = entryPath,
749
+ ): Promise<WriteAck> =>
750
+ runWritePipe({
751
+ resolve: (): WriteResolution =>
752
+ bound
753
+ ? { reaches: 'writer', anchorKind: 'source-prop', destination, write }
754
+ : resolvesLiveOnly(NO_SOURCE_WRITER_REASON),
755
+ record: () => undefined,
756
+ // A session with no writer degrades LOUDLY — the write helpers used to be
757
+ // the ones discovering it, and resolution now discovers it first, so the
758
+ // sentence has to move with it.
759
+ report: (reason) => {
760
+ // biome-ignore lint/suspicious/noConsole: a lane that cannot persist must say so
761
+ console.warn(`[canvas-source ${destination}] this edit stays live-only — ${reason}.`);
762
+ },
763
+ });
764
+
765
+ const reportStruct = (message: string): void => {
766
+ // biome-ignore lint/suspicious/noConsole: source refusal must be visible
767
+ console.warn(`[canvas-source ${entryPath}] ${message}`);
768
+ };
769
+
770
+ /**
771
+ * THE STRUCT DIALECT — the shared one-producer pipe
772
+ * (`struct-write-pipe.ts`), configured with this target's reporter, its
773
+ * live backend and its reconcile hook. The struct verbs resolve on their
774
+ * own door and ack `source-structure`, never the value lane's
775
+ * `source-prop` — the shared module's header carries the full rule.
776
+ */
777
+ const structPipe = createStructWritePipe({
778
+ report: reportStruct,
779
+ noWriterReason: NO_SOURCE_WRITER_REASON,
780
+ backend: () => backend,
781
+ onChanged: async () => {
782
+ await refreshSourceState();
783
+ notify();
784
+ },
785
+ });
786
+ const structRefusal = (reason: string): Promise<WriteAck> => structPipe.structRefusal(reason);
787
+
788
+ const structOp = (
789
+ id: string,
790
+ op: string,
791
+ opts?: StructOpOptions,
792
+ oidOverride?: string,
793
+ ): Promise<WriteAck> =>
794
+ structPipe.structOp(oidOverride ?? oidOf(id), id, op, opts, destinationOf(id));
795
+
796
+ const structMany = (
797
+ ids: readonly string[],
798
+ op: string,
799
+ opts?: { wrapperTag?: string },
800
+ ): Promise<WriteAck> => {
801
+ const oids = [...new Set(ids.map(oidOf).filter((oid): oid is string => Boolean(oid)))];
802
+ if (oids.length !== new Set(ids).size) {
803
+ return structRefusal(`${op} refused: every selected node must be source-addressable.`);
804
+ }
805
+ return structPipe.structMany(oids, op, opts, destinationOf(ids[0] ?? ''));
806
+ };
807
+
808
+ const clipboardPayload = (ids: readonly string[]): CanvasStructureClipboard | null => {
809
+ const topLevel = highestSelectedIds(ids, (id) => a2d.node(id)?.parentId ?? null);
810
+ const elementFor = (id: string): CanvasClipboardElement | null => {
811
+ const oid = oidOf(id);
812
+ const entry = oid ? oidIndex.get(oid) : undefined;
813
+ const source = entry ? sources.get(entry.file) : undefined;
814
+ if (!oid || !entry || source === undefined) return null;
815
+ const offset = lineColToOffset(source, entry.line, entry.col);
816
+ const end = findElementEnd(source, offset);
817
+ return source[offset] === '<' && end > offset
818
+ ? { file: entry.file, offset, text: dedentJsxElement(source, offset, end) }
819
+ : null;
820
+ };
821
+ const elements: CanvasClipboardElement[] = [];
822
+ const seenOids = new Set<string>();
823
+ for (const id of topLevel) {
824
+ const oid = oidOf(id);
825
+ if (!oid || seenOids.has(oid)) continue;
826
+ seenOids.add(oid);
827
+ const element = elementFor(id);
828
+ if (!element) return null;
829
+ elements.push(element);
830
+ }
831
+ if (elements.length === 0) return null;
832
+ const sourceFile = elements[0]!.file;
833
+ if (elements.some((element) => element.file !== sourceFile)) return null;
834
+ elements.sort((a, b) => a.offset - b.offset);
835
+ return {
836
+ sourceFile,
837
+ text: elements.map((element) => element.text).join('\n'),
838
+ count: elements.length,
839
+ };
840
+ };
841
+
842
+ const refuseClipboard = (reason: string): false => {
843
+ reportStruct(reason);
844
+ showTransientHint(reason);
845
+ return false;
846
+ };
847
+
848
+ const copyStructure = async (ids: readonly string[]): Promise<boolean> => {
849
+ structureClipboard = null;
850
+ const payload = clipboardPayload(ids);
851
+ if (!payload) {
852
+ return refuseClipboard('Copy needs source-addressable Canvas nodes from one source file.');
853
+ }
854
+ const clipboard = globalThis.navigator?.clipboard;
855
+ if (!clipboard?.writeText) return refuseClipboard('The system clipboard is unavailable.');
856
+ try {
857
+ await clipboard.writeText(payload.text);
858
+ } catch {
859
+ return refuseClipboard('The system clipboard refused the Canvas copy.');
860
+ }
861
+ structureClipboard = payload;
862
+ showTransientHint(`Copied ${payload.count} ${payload.count === 1 ? 'node' : 'nodes'}.`);
863
+ return true;
864
+ };
865
+
866
+ const removeSourceNodes = async (ids: readonly string[]): Promise<WriteAck> => {
867
+ if (backend?.writeStructMany) return structMany(ids, 'delete');
868
+ // No batch door: the per-id loop IS the write, and the gesture's honest ack
869
+ // is the last id's — one gesture, one answer, never a blanket `true`.
870
+ let ack: WriteAck = LIVE_ONLY_ACK;
871
+ for (const id of ids) ack = await structOp(id, 'delete');
872
+ return ack;
873
+ };
874
+
875
+ const pasteTarget = (
876
+ parentId: string | null,
877
+ ): { id: string; oid: string; file: string; op: 'create' | 'create-sibling' } | null => {
878
+ const targetFor = (id: string, op: 'create' | 'create-sibling', preferDefinition: boolean) => {
879
+ const definitionOid = preferDefinition ? definitionOidOf(id) : null;
880
+ const oid = definitionOid ?? oidOf(id);
881
+ const entry = oid ? oidIndex.get(oid) : undefined;
882
+ return oid && entry ? { id, oid, file: entry.file, op, definition: !!definitionOid } : null;
883
+ };
884
+ if (parentId) {
885
+ return targetFor(parentId, 'create', true);
886
+ }
887
+ const lastRoot = a2d.roots().at(-1);
888
+ if (!lastRoot) return null;
889
+ const target = targetFor(lastRoot.id, 'create-sibling', true);
890
+ return target ? { ...target, op: target.definition ? 'create' : 'create-sibling' } : null;
891
+ };
892
+
893
+ const canPasteStructure = (parentId: string | null): boolean => {
894
+ const target = pasteTarget(parentId);
895
+ return !!structureClipboard && !!target && target.file === structureClipboard.sourceFile;
896
+ };
897
+
898
+ const pasteStructure = async (parentId: string | null): Promise<false | WriteAck> => {
899
+ const payload = structureClipboard;
900
+ if (!payload) return refuseClipboard('Copy or cut a Canvas node before pasting.');
901
+ const clipboard = globalThis.navigator?.clipboard;
902
+ if (!clipboard?.readText) return refuseClipboard('The system clipboard is unavailable.');
903
+ try {
904
+ if ((await clipboard.readText()) !== payload.text) {
905
+ return refuseClipboard('The clipboard changed after the Canvas copy.');
906
+ }
907
+ } catch {
908
+ return refuseClipboard('The system clipboard refused the Canvas paste.');
909
+ }
910
+ const target = pasteTarget(parentId);
911
+ if (!target) return refuseClipboard('Paste needs a source-addressable Canvas parent.');
912
+ if (target.file !== payload.sourceFile) {
913
+ return refuseClipboard(
914
+ 'Cross-file paste is refused because the destination may not import the copied dependencies.',
915
+ );
916
+ }
917
+ return clipboardOutcome(
918
+ await structOp(target.id, target.op, { snippet: payload.text }, target.oid),
919
+ );
920
+ };
921
+
922
+ const localToRootMatrix = (object: Container): Matrix => {
923
+ const chain: Container[] = [];
924
+ let cursor: Container | null = object;
925
+ while (cursor) {
926
+ chain.push(cursor);
927
+ cursor = cursor.parent as Container | null;
928
+ }
929
+ const result = new pixi.Matrix();
930
+ for (let index = chain.length - 1; index >= 0; index--) {
931
+ const node = chain[index]!;
932
+ node.updateLocalTransform();
933
+ result.append(node.localTransform);
934
+ }
935
+ return result;
936
+ };
937
+
938
+ const reparentRebase = (
939
+ id: string,
940
+ parentId: string,
941
+ ): {
942
+ position: readonly number[];
943
+ rotation: readonly number[];
944
+ scale: readonly number[];
945
+ scalarChannels: readonly ['rotation'];
946
+ } | null => {
947
+ const object = a2d.displayObject(id);
948
+ const parent = a2d.displayObject(parentId);
949
+ if (!object || !parent) return null;
950
+ const local = localToRootMatrix(parent).clone().invert().append(localToRootMatrix(object));
951
+ const decomposed = new pixi.Transform();
952
+ local.decompose(decomposed);
953
+ const pivot = object.pivot ?? { x: 0, y: 0 };
954
+ return {
955
+ position: [
956
+ local.tx + pivot.x * local.a + pivot.y * local.c,
957
+ local.ty + pivot.x * local.b + pivot.y * local.d,
958
+ ],
959
+ rotation: [decomposed.rotation],
960
+ scale: [decomposed.scale.x, decomposed.scale.y],
961
+ scalarChannels: ['rotation'],
962
+ };
963
+ };
964
+
965
+ const oidInstanceCount = (oid: string): number => {
966
+ let count = 0;
967
+ const visit = (id: string): void => {
968
+ if (readContainerOid(a2d.displayObject(id)) === oid) count++;
969
+ for (const childId of a2d.node(id)?.childIds ?? []) visit(childId);
970
+ };
971
+ for (const root of a2d.roots()) visit(root.id);
972
+ return count;
973
+ };
974
+
975
+ const affectedInstances = (id: string, prop: string): number => {
976
+ const definitionOid = definitionOidOf(id);
977
+ if (!definitionOid) return 0;
978
+ let count = 1;
979
+ const visit = (candidateId: string): void => {
980
+ if (
981
+ candidateId !== id &&
982
+ definitionOidOf(candidateId) === definitionOid &&
983
+ !attrsOf(candidateId)?.some((attr) => attr.name === prop)
984
+ ) {
985
+ count += 1;
986
+ }
987
+ for (const childId of a2d.node(candidateId)?.childIds ?? []) visit(childId);
988
+ };
989
+ for (const root of a2d.roots()) visit(root.id);
990
+ return count;
991
+ };
992
+
993
+ const removeOverride = async (id: string, path: string): Promise<boolean> => {
994
+ const oid = oidOf(id);
995
+ const prop = path.startsWith('jsx.') ? path.slice(4) : null;
996
+ if (!oid || !prop || !backend?.removeProp) return false;
997
+ const result = await backend.removeProp(oid, prop);
998
+ if (!result.changed) {
999
+ reportStruct(`revert ${prop} refused/no-op: ${result.error ?? 'no change'}.`);
1000
+ return false;
1001
+ }
1002
+ valueEcho.delete(`${id}|${path}`);
1003
+ await refreshSourceState();
1004
+ notify();
1005
+ return true;
1006
+ };
1007
+
1008
+ const authoredTransformProps = (
1009
+ channel: TransformChannel,
1010
+ attrs: readonly JsxAttrInfo[],
1011
+ ): string[] => {
1012
+ const names =
1013
+ channel === 'position'
1014
+ ? ['x', 'y']
1015
+ : channel === 'rotation'
1016
+ ? ['rotation']
1017
+ : attrs.some((attr) => attr.name === 'scale-x' || attr.name === 'scale-y')
1018
+ ? ['scale-x', 'scale-y']
1019
+ : ['scale'];
1020
+ return names.filter((name) => attrs.some((attr) => attr.name === name));
1021
+ };
1022
+
1023
+ const removeTransform = async (id: string, channel: TransformChannel): Promise<boolean> => {
1024
+ const oid = oidOf(id);
1025
+ const attrs = attrsOf(id);
1026
+ if (!oid || !attrs || !backend?.removeProp) return false;
1027
+ const props = authoredTransformProps(channel, attrs);
1028
+ if (props.length === 0) return false;
1029
+ let changed = false;
1030
+ const run = async (selected: SourceWriteBackend): Promise<void> => {
1031
+ for (const prop of props) {
1032
+ const result = await selected.removeProp?.(oid, prop);
1033
+ if (result?.changed) changed = true;
1034
+ else reportStruct(`revert ${prop} refused/no-op: ${result?.error ?? 'no change'}.`);
1035
+ }
1036
+ };
1037
+ if (props.length > 1 && backend.runGesture) {
1038
+ await backend.runGesture(`Remove ${channel}`, (selected) => run(selected));
1039
+ } else {
1040
+ await run(backend);
1041
+ }
1042
+ if (!changed) return false;
1043
+ await refreshSourceState();
1044
+ notify();
1045
+ return true;
1046
+ };
1047
+
1048
+ const instancesProvider: ComponentInstancesProvider = {
1049
+ openComponent: () => {
1050
+ void activateWorkspaceDocument(CANVAS_COMPONENTS_DOCUMENT_ID);
1051
+ },
1052
+ describe: (id): ComponentInstanceDescription | null => {
1053
+ const component = componentIdentityOf(id);
1054
+ if (!component) return null;
1055
+ // THE CSF GATE, and it lives HERE because this target is the one whose
1056
+ // instances ARE portable-CSF prefab instances — the same sentence
1057
+ // `r3f-source-authoring-adapter.ts`'s `instances.describe` says, in the
1058
+ // adapter that owns both halves of it. It was previously a blanket
1059
+ // wrapper in `PixiAuthoringAdapter`, asked of every target that names a
1060
+ // component; once the LIVE target learned to name a class-owned object's
1061
+ // component, that wrapper silently deleted the creation-site lane's whole
1062
+ // instances capability for any class with no matching story. A target
1063
+ // whose instance notion is the CONSTRUCTION STATEMENT has no story to
1064
+ // require, and it answers for itself.
1065
+ if (getComponentPreviewStories(component.name, component.sourcePath).length === 0) {
1066
+ return null;
1067
+ }
1068
+ const attrs = attrsOf(id);
1069
+ if (!attrs) return null;
1070
+ const declared = declaredPropsOf(id);
1071
+ const overrides = attrs
1072
+ .map((attr) => {
1073
+ const path = `jsx.${attr.name}`;
1074
+ const spec = declared.find((candidate) => candidate.name === attr.name);
1075
+ if (
1076
+ isHiddenProp(attr.name) ||
1077
+ PANEL_OWNED_PROPS.has(attr.name) ||
1078
+ !isOverride(id, attr, spec)
1079
+ ) {
1080
+ return null;
1081
+ }
1082
+ const canApplyToComponent = Boolean(
1083
+ spec?.defaultValue !== undefined &&
1084
+ definitionOidOf(id) &&
1085
+ backend?.runGesture &&
1086
+ backend.writeComponentDefault &&
1087
+ backend.removeProp,
1088
+ );
1089
+ return {
1090
+ path,
1091
+ label: humanizeIdentifier(attr.name),
1092
+ value: literalValueOf(attr),
1093
+ ...(spec?.defaultText === undefined ? {} : { defaultText: spec.defaultText }),
1094
+ canApplyToComponent,
1095
+ affectedInstanceCount: affectedInstances(id, attr.name),
1096
+ ...(canApplyToComponent
1097
+ ? {}
1098
+ : {
1099
+ applyUnavailableReason:
1100
+ spec?.defaultValue === undefined
1101
+ ? 'The component default is computed or absent, so source cannot be changed safely.'
1102
+ : 'This session cannot atomically update the component and its callsite.',
1103
+ }),
1104
+ };
1105
+ })
1106
+ .filter((override): override is NonNullable<typeof override> => override !== null);
1107
+ return {
1108
+ componentName: component.name,
1109
+ ...(component.sourcePath ? { sourcePath: component.sourcePath } : {}),
1110
+ overrides,
1111
+ };
1112
+ },
1113
+ revert: async (id, paths) => {
1114
+ let changed = false;
1115
+ const run = async (selected: SourceWriteBackend): Promise<void> => {
1116
+ const oid = oidOf(id);
1117
+ if (!oid) return;
1118
+ for (const path of paths) {
1119
+ const prop = path.startsWith('jsx.') ? path.slice(4) : null;
1120
+ if (!prop) continue;
1121
+ const result = await selected.removeProp?.(oid, prop);
1122
+ changed ||= result?.changed === true;
1123
+ }
1124
+ };
1125
+ if (paths.length > 1 && backend?.runGesture) {
1126
+ await backend.runGesture(`Revert ${paths.length} Overrides`, run);
1127
+ } else if (backend) {
1128
+ await run(backend);
1129
+ }
1130
+ await refreshSourceState();
1131
+ notify();
1132
+ return changed ? { destination: destinationOf(id), persisted: true } : undefined;
1133
+ },
1134
+ applyToComponent: async (id, path): Promise<ComponentInstanceApplyResult> => {
1135
+ const prop = path.startsWith('jsx.') ? path.slice(4) : null;
1136
+ const spec = prop
1137
+ ? declaredPropsOf(id).find((candidate) => candidate.name === prop)
1138
+ : undefined;
1139
+ const definitionOid = definitionOidOf(id);
1140
+ const callsiteOid = oidOf(id);
1141
+ const component = componentIdentityOf(id);
1142
+ if (
1143
+ !prop ||
1144
+ spec?.defaultValue === undefined ||
1145
+ !definitionOid ||
1146
+ !callsiteOid ||
1147
+ !backend?.runGesture ||
1148
+ !backend.writeComponentDefault ||
1149
+ !backend.removeProp
1150
+ ) {
1151
+ return {
1152
+ changed: false,
1153
+ message: 'Apply was refused because both literal source writes are not available.',
1154
+ };
1155
+ }
1156
+ const value = propValue(attrsOf(id), declaredPropsOf(id), prop);
1157
+ await backend.runGesture(
1158
+ `Apply ${prop} to ${component?.name ?? 'Component'}`,
1159
+ async (scoped) => {
1160
+ const applied = await scoped.writeComponentDefault?.(
1161
+ definitionOid,
1162
+ prop,
1163
+ serializeValue(value),
1164
+ );
1165
+ if (!applied?.changed)
1166
+ throw new Error(applied?.error ?? 'The component default did not change.');
1167
+ const reverted = await scoped.removeProp?.(callsiteOid, prop);
1168
+ if (!reverted?.changed)
1169
+ throw new Error(reverted?.error ?? 'The callsite override was not removed.');
1170
+ },
1171
+ );
1172
+ await refreshSourceState();
1173
+ notify();
1174
+ return {
1175
+ changed: true,
1176
+ message: `${prop} now defaults to ${String(value)} in ${component?.name ?? 'the component'}.`,
1177
+ write: {
1178
+ destination: component?.sourcePath ?? destinationOf(id),
1179
+ persisted: true,
1180
+ },
1181
+ };
1182
+ },
1183
+ };
1184
+
1185
+ const sourceStructure: StructureProvider = {
1186
+ // The new element's oid is minted server-side on the remount, so the id
1187
+ // half is '' and the ack half is this creation's own piped write — handed
1188
+ // back rather than fired `void` (see `StructuralIdWrite`).
1189
+ create: (kind, parentId, at) => {
1190
+ // `at` arrives in the new node's parent's own space (the adapter converts it).
1191
+ const base = CREATE_SNIPPETS[kind];
1192
+ const snippet =
1193
+ base && at
1194
+ ? base.replace(/\s*\/>$/, ` x={${formatSourceNumber(at.x)}} y={${formatSourceNumber(at.y)}} />`)
1195
+ : base;
1196
+ // Nothing was attempted (the palette is empty in both cases), so there is
1197
+ // no write to ack.
1198
+ if (!snippet || !backend?.writeStruct) return { id: '', ack: undefined };
1199
+ const options = TEXTURE_SNIPPET_KINDS.has(kind)
1200
+ ? { snippet, ensureImport: { name: 'Texture', module: 'pixi.js' } }
1201
+ : { snippet };
1202
+ if (parentId) return { id: '', ack: structOp(parentId, 'create', options) };
1203
+ const lastRoot = a2d.roots().at(-1);
1204
+ if (!lastRoot) {
1205
+ return {
1206
+ id: '',
1207
+ ack: structRefusal(
1208
+ 'create refused: the source needs one addressable root insertion point.',
1209
+ ),
1210
+ };
1211
+ }
1212
+ return { id: '', ack: structOp(lastRoot.id, 'create-sibling', options) };
1213
+ },
1214
+ creatableKinds: (parentId) => {
1215
+ if (!backend?.writeStruct) return [];
1216
+ if (parentId && !oidOf(parentId)) return [];
1217
+ if (!parentId && a2d.roots().length === 0) return [];
1218
+ return PIXI_CREATABLE_KINDS.filter((entry) => CREATE_SNIPPETS[entry.kind] !== undefined).map((entry) => ({
1219
+ ...entry,
1220
+ }));
1221
+ },
1222
+ // Returns the promise (never `void structOp(…)`): `deleteSelection`
1223
+ // (`editor-hotkeys.ts`) awaits each id so a same-file multi-delete's writes
1224
+ // land strictly one at a time. Firing them un-awaited is the lost-update
1225
+ // race that fix exists for — every id read the file before any of them
1226
+ // wrote it back.
1227
+ remove: (id) => structOp(id, 'delete'),
1228
+ removeMany: (ids) => removeSourceNodes(ids),
1229
+ copy: (ids) => copyStructure(ids),
1230
+ canCopy: (ids) => clipboardPayload(ids) !== null,
1231
+ cut: async (ids) => {
1232
+ if (!(await copyStructure(ids))) return false;
1233
+ return clipboardOutcome(await removeSourceNodes(ids));
1234
+ },
1235
+ paste: (parentId) => pasteStructure(parentId),
1236
+ canPaste: (parentId) => canPasteStructure(parentId),
1237
+ // Same shape as `remove` above, and for the same reason: the write's promise
1238
+ // goes BACK to the caller so `duplicateSelection` can await each id's byte
1239
+ // before firing the next, instead of racing N same-file writes.
1240
+ duplicate: (id) => ({ id, ack: structOp(id, 'duplicate') }),
1241
+ wrap: (id, wrapperTag) => structOp(id, 'wrap', { wrapperTag: wrapperTag ?? 'pixiContainer' }),
1242
+ unwrap: (id) => structOp(id, 'unwrap'),
1243
+ group: (ids) => {
1244
+ const unique = [...new Set(ids)];
1245
+ if (unique.length < 2) return { id: null, ack: undefined };
1246
+ const parentId = a2d.node(unique[0]!)?.parentId ?? null;
1247
+ if (unique.some((id) => a2d.node(id)?.parentId !== parentId)) {
1248
+ return { id: null, ack: structRefusal('group refused: select two or more siblings.') };
1249
+ }
1250
+ return { id: '', ack: structMany(unique, 'group', { wrapperTag: 'pixiContainer' }) };
1251
+ },
1252
+ canUngroup: (id) => {
1253
+ const oid = oidOf(id);
1254
+ return Boolean(
1255
+ oid &&
1256
+ oidIndex.get(oid)?.tag === 'pixiContainer' &&
1257
+ (a2d.node(id)?.childIds.length ?? 0) > 0,
1258
+ );
1259
+ },
1260
+ ungroup: (id) => {
1261
+ if (!sourceStructure.canUngroup?.(id)) return { ids: [], ack: undefined };
1262
+ const children = [...(a2d.node(id)?.childIds ?? [])];
1263
+ return { ids: children, ack: structOp(id, 'unwrap') };
1264
+ },
1265
+ reparent: (id, parentId) => {
1266
+ if (!parentId) {
1267
+ return structRefusal('reparent refused: drop onto a source-addressable container.');
1268
+ }
1269
+ const parentOid = oidOf(parentId);
1270
+ const sourceOid = oidOf(id);
1271
+ const rebase = reparentRebase(id, parentId);
1272
+ if (!parentOid || !sourceOid || !rebase) {
1273
+ return structRefusal('reparent refused: the source or destination is not addressable.');
1274
+ }
1275
+ return structOp(id, 'reparent', {
1276
+ parentOid,
1277
+ rebase,
1278
+ destinationInstances: oidInstanceCount(parentOid),
1279
+ sourceInstances: oidInstanceCount(sourceOid),
1280
+ });
1281
+ },
1282
+ reorder: (id, beforeSiblingId) => {
1283
+ if (beforeSiblingId) {
1284
+ const targetOid = oidOf(beforeSiblingId);
1285
+ if (!targetOid) {
1286
+ return structRefusal('reorder refused: the target has no source identity.');
1287
+ }
1288
+ return structOp(id, 'reorder', { targetOid });
1289
+ }
1290
+ const parentId = a2d.node(id)?.parentId;
1291
+ const parentOid = parentId ? oidOf(parentId) : null;
1292
+ if (!parentOid) {
1293
+ return structRefusal('reorder-to-end refused: the parent has no source identity.');
1294
+ }
1295
+ return structOp(id, 'reorder', { parentOid });
1296
+ },
1297
+ };
1298
+
1299
+ const relativeProjectModule = (fromFile: string, targetFile: string): string =>
1300
+ relativeImportSpecifier(
1301
+ projectRelativeSourceFile(fromFile),
1302
+ projectRelativeSourceFile(targetFile),
1303
+ );
1304
+
1305
+ /**
1306
+ * Does this component declare a placement prop (`x`/`y`) of its own?
1307
+ * `true` yes, `false` it declares props and none of them place it, `null`
1308
+ * when the declaration could not be read at all (no backend, unreadable
1309
+ * file, or a prop surface this syntax-only pass cannot follow).
1310
+ */
1311
+ const declaredSpecsOf = async (item: {
1312
+ name: string;
1313
+ sourcePath?: string;
1314
+ }): Promise<readonly ComponentPropSpec[] | null> => {
1315
+ const file = item.sourcePath;
1316
+ if (!file || !backend?.readSource) return null;
1317
+ try {
1318
+ const { source } = await backend.readSource(file);
1319
+ const { parseAuthoringTsx } = await import('@volter/editor-react/source/ts-ast');
1320
+ const { localComponentPropSpecs } = await import('@volter/editor-react/source/syntactic-prop-specs');
1321
+ return (
1322
+ localComponentPropSpecs(parseAuthoringTsx(file, source), new Map()).get(item.name) ?? null
1323
+ );
1324
+ } catch {
1325
+ return null;
1326
+ }
1327
+ };
1328
+ /** Props the drop cannot supply: required, no default, not an enum (an
1329
+ * enum gets its first option), not something the snippet writes. */
1330
+ const unsuppliedRequiredProps = (specs: readonly ComponentPropSpec[]): ComponentPropSpec[] =>
1331
+ specs.filter(
1332
+ (spec) =>
1333
+ !spec.optional &&
1334
+ spec.defaultText === undefined &&
1335
+ spec.type !== 'enum' &&
1336
+ !DROP_SNIPPET_PROPS.has(spec.name),
1337
+ );
1338
+ const requiredEnumAttrs = (specs: readonly ComponentPropSpec[]): string =>
1339
+ specs
1340
+ .filter(
1341
+ (spec) =>
1342
+ !spec.optional &&
1343
+ spec.defaultText === undefined &&
1344
+ spec.type === 'enum' &&
1345
+ spec.options?.[0] !== undefined,
1346
+ )
1347
+ .map((spec) => {
1348
+ const first = spec.options?.[0];
1349
+ return typeof first === 'string'
1350
+ ? ` ${spec.name}=${JSON.stringify(first)}`
1351
+ : ` ${spec.name}={${first}}`;
1352
+ })
1353
+ .join('');
1354
+
1355
+ const sourceAssetDrop: AssetDropProvider = {
1356
+ accepts: (nodeId, assetPath, context) => {
1357
+ if (!backend?.writeStruct) return false;
1358
+ const target = nodeId ? oidOf(nodeId) : a2d.roots().at(-1)?.id;
1359
+ if (!target) return false;
1360
+ const item = context?.item;
1361
+ return item?.kind === 'component'
1362
+ ? item.surface === 'canvas'
1363
+ : /\.(?:png|jpe?g|webp|gif|avif|svg)$/i.test(assetPath);
1364
+ },
1365
+ // A drop that lands as an ELEMENT in the source IS a structural write, so it
1366
+ // answers with that write's ack — `AssetDropProvider.drop`'s own contract.
1367
+ // This used to `await structOp(…)` and return `undefined`, which threw the
1368
+ // answer away one frame after producing it.
1369
+ drop: async (nodeId, assetPath, context) => {
1370
+ const anchorId = nodeId || a2d.roots().at(-1)?.id || '';
1371
+ const anchorOid = oidOf(anchorId);
1372
+ const anchorEntry = anchorOid ? oidIndex.get(anchorOid) : undefined;
1373
+ if (!anchorId || !anchorEntry) {
1374
+ return structRefusal('asset drop refused: there is no source-addressable insertion point.');
1375
+ }
1376
+ const item = context?.item;
1377
+ const position = context?.position;
1378
+ const xy = position
1379
+ ? ` x={${formatSourceNumber(position[0])}} y={${formatSourceNumber(position[1])}}`
1380
+ : '';
1381
+ let snippet: string;
1382
+ let ensureImport: { name: string; module: string; kind?: 'default' | 'named' };
1383
+ if (item?.kind === 'component') {
1384
+ if (item.surface !== 'canvas') {
1385
+ return structRefusal(`${item.name} is a ${item.surface} component, not a Canvas prefab.`);
1386
+ }
1387
+ // A COMPONENT THAT PLACES ITSELF IS NOT GIVEN COORDINATES. Writing
1388
+ // `x`/`y` onto a callsite whose component declares neither is dead
1389
+ // schema — the same write `channelBlocked` above refuses for a drag,
1390
+ // in this file, for this reason: the component's own code keeps
1391
+ // driving the live object. The drop did it anyway, so a survivor
1392
+ // prefab like `<Grunt>` (props: `phase`) was created carrying
1393
+ // coordinates it ignores, landed where its sim put it, and then
1394
+ // refused every later edit with "position is owned by <Grunt>'s own
1395
+ // code" — which two testers read as the editor being broken (runhuman
1396
+ // passes 115 and 119). Say it at CREATION instead, where the author
1397
+ // is looking, and write only what the component can read. An
1398
+ // UNRESOLVABLE declaration (props from an imported alias this
1399
+ // syntax-only read cannot follow) is not an answer, so it keeps the
1400
+ // coordinates: never turn "cannot tell" into a silent omission.
1401
+ const specs = await declaredSpecsOf(item);
1402
+ // A COMPONENT THAT NEEDS WHAT NO DROP CAN GIVE IS REFUSED BY NAME. The
1403
+ // retro shooter's <Bunker> takes the sim's own `grid: PixelGrid`;
1404
+ // dropped bare it threw in its first render ("Cannot read properties
1405
+ // of undefined (reading 'width')"), the world went down, and the
1406
+ // author saw "no renderable content" (runhuman pass 148). A required
1407
+ // enum gets its first option; anything else required and defaultless
1408
+ // stops the drop here and says which props and where to fix it.
1409
+ const missing = specs ? unsuppliedRequiredProps(specs) : [];
1410
+ if (missing.length > 0) {
1411
+ const named = missing.map((spec) => `\`${spec.name}\``).join(', ');
1412
+ return structRefusal(
1413
+ `${item.name} needs ${named} — required props with no default, which a drop cannot ` +
1414
+ `supply. Place it from a story that provides them, or give them defaults in ` +
1415
+ `${item.sourcePath ?? 'its component'}.`,
1416
+ );
1417
+ }
1418
+ const placement = specs
1419
+ ? specs.some((spec) => spec.name === 'x' || spec.name === 'y')
1420
+ : null;
1421
+ const ownsPlacement = placement === false;
1422
+ if (ownsPlacement && position) {
1423
+ const said =
1424
+ `${item.name} places itself — its own code owns x/y, so the drop point was not ` +
1425
+ 'written. Move it by editing that component, or give it x/y props.';
1426
+ showTransientHint(said);
1427
+ reportStruct(said);
1428
+ }
1429
+ snippet = `<${item.name}${ownsPlacement ? '' : xy}${specs ? requiredEnumAttrs(specs) : ''} />`;
1430
+ ensureImport = {
1431
+ name: item.name,
1432
+ module: relativeProjectModule(anchorEntry.file, item.sourcePath),
1433
+ kind: item.exportKind,
1434
+ };
1435
+ } else {
1436
+ if (!/\.(?:png|jpe?g|webp|gif|avif|svg)$/i.test(assetPath)) {
1437
+ return structRefusal(`${assetPath} is not an image or Canvas prefab.`);
1438
+ }
1439
+ const label =
1440
+ assetPath
1441
+ .split('/')
1442
+ .pop()
1443
+ ?.replace(/\.[^.]+$/, '') || 'Sprite';
1444
+ snippet = `<pixiSprite label="${label.replace(/["\\]/g, '')}" texture={Texture.from(${JSON.stringify(assetPath)})}${xy} />`;
1445
+ ensureImport = { name: 'Texture', module: 'pixi.js' };
1446
+ }
1447
+ return structOp(anchorId, nodeId ? 'create' : 'create-sibling', {
1448
+ snippet,
1449
+ ensureImport,
1450
+ });
1451
+ },
1452
+ };
1453
+
1454
+ return {
1455
+ provenance: {
1456
+ source: 'source-code',
1457
+ label: 'Pixi source',
1458
+ detail:
1459
+ 'Live @pixi/react display tree; literal JSX props write back to the .tsx source ' +
1460
+ '(expression-bound props are read-only).',
1461
+ },
1462
+ // Accepted edits auto-save through project history. The provider is still
1463
+ // the generic shell's honest destination/failure report, exactly like the
1464
+ // R3F source lane; save itself has nothing pending to flush.
1465
+ ...(persistence ? { persistence } : {}),
1466
+ structure: sourceStructure,
1467
+ assetDrop: sourceAssetDrop,
1468
+
1469
+ truth: (id) => ({
1470
+ site: creationSiteOf(id),
1471
+ writeAnchorKind: backend && oidOf(id) ? 'source-prop' : 'live-only',
1472
+ }),
1473
+ componentIdentity: componentIdentityOf,
1474
+ instances: () => instancesProvider,
1475
+
1476
+ bind(context: CanvasWriteContext): void {
1477
+ a2d = context.a2d;
1478
+ notify = context.notify;
1479
+ boundStore = context.store;
1480
+ backend = withProjectSourceHistory(options.writeBackend, context.store.shell.projectHistory);
1481
+ void refreshSourceState();
1482
+ },
1483
+
1484
+ onReindex(): void {
1485
+ // A late subtree can live in files the first fetch never saw (the fetch
1486
+ // caches only files referenced by then-LIVE oids), so refetch when any
1487
+ // live oid is unindexed or its file uncached — otherwise the new rows'
1488
+ // props read as unaddressable rather than editable.
1489
+ for (const oid of liveOids()) {
1490
+ const entry = oidIndex.get(oid);
1491
+ if (entry === undefined || !sources.has(entry.file)) {
1492
+ void refreshSourceState();
1493
+ return;
1494
+ }
1495
+ }
1496
+ },
1497
+
1498
+ transformEditability(id: string, channel: TransformChannel): TransformEditability {
1499
+ const oid = oidOf(id);
1500
+ if (!oid) {
1501
+ return { writable: false, reason: 'This rendered part has no authored source identity.' };
1502
+ }
1503
+ if (!backend?.writeProp) {
1504
+ return { writable: false, reason: 'This session has no source writer.' };
1505
+ }
1506
+ if (dynamicPaths.has(`${id}|${channel}`)) {
1507
+ return { writable: false, reason: `${channel} is controlled by a JSX expression.` };
1508
+ }
1509
+ const attrs = attrsOf(id);
1510
+ if (!attrs) {
1511
+ return { writable: false, reason: 'Source metadata is still loading or unavailable.' };
1512
+ }
1513
+ if (componentOwnedChannel(id, channel, attrs, read2D(id))) {
1514
+ const name = componentIdentityOf(id)?.name ?? 'this component';
1515
+ return {
1516
+ writable: false,
1517
+ reason: `${channel} is owned by <${name}>'s own code — it declares no prop for it.`,
1518
+ };
1519
+ }
1520
+ const plan = planChannelWrite(channel, read2D(id), attrs);
1521
+ return plan.writable
1522
+ ? {
1523
+ writable: true,
1524
+ ...(backend.removeProp && authoredTransformProps(channel, attrs).length > 0
1525
+ ? { removable: true }
1526
+ : {}),
1527
+ }
1528
+ : { writable: false, reason: plan.reason };
1529
+ },
1530
+
1531
+ beginTransformEdit(id: string): void {
1532
+ if (!editStarts.has(id)) editStarts.set(id, read2D(id));
1533
+ },
1534
+
1535
+ /**
1536
+ * Mid-gesture origin write — see {@link CanvasWriteTarget.writeOrigin}. The live object moves
1537
+ * now; the `pivot`/`anchor` prop is written at `endTransformEdit`, in the same gesture as the
1538
+ * position that compensates it.
1539
+ */
1540
+ writeOrigin(id: string, kind: 'pivot' | 'anchor', value: readonly [number, number]): void {
1541
+ const display = a2d.displayObject(id) as (Container & Record<string, unknown>) | null;
1542
+ const current = display?.[kind] as { x?: unknown; y?: unknown } | undefined;
1543
+ if (!display || typeof current?.x !== 'number' || typeof current.y !== 'number') return;
1544
+ const before = pendingOrigins.get(id)?.before ?? ([current.x, current.y] as const);
1545
+ display[kind] = { x: value[0], y: value[1] };
1546
+ pendingOrigins.set(id, { kind, value, before });
1547
+ notify();
1548
+ },
1549
+
1550
+ writeTransform(id: string, next: Transform2DValue): void {
1551
+ // The live tree moves first so the paused frame follows the gesture; the
1552
+ // source write happens once, at the end.
1553
+ a2d.setTransform(id, next);
1554
+ notify();
1555
+ },
1556
+
1557
+ // THE GESTURE'S OWN ACK, through the pipe and AWAITED — the commit still
1558
+ // serializes behind whatever is already queued (one ordered source/history
1559
+ // transaction per gesture is why the queue exists), and the promise this
1560
+ // returns resolves only once the bytes have landed or the lane has honestly
1561
+ // reported that none did.
1562
+ endTransformEdit(id: string): void | Promise<WriteAck> {
1563
+ const before = editStarts.get(id);
1564
+ if (!before) return;
1565
+ editStarts.delete(id);
1566
+ const acked = commitQueue.then(() =>
1567
+ piped(
1568
+ () =>
1569
+ commitTransform(id, before).catch((error: unknown) => {
1570
+ // biome-ignore lint/suspicious/noConsole: a lost source write must stay diagnosable
1571
+ console.error(`[canvas-source ${destinationOf(id)}] transform commit failed`, error);
1572
+ return false;
1573
+ }),
1574
+ backend?.writeProp !== undefined,
1575
+ destinationOf(id),
1576
+ ),
1577
+ );
1578
+ // The QUEUE must never inherit a rejection — one poisoned link would
1579
+ // strand every later gesture on this world. It absorbs and says so; the
1580
+ // gesture's own caller still gets the rejection through `acked`.
1581
+ commitQueue = acked.then(
1582
+ () => undefined,
1583
+ (error: unknown) => {
1584
+ // biome-ignore lint/suspicious/noConsole: a lost source write must stay diagnosable
1585
+ console.error(`[canvas-source ${entryPath}] transform commit failed`, error);
1586
+ },
1587
+ );
1588
+ return acked;
1589
+ },
1590
+
1591
+ removeTransform(id: string, channel: TransformChannel): Promise<WriteAck> {
1592
+ return piped(
1593
+ () => removeTransform(id, channel),
1594
+ backend?.removeProp !== undefined,
1595
+ destinationOf(id),
1596
+ );
1597
+ },
1598
+
1599
+ // Same channel every other refusal on this lane uses, so the world's
1600
+ // authoring story is told in one voice: the `.tsx` is truth for
1601
+ // persistence, and this op did not reach it.
1602
+ reportStructureLiveOnly(label: string, reason: string): void {
1603
+ // biome-ignore lint/suspicious/noConsole: a live-only structural edit must stay diagnosable
1604
+ console.warn(`[canvas-source ${entryPath}] ${label} stayed live-only — ${reason}.`);
1605
+ },
1606
+
1607
+ properties(id: string): PropertyDescriptor[] {
1608
+ if (!a2d.displayObject(id)) return [];
1609
+ // `name` is the reserved path the shell renders the hierarchy rename
1610
+ // field against; on this surface it IS Pixi's `label` prop.
1611
+ const props: PropertyDescriptor[] = [
1612
+ { path: 'name', label: 'Name', type: 'string' },
1613
+ { path: 'visible', label: 'Visible', type: 'boolean', group: 'Visibility' },
1614
+ ];
1615
+ const attrs = attrsOf(id) ?? [];
1616
+ // Godot's Node2D Skew, in its Transform group; a skew the source computes is shown, not edited.
1617
+ const skewAttr = attrs.find((attr) => attr.name === 'skew');
1618
+ const skewFixed = skewAttr ? !skewAttr.isLiteral : false;
1619
+ props.push(
1620
+ { path: 'skew.x', label: 'Skew X (deg)', type: 'number', group: 'Transform', readonly: skewFixed },
1621
+ { path: 'skew.y', label: 'Skew Y (deg)', type: 'number', group: 'Transform', readonly: skewFixed },
1622
+ );
1623
+ const declared = declaredPropsOf(id);
1624
+ const attrByName = new Map(attrs.map((attr) => [attr.name, attr]));
1625
+ const names = [
1626
+ ...declared.map((spec) => spec.name),
1627
+ ...attrs
1628
+ .filter((attr) => !declared.some((spec) => spec.name === attr.name))
1629
+ .map((attr) => attr.name),
1630
+ ];
1631
+ const group = componentIdentityOf(id)?.name ?? 'Properties';
1632
+ for (const name of names) {
1633
+ if (isHiddenProp(name) || PANEL_OWNED_PROPS.has(name)) continue;
1634
+ const attr = attrByName.get(name);
1635
+ const spec = declared.find((candidate) => candidate.name === name);
1636
+ const opaqueDefault =
1637
+ !attr && spec?.defaultText !== undefined && spec.defaultValue === undefined;
1638
+ const resettable = isOverride(id, attr, spec);
1639
+ props.push({
1640
+ path: `jsx.${name}`,
1641
+ label: humanizeIdentifier(name),
1642
+ type: opaqueDefault ? 'string' : (spec?.type ?? (attr ? descriptorType(attr) : 'string')),
1643
+ ...(spec?.options ? { options: [...spec.options] } : {}),
1644
+ readonly: opaqueDefault || (attr ? !attr.isLiteral : false),
1645
+ ...(attr ? {} : { defaulted: true }),
1646
+ ...(resettable ? { resettable: true } : {}),
1647
+ ...(resettable && spec?.defaultText !== undefined ? { revertsTo: spec.defaultText } : {}),
1648
+ group,
1649
+ });
1650
+ }
1651
+ return props;
1652
+ },
1653
+
1654
+ get(id: string, path: string): unknown {
1655
+ const display = a2d.displayObject(id);
1656
+ if (!display) return undefined;
1657
+ const echoed = valueEcho.get(`${id}|${path}`);
1658
+ if (echoed !== undefined) return echoed;
1659
+ if (path === 'name') return display.label ?? '';
1660
+ if (path === 'visible') return display.visible;
1661
+ if (path === 'skew.x' || path === 'skew.y') {
1662
+ return Number((((path === 'skew.x' ? display.skew.x : display.skew.y) * 180) / Math.PI).toFixed(4));
1663
+ }
1664
+ if (!path.startsWith('jsx.')) return undefined;
1665
+ return propValue(attrsOf(id), declaredPropsOf(id), path.slice(4));
1666
+ },
1667
+
1668
+ // Same ack contract as {@link endTransformEdit}: the pipe's answer for THIS
1669
+ // edit, awaited. A path this target owns no prop for performed no write and
1670
+ // returns nothing.
1671
+ set(id: string, path: string, value: unknown): void | Promise<WriteAck> {
1672
+ if (path === 'skew.x' || path === 'skew.y') {
1673
+ return piped(
1674
+ () => writeSkew(id, path === 'skew.x' ? 'x' : 'y', Number(value)),
1675
+ undefined,
1676
+ destinationOf(id),
1677
+ );
1678
+ }
1679
+ const prop =
1680
+ path === 'name'
1681
+ ? 'label'
1682
+ : path === 'visible'
1683
+ ? 'visible'
1684
+ : path.startsWith('jsx.')
1685
+ ? path.slice(4)
1686
+ : null;
1687
+ if (!prop) return;
1688
+ return piped(() => writeJsxProp(id, path, prop, value), undefined, destinationOf(id));
1689
+ },
1690
+
1691
+ // Removal is a write and answers like one — the same pipe, the same
1692
+ // awaited per-edit ack. It is also the ONLY door that can restore
1693
+ // byte-absence: `set` writes a value, so reverting a prop a gesture
1694
+ // APPENDED by setting the default back leaves the attribute in the file.
1695
+ remove(id: string, path: string): void | Promise<WriteAck> {
1696
+ return piped(
1697
+ () => removeOverride(id, path),
1698
+ backend?.removeProp !== undefined,
1699
+ destinationOf(id),
1700
+ );
1701
+ },
1702
+
1703
+ dispose(): void {
1704
+ valueEcho.clear();
1705
+ dynamicPaths.clear();
1706
+ editStarts.clear();
1707
+ },
1708
+ };
1709
+ }