@volter/editor-ui 0.5.211 → 0.5.212
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/NOTICE +12 -0
- package/contributions/component-board.service.ts +10 -2
- package/contributions/design-time-mount.service.ts +39 -2
- package/contributions/react-inspector.service.ts +11 -2
- package/contributions/story-documents.service.ts +14 -2
- package/contributions/ui.page.ts +28 -0
- package/package.json +78 -13
- package/src/host/authoring/struct-write-pipe.ts +102 -0
- package/src/host/components/ResolutionPicker.tsx +83 -0
- package/src/host/crash-null-boundary.ts +24 -0
- package/src/host/portable-stories.ts +44 -0
- package/src/host/projection/dom.ts +40 -0
- package/src/host/react-mount-runtime.ts +76 -0
- package/src/host/realm-services.ts +53 -0
- package/src/host/roots/react-root.ts +82 -0
- package/src/host/stories/story-arg-descriptors.ts +50 -0
- package/src/host/stories/story-media-presence.ts +89 -0
- package/src/host/stories/story-presentation.ts +203 -0
- package/src/host/surface-content.ts +44 -0
- package/src/react/composition-movie.ts +247 -0
- package/src/react/design-time-react-mount.ts +525 -0
- package/src/react/react-inspector-section.tsx +2247 -0
- package/src/react/react-world-authoring-adapter.ts +2602 -0
- package/src/react/story-documents/story-args-section.ts +9 -0
- package/src/react/story-documents/story-documents.tsx +1342 -0
- package/src/react/story-paint-bounds.ts +94 -0
- package/src/react/ui-board-document.tsx +55 -0
- package/src/react/ui-board-title.ts +9 -0
- package/src/react/ui-component-board.ts +52 -0
- package/src/runtime/presentation.ts +60 -0
- package/contributions/play-layers.service.ts +0 -31
|
@@ -0,0 +1,2602 @@
|
|
|
1
|
+
/** Source authoring over mounted, data-oid-stamped React DOM. The shared
|
|
2
|
+
* projector supplies hierarchy and selection; source writes prepare complete
|
|
3
|
+
* next-file text and commit through checksum-guarded history. Backend absence
|
|
4
|
+
* leaves inspection available and makes writes report their unavailable destination. */
|
|
5
|
+
|
|
6
|
+
import { editorHost } from '@volter/sdk/host';
|
|
7
|
+
import { activeBreakpoint } from '@volter/sdk/breakpoint-state';
|
|
8
|
+
import {
|
|
9
|
+
cssTextForStyleValue,
|
|
10
|
+
numericStyleValue,
|
|
11
|
+
preserveNumericStyleUnit,
|
|
12
|
+
} from '@volter/sdk/css-numeric-style';
|
|
13
|
+
import { WORLD_SCOPE_NODE_ID } from '@volter/sdk/stories-scope';
|
|
14
|
+
import { createStructWritePipe, type StructOpOptions } from '../host/authoring/struct-write-pipe';
|
|
15
|
+
import { getRootPan } from '@volter/sdk/world-pan-state';
|
|
16
|
+
import {
|
|
17
|
+
resolvesLiveOnly,
|
|
18
|
+
runWritePipe,
|
|
19
|
+
type WriteAck,
|
|
20
|
+
type WriteResolution,
|
|
21
|
+
} from '@volter/sdk/write-pipe';
|
|
22
|
+
import { editorConsole } from '@volter/sdk/editor-console';
|
|
23
|
+
import type { ShellStore } from '@volter/sdk/shell-store';
|
|
24
|
+
import { withProjectSourceHistory } from '@volter/sdk/history/source-history-backend';
|
|
25
|
+
import { projectOidDom } from '../host/projection/dom';
|
|
26
|
+
import { storyArgPropertyDescriptors } from '../host/stories/story-arg-descriptors';
|
|
27
|
+
import type { StoryPresentationIndex } from '../host/stories/story-presentation';
|
|
28
|
+
import { showTransientHint } from '@volter/sdk/transient-hint';
|
|
29
|
+
import {
|
|
30
|
+
camelToKebab,
|
|
31
|
+
type DesignToken,
|
|
32
|
+
type EmptyCandidate,
|
|
33
|
+
findClassRuleSource,
|
|
34
|
+
findEmptyContainers,
|
|
35
|
+
firstPartyStylesheetFiles,
|
|
36
|
+
getComponentProps,
|
|
37
|
+
getComputedStyleValue,
|
|
38
|
+
getDesignTokens,
|
|
39
|
+
getMatchedCssRules,
|
|
40
|
+
getReactComponentName,
|
|
41
|
+
} from '@volter/sdk/ui-source/inspect';
|
|
42
|
+
import type { ComponentPropSpec, OidEntry } from '@volter/editor-react/source/oid-transform';
|
|
43
|
+
import { relativeImportSpecifier } from '@volter/editor-react/source/relative-import-specifier';
|
|
44
|
+
import type { SourceWriteBackend } from '@volter/sdk/ui-source/source-write-backend';
|
|
45
|
+
import { writeCsfStory, writeNamedStyle } from '@volter/sdk/ui-source/source-write-backend';
|
|
46
|
+
import {
|
|
47
|
+
type CssRuleTarget,
|
|
48
|
+
namedStyleRuleFor,
|
|
49
|
+
pickCssRuleTarget,
|
|
50
|
+
tokenReferenceGuardText,
|
|
51
|
+
} from '@volter/editor-react/source/writer';
|
|
52
|
+
import { UI_COMPONENTS_DOCUMENT_ID } from '@volter/sdk/workspace-document-ids';
|
|
53
|
+
import { activateWorkspaceDocument } from '@volter/sdk/workspace-document-registry';
|
|
54
|
+
import type {
|
|
55
|
+
AssetDropContext,
|
|
56
|
+
AssetDropProvider,
|
|
57
|
+
AuthoringAdapter,
|
|
58
|
+
AuthoringCapabilities,
|
|
59
|
+
AuthoringProvenance,
|
|
60
|
+
BoxEditProvider,
|
|
61
|
+
ColorSampleProvider,
|
|
62
|
+
ComponentInstanceApplyResult,
|
|
63
|
+
ComponentInstancesProvider,
|
|
64
|
+
DOMRectLike,
|
|
65
|
+
EditorNode,
|
|
66
|
+
HierarchyProvider,
|
|
67
|
+
InspectorProvider,
|
|
68
|
+
NodeCreationSite,
|
|
69
|
+
PersistenceProvider,
|
|
70
|
+
PickProvider,
|
|
71
|
+
PropertyDescriptor,
|
|
72
|
+
RectProvider,
|
|
73
|
+
RelatedSubjectsProvider,
|
|
74
|
+
SelectionProvider,
|
|
75
|
+
StoriesProvider,
|
|
76
|
+
StoryRef,
|
|
77
|
+
StructureProvider,
|
|
78
|
+
TextProvider,
|
|
79
|
+
TruthProvider,
|
|
80
|
+
WriteAnchorKind,
|
|
81
|
+
} from '@volter/project/adapter';
|
|
82
|
+
|
|
83
|
+
/** Live DOM members shared by the mounted root and the focused-story proxy.
|
|
84
|
+
* The proxy exposes the layer's attributes, style and geometry with the focused
|
|
85
|
+
* story's children and text; descendants are the mounted DOM elements themselves. */
|
|
86
|
+
export interface OidElementLike {
|
|
87
|
+
readonly tagName: string;
|
|
88
|
+
/** The focused-story proxy omits the namespace; live DOM descendants supply it. */
|
|
89
|
+
readonly namespaceURI?: string | null;
|
|
90
|
+
readonly children: ArrayLike<Element>;
|
|
91
|
+
getAttribute(name: string): string | null;
|
|
92
|
+
readonly style: CSSStyleDeclaration;
|
|
93
|
+
getBoundingClientRect(): DOMRect;
|
|
94
|
+
/** Rendered text for the client-side leaf check; dynamic bodies are guarded in source. */
|
|
95
|
+
readonly textContent: string | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const MAX_ELEMENT_LABEL_LENGTH = 64;
|
|
99
|
+
|
|
100
|
+
/** Compact the browser's semantic text into a stable hierarchy-row label. */
|
|
101
|
+
function normalizeElementText(value: string | null | undefined): string | null {
|
|
102
|
+
const text = value?.replace(/\s+/g, ' ').trim();
|
|
103
|
+
if (!text) return null;
|
|
104
|
+
if (text.length <= MAX_ELEMENT_LABEL_LENGTH) return text;
|
|
105
|
+
return `${text.slice(0, MAX_ELEMENT_LABEL_LENGTH - 1).trimEnd()}…`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read a camelCase inline style property from its live declaration. */
|
|
109
|
+
function styleProp(style: CSSStyleDeclaration, prop: string): string {
|
|
110
|
+
return style.getPropertyValue(camelToKebab(prop));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const RGB_RE = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/;
|
|
114
|
+
|
|
115
|
+
/** Normalize hex and rgb/rgba CSS colors into the #rrggbb shape accepted by
|
|
116
|
+
* color inputs. Unsupported values return undefined. */
|
|
117
|
+
export function cssColorToHex(raw: unknown): string | undefined {
|
|
118
|
+
if (typeof raw !== 'string' || raw === '') return undefined;
|
|
119
|
+
if (raw.startsWith('#')) return raw;
|
|
120
|
+
const m = RGB_RE.exec(raw);
|
|
121
|
+
if (!m) return undefined;
|
|
122
|
+
const toHex = (n: string) => Number.parseInt(n, 10).toString(16).padStart(2, '0');
|
|
123
|
+
return `#${toHex(m[1]!)}${toHex(m[2]!)}${toHex(m[3]!)}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** CSS property and preview-value mapping for a box-edit patch. */
|
|
127
|
+
interface BoxEditPropMapping {
|
|
128
|
+
/** The CSS style prop to write (already the target, e.g. `x` → `left`). */
|
|
129
|
+
prop: string;
|
|
130
|
+
/** Live-preview CSS VALUE for a raw patch number — always px-suffixed for
|
|
131
|
+
* spatial/spacing props, the `rotate(<deg>deg)` transform string for `rotate`. */
|
|
132
|
+
cssValue: (v: number) => string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Map spatial box-edit keys to CSS properties. x/y require positioned
|
|
136
|
+
* elements; rotate becomes a transform string. Unsupported keys return null. */
|
|
137
|
+
function mapBoxEditPatchKey(key: string, isPositioned: boolean): BoxEditPropMapping | null {
|
|
138
|
+
switch (key) {
|
|
139
|
+
case 'width':
|
|
140
|
+
case 'height':
|
|
141
|
+
case 'marginTop':
|
|
142
|
+
case 'marginRight':
|
|
143
|
+
case 'marginBottom':
|
|
144
|
+
case 'marginLeft':
|
|
145
|
+
case 'paddingTop':
|
|
146
|
+
case 'paddingRight':
|
|
147
|
+
case 'paddingBottom':
|
|
148
|
+
case 'paddingLeft':
|
|
149
|
+
return { prop: key, cssValue: (v) => `${v}px` };
|
|
150
|
+
case 'x':
|
|
151
|
+
return isPositioned ? { prop: 'left', cssValue: (v) => `${v}px` } : null;
|
|
152
|
+
case 'y':
|
|
153
|
+
return isPositioned ? { prop: 'top', cssValue: (v) => `${v}px` } : null;
|
|
154
|
+
case 'rotate':
|
|
155
|
+
return { prop: 'transform', cssValue: (v) => `rotate(${v}deg)` };
|
|
156
|
+
default:
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const OID_ATTR = 'data-oid';
|
|
162
|
+
const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
|
|
163
|
+
const VOID_HTML_ELEMENTS = new Set([
|
|
164
|
+
'area',
|
|
165
|
+
'base',
|
|
166
|
+
'br',
|
|
167
|
+
'col',
|
|
168
|
+
'embed',
|
|
169
|
+
'hr',
|
|
170
|
+
'img',
|
|
171
|
+
'input',
|
|
172
|
+
'link',
|
|
173
|
+
'meta',
|
|
174
|
+
'param',
|
|
175
|
+
'source',
|
|
176
|
+
'track',
|
|
177
|
+
'wbr',
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
/** Empty-layout hints are for HTML elements that could receive content.
|
|
181
|
+
* SVG geometry (especially zero-width `line`/`path` bounds) and HTML void
|
|
182
|
+
* elements are leaves by definition, not collapsed layout containers. */
|
|
183
|
+
function canContainDomChildren(el: OidElementLike, tag: string): boolean {
|
|
184
|
+
const namespace = el.namespaceURI;
|
|
185
|
+
if (namespace !== undefined && namespace !== null && namespace !== HTML_NAMESPACE) return false;
|
|
186
|
+
return !VOID_HTML_ELEMENTS.has(tag);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function projectSourcePath(path: string): string {
|
|
190
|
+
const normalized = path.replaceAll('\\', '/').replace(/^\.\//, '');
|
|
191
|
+
const marker = normalized.lastIndexOf('/src/');
|
|
192
|
+
return marker >= 0 ? normalized.slice(marker + 1) : normalized;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** One OID-tagged DOM element resolved into the adapter's internal tree. */
|
|
196
|
+
interface OidNode {
|
|
197
|
+
/** Entity id assigned by the shared DOM projector, disambiguating repeated source OIDs. */
|
|
198
|
+
id: string;
|
|
199
|
+
/** Source OID, shared by repeated instances of the same authored element. */
|
|
200
|
+
oid: string;
|
|
201
|
+
tag: string;
|
|
202
|
+
el: OidElementLike;
|
|
203
|
+
parentId: string | null;
|
|
204
|
+
childIds: string[];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface OidTree {
|
|
208
|
+
/** Every OID-tagged node, keyed by its (disambiguated) entity id. */
|
|
209
|
+
nodes: Map<string, OidNode>;
|
|
210
|
+
/** Top-level entity ids (no OID-tagged ancestor) in DOM/tree order. */
|
|
211
|
+
rootIds: string[];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Derive source OIDs and labels from the projected live DOM. */
|
|
215
|
+
function walkOidTree(root: OidElementLike): OidTree {
|
|
216
|
+
return oidTreeFromProjection(projectOidDom(root));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function oidTreeFromProjection(projection: {
|
|
220
|
+
readonly nodes: ReadonlyMap<
|
|
221
|
+
string,
|
|
222
|
+
{ object: OidElementLike; parentId: string | null; childIds: readonly string[] }
|
|
223
|
+
>;
|
|
224
|
+
readonly rootIds: readonly string[];
|
|
225
|
+
}): OidTree {
|
|
226
|
+
const nodes = new Map<string, OidNode>();
|
|
227
|
+
for (const [id, node] of projection.nodes) {
|
|
228
|
+
nodes.set(id, {
|
|
229
|
+
id,
|
|
230
|
+
oid: node.object.getAttribute(OID_ATTR) ?? id,
|
|
231
|
+
tag: node.object.tagName.toLowerCase(),
|
|
232
|
+
el: node.object,
|
|
233
|
+
parentId: node.parentId,
|
|
234
|
+
childIds: [...node.childIds],
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return { nodes, rootIds: [...projection.rootIds] };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Grouped CSS inspector properties supported by the source writer. */
|
|
241
|
+
const STYLE_PROPERTIES: ReadonlyArray<{
|
|
242
|
+
prop: string;
|
|
243
|
+
label: string;
|
|
244
|
+
type: PropertyDescriptor['type'];
|
|
245
|
+
group: string;
|
|
246
|
+
options?: string[];
|
|
247
|
+
}> = [
|
|
248
|
+
// -- Layout --
|
|
249
|
+
{
|
|
250
|
+
prop: 'display',
|
|
251
|
+
label: 'Display',
|
|
252
|
+
type: 'enum',
|
|
253
|
+
group: 'Layout',
|
|
254
|
+
options: ['block', 'flex', 'grid', 'inline', 'inline-block', 'inline-flex', 'none'],
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
prop: 'flexDirection',
|
|
258
|
+
label: 'Direction',
|
|
259
|
+
type: 'enum',
|
|
260
|
+
group: 'Layout',
|
|
261
|
+
options: ['row', 'column', 'row-reverse', 'column-reverse'],
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
prop: 'flexWrap',
|
|
265
|
+
label: 'Wrap',
|
|
266
|
+
type: 'enum',
|
|
267
|
+
group: 'Layout',
|
|
268
|
+
options: ['nowrap', 'wrap', 'wrap-reverse'],
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
prop: 'justifyContent',
|
|
272
|
+
label: 'Justify',
|
|
273
|
+
type: 'enum',
|
|
274
|
+
group: 'Layout',
|
|
275
|
+
options: ['flex-start', 'center', 'flex-end', 'space-between', 'space-around', 'space-evenly'],
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
prop: 'alignItems',
|
|
279
|
+
label: 'Align',
|
|
280
|
+
type: 'enum',
|
|
281
|
+
group: 'Layout',
|
|
282
|
+
options: ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'],
|
|
283
|
+
},
|
|
284
|
+
{ prop: 'gap', label: 'Gap', type: 'number', group: 'Layout' },
|
|
285
|
+
// CSS Grid container and item properties retain their native value syntax.
|
|
286
|
+
{ prop: 'gridTemplateColumns', label: 'Columns', type: 'string', group: 'Layout' },
|
|
287
|
+
{ prop: 'gridTemplateRows', label: 'Rows', type: 'string', group: 'Layout' },
|
|
288
|
+
{
|
|
289
|
+
prop: 'gridAutoFlow',
|
|
290
|
+
label: 'Flow',
|
|
291
|
+
type: 'enum',
|
|
292
|
+
group: 'Layout',
|
|
293
|
+
options: ['row', 'column', 'row dense', 'column dense'],
|
|
294
|
+
},
|
|
295
|
+
{ prop: 'rowGap', label: 'Row Gap', type: 'number', group: 'Layout' },
|
|
296
|
+
{ prop: 'columnGap', label: 'Col Gap', type: 'number', group: 'Layout' },
|
|
297
|
+
{ prop: 'gridColumn', label: 'Grid Col', type: 'string', group: 'Layout' },
|
|
298
|
+
{ prop: 'gridRow', label: 'Grid Row', type: 'string', group: 'Layout' },
|
|
299
|
+
{
|
|
300
|
+
prop: 'justifySelf',
|
|
301
|
+
label: 'Justify Self',
|
|
302
|
+
type: 'enum',
|
|
303
|
+
group: 'Layout',
|
|
304
|
+
options: ['auto', 'start', 'center', 'end', 'stretch'],
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
prop: 'overflow',
|
|
308
|
+
label: 'Overflow',
|
|
309
|
+
type: 'enum',
|
|
310
|
+
group: 'Layout',
|
|
311
|
+
options: ['visible', 'hidden', 'scroll', 'auto'],
|
|
312
|
+
},
|
|
313
|
+
{ prop: 'width', label: 'Width', type: 'number', group: 'Layout' },
|
|
314
|
+
{ prop: 'height', label: 'Height', type: 'number', group: 'Layout' },
|
|
315
|
+
{ prop: 'minWidth', label: 'Min W', type: 'number', group: 'Layout' },
|
|
316
|
+
{ prop: 'minHeight', label: 'Min H', type: 'number', group: 'Layout' },
|
|
317
|
+
{ prop: 'maxWidth', label: 'Max W', type: 'number', group: 'Layout' },
|
|
318
|
+
{ prop: 'maxHeight', label: 'Max H', type: 'number', group: 'Layout' },
|
|
319
|
+
// -- Position --
|
|
320
|
+
{
|
|
321
|
+
prop: 'position',
|
|
322
|
+
label: 'Position',
|
|
323
|
+
type: 'enum',
|
|
324
|
+
group: 'Position',
|
|
325
|
+
options: ['static', 'relative', 'absolute', 'fixed', 'sticky'],
|
|
326
|
+
},
|
|
327
|
+
{ prop: 'top', label: 'Top', type: 'number', group: 'Position' },
|
|
328
|
+
{ prop: 'right', label: 'Right', type: 'number', group: 'Position' },
|
|
329
|
+
{ prop: 'bottom', label: 'Bottom', type: 'number', group: 'Position' },
|
|
330
|
+
{ prop: 'left', label: 'Left', type: 'number', group: 'Position' },
|
|
331
|
+
{ prop: 'zIndex', label: 'Z Index', type: 'number', group: 'Position' },
|
|
332
|
+
{ prop: 'flexGrow', label: 'Grow', type: 'number', group: 'Position' },
|
|
333
|
+
{ prop: 'flexShrink', label: 'Shrink', type: 'number', group: 'Position' },
|
|
334
|
+
{ prop: 'flexBasis', label: 'Basis', type: 'string', group: 'Position' },
|
|
335
|
+
{
|
|
336
|
+
prop: 'alignSelf',
|
|
337
|
+
label: 'Self Align',
|
|
338
|
+
type: 'enum',
|
|
339
|
+
group: 'Position',
|
|
340
|
+
options: ['auto', 'stretch', 'flex-start', 'center', 'flex-end', 'baseline'],
|
|
341
|
+
},
|
|
342
|
+
// -- Spacing (per-side) --
|
|
343
|
+
{ prop: 'marginTop', label: 'Margin T', type: 'number', group: 'Spacing' },
|
|
344
|
+
{ prop: 'marginRight', label: 'Margin R', type: 'number', group: 'Spacing' },
|
|
345
|
+
{ prop: 'marginBottom', label: 'Margin B', type: 'number', group: 'Spacing' },
|
|
346
|
+
{ prop: 'marginLeft', label: 'Margin L', type: 'number', group: 'Spacing' },
|
|
347
|
+
{ prop: 'paddingTop', label: 'Padding T', type: 'number', group: 'Spacing' },
|
|
348
|
+
{ prop: 'paddingRight', label: 'Padding R', type: 'number', group: 'Spacing' },
|
|
349
|
+
{ prop: 'paddingBottom', label: 'Padding B', type: 'number', group: 'Spacing' },
|
|
350
|
+
{ prop: 'paddingLeft', label: 'Padding L', type: 'number', group: 'Spacing' },
|
|
351
|
+
// -- Type --
|
|
352
|
+
{ prop: 'color', label: 'Color', type: 'color', group: 'Type' },
|
|
353
|
+
{ prop: 'fontSize', label: 'Size', type: 'number', group: 'Type' },
|
|
354
|
+
{ prop: 'fontWeight', label: 'Weight', type: 'string', group: 'Type' },
|
|
355
|
+
{ prop: 'lineHeight', label: 'Line H', type: 'string', group: 'Type' },
|
|
356
|
+
{ prop: 'letterSpacing', label: 'Spacing', type: 'string', group: 'Type' },
|
|
357
|
+
{
|
|
358
|
+
prop: 'textAlign',
|
|
359
|
+
label: 'Align',
|
|
360
|
+
type: 'enum',
|
|
361
|
+
group: 'Type',
|
|
362
|
+
options: ['left', 'center', 'right', 'justify'],
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
prop: 'textTransform',
|
|
366
|
+
label: 'Transform',
|
|
367
|
+
type: 'enum',
|
|
368
|
+
group: 'Type',
|
|
369
|
+
options: ['none', 'uppercase', 'lowercase', 'capitalize'],
|
|
370
|
+
},
|
|
371
|
+
{ prop: 'fontStyle', label: 'Style', type: 'enum', group: 'Type', options: ['normal', 'italic'] },
|
|
372
|
+
{ prop: 'fontFamily', label: 'Font', type: 'string', group: 'Type' },
|
|
373
|
+
{
|
|
374
|
+
prop: 'textDecoration',
|
|
375
|
+
label: 'Decoration',
|
|
376
|
+
type: 'enum',
|
|
377
|
+
group: 'Type',
|
|
378
|
+
options: ['none', 'underline', 'line-through', 'overline'],
|
|
379
|
+
},
|
|
380
|
+
// -- Fill --
|
|
381
|
+
{ prop: 'backgroundColor', label: 'Background', type: 'color', group: 'Fill' },
|
|
382
|
+
// Background longhands keep computed reads consistent with the authored CSS model.
|
|
383
|
+
{ prop: 'backgroundImage', label: 'Fills', type: 'string', group: 'Fill' },
|
|
384
|
+
// Image-layer companions apply to the complete background stack.
|
|
385
|
+
{
|
|
386
|
+
prop: 'backgroundSize',
|
|
387
|
+
label: 'Size',
|
|
388
|
+
type: 'enum',
|
|
389
|
+
group: 'Fill',
|
|
390
|
+
options: ['auto', 'cover', 'contain'],
|
|
391
|
+
},
|
|
392
|
+
{ prop: 'backgroundPosition', label: 'Pos', type: 'string', group: 'Fill' },
|
|
393
|
+
{
|
|
394
|
+
prop: 'backgroundRepeat',
|
|
395
|
+
label: 'Repeat',
|
|
396
|
+
type: 'enum',
|
|
397
|
+
group: 'Fill',
|
|
398
|
+
options: ['repeat', 'no-repeat', 'repeat-x', 'repeat-y'],
|
|
399
|
+
},
|
|
400
|
+
// -- Stroke --
|
|
401
|
+
{ prop: 'borderColor', label: 'Border Color', type: 'color', group: 'Stroke' },
|
|
402
|
+
{ prop: 'borderWidth', label: 'Border Width', type: 'number', group: 'Stroke' },
|
|
403
|
+
{
|
|
404
|
+
prop: 'borderStyle',
|
|
405
|
+
label: 'Border Style',
|
|
406
|
+
type: 'enum',
|
|
407
|
+
group: 'Stroke',
|
|
408
|
+
options: ['none', 'solid', 'dashed', 'dotted', 'double'],
|
|
409
|
+
},
|
|
410
|
+
{ prop: 'borderRadius', label: 'Radius', type: 'number', group: 'Stroke' },
|
|
411
|
+
// -- Effects --
|
|
412
|
+
{ prop: 'opacity', label: 'Opacity', type: 'number', group: 'Effects' },
|
|
413
|
+
{ prop: 'boxShadow', label: 'Box Shadow', type: 'string', group: 'Effects' },
|
|
414
|
+
{
|
|
415
|
+
prop: 'mixBlendMode',
|
|
416
|
+
label: 'Blend',
|
|
417
|
+
type: 'enum',
|
|
418
|
+
group: 'Effects',
|
|
419
|
+
options: [
|
|
420
|
+
'normal',
|
|
421
|
+
'multiply',
|
|
422
|
+
'screen',
|
|
423
|
+
'overlay',
|
|
424
|
+
'darken',
|
|
425
|
+
'lighten',
|
|
426
|
+
'color-dodge',
|
|
427
|
+
'color-burn',
|
|
428
|
+
'hard-light',
|
|
429
|
+
'soft-light',
|
|
430
|
+
'difference',
|
|
431
|
+
'exclusion',
|
|
432
|
+
'hue',
|
|
433
|
+
'saturation',
|
|
434
|
+
'color',
|
|
435
|
+
'luminosity',
|
|
436
|
+
],
|
|
437
|
+
},
|
|
438
|
+
{ prop: 'filter', label: 'Filter', type: 'string', group: 'Effects' },
|
|
439
|
+
{ prop: 'backdropFilter', label: 'Backdrop', type: 'string', group: 'Effects' },
|
|
440
|
+
{ prop: 'textShadow', label: 'Text Shadow', type: 'string', group: 'Effects' },
|
|
441
|
+
{
|
|
442
|
+
prop: 'cursor',
|
|
443
|
+
label: 'Cursor',
|
|
444
|
+
type: 'enum',
|
|
445
|
+
group: 'Effects',
|
|
446
|
+
options: [
|
|
447
|
+
'auto',
|
|
448
|
+
'default',
|
|
449
|
+
'pointer',
|
|
450
|
+
'text',
|
|
451
|
+
'move',
|
|
452
|
+
'wait',
|
|
453
|
+
'help',
|
|
454
|
+
'crosshair',
|
|
455
|
+
'not-allowed',
|
|
456
|
+
'grab',
|
|
457
|
+
'grabbing',
|
|
458
|
+
'zoom-in',
|
|
459
|
+
'zoom-out',
|
|
460
|
+
'col-resize',
|
|
461
|
+
'row-resize',
|
|
462
|
+
'none',
|
|
463
|
+
],
|
|
464
|
+
},
|
|
465
|
+
// -- Transform --
|
|
466
|
+
{ prop: 'transform', label: 'Transform', type: 'string', group: 'Transform' },
|
|
467
|
+
];
|
|
468
|
+
const STYLE_PATH_PREFIX = 'style.';
|
|
469
|
+
/** Where this adapter's writes land — the ONE spelling, read by both the
|
|
470
|
+
* save-status provider and the per-edit ack the pipe returns, so the two can
|
|
471
|
+
* never name different places. */
|
|
472
|
+
const JSX_SOURCE_DESTINATION =
|
|
473
|
+
'component source (JSX, via /__ui-source — writes are immediate; nothing pending to flush)';
|
|
474
|
+
/** …and the honest floor when no writer is bound. */
|
|
475
|
+
const NO_BACKEND_DESTINATION = 'component source (JSX) — no source-write backend in this session';
|
|
476
|
+
/** Inspector path prefix for editable component props. */
|
|
477
|
+
const PROP_PATH_PREFIX = 'prop.';
|
|
478
|
+
/** Inspector path prefix for document design tokens. */
|
|
479
|
+
const TOKEN_PATH_PREFIX = 'token.';
|
|
480
|
+
const PORTABLE_STORY_ARG_PATH_PREFIX = 'story.arg.';
|
|
481
|
+
/** Session guard key for text edits, which have no property descriptor. */
|
|
482
|
+
const TEXT_PATH = 'text';
|
|
483
|
+
/** Watch selection for 30 × 40ms (1.2s) after an HMR update. */
|
|
484
|
+
const SELECTION_REPAIR_ATTEMPTS = 30;
|
|
485
|
+
/** Report selection repair once per session while restoring resolvable IDs. */
|
|
486
|
+
let reportedSelectionRepair = false;
|
|
487
|
+
const SELECTION_REPAIR_INTERVAL_MS = 40;
|
|
488
|
+
|
|
489
|
+
const DYNAMIC_EXPRESSION_GUARD =
|
|
490
|
+
'The source value is a dynamic expression, so replacing it with a literal is guarded.';
|
|
491
|
+
|
|
492
|
+
/** CSS shorthand ownership. Per-side writes expand and remove governing
|
|
493
|
+
* shorthands so React receives one vocabulary for each property. Nested
|
|
494
|
+
* shorthands expand from outermost to innermost. */
|
|
495
|
+
const SHORTHAND_LONGHANDS: Readonly<Record<string, readonly string[]>> = {
|
|
496
|
+
border: ['borderWidth', 'borderStyle', 'borderColor'],
|
|
497
|
+
borderWidth: ['borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'],
|
|
498
|
+
borderStyle: ['borderTopStyle', 'borderRightStyle', 'borderBottomStyle', 'borderLeftStyle'],
|
|
499
|
+
borderColor: ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'],
|
|
500
|
+
borderRadius: [
|
|
501
|
+
'borderTopLeftRadius',
|
|
502
|
+
'borderTopRightRadius',
|
|
503
|
+
'borderBottomRightRadius',
|
|
504
|
+
'borderBottomLeftRadius',
|
|
505
|
+
],
|
|
506
|
+
padding: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'],
|
|
507
|
+
margin: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'],
|
|
508
|
+
inset: ['top', 'right', 'bottom', 'left'],
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
/** Longhand -> its immediate shorthand, derived from the table above so the
|
|
512
|
+
* two directions cannot disagree. */
|
|
513
|
+
const SHORTHAND_OF: ReadonlyMap<string, string> = new Map(
|
|
514
|
+
Object.entries(SHORTHAND_LONGHANDS).flatMap(([shorthand, longhands]) =>
|
|
515
|
+
longhands.map((longhand) => [longhand, shorthand] as const),
|
|
516
|
+
),
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
/** The shorthands covering `prop`, OUTERMOST FIRST — `borderTopWidth` gives
|
|
520
|
+
* `['border', 'borderWidth']`. Empty for a property no shorthand owns. */
|
|
521
|
+
function shorthandChain(prop: string): readonly string[] {
|
|
522
|
+
const chain: string[] = [];
|
|
523
|
+
for (let cursor = SHORTHAND_OF.get(prop); cursor; cursor = SHORTHAND_OF.get(cursor)) {
|
|
524
|
+
chain.unshift(cursor);
|
|
525
|
+
}
|
|
526
|
+
return chain;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** `prop -> declared type`, so `inspector.get()` knows when to run a style
|
|
530
|
+
* value through {@link numericStyleValue} instead of handing it back raw
|
|
531
|
+
* (a real CSSOM length value is always a unit-suffixed string). */
|
|
532
|
+
const STYLE_PROPERTY_TYPE: ReadonlyMap<string, PropertyDescriptor['type']> = new Map(
|
|
533
|
+
STYLE_PROPERTIES.map(({ prop, type }) => [prop, type]),
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
/** Map side and corner longhands to their controlling shorthand for echo
|
|
537
|
+
* updates after removing an override. */
|
|
538
|
+
const LONGHAND_TO_SHORTHAND: Readonly<Record<string, string>> = {
|
|
539
|
+
borderTopLeftRadius: 'borderRadius',
|
|
540
|
+
borderTopRightRadius: 'borderRadius',
|
|
541
|
+
borderBottomLeftRadius: 'borderRadius',
|
|
542
|
+
borderBottomRightRadius: 'borderRadius',
|
|
543
|
+
borderTopWidth: 'borderWidth',
|
|
544
|
+
borderRightWidth: 'borderWidth',
|
|
545
|
+
borderBottomWidth: 'borderWidth',
|
|
546
|
+
borderLeftWidth: 'borderWidth',
|
|
547
|
+
borderTopStyle: 'borderStyle',
|
|
548
|
+
borderRightStyle: 'borderStyle',
|
|
549
|
+
borderBottomStyle: 'borderStyle',
|
|
550
|
+
borderLeftStyle: 'borderStyle',
|
|
551
|
+
borderTopColor: 'borderColor',
|
|
552
|
+
borderRightColor: 'borderColor',
|
|
553
|
+
borderBottomColor: 'borderColor',
|
|
554
|
+
borderLeftColor: 'borderColor',
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
/** Curated HTML kinds offered by structure.create. The source writer emits
|
|
558
|
+
* self-closing JSX tags for these names. */
|
|
559
|
+
const CREATABLE_KINDS: ReadonlyArray<{ kind: string; label: string }> = [
|
|
560
|
+
{ kind: 'div', label: 'Container' },
|
|
561
|
+
{ kind: 'span', label: 'Text' },
|
|
562
|
+
{ kind: 'p', label: 'Paragraph' },
|
|
563
|
+
{ kind: 'button', label: 'Button' },
|
|
564
|
+
{ kind: 'a', label: 'Link' },
|
|
565
|
+
{ kind: 'img', label: 'Image' },
|
|
566
|
+
{ kind: 'ul', label: 'List' },
|
|
567
|
+
{ kind: 'li', label: 'List Item' },
|
|
568
|
+
];
|
|
569
|
+
|
|
570
|
+
type DomAssetDropPlan =
|
|
571
|
+
| {
|
|
572
|
+
ok: true;
|
|
573
|
+
parentId: string;
|
|
574
|
+
snippet: string;
|
|
575
|
+
ensureImport: { name: string; module: string; kind: 'default' | 'named' };
|
|
576
|
+
}
|
|
577
|
+
| { ok: false; reason: string };
|
|
578
|
+
|
|
579
|
+
function serializedComponentValue(value: unknown, spec: ComponentPropSpec): string {
|
|
580
|
+
if (spec.type === 'number') return String(Number(value));
|
|
581
|
+
if (spec.type === 'boolean') return value === true || value === 'true' ? 'true' : 'false';
|
|
582
|
+
if (spec.type === 'vec3' && Array.isArray(value)) {
|
|
583
|
+
return `[${value.map((entry) => Number(entry)).join(', ')}]`;
|
|
584
|
+
}
|
|
585
|
+
if (spec.type === 'json') return JSON.stringify(value);
|
|
586
|
+
return JSON.stringify(String(value));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
interface ReactRootAuthoringOptions {
|
|
590
|
+
/** Source-write transport. Absent in sessions without source-write support. */
|
|
591
|
+
writeBackend?: SourceWriteBackend | undefined;
|
|
592
|
+
/** Design tokens visible in this root's CSS scope. */
|
|
593
|
+
designTokens?: (() => DesignToken[]) | undefined;
|
|
594
|
+
/** Portable CSF stories associated with this React root. */
|
|
595
|
+
portableStories?: ReadonlyArray<PortableStoryRef> | undefined;
|
|
596
|
+
/** Native Storybook Category → Folder → Component index. When present,
|
|
597
|
+
* both the hierarchy and the board consume this same derived projection;
|
|
598
|
+
* CSF remains the only authored data. */
|
|
599
|
+
portableHierarchy?: StoryPresentationIndex | undefined;
|
|
600
|
+
/** Initially mounted portable story id (normally the explicit/default CSF
|
|
601
|
+
* story selected by the design-time layer). */
|
|
602
|
+
activePortableStoryId?: string | undefined;
|
|
603
|
+
/** Re-render hook for a portable CSF selection. The design-time layer owns
|
|
604
|
+
* Storybook mounting; this adapter owns only selection/provider routing. */
|
|
605
|
+
onPortableStoryApplied?: ((storyId: string | null) => void) | undefined;
|
|
606
|
+
/** Session-scoped Storybook Controls write. The design-time layer remounts
|
|
607
|
+
* the same composed story with these args; CSF remains the canonical
|
|
608
|
+
* definition and Reset restores its composed defaults. */
|
|
609
|
+
onPortableStoryArgsChanged?:
|
|
610
|
+
| ((storyId: string, args: Record<string, unknown>) => void)
|
|
611
|
+
| undefined;
|
|
612
|
+
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
interface PortableStoryRef extends StoryRef {
|
|
616
|
+
args: Readonly<Record<string, unknown>>;
|
|
617
|
+
/** CSF export name addressed by save-as, rename and delete. */
|
|
618
|
+
name: string;
|
|
619
|
+
/** Project-relative source path. */
|
|
620
|
+
modulePath: string;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** The default: this adapter's own truth is the component's JSX source. */
|
|
624
|
+
const JSX_SOURCE_PROVENANCE: AuthoringProvenance = {
|
|
625
|
+
source: 'source-code',
|
|
626
|
+
label: 'jsx',
|
|
627
|
+
detail: 'The JSX source is the document — edits write back to the component source file.',
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
/** The element's literal class tokens, off the live DOM `class` attribute. */
|
|
631
|
+
function classTokensOf(el: OidElementLike): string[] {
|
|
632
|
+
const raw = el.getAttribute('class') ?? '';
|
|
633
|
+
return raw.split(/\s+/).filter(Boolean);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Does the LIVE inline style declare this property? Deliberately the live
|
|
637
|
+
* read, not the source: a preview-patched inline value also wins the cascade,
|
|
638
|
+
* so the conservative answer keeps a class-routed write from landing a
|
|
639
|
+
* declaration that never paints. */
|
|
640
|
+
function liveInlineDeclares(el: OidElementLike, prop: string): boolean {
|
|
641
|
+
return el.style.getPropertyValue(camelToKebab(prop)) !== '';
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export class ReactRootAuthoringAdapter implements AuthoringAdapter {
|
|
645
|
+
readonly capabilities: AuthoringCapabilities;
|
|
646
|
+
|
|
647
|
+
readonly provenance = JSX_SOURCE_PROVENANCE;
|
|
648
|
+
private readonly writeBackend: SourceWriteBackend | undefined;
|
|
649
|
+
private hierarchySnapshotCache: OidTree | null = null;
|
|
650
|
+
private hierarchySnapshotClearQueued = false;
|
|
651
|
+
private readonly matchedCssRules = (el: unknown): CssRuleTarget[] =>
|
|
652
|
+
getMatchedCssRules(el as Element) as CssRuleTarget[];
|
|
653
|
+
private readonly designTokens: () => DesignToken[];
|
|
654
|
+
private readonly designTokenValue: (name: string) => string | undefined;
|
|
655
|
+
/** OID → {component, tag, …} labels, fetched once (if a backend exists) from the
|
|
656
|
+
* SAME `/__ui-source/index` the OID store already serves — see `index()` on
|
|
657
|
+
* `SourceWriteBackend`. Absent/unresolved ⇒ nodes label from the DOM tag alone
|
|
658
|
+
* (honest degradation, not a second index). */
|
|
659
|
+
private oidIndex: Map<string, OidEntry> = new Map();
|
|
660
|
+
private dirty = false;
|
|
661
|
+
/** Optimistic inspector values, keyed by node and full property path.
|
|
662
|
+
* Writes populate them before awaiting source; refusals clear individual
|
|
663
|
+
* entries, and HMR reconciliation clears the remaining echo. */
|
|
664
|
+
private readonly valueEcho = new Map<string, Map<string, unknown>>();
|
|
665
|
+
/** Session-scoped source guard reasons. A refused dynamic write disables
|
|
666
|
+
* that field for subsequent edits; this does not predict source literality. */
|
|
667
|
+
private readonly guardedPaths = new Map<string, string>();
|
|
668
|
+
/** Session-local pick locks. Canvas click and marquee skip locked nodes,
|
|
669
|
+
* while hierarchy selection remains available for unlocking. */
|
|
670
|
+
private readonly lockedIds = new Set<string>();
|
|
671
|
+
/** Successful structural/text writes awaiting their post-HMR DOM refresh.
|
|
672
|
+
* Each landed update notifies the store and drains one pending reconcile. */
|
|
673
|
+
private pendingSourceReconcile = 0;
|
|
674
|
+
/**
|
|
675
|
+
* The last non-empty selection this adapter's tree served — the input to
|
|
676
|
+
* {@link repairSelectionAfterReprojection}.
|
|
677
|
+
*/
|
|
678
|
+
private lastResolvedSelection: readonly string[] = [];
|
|
679
|
+
/** One repair watcher per re-projection; see {@link scheduleSelectionRepair}. */
|
|
680
|
+
private selectionRepairWatching = false;
|
|
681
|
+
/** Release the Vite HMR or hosted story-registry reconciliation subscription. */
|
|
682
|
+
private readonly disposeReloadSignal: () => void;
|
|
683
|
+
// Rename and delete update this list before discovery republishes it.
|
|
684
|
+
private readonly portableStories: PortableStoryRef[];
|
|
685
|
+
private readonly portableStoryArgs = new Map<string, Record<string, unknown>>();
|
|
686
|
+
private readonly portableHierarchy: StoryPresentationIndex | null;
|
|
687
|
+
private readonly portableDefaultStoryId: string | null;
|
|
688
|
+
private readonly onPortableStoryApplied: ((storyId: string | null) => void) | undefined;
|
|
689
|
+
private readonly onPortableStoryArgsChanged:
|
|
690
|
+
| ((storyId: string, args: Record<string, unknown>) => void)
|
|
691
|
+
| undefined;
|
|
692
|
+
/** The currently applied portable story, or `null` when this root has no
|
|
693
|
+
* portable CSF document. */
|
|
694
|
+
private activeStoryId: string | null = null;
|
|
695
|
+
/** Current box gesture. touched keeps the final value per CSS property;
|
|
696
|
+
* priorInline captures each property before its first live preview. */
|
|
697
|
+
private boxEditSession: {
|
|
698
|
+
id: string;
|
|
699
|
+
touched: Map<string, string | number>;
|
|
700
|
+
priorInline: Map<string, string>;
|
|
701
|
+
/** The one hint this gesture already showed for a move it cannot write. */
|
|
702
|
+
hinted?: boolean;
|
|
703
|
+
} | null = null;
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Serializes {@link commitBoxEdit} runs so two gestures never overlap.
|
|
707
|
+
*
|
|
708
|
+
* `boxEdit.end()` fire-and-forgets an ASYNC commit (it cannot await — the
|
|
709
|
+
* gesture API is synchronous, driven straight from pointer/key handlers), and
|
|
710
|
+
* `editor-hotkeys.ts` drives `apply()`+`end()` on EVERY keypress. Hold an
|
|
711
|
+
* arrow key, or align a multi-selection, and the second commit calls
|
|
712
|
+
* a second source-history gesture while the first is still awaiting its
|
|
713
|
+
* writes. Chaining here keeps adapter preview/echo updates ordered too; the
|
|
714
|
+
* scoped history backend independently serializes each complete callback.
|
|
715
|
+
*/
|
|
716
|
+
private boxEditTail: Promise<void> = Promise.resolve();
|
|
717
|
+
|
|
718
|
+
constructor(
|
|
719
|
+
private readonly root: OidElementLike,
|
|
720
|
+
private readonly store: ShellStore,
|
|
721
|
+
opts: ReactRootAuthoringOptions,
|
|
722
|
+
) {
|
|
723
|
+
this.writeBackend = withProjectSourceHistory(opts.writeBackend, store.projectHistory);
|
|
724
|
+
// Read project custom properties within this root's scoped CSS container.
|
|
725
|
+
const tokenStyle = () => {
|
|
726
|
+
const root = this.root as unknown as Element;
|
|
727
|
+
// Custom properties inherit from the mounted CSS scope below this layer.
|
|
728
|
+
// Read that scope to include the authored token values.
|
|
729
|
+
const el = root.querySelector('[data-volter-mount-styles]') ??
|
|
730
|
+
root.closest('[data-volter-mount-styles]') ?? root;
|
|
731
|
+
return getComputedStyle(el);
|
|
732
|
+
};
|
|
733
|
+
this.designTokens = opts.designTokens ?? (() => getDesignTokens(tokenStyle()));
|
|
734
|
+
// Read one custom property per field so same-turn CSS edits stay visible.
|
|
735
|
+
this.designTokenValue = opts.designTokens
|
|
736
|
+
? (name) => opts.designTokens!().find((token) => token.name === name)?.value
|
|
737
|
+
: (name) => tokenStyle().getPropertyValue(name).trim() || undefined;
|
|
738
|
+
this.portableStories = [...(opts.portableStories ?? [])];
|
|
739
|
+
for (const story of this.portableStories) {
|
|
740
|
+
this.portableStoryArgs.set(story.id, { ...story.args });
|
|
741
|
+
}
|
|
742
|
+
this.portableHierarchy = opts.portableHierarchy ?? null;
|
|
743
|
+
this.portableDefaultStoryId = opts.activePortableStoryId ?? null;
|
|
744
|
+
this.onPortableStoryApplied = opts.onPortableStoryApplied;
|
|
745
|
+
this.onPortableStoryArgsChanged = opts.onPortableStoryArgsChanged;
|
|
746
|
+
this.activeStoryId = this.portableDefaultStoryId;
|
|
747
|
+
this.capabilities = {
|
|
748
|
+
transform: false, // no 3D gizmo — DOM has no Object3D pose
|
|
749
|
+
inspectorFields: true,
|
|
750
|
+
persist: true,
|
|
751
|
+
};
|
|
752
|
+
// Reconcile optimistic values when HMR or the hosted story registry publishes the updated tree.
|
|
753
|
+
const hot = import.meta.hot;
|
|
754
|
+
if (hot) {
|
|
755
|
+
const onAfterUpdate = (): void => this.reconcileEchoAfterReload();
|
|
756
|
+
hot.on('vite:afterUpdate', onAfterUpdate);
|
|
757
|
+
this.disposeReloadSignal = () => hot.off('vite:afterUpdate', onAfterUpdate);
|
|
758
|
+
} else {
|
|
759
|
+
// Hosted writes refresh story modules; registry publication signals the
|
|
760
|
+
// resulting DOM projection for echo and selection reconciliation.
|
|
761
|
+
const unsubscribe = editorHost().stories.subscribe(() => this.reconcileEchoAfterReload());
|
|
762
|
+
this.disposeReloadSignal = unsubscribe;
|
|
763
|
+
}
|
|
764
|
+
if (this.writeBackend) {
|
|
765
|
+
this.writeBackend
|
|
766
|
+
.index()
|
|
767
|
+
.then((idx) => {
|
|
768
|
+
this.oidIndex = new Map(Object.entries(idx));
|
|
769
|
+
this.store.notifyIngestEdit();
|
|
770
|
+
})
|
|
771
|
+
.catch(() => {
|
|
772
|
+
// Honest degradation: labels stay tag-only if the index can't be fetched.
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
private snapshot(): OidTree {
|
|
778
|
+
return walkOidTree(this.root);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* One coherent tree for a synchronous hierarchy consumer. Composite roots
|
|
783
|
+
* walk every child adapter by calling `node(id)` once per row; projecting the
|
|
784
|
+
* complete DOM on every lookup turns that ordinary walk into O(rows²).
|
|
785
|
+
* Inspector and write paths keep using {@link snapshot} directly, so only
|
|
786
|
+
* hierarchy reads share this task-bounded view and every later task sees the
|
|
787
|
+
* current DOM.
|
|
788
|
+
*/
|
|
789
|
+
private hierarchySnapshot(): OidTree {
|
|
790
|
+
this.hierarchySnapshotCache ??= this.snapshot();
|
|
791
|
+
if (!this.hierarchySnapshotClearQueued) {
|
|
792
|
+
this.hierarchySnapshotClearQueued = true;
|
|
793
|
+
queueMicrotask(() => {
|
|
794
|
+
this.hierarchySnapshotCache = null;
|
|
795
|
+
this.hierarchySnapshotClearQueued = false;
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
return this.hierarchySnapshotCache;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private async refreshOidIndex(): Promise<void> {
|
|
802
|
+
const index = await this.writeBackend?.index();
|
|
803
|
+
if (!index) return;
|
|
804
|
+
this.oidIndex = new Map(Object.entries(index));
|
|
805
|
+
this.store.notifyIngestEdit();
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// --- Optimistic inspector values ---
|
|
809
|
+
private setEcho(id: string, path: string, value: unknown): void {
|
|
810
|
+
let byPath = this.valueEcho.get(id);
|
|
811
|
+
if (!byPath) {
|
|
812
|
+
byPath = new Map();
|
|
813
|
+
this.valueEcho.set(id, byPath);
|
|
814
|
+
}
|
|
815
|
+
byPath.set(path, value);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Drop one echoed entry — used the instant a write is refused/no-op'd, so the field
|
|
819
|
+
* reverts to the live-DOM (unchanged) value rather than sticking on a value the source
|
|
820
|
+
* never took (the "stale in the other direction" guard for rejected writes). */
|
|
821
|
+
private clearEcho(id: string, path: string): void {
|
|
822
|
+
const byPath = this.valueEcho.get(id);
|
|
823
|
+
if (!byPath) return;
|
|
824
|
+
byPath.delete(path);
|
|
825
|
+
if (byPath.size === 0) this.valueEcho.delete(id);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Remember the source guard's reason for this node and property path. */
|
|
829
|
+
private markGuarded(id: string, path: string, reason: string): void {
|
|
830
|
+
this.guardedPaths.set(`${id}|${path}`, reason);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Read-through for `inspector.get`: `{hit:true}` when this id+path was optimistically
|
|
834
|
+
* echoed and not yet reconciled; `{hit:false}` otherwise (fall back to the live DOM). A
|
|
835
|
+
* distinct `hit` flag (not a sentinel value) so a legitimately-`undefined` echoed value
|
|
836
|
+
* still wins over the live-DOM walk. */
|
|
837
|
+
private readEcho(id: string, path: string): { hit: boolean; value: unknown } {
|
|
838
|
+
const byPath = this.valueEcho.get(id);
|
|
839
|
+
if (byPath?.has(path)) return { hit: true, value: byPath.get(path) };
|
|
840
|
+
return { hit: false, value: undefined };
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Clear optimistic values after HMR, notify pending structural/text
|
|
844
|
+
* changes, and schedule bounded selection repair for the remounted tree. */
|
|
845
|
+
reconcileEchoAfterReload(): void {
|
|
846
|
+
// BEFORE the early return: a re-projection triggered by a source edit this
|
|
847
|
+
// adapter did NOT make (a hand edit, another lane's write) has neither an
|
|
848
|
+
// echo nor a pending reconcile, and it drops the selection exactly the
|
|
849
|
+
// same way.
|
|
850
|
+
this.scheduleSelectionRepair();
|
|
851
|
+
if (this.valueEcho.size === 0 && this.pendingSourceReconcile === 0) return;
|
|
852
|
+
this.valueEcho.clear();
|
|
853
|
+
if (this.pendingSourceReconcile > 0) this.pendingSourceReconcile--;
|
|
854
|
+
this.store.notifyIngestEdit();
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Watch the remounted tree for lost selection during a bounded interval.
|
|
858
|
+
* Restore only IDs that still resolve and report once per watch. */
|
|
859
|
+
private scheduleSelectionRepair(): void {
|
|
860
|
+
if (this.selectionRepairWatching) return;
|
|
861
|
+
if (this.lastResolvedSelection.length === 0) return;
|
|
862
|
+
this.selectionRepairWatching = true;
|
|
863
|
+
let attempts = 0;
|
|
864
|
+
let reported = false;
|
|
865
|
+
const tick = (): void => {
|
|
866
|
+
attempts++;
|
|
867
|
+
// Cover every update in the window; repeated repairs share one report.
|
|
868
|
+
reported = this.repairSelectionAfterReprojection(reported) || reported;
|
|
869
|
+
if (attempts >= SELECTION_REPAIR_ATTEMPTS) {
|
|
870
|
+
this.selectionRepairWatching = false;
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
setTimeout(tick, SELECTION_REPAIR_INTERVAL_MS);
|
|
874
|
+
};
|
|
875
|
+
setTimeout(tick, SELECTION_REPAIR_INTERVAL_MS);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* One repair attempt. Returns whether it SAID something, so the watcher can
|
|
880
|
+
* report once and restore as many times as the re-projection needs.
|
|
881
|
+
*
|
|
882
|
+
* The remembered set is NOT consumed: `selection.get` overwrites it with
|
|
883
|
+
* every non-empty selection it serves, so it always names the last selection
|
|
884
|
+
* the author actually had, and a second drop inside the same window is
|
|
885
|
+
* repaired from the same truth.
|
|
886
|
+
*/
|
|
887
|
+
private repairSelectionAfterReprojection(alreadyReported: boolean): boolean {
|
|
888
|
+
const remembered = this.lastResolvedSelection;
|
|
889
|
+
if (remembered.length === 0) return false;
|
|
890
|
+
if (this.store.selectedEntityIds.size > 0) return false; // not lost (yet)
|
|
891
|
+
const tree = this.snapshot();
|
|
892
|
+
const alive = remembered.filter((id) => tree.nodes.has(id));
|
|
893
|
+
if (alive.length === 0) {
|
|
894
|
+
if (!alreadyReported) {
|
|
895
|
+
console.warn(
|
|
896
|
+
'[ReactRootAuthoringAdapter] the re-projection dropped the selection ' +
|
|
897
|
+
`(${remembered.join(', ')}) and none of those ids resolve in the remounted tree, so ` +
|
|
898
|
+
'it cannot be restored. An inspector write issued now has no subject — re-select.',
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
this.lastResolvedSelection = [];
|
|
902
|
+
return true;
|
|
903
|
+
}
|
|
904
|
+
if (!alreadyReported && !reportedSelectionRepair) {
|
|
905
|
+
reportedSelectionRepair = true;
|
|
906
|
+
console.warn(
|
|
907
|
+
'[ReactRootAuthoringAdapter] OPEN DEFECT, repaired: an HMR re-projection drops this ' +
|
|
908
|
+
"root's selection, and the editor is putting it back — restoring " +
|
|
909
|
+
`${alive.length} of ${remembered.length} id(s) that still resolve in the remounted ` +
|
|
910
|
+
`tree (${alive.join(', ')}). A write issued in that window has no subject. Named once ` +
|
|
911
|
+
'per session; the repair runs on every re-projection.',
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
this.store.selectMultiple([...alive]);
|
|
915
|
+
return true;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** Release the HMR or story-registry subscription. Safe to call repeatedly. */
|
|
919
|
+
disposeReactRootAdapter(): void {
|
|
920
|
+
this.disposeReloadSignal();
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
private componentName(n: OidNode): string | null {
|
|
924
|
+
const entry = this.oidIndex.get(n.oid);
|
|
925
|
+
return entry?.component ?? getReactComponentName(n.el) ?? null;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
private elementLabel(n: OidNode, tree: OidTree): string {
|
|
929
|
+
// Preserve the explicit escape hatch, but make ordinary DOM semantics
|
|
930
|
+
// sufficient by default. Authored React should not need component splits
|
|
931
|
+
// or `data-volter-name` merely to produce a legible hierarchy.
|
|
932
|
+
const editorLabel = normalizeElementText(n.el.getAttribute('data-volter-name'));
|
|
933
|
+
if (editorLabel) return editorLabel;
|
|
934
|
+
|
|
935
|
+
const labelledBy = n.el.getAttribute('aria-labelledby');
|
|
936
|
+
if (labelledBy) {
|
|
937
|
+
const labels = labelledBy
|
|
938
|
+
.split(/\s+/)
|
|
939
|
+
.map((id) =>
|
|
940
|
+
Array.from(tree.nodes.values()).find(
|
|
941
|
+
(candidate) => candidate.el.getAttribute('id') === id,
|
|
942
|
+
),
|
|
943
|
+
)
|
|
944
|
+
.map((candidate) => normalizeElementText(candidate?.el.textContent))
|
|
945
|
+
.filter((label): label is string => label !== null);
|
|
946
|
+
const combinedLabel = normalizeElementText(labels.join(' '));
|
|
947
|
+
if (combinedLabel) return combinedLabel;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const accessibleLabel = normalizeElementText(n.el.getAttribute('aria-label'));
|
|
951
|
+
if (accessibleLabel) return accessibleLabel;
|
|
952
|
+
|
|
953
|
+
// A semantic section without explicit ARIA normally takes its identity
|
|
954
|
+
// from its heading. Restrict this to direct children so a large nested
|
|
955
|
+
// subtree cannot accidentally lend an unrelated heading to its parent.
|
|
956
|
+
if (['section', 'article', 'aside', 'nav', 'main', 'header', 'footer'].includes(n.tag)) {
|
|
957
|
+
const heading = n.childIds
|
|
958
|
+
.map((id) => tree.nodes.get(id))
|
|
959
|
+
.find((child) => child !== undefined && /^h[1-6]$/.test(child.tag));
|
|
960
|
+
const headingText = normalizeElementText(heading?.el.textContent);
|
|
961
|
+
if (headingText) return headingText;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Text-bearing HTML elements are already named by their content in the
|
|
965
|
+
// browser/accessibility model. Use the same identity, with a short cap so
|
|
966
|
+
// a paragraph cannot turn into an unbounded hierarchy row.
|
|
967
|
+
if (
|
|
968
|
+
/^(h[1-6]|p|span|label|button|a|time|output|dt|dd|li|legend|caption|summary)$/.test(n.tag)
|
|
969
|
+
) {
|
|
970
|
+
const text = normalizeElementText(n.el.textContent);
|
|
971
|
+
if (text) return text;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
for (const attribute of ['alt', 'title', 'placeholder', 'name']) {
|
|
975
|
+
const value = normalizeElementText(n.el.getAttribute(attribute));
|
|
976
|
+
if (value) return value;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const id = n.el.getAttribute('id');
|
|
980
|
+
if (id) return `#${id}`;
|
|
981
|
+
// Use the first CSS class to distinguish anonymous elements sharing a tag.
|
|
982
|
+
const firstClass = (n.el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean)[0];
|
|
983
|
+
if (firstClass) return `${n.tag}.${firstClass}`;
|
|
984
|
+
return n.tag;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
private toEditorNode(n: OidNode, tree: OidTree): EditorNode {
|
|
988
|
+
// A component name marks only the boundary where that component enters the
|
|
989
|
+
// native tree. Descendants owned by the same component use their own DOM
|
|
990
|
+
// identity, so a component name is not repeated on every row.
|
|
991
|
+
const component = this.componentName(n);
|
|
992
|
+
const parent = n.parentId ? tree.nodes.get(n.parentId) : null;
|
|
993
|
+
const parentComponent = parent ? this.componentName(parent) : null;
|
|
994
|
+
const isComponentBoundary = component !== null && component !== parentComponent;
|
|
995
|
+
const crossSurfaceId = n.el.getAttribute('data-volter-hierarchy-id');
|
|
996
|
+
const crossSurfaceParentId = n.el.getAttribute('data-volter-hierarchy-parent-id');
|
|
997
|
+
const crossSurfaceOrderAttribute = n.el.getAttribute('data-volter-hierarchy-order');
|
|
998
|
+
const crossSurfaceGroupLabel = n.el.getAttribute('data-volter-hierarchy-group-label');
|
|
999
|
+
const crossSurfaceOrder =
|
|
1000
|
+
crossSurfaceOrderAttribute === null ? undefined : Number(crossSurfaceOrderAttribute);
|
|
1001
|
+
const node: EditorNode = {
|
|
1002
|
+
id: n.id,
|
|
1003
|
+
label: isComponentBoundary ? component : this.elementLabel(n, tree),
|
|
1004
|
+
...(isComponentBoundary ? { secondaryLabel: `<${n.tag}>` } : {}),
|
|
1005
|
+
role: isComponentBoundary ? 'component' : 'element',
|
|
1006
|
+
kind: n.tag,
|
|
1007
|
+
parentId: n.parentId,
|
|
1008
|
+
childIds: n.childIds,
|
|
1009
|
+
...(crossSurfaceId === null ? {} : { crossSurfaceId }),
|
|
1010
|
+
...(crossSurfaceParentId === null ? {} : { crossSurfaceParentId }),
|
|
1011
|
+
...(crossSurfaceOrder === undefined || !Number.isInteger(crossSurfaceOrder)
|
|
1012
|
+
? {}
|
|
1013
|
+
: { crossSurfaceOrder }),
|
|
1014
|
+
...(crossSurfaceGroupLabel === null ? {} : { crossSurfaceGroupLabel }),
|
|
1015
|
+
};
|
|
1016
|
+
if (this.portableStories.length > 0 && node.parentId === null) {
|
|
1017
|
+
return { ...node, parentId: this.activeStoryId };
|
|
1018
|
+
}
|
|
1019
|
+
return node;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** Exact DOM-native input signature for the composite's semantic join.
|
|
1023
|
+
* Shell notifications unrelated to this root leave it unchanged. The
|
|
1024
|
+
* nearest semantic ancestor and traversal order are included because
|
|
1025
|
+
* transparent DOM wrappers fold between explicitly identified rows. */
|
|
1026
|
+
private crossSurfaceStructureSignature(): string | null {
|
|
1027
|
+
const tree = this.hierarchySnapshot();
|
|
1028
|
+
const semanticIds = new Set<string>();
|
|
1029
|
+
for (const node of tree.nodes.values()) {
|
|
1030
|
+
if (
|
|
1031
|
+
node.el.getAttribute('data-volter-hierarchy-id') !== null ||
|
|
1032
|
+
node.el.getAttribute('data-volter-hierarchy-parent-id') !== null ||
|
|
1033
|
+
node.el.getAttribute('data-volter-hierarchy-order') !== null ||
|
|
1034
|
+
node.el.getAttribute('data-volter-hierarchy-group-label') !== null
|
|
1035
|
+
) {
|
|
1036
|
+
semanticIds.add(node.id);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
if (semanticIds.size === 0) return null;
|
|
1040
|
+
return JSON.stringify(
|
|
1041
|
+
[...tree.nodes.values()].flatMap((node) => {
|
|
1042
|
+
if (!semanticIds.has(node.id)) return [];
|
|
1043
|
+
let parentId = node.parentId;
|
|
1044
|
+
while (parentId !== null && !semanticIds.has(parentId)) {
|
|
1045
|
+
parentId = tree.nodes.get(parentId)?.parentId ?? null;
|
|
1046
|
+
}
|
|
1047
|
+
return [
|
|
1048
|
+
[
|
|
1049
|
+
node.id,
|
|
1050
|
+
parentId,
|
|
1051
|
+
node.el.getAttribute('data-volter-hierarchy-id'),
|
|
1052
|
+
node.el.getAttribute('data-volter-hierarchy-parent-id'),
|
|
1053
|
+
node.el.getAttribute('data-volter-hierarchy-order'),
|
|
1054
|
+
node.el.getAttribute('data-volter-hierarchy-group-label'),
|
|
1055
|
+
],
|
|
1056
|
+
];
|
|
1057
|
+
}),
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
private portableHierarchyNode(id: string) {
|
|
1062
|
+
return this.portableHierarchy?.nodes.find((node) => node.id === id) ?? null;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
private portableStoriesForNode(nodeId: string): StoryRef[] {
|
|
1066
|
+
// Story application remounts the whole root; the world scope lists all stories.
|
|
1067
|
+
if (nodeId === WORLD_SCOPE_NODE_ID) return [...this.portableStories];
|
|
1068
|
+
const hierarchy = this.portableHierarchy!;
|
|
1069
|
+
const hierarchyNode = this.portableHierarchyNode(nodeId);
|
|
1070
|
+
if (hierarchyNode && hierarchyNode.role !== 'component') return [];
|
|
1071
|
+
const componentId =
|
|
1072
|
+
hierarchyNode?.role === 'component'
|
|
1073
|
+
? hierarchyNode.id
|
|
1074
|
+
: (hierarchy.storyParentIds.get(nodeId) ??
|
|
1075
|
+
hierarchy.storyParentIds.get(this.activeStoryId ?? ''));
|
|
1076
|
+
if (!componentId) return [];
|
|
1077
|
+
const component = this.portableHierarchyNode(componentId);
|
|
1078
|
+
const storyIds = new Set(component?.childIds ?? []);
|
|
1079
|
+
return this.portableStories.filter((story) => storyIds.has(story.id));
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
private portableStoryNode(story: StoryRef, rootIds: string[]): EditorNode {
|
|
1083
|
+
const active = story.id === this.activeStoryId;
|
|
1084
|
+
return {
|
|
1085
|
+
id: story.id,
|
|
1086
|
+
label: story.label,
|
|
1087
|
+
role: 'story',
|
|
1088
|
+
kind: 'story',
|
|
1089
|
+
parentId: this.portableHierarchy!.storyParentIds.get(story.id) ?? null,
|
|
1090
|
+
childIds: active ? rootIds : [],
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
readonly hierarchy: HierarchyProvider = {
|
|
1095
|
+
crossSurfaceStructureSignature: () => this.crossSurfaceStructureSignature(),
|
|
1096
|
+
roots: () => {
|
|
1097
|
+
const tree = this.hierarchySnapshot();
|
|
1098
|
+
const { nodes, rootIds } = tree;
|
|
1099
|
+
if (this.portableStories.length > 0) {
|
|
1100
|
+
return this.portableHierarchy!.rootIds.flatMap((id) => {
|
|
1101
|
+
const node = this.portableHierarchy!.nodes.find((candidate) => candidate.id === id);
|
|
1102
|
+
return node ? [{ ...node, childIds: [...node.childIds] }] : [];
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
return rootIds.map((id) => this.toEditorNode(nodes.get(id)!, tree));
|
|
1106
|
+
},
|
|
1107
|
+
node: (id) => {
|
|
1108
|
+
if (this.portableStories.length > 0) {
|
|
1109
|
+
const hierarchyNode = this.portableHierarchy?.nodes.find(
|
|
1110
|
+
(candidate) => candidate.id === id,
|
|
1111
|
+
);
|
|
1112
|
+
if (hierarchyNode) return { ...hierarchyNode, childIds: [...hierarchyNode.childIds] };
|
|
1113
|
+
const story = this.portableStories.find((candidate) => candidate.id === id);
|
|
1114
|
+
if (story) return this.portableStoryNode(story, this.hierarchySnapshot().rootIds);
|
|
1115
|
+
}
|
|
1116
|
+
const tree = this.hierarchySnapshot();
|
|
1117
|
+
const n = tree.nodes.get(id);
|
|
1118
|
+
return n ? this.toEditorNode(n, tree) : null;
|
|
1119
|
+
},
|
|
1120
|
+
// No `nativeObject`/`idForNativeObject`: react entities are DOM, not Object3D —
|
|
1121
|
+
// the concept does not apply here (no 3D gizmo binding).
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
readonly selection: SelectionProvider = {
|
|
1125
|
+
get: () => {
|
|
1126
|
+
const ids = [...this.store.selectedEntityIds];
|
|
1127
|
+
// Remember the last NON-EMPTY selection so a re-projection that drops it
|
|
1128
|
+
// can be repaired — see `repairSelectionAfterReprojection`. Recorded on
|
|
1129
|
+
// the read because that is the one call every projection of the
|
|
1130
|
+
// selection already makes; the adapter has no store subscription of its
|
|
1131
|
+
// own and adding one to observe a value it is handed anyway would be a
|
|
1132
|
+
// second source of the same fact.
|
|
1133
|
+
if (ids.length > 0) this.lastResolvedSelection = ids;
|
|
1134
|
+
return ids;
|
|
1135
|
+
},
|
|
1136
|
+
set: (ids) => this.store.selectMultiple(ids),
|
|
1137
|
+
resolve: (rawId, options) => {
|
|
1138
|
+
const raw = this.hierarchy.node(rawId);
|
|
1139
|
+
if (!raw) return null;
|
|
1140
|
+
|
|
1141
|
+
const innerToOuter: EditorNode[] = [];
|
|
1142
|
+
let cursor: EditorNode | null = raw;
|
|
1143
|
+
let guard = 0;
|
|
1144
|
+
while (cursor && guard++ < 1000) {
|
|
1145
|
+
if (
|
|
1146
|
+
cursor.role === 'component' ||
|
|
1147
|
+
cursor.role === 'instance' ||
|
|
1148
|
+
cursor.role === 'boundary'
|
|
1149
|
+
) {
|
|
1150
|
+
innerToOuter.push(cursor);
|
|
1151
|
+
}
|
|
1152
|
+
cursor = cursor.parentId ? this.hierarchy.node(cursor.parentId) : null;
|
|
1153
|
+
}
|
|
1154
|
+
const chain = innerToOuter.reverse();
|
|
1155
|
+
if (chain.length === 0) {
|
|
1156
|
+
return { id: rawId };
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const scopeIndex = options?.scopeId
|
|
1160
|
+
? chain.findIndex((candidate) => candidate.id === options.scopeId)
|
|
1161
|
+
: -1;
|
|
1162
|
+
let resolvedIndex = scopeIndex >= 0 ? scopeIndex + 1 : 0;
|
|
1163
|
+
if (options?.intent === 'deep') resolvedIndex += 1;
|
|
1164
|
+
|
|
1165
|
+
// Unlike the R3F projection, React keeps authored host elements in the
|
|
1166
|
+
// hierarchy. Once every component boundary on the route is open, the
|
|
1167
|
+
// exact DOM element becomes the honest selection subject.
|
|
1168
|
+
const subject = chain[resolvedIndex] ?? raw;
|
|
1169
|
+
return { id: subject.id };
|
|
1170
|
+
},
|
|
1171
|
+
};
|
|
1172
|
+
|
|
1173
|
+
readonly truth: TruthProvider = {
|
|
1174
|
+
resolve: (id): { site: NodeCreationSite; writeAnchorKind: WriteAnchorKind | undefined } => {
|
|
1175
|
+
const node = this.snapshot().nodes.get(id);
|
|
1176
|
+
if (!node) {
|
|
1177
|
+
return {
|
|
1178
|
+
site: { anchored: false, reason: 'This row is a design document, not a JSX element.' },
|
|
1179
|
+
writeAnchorKind: undefined,
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
const entry = this.oidIndex.get(node.oid);
|
|
1183
|
+
const writeAnchorKind = this.writeBackend ? 'source-prop' : 'live-only';
|
|
1184
|
+
if (!entry) {
|
|
1185
|
+
return {
|
|
1186
|
+
site: { anchored: false, reason: 'Source metadata is still loading or unavailable.' },
|
|
1187
|
+
writeAnchorKind,
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
const file = projectSourcePath(entry.file);
|
|
1191
|
+
return {
|
|
1192
|
+
site: {
|
|
1193
|
+
anchored: true,
|
|
1194
|
+
kind: 'source',
|
|
1195
|
+
file,
|
|
1196
|
+
line: entry.line,
|
|
1197
|
+
col: entry.col,
|
|
1198
|
+
display: `${file}:${entry.line}`,
|
|
1199
|
+
},
|
|
1200
|
+
writeAnchorKind,
|
|
1201
|
+
};
|
|
1202
|
+
},
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
readonly related: RelatedSubjectsProvider = {
|
|
1206
|
+
links: (id) => {
|
|
1207
|
+
const node = this.hierarchy.node(id);
|
|
1208
|
+
if (node?.role !== 'component' || this.stories.storiesFor(id).length === 0) return [];
|
|
1209
|
+
return [
|
|
1210
|
+
{
|
|
1211
|
+
// `definition:` marks this as the subject's OPEN-FOR-EDIT target (the
|
|
1212
|
+
// component's own UI Components document), so the inspector surfaces it
|
|
1213
|
+
// as the labeled Edit button by kind, never by list position.
|
|
1214
|
+
id: `definition:${UI_COMPONENTS_DOCUMENT_ID}`,
|
|
1215
|
+
title: 'Open in UI Components',
|
|
1216
|
+
open: () => {
|
|
1217
|
+
void activateWorkspaceDocument(UI_COMPONENTS_DOCUMENT_ID);
|
|
1218
|
+
},
|
|
1219
|
+
},
|
|
1220
|
+
];
|
|
1221
|
+
},
|
|
1222
|
+
};
|
|
1223
|
+
|
|
1224
|
+
private instanceSource(id: string): {
|
|
1225
|
+
node: OidNode;
|
|
1226
|
+
callSiteOid: string;
|
|
1227
|
+
callSite: OidEntry;
|
|
1228
|
+
props: Record<string, string>;
|
|
1229
|
+
} | null {
|
|
1230
|
+
const node = this.snapshot().nodes.get(id);
|
|
1231
|
+
if (!node || this.hierarchy.node(id)?.role !== 'component') return null;
|
|
1232
|
+
const component = getComponentProps(node.el);
|
|
1233
|
+
if (!component) return null;
|
|
1234
|
+
const callSite = this.oidIndex.get(component.callSiteOid);
|
|
1235
|
+
if (!callSite || !/^[A-Z]/.test(callSite.tag)) return null;
|
|
1236
|
+
return {
|
|
1237
|
+
node,
|
|
1238
|
+
callSiteOid: component.callSiteOid,
|
|
1239
|
+
callSite,
|
|
1240
|
+
props: component.props,
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
private instanceProp(
|
|
1245
|
+
id: string,
|
|
1246
|
+
path: string,
|
|
1247
|
+
): {
|
|
1248
|
+
source: NonNullable<ReturnType<ReactRootAuthoringAdapter['instanceSource']>>;
|
|
1249
|
+
prop: string;
|
|
1250
|
+
spec: ComponentPropSpec;
|
|
1251
|
+
authored: NonNullable<OidEntry['authoredProps']>[number];
|
|
1252
|
+
} | null {
|
|
1253
|
+
if (!path.startsWith(PROP_PATH_PREFIX)) return null;
|
|
1254
|
+
const source = this.instanceSource(id);
|
|
1255
|
+
if (!source) return null;
|
|
1256
|
+
const prop = path.slice(PROP_PATH_PREFIX.length);
|
|
1257
|
+
const spec = source.callSite.props?.find((candidate) => candidate.name === prop);
|
|
1258
|
+
const authored = source.callSite.authoredProps?.find((candidate) => candidate.name === prop);
|
|
1259
|
+
return spec && authored ? { source, prop, spec, authored } : null;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
private affectedInstances(id: string, prop: string): number {
|
|
1263
|
+
const selected = this.instanceSource(id);
|
|
1264
|
+
if (!selected) return 0;
|
|
1265
|
+
let count = 0;
|
|
1266
|
+
for (const candidate of this.snapshot().nodes.values()) {
|
|
1267
|
+
const component = getComponentProps(candidate.el);
|
|
1268
|
+
if (!component) continue;
|
|
1269
|
+
const entry = this.oidIndex.get(component.callSiteOid);
|
|
1270
|
+
if (!entry || entry.tag !== selected.callSite.tag) continue;
|
|
1271
|
+
if (candidate.id === id || !entry.authoredProps?.some((item) => item.name === prop)) count++;
|
|
1272
|
+
}
|
|
1273
|
+
return Math.max(1, count);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
readonly instances: ComponentInstancesProvider = {
|
|
1277
|
+
openComponent: () => {
|
|
1278
|
+
void activateWorkspaceDocument(UI_COMPONENTS_DOCUMENT_ID);
|
|
1279
|
+
},
|
|
1280
|
+
describe: (id) => {
|
|
1281
|
+
const source = this.instanceSource(id);
|
|
1282
|
+
if (!source || this.stories.storiesFor(id).length === 0) return null;
|
|
1283
|
+
const overrides = Object.entries(source.props).flatMap(([prop, value]) => {
|
|
1284
|
+
const path = `${PROP_PATH_PREFIX}${prop}`;
|
|
1285
|
+
const detail = this.instanceProp(id, path);
|
|
1286
|
+
if (!detail) return [];
|
|
1287
|
+
const canApplyToComponent =
|
|
1288
|
+
detail.authored.literal &&
|
|
1289
|
+
detail.spec.defaultValue !== undefined &&
|
|
1290
|
+
source.node.oid !== source.callSiteOid &&
|
|
1291
|
+
!!this.writeBackend?.runGesture &&
|
|
1292
|
+
!!this.writeBackend.writeComponentDefault;
|
|
1293
|
+
return [
|
|
1294
|
+
{
|
|
1295
|
+
path,
|
|
1296
|
+
label: prop,
|
|
1297
|
+
value,
|
|
1298
|
+
...(detail.spec.defaultText === undefined
|
|
1299
|
+
? {}
|
|
1300
|
+
: { defaultText: detail.spec.defaultText }),
|
|
1301
|
+
canApplyToComponent,
|
|
1302
|
+
affectedInstanceCount: this.affectedInstances(id, prop),
|
|
1303
|
+
...(canApplyToComponent
|
|
1304
|
+
? {}
|
|
1305
|
+
: {
|
|
1306
|
+
applyUnavailableReason: !detail.authored.literal
|
|
1307
|
+
? 'The callsite value is a dynamic expression, so it cannot become a component literal.'
|
|
1308
|
+
: detail.spec.defaultValue === undefined
|
|
1309
|
+
? 'The component default is computed or absent, so source cannot be changed safely.'
|
|
1310
|
+
: 'This session cannot atomically update the component and its callsite.',
|
|
1311
|
+
}),
|
|
1312
|
+
},
|
|
1313
|
+
];
|
|
1314
|
+
});
|
|
1315
|
+
return {
|
|
1316
|
+
componentName: source.callSite.tag,
|
|
1317
|
+
sourcePath: projectSourcePath(source.callSite.file),
|
|
1318
|
+
overrides,
|
|
1319
|
+
};
|
|
1320
|
+
},
|
|
1321
|
+
revert: async (id, paths) => {
|
|
1322
|
+
const source = this.instanceSource(id);
|
|
1323
|
+
const backend = this.writeBackend;
|
|
1324
|
+
if (!source || !backend) return;
|
|
1325
|
+
const props = paths
|
|
1326
|
+
.map((path) => this.instanceProp(id, path)?.prop)
|
|
1327
|
+
.filter((prop): prop is string => !!prop);
|
|
1328
|
+
let changed = false;
|
|
1329
|
+
const remove = async (selected: SourceWriteBackend): Promise<void> => {
|
|
1330
|
+
for (const prop of props) {
|
|
1331
|
+
const result = await selected.removeProp(source.callSiteOid, prop);
|
|
1332
|
+
changed ||= result.changed;
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1335
|
+
if (props.length > 1 && backend.runGesture) {
|
|
1336
|
+
await backend.runGesture(`Revert ${props.length} Overrides`, remove);
|
|
1337
|
+
} else {
|
|
1338
|
+
await remove(backend);
|
|
1339
|
+
}
|
|
1340
|
+
if (!changed) return;
|
|
1341
|
+
this.dirty = true;
|
|
1342
|
+
this.pendingSourceReconcile++;
|
|
1343
|
+
await this.refreshOidIndex();
|
|
1344
|
+
return { destination: JSX_SOURCE_DESTINATION, persisted: true };
|
|
1345
|
+
},
|
|
1346
|
+
applyToComponent: async (id, path): Promise<ComponentInstanceApplyResult> => {
|
|
1347
|
+
const detail = this.instanceProp(id, path);
|
|
1348
|
+
const backend = this.writeBackend;
|
|
1349
|
+
if (
|
|
1350
|
+
!detail ||
|
|
1351
|
+
!detail.authored.literal ||
|
|
1352
|
+
detail.spec.defaultValue === undefined ||
|
|
1353
|
+
detail.source.node.oid === detail.source.callSiteOid ||
|
|
1354
|
+
!backend?.runGesture ||
|
|
1355
|
+
!backend.writeComponentDefault
|
|
1356
|
+
) {
|
|
1357
|
+
return {
|
|
1358
|
+
changed: false,
|
|
1359
|
+
message: 'Apply was refused because both literal source writes are not available.',
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
const value = detail.source.props[detail.prop];
|
|
1363
|
+
await backend.runGesture(
|
|
1364
|
+
`Apply ${detail.prop} to ${detail.source.callSite.tag}`,
|
|
1365
|
+
async (scoped) => {
|
|
1366
|
+
const applied = await scoped.writeComponentDefault?.(
|
|
1367
|
+
detail.source.node.oid,
|
|
1368
|
+
detail.prop,
|
|
1369
|
+
serializedComponentValue(value, detail.spec),
|
|
1370
|
+
);
|
|
1371
|
+
if (!applied?.changed) {
|
|
1372
|
+
throw new Error(applied?.error ?? 'The component default did not change.');
|
|
1373
|
+
}
|
|
1374
|
+
const reverted = await scoped.removeProp(detail.source.callSiteOid, detail.prop);
|
|
1375
|
+
if (!reverted.changed) {
|
|
1376
|
+
throw new Error(reverted.error ?? 'The callsite override was not removed.');
|
|
1377
|
+
}
|
|
1378
|
+
},
|
|
1379
|
+
);
|
|
1380
|
+
this.dirty = true;
|
|
1381
|
+
this.pendingSourceReconcile++;
|
|
1382
|
+
await this.refreshOidIndex();
|
|
1383
|
+
return {
|
|
1384
|
+
changed: true,
|
|
1385
|
+
message: `${detail.prop} now defaults to ${String(value)} in ${detail.source.callSite.tag}.`,
|
|
1386
|
+
write: { destination: JSX_SOURCE_DESTINATION, persisted: true },
|
|
1387
|
+
};
|
|
1388
|
+
},
|
|
1389
|
+
};
|
|
1390
|
+
|
|
1391
|
+
/** Pick the smallest unlocked OID element containing the point, breaking
|
|
1392
|
+
* ties by later tree order. Ignore inline pointer-events:none; the host
|
|
1393
|
+
* wrapper's computed pointer policy does not hide authorable descendants. */
|
|
1394
|
+
readonly pickable: PickProvider = {
|
|
1395
|
+
pick: (clientX, clientY) => {
|
|
1396
|
+
const { nodes } = this.snapshot();
|
|
1397
|
+
let bestId: string | null = null;
|
|
1398
|
+
let bestArea = Number.POSITIVE_INFINITY;
|
|
1399
|
+
let bestOrder = -1;
|
|
1400
|
+
let order = -1;
|
|
1401
|
+
for (const node of nodes.values()) {
|
|
1402
|
+
order++;
|
|
1403
|
+
// Locked nodes do not compete for canvas selection.
|
|
1404
|
+
if (this.lockedIds.has(node.id)) continue;
|
|
1405
|
+
if (styleProp(node.el.style, 'pointerEvents') === 'none') continue;
|
|
1406
|
+
const rect = node.el.getBoundingClientRect();
|
|
1407
|
+
if (
|
|
1408
|
+
clientX < rect.left ||
|
|
1409
|
+
clientX > rect.right ||
|
|
1410
|
+
clientY < rect.top ||
|
|
1411
|
+
clientY > rect.bottom
|
|
1412
|
+
) {
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
const area = rect.width * rect.height;
|
|
1416
|
+
// smallest-area (deepest) wins; ties broken by later tree-order (topmost)
|
|
1417
|
+
if (area < bestArea || (area === bestArea && order > bestOrder)) {
|
|
1418
|
+
bestId = node.id;
|
|
1419
|
+
bestArea = area;
|
|
1420
|
+
bestOrder = order;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
return bestId;
|
|
1424
|
+
},
|
|
1425
|
+
};
|
|
1426
|
+
|
|
1427
|
+
/** Mounted layer origin used to express overlay rectangles in host coordinates. */
|
|
1428
|
+
private hostRect(): { left: number; top: number } {
|
|
1429
|
+
const r = this.root.getBoundingClientRect();
|
|
1430
|
+
return { left: r.left, top: r.top };
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
private toHostRelative(r: {
|
|
1434
|
+
left: number;
|
|
1435
|
+
top: number;
|
|
1436
|
+
width: number;
|
|
1437
|
+
height: number;
|
|
1438
|
+
}): DOMRectLike {
|
|
1439
|
+
const host = this.hostRect();
|
|
1440
|
+
const zoom = getRootPan().zoom;
|
|
1441
|
+
return {
|
|
1442
|
+
x: (r.left - host.left) / zoom,
|
|
1443
|
+
y: (r.top - host.top) / zoom,
|
|
1444
|
+
width: r.width / zoom,
|
|
1445
|
+
height: r.height / zoom,
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/** Host-relative node geometry for overlays, snapping, and marquee selection. */
|
|
1450
|
+
readonly rects: RectProvider = {
|
|
1451
|
+
rect: (id) => {
|
|
1452
|
+
const n = this.snapshot().nodes.get(id);
|
|
1453
|
+
const r = n?.el.getBoundingClientRect();
|
|
1454
|
+
return r ? this.toHostRelative(r) : null;
|
|
1455
|
+
},
|
|
1456
|
+
contextRects: (id) => {
|
|
1457
|
+
const { nodes } = this.snapshot();
|
|
1458
|
+
const n = nodes.get(id);
|
|
1459
|
+
if (!n) return {};
|
|
1460
|
+
const parentNode = n.parentId ? nodes.get(n.parentId) : undefined;
|
|
1461
|
+
const parentRect = parentNode?.el.getBoundingClientRect();
|
|
1462
|
+
const siblingIds = (parentNode ? parentNode.childIds : this.snapshot().rootIds).filter(
|
|
1463
|
+
(sid) => sid !== id,
|
|
1464
|
+
);
|
|
1465
|
+
const siblings = siblingIds
|
|
1466
|
+
.map((sid) => nodes.get(sid)?.el.getBoundingClientRect())
|
|
1467
|
+
.filter((r): r is NonNullable<typeof r> => r != null)
|
|
1468
|
+
.map((r) => this.toHostRelative(r));
|
|
1469
|
+
// Padding box (CSS box model, inside the border) — border widths read off
|
|
1470
|
+
// the same computed styles the inspector uses; absent/unparsable
|
|
1471
|
+
// border widths degrade to 0 (padding box === border box), never thrown.
|
|
1472
|
+
const el = n.el;
|
|
1473
|
+
const rect = el.getBoundingClientRect();
|
|
1474
|
+
const bt =
|
|
1475
|
+
numericStyleValue(getComputedStyleValue(el, 'borderTopWidth')) ?? 0;
|
|
1476
|
+
const br =
|
|
1477
|
+
numericStyleValue(getComputedStyleValue(el, 'borderRightWidth')) ?? 0;
|
|
1478
|
+
const bb =
|
|
1479
|
+
numericStyleValue(getComputedStyleValue(el, 'borderBottomWidth')) ??
|
|
1480
|
+
0;
|
|
1481
|
+
const bl =
|
|
1482
|
+
numericStyleValue(getComputedStyleValue(el, 'borderLeftWidth')) ?? 0;
|
|
1483
|
+
const paddingBox = this.toHostRelative({
|
|
1484
|
+
left: rect.left + bl,
|
|
1485
|
+
top: rect.top + bt,
|
|
1486
|
+
width: Math.max(0, rect.width - bl - br),
|
|
1487
|
+
height: Math.max(0, rect.height - bt - bb),
|
|
1488
|
+
});
|
|
1489
|
+
// Persistent board guides (board-guides.ts), converted from client
|
|
1490
|
+
// space into the same host-relative units as every rect above.
|
|
1491
|
+
const guideClients = editorHost().documents.boardGuideEdges();
|
|
1492
|
+
const host = this.hostRect();
|
|
1493
|
+
const zoom = getRootPan().zoom;
|
|
1494
|
+
const guideEdges =
|
|
1495
|
+
guideClients.x.length > 0 || guideClients.y.length > 0
|
|
1496
|
+
? {
|
|
1497
|
+
x: guideClients.x.map((cx) => (cx - host.left) / zoom),
|
|
1498
|
+
y: guideClients.y.map((cy) => (cy - host.top) / zoom),
|
|
1499
|
+
}
|
|
1500
|
+
: undefined;
|
|
1501
|
+
return {
|
|
1502
|
+
...(parentRect ? { parent: this.toHostRelative(parentRect) } : {}),
|
|
1503
|
+
...(siblings.length ? { siblings } : {}),
|
|
1504
|
+
paddingBox,
|
|
1505
|
+
...(guideEdges ? { guideEdges } : {}),
|
|
1506
|
+
};
|
|
1507
|
+
},
|
|
1508
|
+
// Empty containers have no visible element children or non-whitespace text.
|
|
1509
|
+
emptyContainers: () => {
|
|
1510
|
+
const { nodes } = this.snapshot();
|
|
1511
|
+
const candidates: EmptyCandidate[] = [];
|
|
1512
|
+
for (const n of nodes.values()) {
|
|
1513
|
+
const r = n.el.getBoundingClientRect();
|
|
1514
|
+
if (!canContainDomChildren(n.el, n.tag)) continue;
|
|
1515
|
+
candidates.push({
|
|
1516
|
+
oid: n.id,
|
|
1517
|
+
rect: this.toHostRelative(r),
|
|
1518
|
+
displayName: n.tag,
|
|
1519
|
+
hasVisibleChildren: n.el.children.length > 0,
|
|
1520
|
+
hasText: (n.el.textContent ?? '').trim().length > 0,
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
return findEmptyContainers(candidates).map((c) => ({
|
|
1524
|
+
id: c.oid,
|
|
1525
|
+
rect: c.rect,
|
|
1526
|
+
displayName: c.displayName,
|
|
1527
|
+
}));
|
|
1528
|
+
},
|
|
1529
|
+
};
|
|
1530
|
+
|
|
1531
|
+
/** Sample the topmost eligible node's computed color at the pointer. */
|
|
1532
|
+
readonly colorSample: ColorSampleProvider = {
|
|
1533
|
+
backgroundChainAt: (clientX, clientY) => {
|
|
1534
|
+
const hitId = this.pickable.pick(clientX, clientY);
|
|
1535
|
+
if (!hitId) return null;
|
|
1536
|
+
const { nodes } = this.snapshot();
|
|
1537
|
+
const chain: string[] = [];
|
|
1538
|
+
let cur: OidNode | undefined = nodes.get(hitId);
|
|
1539
|
+
while (cur) {
|
|
1540
|
+
chain.push(getComputedStyleValue(cur.el, 'backgroundColor'));
|
|
1541
|
+
cur = cur.parentId ? nodes.get(cur.parentId) : undefined;
|
|
1542
|
+
}
|
|
1543
|
+
return chain;
|
|
1544
|
+
},
|
|
1545
|
+
};
|
|
1546
|
+
|
|
1547
|
+
/** Preview box gestures on live DOM, then persist touched properties at end. */
|
|
1548
|
+
readonly boxEdit: BoxEditProvider = {
|
|
1549
|
+
begin: (id) => {
|
|
1550
|
+
// Starting a gesture replaces any uncommitted session; its
|
|
1551
|
+
// preview mutations are already live on the DOM either way.
|
|
1552
|
+
this.boxEditSession = { id, touched: new Map(), priorInline: new Map() };
|
|
1553
|
+
},
|
|
1554
|
+
apply: (id, patch) => {
|
|
1555
|
+
const session = this.boxEditSession;
|
|
1556
|
+
if (!session || session.id !== id) return; // no open gesture for this id
|
|
1557
|
+
const n = this.snapshot().nodes.get(id);
|
|
1558
|
+
if (!n) return; // unresolved id — no-op (per contract)
|
|
1559
|
+
const pos = getComputedStyleValue(n.el, 'position');
|
|
1560
|
+
const isPositioned = pos === 'absolute' || pos === 'fixed';
|
|
1561
|
+
for (const [key, v] of Object.entries(patch)) {
|
|
1562
|
+
const mapped = mapBoxEditPatchKey(key, isPositioned);
|
|
1563
|
+
if (!mapped) {
|
|
1564
|
+
console.warn(
|
|
1565
|
+
`[ReactRootAuthoringAdapter] boxEdit: dropping patch key "${key}" for "${id}" — ` +
|
|
1566
|
+
(key === 'x' || key === 'y'
|
|
1567
|
+
? `node is not absolutely/fixed positioned (computed position: "${pos}"), ` +
|
|
1568
|
+
'no left/top to move'
|
|
1569
|
+
: 'unrecognized box-edit patch key'),
|
|
1570
|
+
);
|
|
1571
|
+
// Explain why a flow-positioned element cannot accept a spatial move.
|
|
1572
|
+
if ((key === 'x' || key === 'y') && !session.hinted) {
|
|
1573
|
+
session.hinted = true;
|
|
1574
|
+
showTransientHint(
|
|
1575
|
+
`${n.tag} flows in its layout (position: ${pos}), so a drag ` +
|
|
1576
|
+
'cannot move it. Set Position to "abs" in the Inspector to place it freely; ' +
|
|
1577
|
+
'drag a handle to resize.',
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
// Lazily snapshot the TRUE pre-gesture inline value the first time THIS
|
|
1583
|
+
// prop is touched in this gesture — before mutating it — see
|
|
1584
|
+
// `boxEditSession`'s doc comment for why this can't be re-derived later.
|
|
1585
|
+
if (!session.priorInline.has(mapped.prop)) {
|
|
1586
|
+
const prevRaw = styleProp(n.el.style, mapped.prop);
|
|
1587
|
+
session.priorInline.set(mapped.prop, prevRaw);
|
|
1588
|
+
}
|
|
1589
|
+
// x/y arrive HOST-RELATIVE (the rect provider's space), but CSS
|
|
1590
|
+
// left/top are OFFSET-PARENT-relative. They only coincide when the
|
|
1591
|
+
// offset parent sits at the host origin — which a story-frame child
|
|
1592
|
+
// never does, so writing them as-is would put BOARD coordinates into
|
|
1593
|
+
// the element's source and move it outside its own frame.
|
|
1594
|
+
// parentOrigin = rect − offsetLeft is constant through the gesture
|
|
1595
|
+
// (both shift together as we mutate), and an element positioned via
|
|
1596
|
+
// translate() cancels exactly: rect includes the transform, so the
|
|
1597
|
+
// written left lands the VISUAL box at the requested position.
|
|
1598
|
+
let write = v;
|
|
1599
|
+
if ((key === 'x' || key === 'y') && typeof v === 'number') {
|
|
1600
|
+
const el = n.el as unknown as {
|
|
1601
|
+
getBoundingClientRect: () => {
|
|
1602
|
+
left: number;
|
|
1603
|
+
top: number;
|
|
1604
|
+
width: number;
|
|
1605
|
+
height: number;
|
|
1606
|
+
};
|
|
1607
|
+
offsetLeft?: number;
|
|
1608
|
+
offsetTop?: number;
|
|
1609
|
+
};
|
|
1610
|
+
const rect = el.getBoundingClientRect();
|
|
1611
|
+
if (typeof el.offsetLeft === 'number' && typeof el.offsetTop === 'number') {
|
|
1612
|
+
const hostRel = this.toHostRelative(rect);
|
|
1613
|
+
const parentOrigin = key === 'x' ? hostRel.x - el.offsetLeft : hostRel.y - el.offsetTop;
|
|
1614
|
+
// Whole-pixel, like every other gesture write (see the spacing
|
|
1615
|
+
// scrub's own doc comment): the patch is grid/edge-snapped, but
|
|
1616
|
+
// edge targets and this origin are MEASURED (rect ÷ zoom), so
|
|
1617
|
+
// without rounding a 65%-zoom drag authors `left:
|
|
1618
|
+
// '215.00005607057415px'` — measurement noise, not intent.
|
|
1619
|
+
write = Math.round(v - parentOrigin);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
const cssValue = mapped.cssValue(write);
|
|
1623
|
+
(n.el.style as unknown as Record<string, unknown>)[mapped.prop] = cssValue;
|
|
1624
|
+
// Commit numerics as plain numbers (matching every other numeric style
|
|
1625
|
+
// write in this adapter — see `writeStyle`'s callers), transform as a
|
|
1626
|
+
// string — NOT the px-suffixed preview string, which is preview-only.
|
|
1627
|
+
session.touched.set(mapped.prop, mapped.prop === 'transform' ? cssValue : write);
|
|
1628
|
+
}
|
|
1629
|
+
},
|
|
1630
|
+
end: (id) => {
|
|
1631
|
+
const session = this.boxEditSession;
|
|
1632
|
+
this.boxEditSession = null;
|
|
1633
|
+
if (!session || session.id !== id || session.touched.size === 0) return;
|
|
1634
|
+
// Queue behind any still-running commit — see `boxEditTail`. A failed
|
|
1635
|
+
// commit must not poison the chain for every later gesture, so the tail
|
|
1636
|
+
// absorbs the rejection (commitBoxEdit reports its own failures).
|
|
1637
|
+
this.boxEditTail = this.boxEditTail.then(
|
|
1638
|
+
() => this.commitBoxEdit(id, session.touched, session.priorInline),
|
|
1639
|
+
() => this.commitBoxEdit(id, session.touched, session.priorInline),
|
|
1640
|
+
);
|
|
1641
|
+
void this.boxEditTail.catch((error) => {
|
|
1642
|
+
console.error('[ReactRootAuthoringAdapter] box edit failed and was not committed.', error);
|
|
1643
|
+
});
|
|
1644
|
+
},
|
|
1645
|
+
};
|
|
1646
|
+
|
|
1647
|
+
/** Commit touched properties as one gesture with one source-history transaction. */
|
|
1648
|
+
private async commitBoxEdit(
|
|
1649
|
+
id: string,
|
|
1650
|
+
touched: Map<string, string | number>,
|
|
1651
|
+
priorInline: Map<string, string>,
|
|
1652
|
+
): Promise<void> {
|
|
1653
|
+
const wasDirty = this.dirty;
|
|
1654
|
+
// Echo all touched values before awaiting source writes.
|
|
1655
|
+
for (const [prop, value] of touched) {
|
|
1656
|
+
this.setEcho(id, `${STYLE_PATH_PREFIX}${prop}`, value);
|
|
1657
|
+
}
|
|
1658
|
+
// Surface a refused gesture in the editor console and near the pointer.
|
|
1659
|
+
let applied = 0;
|
|
1660
|
+
this.lastStyleWriteRefusal = null;
|
|
1661
|
+
const commit = async (backend: SourceWriteBackend | undefined): Promise<void> => {
|
|
1662
|
+
for (const [prop, value] of touched) {
|
|
1663
|
+
// Persist numeric length values as CSS lengths; transform values remain strings.
|
|
1664
|
+
const writeValue = typeof value === 'number' ? `${value}px` : value;
|
|
1665
|
+
if (await this.writeStyleEntry(id, prop, writeValue, priorInline.get(prop), backend)) {
|
|
1666
|
+
applied += 1;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
if (applied === 0 && touched.size > 0) {
|
|
1670
|
+
const reason = this.lastStyleWriteRefusal ?? 'the write was refused without a reason';
|
|
1671
|
+
editorConsole.error(`The last edit on this element was NOT saved — ${reason}`, 'authoring');
|
|
1672
|
+
showTransientHint('Not saved: the edit could not be written to source — see the Console.');
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
// The history-managed backend supplies an explicit scoped writer. Holding
|
|
1676
|
+
// the callback as one queue entry makes the gesture failure-atomic and
|
|
1677
|
+
// prevents unrelated inspector writes from being absorbed/interleaved.
|
|
1678
|
+
try {
|
|
1679
|
+
if (this.writeBackend?.runGesture) {
|
|
1680
|
+
await this.writeBackend.runGesture(`Edit ${touched.size} Styles`, commit);
|
|
1681
|
+
} else {
|
|
1682
|
+
await commit(this.writeBackend);
|
|
1683
|
+
}
|
|
1684
|
+
} catch (error) {
|
|
1685
|
+
const node = this.snapshot().nodes.get(id);
|
|
1686
|
+
for (const [prop, prior] of priorInline) {
|
|
1687
|
+
this.clearEcho(id, `${STYLE_PATH_PREFIX}${prop}`);
|
|
1688
|
+
if (node) (node.el.style as unknown as Record<string, unknown>)[prop] = prior;
|
|
1689
|
+
}
|
|
1690
|
+
this.dirty = wasDirty;
|
|
1691
|
+
this.store.notifyIngestEdit();
|
|
1692
|
+
throw error;
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/** Read leaf text from DOM and write through the source-side dynamic-body guard. */
|
|
1697
|
+
readonly text: TextProvider = {
|
|
1698
|
+
get: (id) => {
|
|
1699
|
+
// Do not reopen text editing for a body already refused by the source guard.
|
|
1700
|
+
if (this.guardedPaths.has(`${id}|${TEXT_PATH}`)) return null;
|
|
1701
|
+
const n = this.snapshot().nodes.get(id);
|
|
1702
|
+
if (!n || n.el.children.length > 0) return null;
|
|
1703
|
+
const raw = n.el.textContent;
|
|
1704
|
+
if (raw == null) return null;
|
|
1705
|
+
const trimmed = raw.trim();
|
|
1706
|
+
return trimmed ? trimmed : null;
|
|
1707
|
+
},
|
|
1708
|
+
set: (id, text) => {
|
|
1709
|
+
void this.editText(id, text);
|
|
1710
|
+
},
|
|
1711
|
+
};
|
|
1712
|
+
|
|
1713
|
+
readonly inspector: InspectorProvider = {
|
|
1714
|
+
properties: (id: string): PropertyDescriptor[] => {
|
|
1715
|
+
const portableHierarchyNode = this.portableHierarchyNode(id);
|
|
1716
|
+
if (portableHierarchyNode) {
|
|
1717
|
+
return portableHierarchyNode.secondaryLabel
|
|
1718
|
+
? [
|
|
1719
|
+
{
|
|
1720
|
+
path: 'document.path',
|
|
1721
|
+
label: 'Source',
|
|
1722
|
+
type: 'string',
|
|
1723
|
+
readonly: true,
|
|
1724
|
+
group: 'Document',
|
|
1725
|
+
},
|
|
1726
|
+
]
|
|
1727
|
+
: [];
|
|
1728
|
+
}
|
|
1729
|
+
if (this.portableStories.some((story) => story.id === id))
|
|
1730
|
+
return this.portableStoryArgDescriptors(id);
|
|
1731
|
+
const style = STYLE_PROPERTIES.map(({ prop, label, type, group, options }) => ({
|
|
1732
|
+
path: `${STYLE_PATH_PREFIX}${prop}`,
|
|
1733
|
+
label,
|
|
1734
|
+
type,
|
|
1735
|
+
group,
|
|
1736
|
+
...(options ? { options } : {}),
|
|
1737
|
+
}));
|
|
1738
|
+
// Append component props from the selected node's source contract.
|
|
1739
|
+
const all = [...style, ...this.propDescriptors(id), ...this.tokenDescriptors()];
|
|
1740
|
+
// Return remembered source guards as read-only descriptors with their own reason.
|
|
1741
|
+
if (this.guardedPaths.size === 0) return all;
|
|
1742
|
+
return all.map((p) => {
|
|
1743
|
+
const reason = this.guardedPaths.get(`${id}|${p.path}`);
|
|
1744
|
+
return reason ? { ...p, readonly: true, readonlyReason: reason } : p;
|
|
1745
|
+
});
|
|
1746
|
+
},
|
|
1747
|
+
get: (id, path) => {
|
|
1748
|
+
const portableHierarchyNode = this.portableHierarchyNode(id);
|
|
1749
|
+
if (portableHierarchyNode) {
|
|
1750
|
+
return path === 'document.path' ? portableHierarchyNode.secondaryLabel : undefined;
|
|
1751
|
+
}
|
|
1752
|
+
if (this.portableStories.some((story) => story.id === id)) {
|
|
1753
|
+
if (path === 'story.active') return id === this.activeStoryId;
|
|
1754
|
+
if (path.startsWith(PORTABLE_STORY_ARG_PATH_PREFIX)) {
|
|
1755
|
+
return this.portableStoryArgs.get(id)?.[
|
|
1756
|
+
path.slice(PORTABLE_STORY_ARG_PATH_PREFIX.length)
|
|
1757
|
+
];
|
|
1758
|
+
}
|
|
1759
|
+
return undefined;
|
|
1760
|
+
}
|
|
1761
|
+
// A freshly committed echo takes precedence over the live DOM value.
|
|
1762
|
+
const echoed = this.readEcho(id, path);
|
|
1763
|
+
if (echoed.hit) return echoed.value;
|
|
1764
|
+
// Lock state is session-local; visibility reads its source-backed style echo.
|
|
1765
|
+
if (path === 'locked') return this.lockedIds.has(id);
|
|
1766
|
+
if (path === 'visible') {
|
|
1767
|
+
const echoedStyle = this.readEcho(id, `${STYLE_PATH_PREFIX}visibility`);
|
|
1768
|
+
if (echoedStyle.hit) return echoedStyle.value !== 'hidden';
|
|
1769
|
+
const n = this.snapshot().nodes.get(id);
|
|
1770
|
+
if (!n) return undefined;
|
|
1771
|
+
const raw = getComputedStyleValue(n.el, 'visibility');
|
|
1772
|
+
return raw !== 'hidden';
|
|
1773
|
+
}
|
|
1774
|
+
if (path.startsWith(TOKEN_PATH_PREFIX)) {
|
|
1775
|
+
const name = path.slice(TOKEN_PATH_PREFIX.length);
|
|
1776
|
+
return this.designTokenValue(name);
|
|
1777
|
+
}
|
|
1778
|
+
if (path.startsWith(PROP_PATH_PREFIX)) {
|
|
1779
|
+
const n = this.snapshot().nodes.get(id);
|
|
1780
|
+
const cp = n ? getComponentProps(n.el) : null;
|
|
1781
|
+
return cp?.props[path.slice(PROP_PATH_PREFIX.length)];
|
|
1782
|
+
}
|
|
1783
|
+
if (!path.startsWith(STYLE_PATH_PREFIX)) return undefined;
|
|
1784
|
+
const prop = path.slice(STYLE_PATH_PREFIX.length);
|
|
1785
|
+
const n = this.snapshot().nodes.get(id);
|
|
1786
|
+
if (!n) return undefined;
|
|
1787
|
+
// Prefer authored inline values; computed style resolves class and stylesheet values.
|
|
1788
|
+
const authored = styleProp(n.el.style, prop);
|
|
1789
|
+
const raw = authored || getComputedStyleValue(n.el, prop);
|
|
1790
|
+
const declaredType = STYLE_PROPERTY_TYPE.get(prop);
|
|
1791
|
+
// Convert CSSStyleDeclaration's unit-suffixed strings to numbers for numeric inspector
|
|
1792
|
+
// descriptors, and normalize color descriptors to #rrggbb through cssColorToHex.
|
|
1793
|
+
if (declaredType === 'number') return numericStyleValue(raw);
|
|
1794
|
+
if (declaredType === 'color') return cssColorToHex(raw);
|
|
1795
|
+
return raw || undefined; // an unset computed value reads '' — surface as blank
|
|
1796
|
+
},
|
|
1797
|
+
set: (id, path, value) => {
|
|
1798
|
+
if (this.portableHierarchyNode(id)) return;
|
|
1799
|
+
if (this.portableStories.some((story) => story.id === id)) {
|
|
1800
|
+
if (!path.startsWith(PORTABLE_STORY_ARG_PATH_PREFIX)) return;
|
|
1801
|
+
const name = path.slice(PORTABLE_STORY_ARG_PATH_PREFIX.length);
|
|
1802
|
+
const current = this.portableStoryArgs.get(id);
|
|
1803
|
+
if (!current || !(name in current)) return;
|
|
1804
|
+
const next = { ...current, [name]: value };
|
|
1805
|
+
this.portableStoryArgs.set(id, next);
|
|
1806
|
+
this.onPortableStoryArgsChanged?.(id, { ...next });
|
|
1807
|
+
this.store.notifyIngestEdit();
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
// Apply session lock and source-backed visibility changes through their own paths.
|
|
1811
|
+
if (path === 'locked') {
|
|
1812
|
+
if (value) this.lockedIds.add(id);
|
|
1813
|
+
else this.lockedIds.delete(id);
|
|
1814
|
+
this.store.notifyIngestEdit();
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
if (path === 'visible') {
|
|
1818
|
+
const cssValue = value ? 'visible' : 'hidden';
|
|
1819
|
+
this.setEcho(id, `${STYLE_PATH_PREFIX}visibility`, cssValue);
|
|
1820
|
+
return this.pipedSourceWrite(() => this.writeStyle(id, 'visibility', cssValue));
|
|
1821
|
+
}
|
|
1822
|
+
if (path.startsWith(PROP_PATH_PREFIX)) {
|
|
1823
|
+
// Echo before awaiting the source write.
|
|
1824
|
+
this.setEcho(id, path, value);
|
|
1825
|
+
return this.pipedSourceWrite(() =>
|
|
1826
|
+
this.writePropEdit(id, path.slice(PROP_PATH_PREFIX.length), String(value)),
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1829
|
+
if (path.startsWith(TOKEN_PATH_PREFIX)) {
|
|
1830
|
+
// Tokens-as-noun: a token declared by a project stylesheet is EDITED
|
|
1831
|
+
// AT ITS DECLARATION (`writeCss` on the traced rule) — the one write
|
|
1832
|
+
// that moves every binding at once. Undeclared tokens (script-set)
|
|
1833
|
+
// refuse with the pointer; their descriptor is read-only anyway.
|
|
1834
|
+
this.setEcho(id, path, value);
|
|
1835
|
+
return this.pipedSourceWrite(() =>
|
|
1836
|
+
this.writeTokenValue(id, path.slice(TOKEN_PATH_PREFIX.length), String(value)),
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
if (!path.startsWith(STYLE_PATH_PREFIX)) return;
|
|
1840
|
+
const prop = path.slice(STYLE_PATH_PREFIX.length);
|
|
1841
|
+
// Echo BEFORE the async write: the field reflects the commit
|
|
1842
|
+
// within one frame; the write path clears this echo if the write is refused, and HMR
|
|
1843
|
+
// clears it once the re-render lands.
|
|
1844
|
+
this.setEcho(id, path, value);
|
|
1845
|
+
return this.pipedSourceWrite(() => this.writeStyle(id, prop, value as string | number));
|
|
1846
|
+
},
|
|
1847
|
+
remove: (id, path) => {
|
|
1848
|
+
// Remove longhand overrides so a uniform shorthand controls the rendered value.
|
|
1849
|
+
if (!path.startsWith(STYLE_PATH_PREFIX)) return;
|
|
1850
|
+
const prop = path.slice(STYLE_PATH_PREFIX.length);
|
|
1851
|
+
// Point the removed longhand's echo at the value it actually renders at
|
|
1852
|
+
// once the override is gone — the shorthand's current (just-committed)
|
|
1853
|
+
// value — so `inspector.get(longhand)` reports the resolved corner value,
|
|
1854
|
+
// not a stale override or an empty computed read, and stays consistent
|
|
1855
|
+
// after HMR clears the echo (the shorthand then cascades to it).
|
|
1856
|
+
const shorthand = LONGHAND_TO_SHORTHAND[prop];
|
|
1857
|
+
if (shorthand)
|
|
1858
|
+
this.setEcho(id, path, this.inspector.get(id, `${STYLE_PATH_PREFIX}${shorthand}`));
|
|
1859
|
+
else this.clearEcho(id, path);
|
|
1860
|
+
// Through the SAME pipe `set` uses, so a removal answers for itself with
|
|
1861
|
+
// an awaited per-edit ack instead of being fired into the void. It is the
|
|
1862
|
+
// only door that can express byte-ABSENCE — `set` writes a value, and a
|
|
1863
|
+
// longhand set back to the shorthand's value is still a longhand in the
|
|
1864
|
+
// file.
|
|
1865
|
+
return this.pipedSourceWrite(() => this.removeStyleProp(id, prop));
|
|
1866
|
+
},
|
|
1867
|
+
};
|
|
1868
|
+
|
|
1869
|
+
/** Storybook Controls descriptors for an ordinary CSF story node. Functions
|
|
1870
|
+
* and other non-serializable implementation values stay out of the form;
|
|
1871
|
+
* they remain present in the complete args object passed to the composed
|
|
1872
|
+
* story. Per-story documents share the rule in
|
|
1873
|
+
* ../host/stories/story-arg-descriptors.ts. */
|
|
1874
|
+
private portableStoryArgDescriptors(storyId: string): PropertyDescriptor[] {
|
|
1875
|
+
return storyArgPropertyDescriptors(
|
|
1876
|
+
this.portableStoryArgs.get(storyId) ?? {},
|
|
1877
|
+
PORTABLE_STORY_ARG_PATH_PREFIX,
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
/** Current complete args for a portable story, including callbacks omitted
|
|
1882
|
+
* from the visual Controls list. Returned as a copy so inspector UI cannot
|
|
1883
|
+
* mutate adapter state behind the provider contract. */
|
|
1884
|
+
portableArgs(storyId: string): Record<string, unknown> | null {
|
|
1885
|
+
const args = this.portableStoryArgs.get(storyId);
|
|
1886
|
+
return args ? { ...args } : null;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
/** Classes on the element, their scope-local use counts, and any matching
|
|
1890
|
+
* first-party rule that makes the class an editable named style. */
|
|
1891
|
+
elementClasses(id: string): { name: string; count: number; styledIn: string | null }[] {
|
|
1892
|
+
const n = this.snapshot().nodes.get(id);
|
|
1893
|
+
const el = n?.el as unknown as Element | undefined;
|
|
1894
|
+
if (!el) return [];
|
|
1895
|
+
const tokens = (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
|
|
1896
|
+
// Count class uses inside the element's mounted CSS scope, or its document.
|
|
1897
|
+
const scope = el.closest('[data-volter-mount-styles]') ?? el.ownerDocument;
|
|
1898
|
+
return tokens.map((name) => {
|
|
1899
|
+
const count = scope.querySelectorAll(`.${CSS.escape(name)}`).length;
|
|
1900
|
+
return { name, count, styledIn: findClassRuleSource(name)?.sourceFile ?? null };
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* The named-style write: APPLY when any first-party stylesheet already
|
|
1906
|
+
* declares `.className` (the class is an existing style — adding the token
|
|
1907
|
+
* is the whole gesture), else CREATE — mint the rule in the element's own
|
|
1908
|
+
* stylesheet (the last matched first-party rule's file, else the first
|
|
1909
|
+
* first-party stylesheet in the document), moving the element's literal
|
|
1910
|
+
* inline declarations into it. `remove` strips the token. Refusals are
|
|
1911
|
+
* returned AND logged, so the calling UI can show the sentence.
|
|
1912
|
+
*/
|
|
1913
|
+
async writeNamedStyle(
|
|
1914
|
+
id: string,
|
|
1915
|
+
op: { kind: 'set' | 'remove'; className: string },
|
|
1916
|
+
): Promise<{ changed: boolean; created?: boolean; error?: string }> {
|
|
1917
|
+
const n = this.snapshot().nodes.get(id);
|
|
1918
|
+
if (!n) return { changed: false, error: `"${id}" no longer resolves in this root` };
|
|
1919
|
+
const refuse = (error: string): { changed: false; error: string } => {
|
|
1920
|
+
console.warn(`[ReactRootAuthoringAdapter] named-style ${op.kind} refused: ${error}`);
|
|
1921
|
+
return { changed: false, error };
|
|
1922
|
+
};
|
|
1923
|
+
if (op.kind === 'remove') {
|
|
1924
|
+
const res = await writeNamedStyle({ op: 'remove', oid: n.oid, className: op.className });
|
|
1925
|
+
if (!res.changed) return refuse(res.error ?? 'no change');
|
|
1926
|
+
this.dirty = true;
|
|
1927
|
+
this.store.notifyIngestEdit();
|
|
1928
|
+
return { changed: true };
|
|
1929
|
+
}
|
|
1930
|
+
const existing = findClassRuleSource(op.className);
|
|
1931
|
+
if (existing) {
|
|
1932
|
+
const res = await writeNamedStyle({ op: 'apply', oid: n.oid, className: op.className });
|
|
1933
|
+
if (!res.changed) return refuse(res.error ?? 'the element already wears this class');
|
|
1934
|
+
this.dirty = true;
|
|
1935
|
+
this.store.notifyIngestEdit();
|
|
1936
|
+
return { changed: true };
|
|
1937
|
+
}
|
|
1938
|
+
// The rule's home: the last matched first-party rule's own file, else any
|
|
1939
|
+
// first-party stylesheet in the document — and with NEITHER, omit `file`
|
|
1940
|
+
// and the server MINTS a stylesheet beside the element's module (adding
|
|
1941
|
+
// the bare css import), so the first named style in a css-less project
|
|
1942
|
+
// works instead of refusing.
|
|
1943
|
+
// Candidate homes are the GAME's stylesheets. The dev-id enumeration also
|
|
1944
|
+
// sees the editor's own vite-served CSS, and offering one of those as the
|
|
1945
|
+
// home would (rightly) bounce off the server's scope guard — so mirror the
|
|
1946
|
+
// guard's exclusions here and let a css-less project fall through to the
|
|
1947
|
+
// server's minting path instead.
|
|
1948
|
+
const gameCss = (f: string | undefined): boolean =>
|
|
1949
|
+
f !== undefined &&
|
|
1950
|
+
!f.includes('/packages/editor-ui/') &&
|
|
1951
|
+
!f.includes('/node_modules/') &&
|
|
1952
|
+
!f.includes('/vendor/');
|
|
1953
|
+
const matched = this.matchedCssRules(n.el).filter((rule) => gameCss(rule.sourceFile));
|
|
1954
|
+
const cssFile =
|
|
1955
|
+
matched[matched.length - 1]?.sourceFile ?? firstPartyStylesheetFiles().find(gameCss);
|
|
1956
|
+
const res = await writeNamedStyle({
|
|
1957
|
+
op: 'create',
|
|
1958
|
+
oid: n.oid,
|
|
1959
|
+
className: op.className,
|
|
1960
|
+
...(cssFile !== undefined ? { file: cssFile } : {}),
|
|
1961
|
+
});
|
|
1962
|
+
if (!res.changed) return refuse(res.error ?? 'no change');
|
|
1963
|
+
this.dirty = true;
|
|
1964
|
+
this.store.notifyIngestEdit();
|
|
1965
|
+
return { changed: true, created: true };
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
async writeStory(
|
|
1969
|
+
storyId: string,
|
|
1970
|
+
op:
|
|
1971
|
+
| { kind: 'save-as'; name: string }
|
|
1972
|
+
| { kind: 'rename'; newName: string }
|
|
1973
|
+
| { kind: 'delete' },
|
|
1974
|
+
): Promise<{ changed: boolean; error?: string }> {
|
|
1975
|
+
const story = this.portableStories.find((candidate) => candidate.id === storyId);
|
|
1976
|
+
if (!story) return { changed: false, error: `unknown portable story '${storyId}'` };
|
|
1977
|
+
const request =
|
|
1978
|
+
op.kind === 'save-as'
|
|
1979
|
+
? {
|
|
1980
|
+
op: 'save' as const,
|
|
1981
|
+
file: story.modulePath,
|
|
1982
|
+
name: op.name,
|
|
1983
|
+
args: this.portableStoryArgs.get(storyId) ?? {},
|
|
1984
|
+
}
|
|
1985
|
+
: op.kind === 'rename'
|
|
1986
|
+
? { op: 'rename' as const, file: story.modulePath, name: story.name, newName: op.newName }
|
|
1987
|
+
: { op: 'delete' as const, file: story.modulePath, name: story.name };
|
|
1988
|
+
const result = await writeCsfStory(request);
|
|
1989
|
+
if (!result.changed) {
|
|
1990
|
+
console.warn(
|
|
1991
|
+
`[ReactRootAuthoringAdapter] csf-story ${op.kind} refused for '${story.label}': ` +
|
|
1992
|
+
`${result.error ?? 'no change'}`,
|
|
1993
|
+
);
|
|
1994
|
+
return { changed: false, ...(result.error !== undefined ? { error: result.error } : {}) };
|
|
1995
|
+
}
|
|
1996
|
+
// Update renamed and deleted entries immediately; save-as becomes visible
|
|
1997
|
+
// when story discovery refreshes.
|
|
1998
|
+
if (op.kind === 'rename') {
|
|
1999
|
+
story.name = op.newName;
|
|
2000
|
+
story.label = op.newName.replace(/([a-z])([A-Z])/g, '$1 $2');
|
|
2001
|
+
} else if (op.kind === 'delete') {
|
|
2002
|
+
const index = this.portableStories.indexOf(story);
|
|
2003
|
+
if (index >= 0) this.portableStories.splice(index, 1);
|
|
2004
|
+
if (this.activeStoryId === storyId) this.activeStoryId = null;
|
|
2005
|
+
}
|
|
2006
|
+
this.dirty = true;
|
|
2007
|
+
this.store.notifyIngestEdit();
|
|
2008
|
+
return { changed: true };
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
/** Restore the selected story's composed CSF defaults. */
|
|
2012
|
+
resetPortableArgs(storyId: string): void {
|
|
2013
|
+
const story = this.portableStories.find((candidate) => candidate.id === storyId);
|
|
2014
|
+
if (!story) return;
|
|
2015
|
+
const next = { ...story.args };
|
|
2016
|
+
this.portableStoryArgs.set(storyId, next);
|
|
2017
|
+
this.onPortableStoryArgsChanged?.(storyId, { ...next });
|
|
2018
|
+
this.store.notifyIngestEdit();
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/** Editable component props read from the mounted React fiber. */
|
|
2022
|
+
private propDescriptors(id: string): PropertyDescriptor[] {
|
|
2023
|
+
const n = this.snapshot().nodes.get(id);
|
|
2024
|
+
const cp = n ? getComponentProps(n.el) : null;
|
|
2025
|
+
if (!cp) return [];
|
|
2026
|
+
return Object.keys(cp.props).map((name) => ({
|
|
2027
|
+
path: `${PROP_PATH_PREFIX}${name}`,
|
|
2028
|
+
label: name,
|
|
2029
|
+
type: 'string' as const,
|
|
2030
|
+
group: 'Props',
|
|
2031
|
+
}));
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/** Design-token descriptors. A traced project declaration is writable. */
|
|
2035
|
+
private tokenDescriptors(): PropertyDescriptor[] {
|
|
2036
|
+
// Tokens-as-noun: a token whose `:root` declaration traces to a project
|
|
2037
|
+
// stylesheet is WRITABLE (the edit lands on that declaration through the
|
|
2038
|
+
// css door and every binding moves at once); one set by script — a theme
|
|
2039
|
+
// data module mirroring itself onto `:root`, or inherited host style —
|
|
2040
|
+
// stays read-only, because its truth lives in that source, not in a rule
|
|
2041
|
+
// the css writer can reach.
|
|
2042
|
+
return this.designTokens().map((t) => ({
|
|
2043
|
+
path: `${TOKEN_PATH_PREFIX}${t.name}`,
|
|
2044
|
+
label: t.name,
|
|
2045
|
+
type: 'string' as const,
|
|
2046
|
+
group: 'Tokens',
|
|
2047
|
+
readonly: !t.declaredIn,
|
|
2048
|
+
}));
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
/**
|
|
2052
|
+
* The token-edit write: `writeCss` on the token's TRACED `:root`
|
|
2053
|
+
* declaration. Refusals are loud and name the remedy — an untraced token's
|
|
2054
|
+
* edit door is its declaring source (plain data), not this path.
|
|
2055
|
+
*/
|
|
2056
|
+
private async writeTokenValue(id: string, name: string, value: string): Promise<boolean> {
|
|
2057
|
+
const echoPath = `${TOKEN_PATH_PREFIX}${name}`;
|
|
2058
|
+
const token = this.designTokens().find((t) => t.name === name);
|
|
2059
|
+
if (!token) {
|
|
2060
|
+
this.clearEcho(id, echoPath);
|
|
2061
|
+
console.warn(`[ReactRootAuthoringAdapter] unknown design token "${name}".`);
|
|
2062
|
+
return false;
|
|
2063
|
+
}
|
|
2064
|
+
if (!token.declaredIn) {
|
|
2065
|
+
this.clearEcho(id, echoPath);
|
|
2066
|
+
console.warn(
|
|
2067
|
+
`[ReactRootAuthoringAdapter] token "${name}" has no stylesheet declaration to edit — ` +
|
|
2068
|
+
'it is set by script (a theme data module mirroring itself onto :root, or host ' +
|
|
2069
|
+
'style). Edit that source; it is plain data.',
|
|
2070
|
+
);
|
|
2071
|
+
return false;
|
|
2072
|
+
}
|
|
2073
|
+
const backend = this.writeBackend;
|
|
2074
|
+
if (!backend) {
|
|
2075
|
+
this.clearEcho(id, echoPath);
|
|
2076
|
+
console.warn(
|
|
2077
|
+
`[ReactRootAuthoringAdapter] cannot write token "${name}": no ` +
|
|
2078
|
+
'source-write backend in this session.',
|
|
2079
|
+
);
|
|
2080
|
+
return false;
|
|
2081
|
+
}
|
|
2082
|
+
const res = await backend.writeCss(
|
|
2083
|
+
token.declaredIn.sourceFile,
|
|
2084
|
+
token.declaredIn.selector,
|
|
2085
|
+
name,
|
|
2086
|
+
value,
|
|
2087
|
+
);
|
|
2088
|
+
if (!res.changed) {
|
|
2089
|
+
this.clearEcho(id, echoPath);
|
|
2090
|
+
console.warn(
|
|
2091
|
+
`[ReactRootAuthoringAdapter] token write refused/no-op for "${name}" at ` +
|
|
2092
|
+
`${token.declaredIn.sourceFile} (${token.declaredIn.selector}): ${res.error ?? 'no change'}`,
|
|
2093
|
+
);
|
|
2094
|
+
return false;
|
|
2095
|
+
}
|
|
2096
|
+
this.dirty = true;
|
|
2097
|
+
this.store.notifyIngestEdit();
|
|
2098
|
+
return true;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
/**
|
|
2102
|
+
* Portable Storybook CSF stories. `active`/`apply` are world-level because
|
|
2103
|
+
* applying a story remounts the whole React root. `storiesFor` narrows
|
|
2104
|
+
* presentation to the stories of the node's own component
|
|
2105
|
+
* (`portableStoriesForNode`) — but the world question, `WORLD_SCOPE_NODE_ID`,
|
|
2106
|
+
* is still answered with the whole list, because that is the id the shell
|
|
2107
|
+
* probes this provider's SCOPE with and the scope is world-level.
|
|
2108
|
+
* `apply` validates the id, updates `activeStoryId`, asks the design-time
|
|
2109
|
+
* layer to render that composed CSF story, and notifies the shell.
|
|
2110
|
+
*/
|
|
2111
|
+
readonly stories: StoriesProvider = {
|
|
2112
|
+
storiesFor: (nodeId): StoryRef[] =>
|
|
2113
|
+
this.portableStories.length > 0 ? this.portableStoriesForNode(nodeId) : [],
|
|
2114
|
+
active: () => this.activeStoryId,
|
|
2115
|
+
apply: (_nodeId, storyId) => {
|
|
2116
|
+
if (storyId !== null && !this.portableStories.some((story) => story.id === storyId)) {
|
|
2117
|
+
console.warn(
|
|
2118
|
+
`[ReactRootAuthoringAdapter] stories.apply: unknown portable CSF story id "${storyId}" — ignoring.`,
|
|
2119
|
+
);
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
// Portable CSF always renders a design state. Clearing the picker means
|
|
2123
|
+
// "restore the document's default", not "orphan the live DOM roots
|
|
2124
|
+
// outside every story row".
|
|
2125
|
+
this.activeStoryId = storyId ?? this.portableDefaultStoryId;
|
|
2126
|
+
this.onPortableStoryApplied?.(storyId);
|
|
2127
|
+
this.store.notifyIngestEdit();
|
|
2128
|
+
},
|
|
2129
|
+
// Publish this provider's discovery state alongside its story list.
|
|
2130
|
+
unavailable: () => editorHost().stories.discoveryUnavailable(),
|
|
2131
|
+
};
|
|
2132
|
+
|
|
2133
|
+
readonly structure: StructureProvider = {
|
|
2134
|
+
// Every structural op journals complete next-file text. Undo never needs to
|
|
2135
|
+
// address a moved or deleted element by an OID that may no longer exist.
|
|
2136
|
+
// The new element's OID is minted by the server on the next index, so the
|
|
2137
|
+
// id half is '' and the ack half is the write itself (`StructuralIdWrite`).
|
|
2138
|
+
create: (kind, parentId) => ({
|
|
2139
|
+
id: '',
|
|
2140
|
+
ack: this.structOp(parentId ?? '', 'create', { wrapperTag: kind }),
|
|
2141
|
+
}),
|
|
2142
|
+
// Preserve the acknowledgement so callers can await the source write.
|
|
2143
|
+
remove: (id) => this.removeElement(id),
|
|
2144
|
+
// Delete the selection in one request against one source snapshot, independent of DOM order.
|
|
2145
|
+
removeMany: (ids) => this.removeManyElements(ids),
|
|
2146
|
+
// Serialize duplication writes so same-file edits do not read competing snapshots.
|
|
2147
|
+
duplicate: (id) => ({ id, ack: this.structOp(id, 'duplicate') }),
|
|
2148
|
+
reparent: (id, newParentId) => {
|
|
2149
|
+
const parentOid = newParentId ? this.oidOf(newParentId) : undefined;
|
|
2150
|
+
if (!parentOid) {
|
|
2151
|
+
return this.structRefusal(
|
|
2152
|
+
`reparent refused: "${newParentId ?? 'the document root'}" has no authored source element to move into.`,
|
|
2153
|
+
);
|
|
2154
|
+
}
|
|
2155
|
+
return this.structOp(id, 'reparent', { parentOid });
|
|
2156
|
+
},
|
|
2157
|
+
// An absent before-sibling means append to the node's current parent.
|
|
2158
|
+
reorder: (id, beforeSiblingId) => {
|
|
2159
|
+
const parentId =
|
|
2160
|
+
beforeSiblingId === null ? (this.snapshot().nodes.get(id)?.parentId ?? null) : null;
|
|
2161
|
+
return this.reorder(id, beforeSiblingId, parentId);
|
|
2162
|
+
},
|
|
2163
|
+
// Every OID container accepts the curated HTML child kinds.
|
|
2164
|
+
creatableKinds: () => [...CREATABLE_KINDS],
|
|
2165
|
+
// Route wrapping through the same source-backed structure contract.
|
|
2166
|
+
wrap: (id, wrapperTag) => this.wrap(id, wrapperTag),
|
|
2167
|
+
unwrap: (id) => this.unwrap(id),
|
|
2168
|
+
};
|
|
2169
|
+
|
|
2170
|
+
readonly assetDrop: AssetDropProvider = {
|
|
2171
|
+
accepts: (nodeId, _assetPath, context) => this.assetDropPlan(nodeId, context).ok,
|
|
2172
|
+
drop: (nodeId, _assetPath, context) => {
|
|
2173
|
+
const plan = this.assetDropPlan(nodeId, context);
|
|
2174
|
+
if (!plan.ok) return this.structRefusal(`asset drop refused — ${plan.reason}`);
|
|
2175
|
+
return this.structOp(plan.parentId, 'create', {
|
|
2176
|
+
snippet: plan.snippet,
|
|
2177
|
+
ensureImport: plan.ensureImport,
|
|
2178
|
+
});
|
|
2179
|
+
},
|
|
2180
|
+
};
|
|
2181
|
+
|
|
2182
|
+
private assetDropPlan(nodeId: string, context: AssetDropContext): DomAssetDropPlan {
|
|
2183
|
+
if (!this.writeBackend) {
|
|
2184
|
+
return { ok: false, reason: 'This session has no source writer, so nothing can be added.' };
|
|
2185
|
+
}
|
|
2186
|
+
const node = this.snapshot().nodes.get(nodeId);
|
|
2187
|
+
if (!node) return { ok: false, reason: 'That row has no authored JSX element.' };
|
|
2188
|
+
if (!canContainDomChildren(node.el, node.tag)) {
|
|
2189
|
+
return { ok: false, reason: `<${node.tag}> cannot contain child elements.` };
|
|
2190
|
+
}
|
|
2191
|
+
const component = context.item;
|
|
2192
|
+
if (component.surface !== 'dom') {
|
|
2193
|
+
return {
|
|
2194
|
+
ok: false,
|
|
2195
|
+
reason: `${component.name} is a ${component.surface} component, not a React UI component.`,
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2198
|
+
const from = this.sourceLocation(nodeId)?.file;
|
|
2199
|
+
if (!from) {
|
|
2200
|
+
return { ok: false, reason: 'The target source location has not loaded yet.' };
|
|
2201
|
+
}
|
|
2202
|
+
return {
|
|
2203
|
+
ok: true,
|
|
2204
|
+
parentId: nodeId,
|
|
2205
|
+
snippet: `<${component.name} />`,
|
|
2206
|
+
ensureImport: {
|
|
2207
|
+
name: component.name,
|
|
2208
|
+
module: this.relativeProjectModule(from, component.sourcePath),
|
|
2209
|
+
kind: component.exportKind,
|
|
2210
|
+
},
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
private relativeProjectModule(fromFile: string, targetFile: string): string {
|
|
2215
|
+
return relativeImportSpecifier(projectSourcePath(fromFile), projectSourcePath(targetFile));
|
|
2216
|
+
}
|
|
2217
|
+
/** Wrap the element in a new container. */
|
|
2218
|
+
wrap(id: string, wrapperTag = 'div'): Promise<WriteAck> {
|
|
2219
|
+
return this.structOp(id, 'wrap', { wrapperTag });
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/** Replace the element with its children. */
|
|
2223
|
+
unwrap(id: string): Promise<WriteAck> {
|
|
2224
|
+
return this.structOp(id, 'unwrap');
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
/** Reorder the element before a sibling (`beforeId`), or to the end of `parentId` when
|
|
2228
|
+
* `beforeId` is null. */
|
|
2229
|
+
reorder(id: string, beforeId: string | null, parentId: string | null): Promise<WriteAck> {
|
|
2230
|
+
const opts: { targetOid?: string; parentOid?: string } = {};
|
|
2231
|
+
const targetOid = beforeId ? this.oidOf(beforeId) : undefined;
|
|
2232
|
+
const parentOid = parentId ? this.oidOf(parentId) : undefined;
|
|
2233
|
+
if (targetOid) opts.targetOid = targetOid;
|
|
2234
|
+
if (parentOid) opts.parentOid = parentOid;
|
|
2235
|
+
return this.structOp(id, 'reorder', opts);
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
/** The underlying (component-global) OID for an entity id (strips the `#n` repeat tag). */
|
|
2239
|
+
private oidOf(id: string): string | undefined {
|
|
2240
|
+
return this.snapshot().nodes.get(id)?.oid;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/** Indexed source location for an OID node, absent before the index resolves. */
|
|
2244
|
+
sourceLocation(id: string): OidEntry | undefined {
|
|
2245
|
+
const oid = this.oidOf(id);
|
|
2246
|
+
if (!oid) return undefined;
|
|
2247
|
+
return this.oidIndex.get(oid);
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/** Run a structural op through the persistence pipe (loud degradation without
|
|
2251
|
+
* a writer). */
|
|
2252
|
+
private structOp(id: string, op: string, opts?: StructOpOptions): Promise<WriteAck> {
|
|
2253
|
+
return this.structPipe.structOp(this.oidOf(id), id, op, opts, JSX_SOURCE_DESTINATION);
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/** Most recent style refusal, surfaced when a gesture writes nothing. */
|
|
2257
|
+
private lastStyleWriteRefusal: string | null = null;
|
|
2258
|
+
private refuseStyleWrite(message: string): void {
|
|
2259
|
+
this.lastStyleWriteRefusal = message;
|
|
2260
|
+
console.warn(message);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
private async writeStyleEntry(
|
|
2264
|
+
id: string,
|
|
2265
|
+
prop: string,
|
|
2266
|
+
value: string | number,
|
|
2267
|
+
priorInlineOverride?: string,
|
|
2268
|
+
backend: SourceWriteBackend | undefined = this.writeBackend,
|
|
2269
|
+
): Promise<boolean> {
|
|
2270
|
+
// Clear this write's echo on every refused or unavailable path.
|
|
2271
|
+
const echoPath = `${STYLE_PATH_PREFIX}${prop}`;
|
|
2272
|
+
const n = this.snapshot().nodes.get(id);
|
|
2273
|
+
if (!n) {
|
|
2274
|
+
this.clearEcho(id, echoPath);
|
|
2275
|
+
// Report stale selection when the node disappeared before the source write.
|
|
2276
|
+
this.refuseStyleWrite(
|
|
2277
|
+
`[ReactRootAuthoringAdapter] cannot write "${prop}": "${id}" no longer resolves in this ` +
|
|
2278
|
+
'root (the tree was re-projected — an HMR remount — between the selection and the ' +
|
|
2279
|
+
'write). Nothing was written. Re-select and retry.',
|
|
2280
|
+
);
|
|
2281
|
+
return false;
|
|
2282
|
+
}
|
|
2283
|
+
if (!backend) {
|
|
2284
|
+
this.clearEcho(id, echoPath);
|
|
2285
|
+
this.refuseStyleWrite(
|
|
2286
|
+
`[ReactRootAuthoringAdapter] cannot write "${prop}" on "${id}": no source-write ` +
|
|
2287
|
+
'backend in this session (hosted/no dev server) — selection/inspection still work.',
|
|
2288
|
+
);
|
|
2289
|
+
return false;
|
|
2290
|
+
}
|
|
2291
|
+
// A per-side longhand cannot share a style object with the shorthand that
|
|
2292
|
+
// owns it — React raises its own conflicting-property error and the winner
|
|
2293
|
+
// becomes key-order dependent. Expand the shorthand first, so the element
|
|
2294
|
+
// is authored in ONE vocabulary. A source no-op on a clean element.
|
|
2295
|
+
await this.expandShorthandsFor(id, prop, backend);
|
|
2296
|
+
// Route matching first-party declarations to their stylesheet.
|
|
2297
|
+
const breakpoint = activeBreakpoint();
|
|
2298
|
+
if (breakpoint !== null) {
|
|
2299
|
+
const named = namedStyleRuleFor(this.matchedCssRules(n.el), classTokensOf(n.el));
|
|
2300
|
+
if (!named) {
|
|
2301
|
+
const guard =
|
|
2302
|
+
'no style class carries this element — create one in the Style class row first ' +
|
|
2303
|
+
'(inline styles cannot be responsive, so a breakpoint edit needs a class rule).';
|
|
2304
|
+
this.clearEcho(id, echoPath);
|
|
2305
|
+
this.markGuarded(id, echoPath, guard);
|
|
2306
|
+
showTransientHint(guard);
|
|
2307
|
+
this.store.notifyIngestEdit();
|
|
2308
|
+
this.refuseStyleWrite(`[ReactRootAuthoringAdapter] breakpoint edit refused: ${guard}`);
|
|
2309
|
+
return false;
|
|
2310
|
+
}
|
|
2311
|
+
const res = await backend.writeCss(
|
|
2312
|
+
named.sourceFile,
|
|
2313
|
+
named.selectorText,
|
|
2314
|
+
prop,
|
|
2315
|
+
cssTextForStyleValue(prop, value),
|
|
2316
|
+
breakpoint,
|
|
2317
|
+
);
|
|
2318
|
+
if (!res.changed) {
|
|
2319
|
+
this.clearEcho(id, echoPath);
|
|
2320
|
+
this.refuseStyleWrite(
|
|
2321
|
+
`[ReactRootAuthoringAdapter] breakpoint css write refused/no-op for ` +
|
|
2322
|
+
`"${named.selectorText}" ${prop} at ${breakpoint}: ${res.error ?? 'no change'}`,
|
|
2323
|
+
);
|
|
2324
|
+
return false;
|
|
2325
|
+
}
|
|
2326
|
+
this.dirty = true;
|
|
2327
|
+
this.store.notifyIngestEdit();
|
|
2328
|
+
return true;
|
|
2329
|
+
}
|
|
2330
|
+
const matchedRules = this.matchedCssRules(n.el);
|
|
2331
|
+
const cssTarget = pickCssRuleTarget(matchedRules, prop);
|
|
2332
|
+
// Add undeclared properties to an existing named style unless inline CSS would shadow it.
|
|
2333
|
+
const namedTarget =
|
|
2334
|
+
!cssTarget && !liveInlineDeclares(n.el, prop)
|
|
2335
|
+
? namedStyleRuleFor(matchedRules, classTokensOf(n.el))
|
|
2336
|
+
: null;
|
|
2337
|
+
const routedRule = cssTarget ?? namedTarget;
|
|
2338
|
+
if (routedRule) {
|
|
2339
|
+
// A CSS FILE declaration is CSS TEXT, so a numeric value needs its unit
|
|
2340
|
+
// spelled here (`300` -> `300px`) — unlike the JSX object below, where
|
|
2341
|
+
// React does the px-ifying. `css-numeric-style.ts` owns that rule once.
|
|
2342
|
+
const res = await backend.writeCss(
|
|
2343
|
+
routedRule.sourceFile,
|
|
2344
|
+
routedRule.selectorText,
|
|
2345
|
+
prop,
|
|
2346
|
+
cssTextForStyleValue(prop, value),
|
|
2347
|
+
);
|
|
2348
|
+
if (res.changed) {
|
|
2349
|
+
this.dirty = true;
|
|
2350
|
+
this.store.notifyIngestEdit();
|
|
2351
|
+
return true;
|
|
2352
|
+
}
|
|
2353
|
+
if (!res.generated) {
|
|
2354
|
+
this.clearEcho(id, echoPath);
|
|
2355
|
+
this.refuseStyleWrite(
|
|
2356
|
+
`[ReactRootAuthoringAdapter] CSS write refused/no-op for selector ` +
|
|
2357
|
+
`"${routedRule.selectorText}" prop "${prop}": ${res.error ?? 'no change'}`,
|
|
2358
|
+
);
|
|
2359
|
+
return false;
|
|
2360
|
+
}
|
|
2361
|
+
// res.generated ⇒ selector is generated CSS — fall through to inline/class routing.
|
|
2362
|
+
}
|
|
2363
|
+
value = preserveNumericStyleUnit(value, styleProp(n.el.style, prop));
|
|
2364
|
+
// NOT `String(value)`. A pixel-valued numeric descriptor must reach the writer as a
|
|
2365
|
+
// NUMBER so the JSX carries a bare `300`, which React px-ifies at render;
|
|
2366
|
+
// a quoted `'300'` is passed through verbatim by React
|
|
2367
|
+
// and REJECTED by the CSSOM, which keeps the previous value — a source
|
|
2368
|
+
// write with no rendered effect, acked `persisted: true`.
|
|
2369
|
+
const res = await backend.writeStyle(n.oid, prop, value);
|
|
2370
|
+
if (!res.changed) {
|
|
2371
|
+
this.clearEcho(id, echoPath);
|
|
2372
|
+
// Expression and token-reference refusals keep their distinct reasons.
|
|
2373
|
+
const guard = res.tokenRef
|
|
2374
|
+
? tokenReferenceGuardText(prop, res.tokenRef)
|
|
2375
|
+
: res.dynamic
|
|
2376
|
+
? DYNAMIC_EXPRESSION_GUARD
|
|
2377
|
+
: null;
|
|
2378
|
+
if (guard) this.markGuarded(id, echoPath, guard);
|
|
2379
|
+
// Notify after clearing echo or marking the descriptor read-only.
|
|
2380
|
+
this.store.notifyIngestEdit();
|
|
2381
|
+
this.refuseStyleWrite(
|
|
2382
|
+
`[ReactRootAuthoringAdapter] style write refused/no-op for oid "${n.oid}" ` +
|
|
2383
|
+
`prop "${prop}": ${guard ?? res.error ?? 'no change'}`,
|
|
2384
|
+
);
|
|
2385
|
+
return false;
|
|
2386
|
+
}
|
|
2387
|
+
this.dirty = true;
|
|
2388
|
+
// Apply successful inline writes immediately for inspector feedback. Box
|
|
2389
|
+
// gestures already previewed their values; stylesheet writes return above
|
|
2390
|
+
// so an inline patch cannot shadow their rule. Live CSS lengths need units.
|
|
2391
|
+
if (priorInlineOverride === undefined) {
|
|
2392
|
+
(n.el.style as unknown as Record<string, unknown>)[prop] = cssTextForStyleValue(prop, value);
|
|
2393
|
+
}
|
|
2394
|
+
this.store.notifyIngestEdit();
|
|
2395
|
+
return true;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
private async writeStyle(id: string, prop: string, value: string | number): Promise<boolean> {
|
|
2399
|
+
return this.writeStyleEntry(id, prop, value);
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
/** Expand governing shorthands before writing a per-side property. */
|
|
2403
|
+
private async expandShorthandsFor(
|
|
2404
|
+
id: string,
|
|
2405
|
+
prop: string,
|
|
2406
|
+
backend: SourceWriteBackend | undefined,
|
|
2407
|
+
): Promise<void> {
|
|
2408
|
+
const chain = shorthandChain(prop);
|
|
2409
|
+
if (chain.length === 0) return;
|
|
2410
|
+
const n = this.snapshot().nodes.get(id);
|
|
2411
|
+
if (!n || !backend) return;
|
|
2412
|
+
for (const shorthand of chain) {
|
|
2413
|
+
const longhands = SHORTHAND_LONGHANDS[shorthand] ?? [];
|
|
2414
|
+
// Read BEFORE the removal — the removal drops the live inline override,
|
|
2415
|
+
// so a read afterwards would see the cascade without it.
|
|
2416
|
+
const resolved = longhands.map((longhand) =>
|
|
2417
|
+
getComputedStyleValue(n.el, longhand),
|
|
2418
|
+
);
|
|
2419
|
+
// The SAME backend the caller writes through — a box-edit commit hands
|
|
2420
|
+
// in a scoped writer for atomicity, and an expansion that reached around
|
|
2421
|
+
// it would split one gesture across two transactions.
|
|
2422
|
+
if (!(await this.removeStyleProp(id, shorthand, backend))) continue; // not authored here
|
|
2423
|
+
for (const [index, longhand] of longhands.entries()) {
|
|
2424
|
+
if (longhand === prop) continue; // the caller writes this one itself
|
|
2425
|
+
const value = resolved[index];
|
|
2426
|
+
if (!value) continue;
|
|
2427
|
+
await this.writeStyleEntry(id, longhand, value, undefined, backend);
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
/** Remove an authored style override through the source writer. */
|
|
2433
|
+
private async removeStyleProp(
|
|
2434
|
+
id: string,
|
|
2435
|
+
prop: string,
|
|
2436
|
+
backend: SourceWriteBackend | undefined = this.writeBackend,
|
|
2437
|
+
): Promise<boolean> {
|
|
2438
|
+
const n = this.snapshot().nodes.get(id);
|
|
2439
|
+
if (!n || !backend) return false;
|
|
2440
|
+
const res = await backend.removeStyle(n.oid, prop);
|
|
2441
|
+
if (!res.changed) return false; // longhand wasn't authored — nothing removed, no undo
|
|
2442
|
+
this.dirty = true;
|
|
2443
|
+
delete (n.el.style as unknown as Record<string, unknown>)[prop];
|
|
2444
|
+
this.store.notifyIngestEdit();
|
|
2445
|
+
return true;
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/** Replace leaf text through the source-side expression/children guard. */
|
|
2449
|
+
async editText(id: string, newText: string): Promise<void> {
|
|
2450
|
+
const n = this.snapshot().nodes.get(id);
|
|
2451
|
+
if (!n) return;
|
|
2452
|
+
if (!this.writeBackend) {
|
|
2453
|
+
console.warn(
|
|
2454
|
+
`[ReactRootAuthoringAdapter] cannot edit text on "${id}": no source-write backend ` +
|
|
2455
|
+
'in this session.',
|
|
2456
|
+
);
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
const res = await this.writeBackend.writeText(n.oid, newText);
|
|
2460
|
+
if (!res.changed) {
|
|
2461
|
+
// Remember dynamic-body refusal and notify its read-only indicator.
|
|
2462
|
+
if (res.dynamic) {
|
|
2463
|
+
this.markGuarded(
|
|
2464
|
+
id,
|
|
2465
|
+
TEXT_PATH,
|
|
2466
|
+
'The body has an expression or child elements, so replacing it with text is guarded.',
|
|
2467
|
+
);
|
|
2468
|
+
}
|
|
2469
|
+
this.store.notifyIngestEdit();
|
|
2470
|
+
console.warn(
|
|
2471
|
+
`[ReactRootAuthoringAdapter] text edit refused/no-op for oid "${n.oid}": ` +
|
|
2472
|
+
`${res.dynamic ? 'body has an expression/children (guarded)' : (res.error ?? 'no change')}`,
|
|
2473
|
+
);
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
this.dirty = true;
|
|
2477
|
+
// Reconcile text-derived layout hints after the HMR DOM update.
|
|
2478
|
+
this.pendingSourceReconcile++;
|
|
2479
|
+
this.store.notifyIngestEdit();
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/** Write a component prop at the callsite through its source guard. */
|
|
2483
|
+
private async writePropEdit(id: string, prop: string, value: string): Promise<boolean> {
|
|
2484
|
+
// Clear this prop write's optimistic echo on refusal.
|
|
2485
|
+
const echoPath = `${PROP_PATH_PREFIX}${prop}`;
|
|
2486
|
+
const n = this.snapshot().nodes.get(id);
|
|
2487
|
+
if (!n) {
|
|
2488
|
+
this.clearEcho(id, echoPath);
|
|
2489
|
+
return false;
|
|
2490
|
+
}
|
|
2491
|
+
if (!this.writeBackend) {
|
|
2492
|
+
this.clearEcho(id, echoPath);
|
|
2493
|
+
console.warn(
|
|
2494
|
+
`[ReactRootAuthoringAdapter] cannot write prop "${prop}" on "${id}": no source-write ` +
|
|
2495
|
+
'backend in this session.',
|
|
2496
|
+
);
|
|
2497
|
+
return false;
|
|
2498
|
+
}
|
|
2499
|
+
const cp = getComponentProps(n.el);
|
|
2500
|
+
if (!cp) {
|
|
2501
|
+
this.clearEcho(id, echoPath);
|
|
2502
|
+
console.warn(
|
|
2503
|
+
`[ReactRootAuthoringAdapter] "${id}" is not a component call site with props — ` +
|
|
2504
|
+
`cannot write prop "${prop}".`,
|
|
2505
|
+
);
|
|
2506
|
+
return false;
|
|
2507
|
+
}
|
|
2508
|
+
const res = await this.writeBackend.writeProp(cp.callSiteOid, prop, value);
|
|
2509
|
+
if (!res.changed) {
|
|
2510
|
+
this.clearEcho(id, echoPath);
|
|
2511
|
+
if (res.dynamic) this.markGuarded(id, echoPath, DYNAMIC_EXPRESSION_GUARD);
|
|
2512
|
+
// Notify the cleared echo and any newly read-only descriptor.
|
|
2513
|
+
this.store.notifyIngestEdit();
|
|
2514
|
+
console.warn(
|
|
2515
|
+
`[ReactRootAuthoringAdapter] prop write refused/no-op for oid "${cp.callSiteOid}" ` +
|
|
2516
|
+
`prop "${prop}": ${res.dynamic ? 'value is a dynamic expression (guarded)' : (res.error ?? 'no change')}`,
|
|
2517
|
+
);
|
|
2518
|
+
return false;
|
|
2519
|
+
}
|
|
2520
|
+
this.dirty = true;
|
|
2521
|
+
this.store.notifyIngestEdit();
|
|
2522
|
+
return true;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
private removeElement(id: string): Promise<WriteAck> {
|
|
2526
|
+
const n = this.snapshot().nodes.get(id);
|
|
2527
|
+
if (!n) return this.structRefusal(`cannot delete "${id}": no such node in this world.`);
|
|
2528
|
+
// Whole-file source history restores deletion without fabricating an inverse
|
|
2529
|
+
// element insertion from an OID that no longer exists.
|
|
2530
|
+
return this.structPipe.structOp(n.oid, id, 'delete', undefined, JSX_SOURCE_DESTINATION);
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
/** Resolve all selected OIDs and delete them against one source snapshot. */
|
|
2534
|
+
private removeManyElements(ids: readonly string[]): Promise<WriteAck> {
|
|
2535
|
+
const snapshot = this.snapshot();
|
|
2536
|
+
const oids = [
|
|
2537
|
+
...new Set(
|
|
2538
|
+
ids.map((id) => snapshot.nodes.get(id)?.oid).filter((oid): oid is string => !!oid),
|
|
2539
|
+
),
|
|
2540
|
+
];
|
|
2541
|
+
if (oids.length === 0) {
|
|
2542
|
+
return this.structRefusal('batch delete reached no source-addressable element.');
|
|
2543
|
+
}
|
|
2544
|
+
// One prepared batch source write becomes one history transaction.
|
|
2545
|
+
return this.structPipe.deleteMany(oids, JSX_SOURCE_DESTINATION);
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
subscribe(listener: () => void): () => void {
|
|
2549
|
+
return this.store.subscribe(listener);
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
// A class GETTER, not a field initializer: field initializers run before the
|
|
2553
|
+
// constructor body assigns `this.writeBackend`.
|
|
2554
|
+
get persistence(): PersistenceProvider {
|
|
2555
|
+
const backend = this.writeBackend;
|
|
2556
|
+
return {
|
|
2557
|
+
isDirty: () => this.dirty,
|
|
2558
|
+
save: async () => {
|
|
2559
|
+
// Source writes persist immediately; save has no pending queue to flush.
|
|
2560
|
+
},
|
|
2561
|
+
destination: backend ? JSX_SOURCE_DESTINATION : NO_BACKEND_DESTINATION,
|
|
2562
|
+
};
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
/** Value writes resolve through the shared persistence pipe. The backend
|
|
2566
|
+
* owns whole-file history, so this layer records no second undo entry. */
|
|
2567
|
+
private pipedSourceWrite(write: () => Promise<boolean>): Promise<WriteAck> {
|
|
2568
|
+
return runWritePipe({
|
|
2569
|
+
resolve: (): WriteResolution =>
|
|
2570
|
+
this.writeBackend
|
|
2571
|
+
? {
|
|
2572
|
+
reaches: 'writer',
|
|
2573
|
+
destination: JSX_SOURCE_DESTINATION,
|
|
2574
|
+
write,
|
|
2575
|
+
}
|
|
2576
|
+
: resolvesLiveOnly(NO_BACKEND_DESTINATION),
|
|
2577
|
+
record: () => undefined,
|
|
2578
|
+
// Report unavailable source writes at the pipe's resolution boundary.
|
|
2579
|
+
report: (reason) =>
|
|
2580
|
+
console.warn(`[ReactRootAuthoringAdapter] this edit stays live-only — ${reason}.`),
|
|
2581
|
+
});
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
/** Structural writes use the shared pipe and queue a post-HMR notification.
|
|
2585
|
+
* The backend owns their complete-file history transaction. */
|
|
2586
|
+
private readonly structPipe = createStructWritePipe({
|
|
2587
|
+
// biome-ignore lint/suspicious/noConsole: a refused source write must be visible
|
|
2588
|
+
report: (message) => console.warn(`[ReactRootAuthoringAdapter] ${message}`),
|
|
2589
|
+
noWriterReason: NO_BACKEND_DESTINATION,
|
|
2590
|
+
backend: () => this.writeBackend,
|
|
2591
|
+
onChanged: () => {
|
|
2592
|
+
this.dirty = true;
|
|
2593
|
+
this.pendingSourceReconcile++;
|
|
2594
|
+
this.store.notifyIngestEdit();
|
|
2595
|
+
},
|
|
2596
|
+
});
|
|
2597
|
+
|
|
2598
|
+
/** The shared pipe's live-only floor, under this lane's own label. */
|
|
2599
|
+
private structRefusal(reason: string): Promise<WriteAck> {
|
|
2600
|
+
return this.structPipe.structRefusal(reason);
|
|
2601
|
+
}
|
|
2602
|
+
}
|