@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.
- package/contributions/audio-unlock.service.ts +2 -2
- package/contributions/autoplay.service.ts +1 -1
- package/contributions/bridge.command.ts +3 -3
- package/contributions/build-player.document.tsx +95 -0
- package/contributions/canvas/component-board.service.ts +16 -0
- package/contributions/canvas/design-time-mount.service.ts +45 -0
- package/contributions/canvas-story-capture.service.ts +12 -0
- package/contributions/edit-mode-audio.service.ts +2 -2
- package/contributions/edit-mode-networking.service.ts +1 -1
- package/contributions/gameplay.command.ts +4 -4
- package/contributions/generation.service.ts +1 -1
- package/contributions/godot.style.ts +26 -6
- package/contributions/godot.view.ts +6 -0
- package/contributions/ingest.service.ts +2 -2
- package/contributions/instances.command.ts +1 -1
- package/contributions/navmesh.menu.ts +1 -1
- package/contributions/network-observer.service.ts +14 -0
- package/contributions/play.command.ts +10 -4
- package/contributions/react/component-board.service.ts +1 -1
- package/contributions/react/design-time-mount.service.ts +1 -1
- package/contributions/scene-document.service.ts +3 -3
- package/contributions/state-watch.menu.ts +1 -1
- package/contributions/state-watch.utility.tsx +1 -1
- package/contributions/team-playtest.service.ts +4 -4
- package/contributions/three/component-board.service.ts +1 -1
- package/contributions/three/component-verbs.command.ts +6 -6
- package/contributions/three/three-authoring.service.ts +8 -5
- package/contributions/three-story-capture.service.ts +12 -0
- package/contributions/unity.style.ts +17 -7
- package/contributions/unity.view.ts +6 -0
- package/contributions/unreal.style.ts +10 -2
- package/contributions/unreal.view.ts +5 -0
- package/package.json +21 -10
- package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
- package/src/asset-budget/asset-budget-model.ts +1 -1
- package/src/audio/AudioDebuggerPanel.tsx +1 -1
- package/src/bridge/dispatch.ts +14 -14
- package/src/bridge/live-frames.ts +1 -1
- package/src/bridge/screenshot.ts +3 -3
- package/src/build/BuildProfilesPanel.tsx +14 -3
- package/src/build/build-session.ts +35 -0
- package/src/canvas/canvas-board/CanvasBoardDocument.tsx +749 -0
- package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
- package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
- package/src/canvas/canvas-design-mount.ts +524 -0
- package/src/canvas/design-time-canvas-mount.ts +79 -0
- package/src/coverage/live-authoring-surface.ts +5 -5
- package/src/coverage/live-project-verbs.ts +1 -1
- package/src/coverage/native-system-coverage.ts +5 -5
- package/src/coverage/root-coverage.ts +1 -1
- package/src/coverage/session-coverage.ts +3 -3
- package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
- package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
- package/src/edit-mode/edit-mode-audio.ts +4 -4
- package/src/edit-mode/edit-mode-networking.ts +2 -2
- package/src/game-document/GameCaptureFrameButton.tsx +1 -1
- package/src/game-document/GameDocument.tsx +2 -2
- package/src/game-document/GamePanel.tsx +4 -4
- package/src/game-document/InstanceInspectorPicker.tsx +2 -2
- package/src/game-document/crowd-debug.ts +1 -1
- package/src/game-document/device-preview.ts +10 -11
- package/src/game-document/physics-debug.ts +1 -1
- package/src/generation/GenerationActivity.tsx +3 -3
- package/src/generation/generation-documents.tsx +5 -5
- package/src/generation/generation-jobs.ts +1 -1
- package/src/host/adapter-runtime-bindings.ts +96 -5
- package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
- package/src/host/authoring/gesture-persist.ts +1 -1
- package/src/host/authoring/ingest-data-writer.ts +1 -1
- package/src/host/authoring/ingest-source-persistence.ts +4 -4
- package/src/host/authoring/mounted-authoring.ts +4 -4
- package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
- package/src/host/authoring/pixi-authoring-adapter.ts +178 -31
- package/src/host/authoring/pixi-creatable-kinds.ts +62 -0
- package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
- package/src/host/authoring/pixi-live-write-target.ts +4 -4
- package/src/host/authoring/pixi-source-identity.ts +3 -3
- package/src/host/authoring/pixi-source-write-target.ts +1709 -0
- package/src/host/authoring/pixi-still-presentation.ts +1 -1
- package/src/host/authoring/pixi-structure-history.ts +2 -2
- package/src/host/authoring/pixi-transform-channels.ts +16 -14
- package/src/host/authoring/source-persistence-backend.ts +3 -3
- package/src/host/authoring/struct-write-pipe.ts +1 -1
- package/src/host/binding-resolver.ts +8 -9
- package/src/host/browser-transpile.ts +1 -1
- package/src/host/canvas-entry-runtime.ts +58 -47
- package/src/host/canvas-preview-frames.ts +482 -0
- package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
- package/src/host/components/HeaderTelemetry.tsx +4 -4
- package/src/host/components/PixiIsolationSceneContent.tsx +11 -11
- package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
- package/src/host/components/frame-debugger-model.ts +2 -2
- package/src/host/components/header-telemetry-model.ts +2 -2
- package/src/host/components/scene-document.tsx +14 -14
- package/src/host/components/utility-view-state.ts +1 -1
- package/src/host/components/world-root-stage-binding.tsx +12 -12
- package/src/host/components/world-root-stage.ts +70 -49
- package/src/host/coverage/system-adapter-coverage.ts +3 -4
- package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
- package/src/host/document-preview-three.ts +1 -1
- package/src/host/entry-adjudication.ts +6 -6
- package/src/host/game-css-scope-transform.ts +4 -0
- package/src/host/game-realm-page.ts +1 -1
- package/src/host/gameplay-export.ts +25 -14
- package/src/host/gameplay-recording.ts +5 -5
- package/src/host/gated-globals.ts +2 -2
- package/src/host/history/json-history-resource.ts +1 -1
- package/src/host/projection/pixi.ts +24 -2
- package/src/host/r3f-entry-runtime.ts +65 -34
- package/src/host/react-mount-runtime.ts +7 -48
- package/src/host/realm-services.ts +1 -1
- package/src/host/roots/canvas-root.tsx +373 -0
- package/src/host/roots/r3f-root.tsx +473 -0
- package/src/host/roots/react-root.ts +9 -43
- package/src/host/served-bundle-runtime-modules.ts +3 -19
- package/src/host/server-log-bridge.ts +2 -2
- package/src/host/stories/mounted-story-viewport-source.ts +1 -1
- package/src/host/stories/pixi-story-model.ts +30 -0
- package/src/host/stories/story-media-captures.ts +46 -0
- package/src/host/stories/story-media-presence.ts +3 -3
- package/src/host/stories/story-pixi-preview.ts +408 -0
- package/src/host/stories/story-three-preview.ts +806 -0
- package/src/host/stories/three-story-captures.ts +35 -0
- package/src/host/story-three-preview-runtime.ts +56 -0
- package/src/host/use-active-performance-source.ts +2 -2
- package/src/host/viewport-pose-memory.ts +1 -1
- package/src/host/viewport-root-presentation.ts +6 -5
- package/src/ingest/active-ingest.ts +1 -1
- package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
- package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
- package/src/ingest/deferred-ingest-play.ts +6 -6
- package/src/ingest/discovery-public-ingest.ts +2 -2
- package/src/ingest/ingest-boot-viewport.ts +2 -2
- package/src/ingest/ingest-canvas-scene-document.tsx +9 -9
- package/src/ingest/ingest-canvas-scene.ts +3 -3
- package/src/ingest/ingest-evidence-hook.ts +1 -1
- package/src/ingest/ingest-frame-snapshot.ts +1 -1
- package/src/ingest/ingest-play-control.ts +1 -1
- package/src/ingest/ingest-render-debug.ts +10 -10
- package/src/ingest/ingest-siblings.ts +13 -25
- package/src/ingest/module-mode.ts +14 -14
- package/src/ingest/mount-canvas-ingest-root.ts +21 -21
- package/src/ingest/mount-coverage.ts +2 -2
- package/src/ingest/mount-dom-ingest-root.ts +11 -11
- package/src/ingest/mount-ingest-root.ts +8 -8
- package/src/ingest/mount-three-ingest-root.ts +8 -8
- package/src/ingest/resolve-canvas.ts +1 -1
- package/src/ingest/served-html-boot.ts +1 -1
- package/src/ingest/surface-canvas.ts +1 -1
- package/src/ingest/unmount-ingest-root.ts +5 -5
- package/src/navmesh/navmesh-handler.ts +24 -16
- package/src/network/NetworkInspectorPanel.tsx +939 -37
- package/src/network/network-inspector-model.ts +20 -2
- package/src/play/play-log-events.ts +1 -1
- package/src/play/play-mode.ts +76 -133
- package/src/play/play-recording.ts +1 -1
- package/src/play/react-play-live-authoring.ts +3 -3
- package/src/play/run-selection.ts +93 -0
- package/src/play-bar/PlayBar.tsx +20 -39
- package/src/profiler/FrameDebuggerPanel.tsx +1 -1
- package/src/profiler/PerformancePanel.tsx +3 -3
- package/src/react/design-time-react-mount.ts +23 -65
- package/src/react/dom-authoring-adapter.ts +9 -9
- package/src/react/react-inspector-section.tsx +5 -5
- package/src/react/react-world-authoring-adapter.ts +11 -11
- package/src/react/story-documents/story-documents.tsx +9 -9
- package/src/react/ui-board-document.tsx +7 -7
- package/src/react/ui-component-board.ts +2 -2
- package/src/runtime/adapter/audio-meter.ts +21 -0
- package/src/runtime/adapter/first-party-audio-system.ts +230 -0
- package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
- package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
- package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
- package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
- package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
- package/src/runtime/adapter/native-debug-module.ts +498 -0
- package/src/runtime/audio/bus-mixer.ts +161 -0
- package/src/runtime/audio/pose-guard.ts +80 -0
- package/src/runtime/core/frame-pacing.ts +126 -0
- package/src/runtime/core/game-loop.ts +225 -0
- package/src/runtime/core/game-scoped-slot.ts +28 -0
- package/src/runtime/core/seeded-random.ts +162 -0
- package/src/runtime/core/sim-clock.ts +391 -0
- package/src/runtime/core/system-runner.ts +269 -0
- package/src/runtime/core/types.ts +104 -0
- package/src/runtime/create-runtime.ts +1128 -0
- package/src/runtime/debug-bridge.ts +570 -0
- package/src/runtime/debug-registry.ts +899 -0
- package/src/runtime/dev/chrome-trace.ts +153 -0
- package/src/runtime/dev/instruments.ts +403 -0
- package/src/runtime/dev/logger.ts +119 -0
- package/src/runtime/dev/performance-profiler.ts +367 -0
- package/src/runtime/dev/register-render-vitals.ts +276 -0
- package/src/runtime/dev/render-census.ts +354 -0
- package/src/runtime/dev/render-debug-adapter.ts +218 -0
- package/src/runtime/dev/render-memory.ts +226 -0
- package/src/runtime/dev/render-vitals.ts +338 -0
- package/src/runtime/dev/static-batch-advisor.ts +188 -0
- package/src/runtime/dev/webgl-frame-capture.ts +366 -0
- package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
- package/src/runtime/dev-build.ts +47 -0
- package/src/runtime/game.ts +1636 -0
- package/src/runtime/gameplay-rng-trap.ts +135 -0
- package/src/runtime/host-context.ts +64 -0
- package/src/runtime/input-router.ts +182 -0
- package/src/runtime/mount-manifest.ts +480 -0
- package/src/runtime/pixi/authoring.ts +706 -0
- package/src/runtime/pixi/ingest.ts +116 -0
- package/src/runtime/pixi/physics-registry.ts +49 -0
- package/src/runtime/pixi/render-pass-bracket.ts +117 -0
- package/src/runtime/pixi/scene-capture.ts +179 -0
- package/src/runtime/pixi/system-adapters.ts +69 -0
- package/src/runtime/playtest.ts +22 -0
- package/src/runtime/presentation.ts +141 -0
- package/src/runtime/render-control.ts +642 -0
- package/src/runtime/render-seed.ts +77 -0
- package/src/runtime/run-ticks-settled.ts +73 -0
- package/src/runtime/setup/setup-audio.ts +72 -0
- package/src/services/audio-pose-guard.ts +2 -2
- package/src/services/game-audio.ts +152 -0
- package/src/services/game-network.ts +767 -0
- package/src/services/game-physics.ts +334 -0
- package/src/state-watch/StateWatchPanel.tsx +1 -1
- package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
- package/src/three/authoring/constraint-inspector-section.tsx +6 -5
- package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
- package/src/three/authoring/oid-source-persistence.ts +7 -7
- package/src/three/authoring/r3f-design-session.ts +58 -54
- package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
- package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
- package/src/three/authoring/three-authoring-adapter.ts +29 -29
- package/src/three/component-verbs/extract-menu.ts +7 -6
- package/src/three/component-verbs/fork-menu.ts +7 -6
- package/src/three/component-verbs/internals-menu.ts +2 -2
- package/src/three/story-documents/three-story-documents.tsx +13 -13
- package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
- package/src/three/three-board/board-scene.ts +6 -6
- package/src/three/three-board/three-component-board.ts +2 -2
- 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
|
+
}
|