@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,2247 @@
|
|
|
1
|
+
import { setAuthoringSelection } from '@volter/sdk/authoring/consumer-actions';
|
|
2
|
+
import { faBookBookmark, faCode, faPalette } from '@fortawesome/free-solid-svg-icons';
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Checkbox,
|
|
6
|
+
DraftTextInput,
|
|
7
|
+
Select,
|
|
8
|
+
TextArea,
|
|
9
|
+
TextInput,
|
|
10
|
+
} from '@volter/sdk/widgets';
|
|
11
|
+
|
|
12
|
+
/** React inspector contribution. Resolve the selected node's owning React
|
|
13
|
+
* adapter through the composite contract, group its property descriptors, and
|
|
14
|
+
* render each with the matching widget. Both change handlers commit through
|
|
15
|
+
* inspector.set, which owns optimistic echo and source writes. */
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
activeBreakpoint,
|
|
19
|
+
setActiveBreakpoint,
|
|
20
|
+
subscribeBreakpoint,
|
|
21
|
+
} from '@volter/sdk/breakpoint-state';
|
|
22
|
+
import { CompositeAuthoringAdapter } from '@volter/sdk/authoring/composite-authoring-adapter';
|
|
23
|
+
import { numericStyleValue, UNITLESS_NUMBER_PROPS } from '@volter/sdk/css-numeric-style';
|
|
24
|
+
import { beginEyedropperSession } from '@volter/sdk/eyedropper-session';
|
|
25
|
+
import { groupProperties } from '@volter/sdk/inspector-property-grouping';
|
|
26
|
+
import { useEditorStore } from '@volter/sdk/editor-runtime';
|
|
27
|
+
import { createHmrRegistrationGroup } from '@volter/sdk/hmr-registration-group';
|
|
28
|
+
import { groupIcon } from '@volter/sdk/inspection/group-icons';
|
|
29
|
+
import {
|
|
30
|
+
GROUP_SECTION_ORDER,
|
|
31
|
+
groupSectionId,
|
|
32
|
+
type InspectionSection,
|
|
33
|
+
PROPERTIES_SECTION_ID,
|
|
34
|
+
PROPERTIES_SECTION_ORDER,
|
|
35
|
+
} from '@volter/sdk/inspection-model';
|
|
36
|
+
import { registerInspectorSections } from '@volter/sdk/inspector-section-registry';
|
|
37
|
+
import { effectiveColorFromChain, getAvailableFonts } from '@volter/sdk/ui-source/inspect';
|
|
38
|
+
import type { OidEntry } from '@volter/editor-react/source/oid-transform';
|
|
39
|
+
import type { AuthoringAdapter, EditorNode, PropertyDescriptor } from '@volter/project/adapter';
|
|
40
|
+
import {
|
|
41
|
+
AlignmentGrid,
|
|
42
|
+
type AlignmentValue,
|
|
43
|
+
BorderEditor,
|
|
44
|
+
BorderRadiusEditor,
|
|
45
|
+
type BorderRadiusValue,
|
|
46
|
+
type BorderSide,
|
|
47
|
+
type BorderValue,
|
|
48
|
+
ColorSwatch,
|
|
49
|
+
composeFilter,
|
|
50
|
+
composeGradient,
|
|
51
|
+
composeShadows,
|
|
52
|
+
FilterEditor,
|
|
53
|
+
type FilterFunc,
|
|
54
|
+
FONT_MONO,
|
|
55
|
+
FontPicker,
|
|
56
|
+
fontSizeVar,
|
|
57
|
+
GradientEditor,
|
|
58
|
+
type GradientValue,
|
|
59
|
+
isUniformBorder,
|
|
60
|
+
isUniformRadius,
|
|
61
|
+
LABEL_COL_WIDTH,
|
|
62
|
+
lineHeightVar,
|
|
63
|
+
parseFilterFunctions,
|
|
64
|
+
parseGradient,
|
|
65
|
+
parseShadows,
|
|
66
|
+
radius,
|
|
67
|
+
rowStyle,
|
|
68
|
+
ScrubbableInput,
|
|
69
|
+
ShadowEditor,
|
|
70
|
+
spaceVar,
|
|
71
|
+
TextShadowEditor,
|
|
72
|
+
THEME,
|
|
73
|
+
} from '@volter/sdk/widgets';
|
|
74
|
+
import { createElement, useMemo, useState, useSyncExternalStore } from 'react';
|
|
75
|
+
import { cssColorToHex, ReactRootAuthoringAdapter } from './react-world-authoring-adapter';
|
|
76
|
+
|
|
77
|
+
/** Resolve the React adapter that owns the selected node, including
|
|
78
|
+
* composite children. Return null for an unresolved or non-React subject. */
|
|
79
|
+
function resolveReactOwner(
|
|
80
|
+
node: EditorNode | null,
|
|
81
|
+
adapter: AuthoringAdapter,
|
|
82
|
+
): ReactRootAuthoringAdapter | null {
|
|
83
|
+
if (!node) return null;
|
|
84
|
+
const target: AuthoringAdapter | null = (() => {
|
|
85
|
+
if (!(adapter instanceof CompositeAuthoringAdapter)) return adapter;
|
|
86
|
+
const owner = adapter.ownerOf(node.id);
|
|
87
|
+
if (owner === null) return null;
|
|
88
|
+
return adapter.childAdapters().find((c) => c.worldId === owner)?.adapter ?? null;
|
|
89
|
+
})();
|
|
90
|
+
if (target instanceof ReactRootAuthoringAdapter) return target;
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The registry `match`: any real selection owned by a react/DOM adapter. */
|
|
95
|
+
const isDomSelection = (node: EditorNode | null, adapter: AuthoringAdapter): boolean =>
|
|
96
|
+
resolveReactOwner(node, adapter) !== null;
|
|
97
|
+
|
|
98
|
+
/** Element and story facets precede property groups on the shared inspection ordering scale.
|
|
99
|
+
* Section ids name the inspected facet; the subject's identity row names the element itself. */
|
|
100
|
+
const REACT_ELEMENT_SECTION_ID = 'element';
|
|
101
|
+
const REACT_ELEMENT_SECTION_ORDER = 1500;
|
|
102
|
+
const REACT_STORY_SECTION_ID = 'story';
|
|
103
|
+
const REACT_STORY_SECTION_ORDER = 1600;
|
|
104
|
+
|
|
105
|
+
// --- Property descriptor to widget mapping ---
|
|
106
|
+
|
|
107
|
+
type ReactPropertyWidgetKind =
|
|
108
|
+
| 'color'
|
|
109
|
+
| 'font'
|
|
110
|
+
| 'gradient'
|
|
111
|
+
| 'shadow'
|
|
112
|
+
| 'text-shadow'
|
|
113
|
+
| 'filter'
|
|
114
|
+
| 'backdrop-filter'
|
|
115
|
+
| 'radius'
|
|
116
|
+
| 'transform'
|
|
117
|
+
| 'scrub'
|
|
118
|
+
| 'select'
|
|
119
|
+
| 'checkbox'
|
|
120
|
+
| 'json'
|
|
121
|
+
| 'text';
|
|
122
|
+
|
|
123
|
+
function propNameFromPath(path: string): string {
|
|
124
|
+
const idx = path.indexOf('.');
|
|
125
|
+
return idx === -1 ? path : path.slice(idx + 1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Map structured CSS properties by name and other descriptors by type. */
|
|
129
|
+
function widgetKindFor(prop: PropertyDescriptor): ReactPropertyWidgetKind {
|
|
130
|
+
const name = propNameFromPath(prop.path);
|
|
131
|
+
if (prop.type === 'color') return 'color';
|
|
132
|
+
if (/^fontFamily$/i.test(name)) return 'font';
|
|
133
|
+
if (name === 'background' || name === 'backgroundImage') return 'gradient';
|
|
134
|
+
if (name === 'boxShadow') return 'shadow';
|
|
135
|
+
if (name === 'textShadow') return 'text-shadow';
|
|
136
|
+
if (name === 'filter') return 'filter';
|
|
137
|
+
if (name === 'backdropFilter') return 'backdrop-filter';
|
|
138
|
+
if (name === 'borderRadius') return 'radius';
|
|
139
|
+
// Scoped to `type: 'string'` (the react-world `style.transform` CSS-string
|
|
140
|
+
// descriptor) so an unrelated `transform` name of a DIFFERENT type — e.g. a
|
|
141
|
+
// three `vec3` position/transform prop — still falls through to its own
|
|
142
|
+
// generic widget instead of being misread as a CSS transform string.
|
|
143
|
+
if (prop.type === 'string' && name === 'transform') return 'transform';
|
|
144
|
+
if (prop.type === 'boolean') return 'checkbox';
|
|
145
|
+
if (prop.type === 'enum') return 'select';
|
|
146
|
+
if (prop.type === 'json') return 'json';
|
|
147
|
+
if (prop.type === 'number') return 'scrub';
|
|
148
|
+
// Other descriptors use the plain text input.
|
|
149
|
+
return 'text';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A composed row: either one property, or a "combo" of several sibling
|
|
153
|
+
* properties this section renders as ONE richer widget (per-side border,
|
|
154
|
+
* 3x3 flex alignment) instead of N separate fields. */
|
|
155
|
+
type PropertyRow =
|
|
156
|
+
| {
|
|
157
|
+
kind: 'border';
|
|
158
|
+
width: PropertyDescriptor;
|
|
159
|
+
style: PropertyDescriptor;
|
|
160
|
+
color: PropertyDescriptor;
|
|
161
|
+
}
|
|
162
|
+
| { kind: 'align'; justify: PropertyDescriptor; align: PropertyDescriptor }
|
|
163
|
+
| { kind: 'single'; prop: PropertyDescriptor };
|
|
164
|
+
|
|
165
|
+
/** Emit one combo at its first member and omit members already consumed. */
|
|
166
|
+
function tryBorderCombo(
|
|
167
|
+
p: PropertyDescriptor,
|
|
168
|
+
byPath: ReadonlyMap<string, PropertyDescriptor>,
|
|
169
|
+
consumed: Set<string>,
|
|
170
|
+
rows: PropertyRow[],
|
|
171
|
+
): boolean {
|
|
172
|
+
const isBorderProp =
|
|
173
|
+
p.path === 'style.borderWidth' ||
|
|
174
|
+
p.path === 'style.borderStyle' ||
|
|
175
|
+
p.path === 'style.borderColor';
|
|
176
|
+
if (!isBorderProp) return false;
|
|
177
|
+
const width = byPath.get('style.borderWidth');
|
|
178
|
+
const style = byPath.get('style.borderStyle');
|
|
179
|
+
const color = byPath.get('style.borderColor');
|
|
180
|
+
if (!width || !style || !color) return false;
|
|
181
|
+
rows.push({ kind: 'border', width, style, color });
|
|
182
|
+
consumed.add(width.path);
|
|
183
|
+
consumed.add(style.path);
|
|
184
|
+
consumed.add(color.path);
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Emit one alignment combo when both justifyContent and alignItems are present. */
|
|
189
|
+
function tryAlignCombo(
|
|
190
|
+
p: PropertyDescriptor,
|
|
191
|
+
byPath: ReadonlyMap<string, PropertyDescriptor>,
|
|
192
|
+
consumed: Set<string>,
|
|
193
|
+
rows: PropertyRow[],
|
|
194
|
+
): boolean {
|
|
195
|
+
const isAlignProp = p.path === 'style.justifyContent' || p.path === 'style.alignItems';
|
|
196
|
+
if (!isAlignProp) return false;
|
|
197
|
+
const justify = byPath.get('style.justifyContent');
|
|
198
|
+
const align = byPath.get('style.alignItems');
|
|
199
|
+
if (!justify || !align) return false;
|
|
200
|
+
rows.push({ kind: 'align', justify, align });
|
|
201
|
+
consumed.add(justify.path);
|
|
202
|
+
consumed.add(align.path);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function buildPropertyRows(props: readonly PropertyDescriptor[]): PropertyRow[] {
|
|
207
|
+
const byPath = new Map(props.map((p) => [p.path, p] as const));
|
|
208
|
+
const consumed = new Set<string>();
|
|
209
|
+
const rows: PropertyRow[] = [];
|
|
210
|
+
for (const p of props) {
|
|
211
|
+
if (consumed.has(p.path)) continue;
|
|
212
|
+
if (tryBorderCombo(p, byPath, consumed, rows)) continue;
|
|
213
|
+
if (tryAlignCombo(p, byPath, consumed, rows)) continue;
|
|
214
|
+
rows.push({ kind: 'single', prop: p });
|
|
215
|
+
}
|
|
216
|
+
return rows;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function numericValue(v: unknown): number {
|
|
220
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function testIdFor(path: string): string {
|
|
224
|
+
return `react-prop-${path.replace(/\./g, '-')}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const DEFAULT_GRADIENT: GradientValue = {
|
|
228
|
+
type: 'linear',
|
|
229
|
+
angle: 180,
|
|
230
|
+
stops: [
|
|
231
|
+
{ color: '#ffffff', position: 0 },
|
|
232
|
+
{ color: '#000000', position: 1 },
|
|
233
|
+
],
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** Labeled property row. Its wrapper uses testId-row, keeping the control
|
|
237
|
+
* itself addressable by the unsuffixed testId. */
|
|
238
|
+
function Field({
|
|
239
|
+
label,
|
|
240
|
+
testId,
|
|
241
|
+
children,
|
|
242
|
+
}: {
|
|
243
|
+
label: string;
|
|
244
|
+
testId: string;
|
|
245
|
+
children: React.ReactNode;
|
|
246
|
+
}): React.ReactElement {
|
|
247
|
+
return (
|
|
248
|
+
<div
|
|
249
|
+
data-testid={`${testId}-row`}
|
|
250
|
+
style={{ ...rowStyle, padding: `${spaceVar[2]} ${spaceVar[4]}` }}
|
|
251
|
+
>
|
|
252
|
+
<span
|
|
253
|
+
style={{
|
|
254
|
+
width: LABEL_COL_WIDTH,
|
|
255
|
+
flexShrink: 0,
|
|
256
|
+
fontSize: fontSizeVar.base,
|
|
257
|
+
color: THEME.textMuted,
|
|
258
|
+
}}
|
|
259
|
+
>
|
|
260
|
+
{label}
|
|
261
|
+
</span>
|
|
262
|
+
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The border and alignment variants rendered by ComboRow. */
|
|
268
|
+
type ComboPropertyRow = Extract<PropertyRow, { kind: 'border' } | { kind: 'align' }>;
|
|
269
|
+
|
|
270
|
+
interface ComboRowProps {
|
|
271
|
+
adapter: AuthoringAdapter;
|
|
272
|
+
id: string;
|
|
273
|
+
row: ComboPropertyRow;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Shared row inputs: raw is read from the Inspector; commit writes the
|
|
277
|
+
* requested path and notifies the store through the row's existing edit path. */
|
|
278
|
+
interface KindRowProps {
|
|
279
|
+
prop: PropertyDescriptor;
|
|
280
|
+
raw: unknown;
|
|
281
|
+
disabled: boolean | undefined;
|
|
282
|
+
testId: string;
|
|
283
|
+
commit: (path: string, value: unknown) => void;
|
|
284
|
+
fonts: readonly string[];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function ColorRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
|
|
288
|
+
return (
|
|
289
|
+
<Field label={prop.label} testId={testId}>
|
|
290
|
+
<ColorSwatch
|
|
291
|
+
testId={testId}
|
|
292
|
+
value={String(raw ?? '#ffffff')}
|
|
293
|
+
disabled={disabled}
|
|
294
|
+
onChange={(c) => commit(prop.path, c)}
|
|
295
|
+
onChangeEnd={(c) => commit(prop.path, c)}
|
|
296
|
+
eyedropperFallback={() => effectiveColorFromChain([String(raw ?? '')])}
|
|
297
|
+
// Use an interactive canvas sample session when native EyeDropper is unavailable.
|
|
298
|
+
onEyedropperPick={(apply) => beginEyedropperSession(apply)}
|
|
299
|
+
/>
|
|
300
|
+
</Field>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function FontRow({ prop, raw, disabled, fonts, testId, commit }: KindRowProps): React.ReactElement {
|
|
305
|
+
return (
|
|
306
|
+
<Field label={prop.label} testId={testId}>
|
|
307
|
+
<FontPicker
|
|
308
|
+
testId={testId}
|
|
309
|
+
value={String(raw ?? '')}
|
|
310
|
+
fonts={fonts}
|
|
311
|
+
disabled={disabled}
|
|
312
|
+
onChange={(f) => commit(prop.path, f)}
|
|
313
|
+
/>
|
|
314
|
+
</Field>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function GradientRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
|
|
319
|
+
const parsed = parseGradient(String(raw ?? 'none'));
|
|
320
|
+
// Show an empty state without a real gradient; seed defaults only for an explicit add.
|
|
321
|
+
if (!parsed) {
|
|
322
|
+
return (
|
|
323
|
+
<Field label={prop.label} testId={testId}>
|
|
324
|
+
<Button
|
|
325
|
+
type="button"
|
|
326
|
+
variant="secondary"
|
|
327
|
+
className="volter-field-trigger"
|
|
328
|
+
data-testid={testId}
|
|
329
|
+
disabled={disabled}
|
|
330
|
+
onClick={() => commit(prop.path, composeGradient(DEFAULT_GRADIENT))}
|
|
331
|
+
>
|
|
332
|
+
No gradient — click to add
|
|
333
|
+
</Button>
|
|
334
|
+
</Field>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return (
|
|
338
|
+
<Field label={prop.label} testId={testId}>
|
|
339
|
+
<GradientEditor
|
|
340
|
+
testId={testId}
|
|
341
|
+
value={parsed}
|
|
342
|
+
disabled={disabled}
|
|
343
|
+
onChange={(g) => commit(prop.path, composeGradient(g))}
|
|
344
|
+
onChangeEnd={(g) => commit(prop.path, composeGradient(g))}
|
|
345
|
+
/>
|
|
346
|
+
</Field>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function ShadowRow({
|
|
351
|
+
prop,
|
|
352
|
+
raw,
|
|
353
|
+
disabled,
|
|
354
|
+
testId,
|
|
355
|
+
commit,
|
|
356
|
+
withSpreadInset,
|
|
357
|
+
}: KindRowProps & { withSpreadInset: boolean }): React.ReactElement {
|
|
358
|
+
const parsed = parseShadows(String(raw ?? 'none'), withSpreadInset);
|
|
359
|
+
const Widget = withSpreadInset ? ShadowEditor : TextShadowEditor;
|
|
360
|
+
return (
|
|
361
|
+
<Field label={prop.label} testId={testId}>
|
|
362
|
+
<Widget
|
|
363
|
+
value={parsed}
|
|
364
|
+
disabled={disabled}
|
|
365
|
+
onChange={(list) => commit(prop.path, composeShadows(list, withSpreadInset))}
|
|
366
|
+
onChangeEnd={(list) => commit(prop.path, composeShadows(list, withSpreadInset))}
|
|
367
|
+
/>
|
|
368
|
+
</Field>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function FilterRow({
|
|
373
|
+
prop,
|
|
374
|
+
raw,
|
|
375
|
+
disabled,
|
|
376
|
+
testId,
|
|
377
|
+
commit,
|
|
378
|
+
label,
|
|
379
|
+
}: KindRowProps & { label: string }): React.ReactElement {
|
|
380
|
+
const parsed = parseFilterFunctions(String(raw ?? 'none'));
|
|
381
|
+
return (
|
|
382
|
+
<Field label={prop.label} testId={testId}>
|
|
383
|
+
<FilterEditor
|
|
384
|
+
testId={testId}
|
|
385
|
+
label={label}
|
|
386
|
+
value={parsed}
|
|
387
|
+
disabled={disabled}
|
|
388
|
+
onChange={(f: FilterFunc[]) => commit(prop.path, composeFilter(f))}
|
|
389
|
+
onChangeEnd={(f: FilterFunc[]) => commit(prop.path, composeFilter(f))}
|
|
390
|
+
/>
|
|
391
|
+
</Field>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Corner suffixes (`borderTopLeftRadius`, …) shared by {@link RadiusRow}'s
|
|
396
|
+
* read + write-back — the per-corner longhand CSS the shorthand `borderRadius`
|
|
397
|
+
* descriptor (`prop`/`raw`/`commit` above) never had a slot for. */
|
|
398
|
+
const RADIUS_CORNERS: ReadonlyArray<{ key: keyof BorderRadiusValue; suffix: string }> = [
|
|
399
|
+
{ key: 'topLeft', suffix: 'TopLeft' },
|
|
400
|
+
{ key: 'topRight', suffix: 'TopRight' },
|
|
401
|
+
{ key: 'bottomLeft', suffix: 'BottomLeft' },
|
|
402
|
+
{ key: 'bottomRight', suffix: 'BottomRight' },
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
/** Read each corner longhand, with shorthand fallback when unavailable.
|
|
406
|
+
* Uniform writes use borderRadius; nonuniform writes use four corner values. */
|
|
407
|
+
function RadiusRow({
|
|
408
|
+
adapter,
|
|
409
|
+
id,
|
|
410
|
+
prop,
|
|
411
|
+
raw,
|
|
412
|
+
disabled,
|
|
413
|
+
testId,
|
|
414
|
+
commit,
|
|
415
|
+
removeStyle,
|
|
416
|
+
}: KindRowProps & {
|
|
417
|
+
adapter: AuthoringAdapter;
|
|
418
|
+
id: string;
|
|
419
|
+
removeStyle: (path: string) => void;
|
|
420
|
+
}): React.ReactElement {
|
|
421
|
+
const fallback = numericValue(raw);
|
|
422
|
+
const value: BorderRadiusValue = RADIUS_CORNERS.reduce((acc, { key, suffix }) => {
|
|
423
|
+
const cornerRaw = adapter.inspector?.get(id, `style.border${suffix}Radius`);
|
|
424
|
+
acc[key] = numericStyleValue(cornerRaw) ?? fallback;
|
|
425
|
+
return acc;
|
|
426
|
+
}, {} as BorderRadiusValue);
|
|
427
|
+
const writeBack = (v: BorderRadiusValue): void => {
|
|
428
|
+
if (isUniformRadius(v)) {
|
|
429
|
+
commit(prop.path, v.topLeft);
|
|
430
|
+
// Remove corner longhands so a uniform shorthand controls every corner.
|
|
431
|
+
for (const { suffix } of RADIUS_CORNERS) {
|
|
432
|
+
removeStyle(`style.border${suffix}Radius`);
|
|
433
|
+
}
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
for (const { key, suffix } of RADIUS_CORNERS) {
|
|
437
|
+
commit(`style.border${suffix}Radius`, v[key]);
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
return (
|
|
441
|
+
<Field label={prop.label} testId={testId}>
|
|
442
|
+
<BorderRadiusEditor
|
|
443
|
+
value={value}
|
|
444
|
+
disabled={disabled}
|
|
445
|
+
onChange={writeBack}
|
|
446
|
+
onChangeEnd={writeBack}
|
|
447
|
+
/>
|
|
448
|
+
</Field>
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function ScrubRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
|
|
453
|
+
const unit = UNITLESS_NUMBER_PROPS.has(propNameFromPath(prop.path)) ? '' : 'px';
|
|
454
|
+
return (
|
|
455
|
+
<ScrubbableInput
|
|
456
|
+
testId={testId}
|
|
457
|
+
label={prop.label}
|
|
458
|
+
value={numericValue(raw)}
|
|
459
|
+
unit={unit}
|
|
460
|
+
disabled={disabled}
|
|
461
|
+
onChange={(v) => commit(prop.path, v)}
|
|
462
|
+
onChangeEnd={(v) => commit(prop.path, v)}
|
|
463
|
+
style={{ padding: `${spaceVar[2]} ${spaceVar[4]}` }}
|
|
464
|
+
/>
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function SelectRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
|
|
469
|
+
return (
|
|
470
|
+
<Field label={prop.label} testId={testId}>
|
|
471
|
+
<Select
|
|
472
|
+
data-testid={testId}
|
|
473
|
+
value={String(raw ?? '')}
|
|
474
|
+
disabled={disabled}
|
|
475
|
+
data-dynamic={disabled || undefined}
|
|
476
|
+
onChange={(e) => commit(prop.path, e.target.value)}
|
|
477
|
+
style={{ width: '100%' }}
|
|
478
|
+
>
|
|
479
|
+
{(prop.options ?? []).map((o) => (
|
|
480
|
+
<option key={String(o)} value={String(o)}>
|
|
481
|
+
{String(o)}
|
|
482
|
+
</option>
|
|
483
|
+
))}
|
|
484
|
+
</Select>
|
|
485
|
+
</Field>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function CheckboxRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
|
|
490
|
+
return (
|
|
491
|
+
<label
|
|
492
|
+
style={{
|
|
493
|
+
display: 'flex',
|
|
494
|
+
gap: spaceVar[3],
|
|
495
|
+
alignItems: 'center',
|
|
496
|
+
padding: `${spaceVar[3]} ${spaceVar[4]}`,
|
|
497
|
+
fontSize: fontSizeVar.md,
|
|
498
|
+
}}
|
|
499
|
+
>
|
|
500
|
+
<Checkbox
|
|
501
|
+
className="volter-checkbox"
|
|
502
|
+
data-testid={testId}
|
|
503
|
+
checked={raw !== false}
|
|
504
|
+
disabled={disabled}
|
|
505
|
+
onChange={(e) => commit(prop.path, e.target.checked)}
|
|
506
|
+
/>
|
|
507
|
+
{prop.label}
|
|
508
|
+
</label>
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function JsonRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
|
|
513
|
+
return (
|
|
514
|
+
<Field label={prop.label} testId={testId}>
|
|
515
|
+
<TextArea
|
|
516
|
+
data-testid={testId}
|
|
517
|
+
defaultValue={JSON.stringify(raw ?? null)}
|
|
518
|
+
disabled={disabled}
|
|
519
|
+
data-dynamic={disabled || undefined}
|
|
520
|
+
data-variant="code"
|
|
521
|
+
style={{ width: '100%', minHeight: 40 }}
|
|
522
|
+
onBlur={(e) => {
|
|
523
|
+
try {
|
|
524
|
+
commit(prop.path, JSON.parse(e.target.value));
|
|
525
|
+
} catch {
|
|
526
|
+
// Invalid JSON — leave the field as typed, don't write garbage.
|
|
527
|
+
}
|
|
528
|
+
}}
|
|
529
|
+
/>
|
|
530
|
+
</Field>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function TextRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
|
|
535
|
+
// Commit the draft on Enter or blur so source reprojection does not interrupt typing.
|
|
536
|
+
return (
|
|
537
|
+
<Field label={prop.label} testId={testId}>
|
|
538
|
+
<DraftTextInput
|
|
539
|
+
type="text"
|
|
540
|
+
data-testid={testId}
|
|
541
|
+
readOnly={disabled}
|
|
542
|
+
data-dynamic={disabled || undefined}
|
|
543
|
+
value={String(raw ?? '')}
|
|
544
|
+
onCommit={(next) => commit(prop.path, next)}
|
|
545
|
+
style={{ width: '100%' }}
|
|
546
|
+
/>
|
|
547
|
+
</Field>
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Rotate and scale controls for losslessly representable transforms. */
|
|
552
|
+
interface TransformValue {
|
|
553
|
+
rotateDeg: number;
|
|
554
|
+
scale: number;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const TRANSFORM_ROTATE_SCALE_RE = /^rotate\(\s*(-?[\d.]+)deg\s*\)\s*scale\(\s*(-?[\d.]+)\s*\)$/i;
|
|
558
|
+
const TRANSFORM_SCALE_ROTATE_RE = /^scale\(\s*(-?[\d.]+)\s*\)\s*rotate\(\s*(-?[\d.]+)deg\s*\)$/i;
|
|
559
|
+
// Authored single-function literals also support the structured control.
|
|
560
|
+
const TRANSFORM_ROTATE_ONLY_RE = /^rotate\(\s*(-?[\d.]+)deg\s*\)$/i;
|
|
561
|
+
const TRANSFORM_SCALE_ONLY_RE = /^scale\(\s*(-?[\d.]+)\s*\)$/i;
|
|
562
|
+
const TRANSFORM_MATRIX_RE =
|
|
563
|
+
/^matrix\(\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*\)$/;
|
|
564
|
+
const TRANSFORM_EPS = 1e-4;
|
|
565
|
+
|
|
566
|
+
/** Parse authored rotate/scale literals and browser-computed 2D matrices.
|
|
567
|
+
* Translation, skew, nonuniform or degenerate scale, and other transform
|
|
568
|
+
* functions remain editable as raw text rather than losing information. */
|
|
569
|
+
function parseTransform(raw: string): TransformValue | null {
|
|
570
|
+
const v = raw.trim();
|
|
571
|
+
if (v === '' || v === 'none') return { rotateDeg: 0, scale: 1 };
|
|
572
|
+
const lit = TRANSFORM_ROTATE_SCALE_RE.exec(v);
|
|
573
|
+
if (lit) return { rotateDeg: Number(lit[1]), scale: Number(lit[2]) };
|
|
574
|
+
const swapped = TRANSFORM_SCALE_ROTATE_RE.exec(v);
|
|
575
|
+
if (swapped) return { rotateDeg: Number(swapped[2]), scale: Number(swapped[1]) };
|
|
576
|
+
const rotOnly = TRANSFORM_ROTATE_ONLY_RE.exec(v);
|
|
577
|
+
if (rotOnly) return { rotateDeg: Number(rotOnly[1]), scale: 1 }; // Missing scale defaults to one.
|
|
578
|
+
const scaleOnly = TRANSFORM_SCALE_ONLY_RE.exec(v);
|
|
579
|
+
if (scaleOnly) return { rotateDeg: 0, scale: Number(scaleOnly[1]) }; // Missing rotation defaults to zero.
|
|
580
|
+
const m = TRANSFORM_MATRIX_RE.exec(v);
|
|
581
|
+
if (!m) return null; // matrix3d / translate / skew / any other function list
|
|
582
|
+
const [a, b, c, d, e, f] = m.slice(1, 7).map(Number) as [
|
|
583
|
+
number,
|
|
584
|
+
number,
|
|
585
|
+
number,
|
|
586
|
+
number,
|
|
587
|
+
number,
|
|
588
|
+
number,
|
|
589
|
+
];
|
|
590
|
+
if (Math.abs(e) > TRANSFORM_EPS || Math.abs(f) > TRANSFORM_EPS) return null; // has a translate
|
|
591
|
+
if (Math.abs(c + b) > TRANSFORM_EPS || Math.abs(d - a) > TRANSFORM_EPS) return null; // skew / non-uniform scale
|
|
592
|
+
const scale = Math.hypot(a, b);
|
|
593
|
+
if (scale < TRANSFORM_EPS) return null; // degenerate (zero scale)
|
|
594
|
+
const rotateDeg = (Math.atan2(b, a) * 180) / Math.PI;
|
|
595
|
+
return {
|
|
596
|
+
rotateDeg: Math.round(rotateDeg * 100) / 100,
|
|
597
|
+
scale: Math.round(scale * 1000) / 1000,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** The inverse of {@link parseTransform}'s literal fast-path — always
|
|
602
|
+
* composes the readable `rotate()/scale()` form, never a matrix, so the
|
|
603
|
+
* written source stays human-editable. */
|
|
604
|
+
function composeTransform(v: TransformValue): string {
|
|
605
|
+
return `rotate(${v.rotateDeg}deg) scale(${v.scale})`;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function TransformRow(props: KindRowProps): React.ReactElement {
|
|
609
|
+
const { prop, raw, disabled, testId, commit } = props;
|
|
610
|
+
const parsed = parseTransform(String(raw ?? 'none'));
|
|
611
|
+
if (!parsed) return <TextRow {...props} />; // safety net — no expressiveness lost
|
|
612
|
+
const writeBack = (next: TransformValue): void => commit(prop.path, composeTransform(next));
|
|
613
|
+
return (
|
|
614
|
+
<Field label={prop.label} testId={testId}>
|
|
615
|
+
<div style={{ display: 'flex', gap: spaceVar[3] }}>
|
|
616
|
+
<ScrubbableInput
|
|
617
|
+
testId={`${testId}-rotate`}
|
|
618
|
+
label="Rotate"
|
|
619
|
+
value={parsed.rotateDeg}
|
|
620
|
+
unit="deg"
|
|
621
|
+
disabled={disabled}
|
|
622
|
+
onChange={(v) => writeBack({ ...parsed, rotateDeg: v })}
|
|
623
|
+
onChangeEnd={(v) => writeBack({ ...parsed, rotateDeg: v })}
|
|
624
|
+
style={{ flex: 1 }}
|
|
625
|
+
/>
|
|
626
|
+
<ScrubbableInput
|
|
627
|
+
testId={`${testId}-scale`}
|
|
628
|
+
label="Scale"
|
|
629
|
+
value={parsed.scale}
|
|
630
|
+
unit="x"
|
|
631
|
+
step={0.1}
|
|
632
|
+
disabled={disabled}
|
|
633
|
+
onChange={(v) => writeBack({ ...parsed, scale: v })}
|
|
634
|
+
onChangeEnd={(v) => writeBack({ ...parsed, scale: v })}
|
|
635
|
+
style={{ flex: 1 }}
|
|
636
|
+
/>
|
|
637
|
+
</div>
|
|
638
|
+
</Field>
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Select the widget for one property row. Preview and final changes both
|
|
643
|
+
* use the same source commit path. */
|
|
644
|
+
function SingleRow({
|
|
645
|
+
adapter,
|
|
646
|
+
id,
|
|
647
|
+
prop,
|
|
648
|
+
fonts,
|
|
649
|
+
}: {
|
|
650
|
+
adapter: AuthoringAdapter;
|
|
651
|
+
id: string;
|
|
652
|
+
prop: PropertyDescriptor;
|
|
653
|
+
fonts: readonly string[];
|
|
654
|
+
}): React.ReactElement {
|
|
655
|
+
const store = useEditorStore();
|
|
656
|
+
const commit = (path: string, value: unknown): void => {
|
|
657
|
+
adapter.inspector?.set(id, path, value);
|
|
658
|
+
store.notifyIngestEdit();
|
|
659
|
+
};
|
|
660
|
+
// Remove an authored longhand when this inspector supports removal.
|
|
661
|
+
const removeStyle = (path: string): void => {
|
|
662
|
+
adapter.inspector?.remove?.(id, path);
|
|
663
|
+
store.notifyIngestEdit();
|
|
664
|
+
};
|
|
665
|
+
const rowProps: KindRowProps = {
|
|
666
|
+
prop,
|
|
667
|
+
raw: adapter.inspector?.get(id, prop.path),
|
|
668
|
+
disabled: prop.readonly,
|
|
669
|
+
testId: testIdFor(prop.path),
|
|
670
|
+
commit,
|
|
671
|
+
fonts,
|
|
672
|
+
};
|
|
673
|
+
switch (widgetKindFor(prop)) {
|
|
674
|
+
case 'color':
|
|
675
|
+
return <ColorRow {...rowProps} />;
|
|
676
|
+
case 'font':
|
|
677
|
+
return <FontRow {...rowProps} />;
|
|
678
|
+
case 'gradient':
|
|
679
|
+
return <GradientRow {...rowProps} />;
|
|
680
|
+
case 'shadow':
|
|
681
|
+
return <ShadowRow {...rowProps} withSpreadInset />;
|
|
682
|
+
case 'text-shadow':
|
|
683
|
+
return <ShadowRow {...rowProps} withSpreadInset={false} />;
|
|
684
|
+
case 'filter':
|
|
685
|
+
return <FilterRow {...rowProps} label="Filter" />;
|
|
686
|
+
case 'backdrop-filter':
|
|
687
|
+
return <FilterRow {...rowProps} label="Backdrop Filter" />;
|
|
688
|
+
case 'radius':
|
|
689
|
+
return <RadiusRow {...rowProps} adapter={adapter} id={id} removeStyle={removeStyle} />;
|
|
690
|
+
case 'transform':
|
|
691
|
+
return <TransformRow {...rowProps} />;
|
|
692
|
+
case 'scrub':
|
|
693
|
+
return <ScrubRow {...rowProps} />;
|
|
694
|
+
case 'select':
|
|
695
|
+
return <SelectRow {...rowProps} />;
|
|
696
|
+
case 'checkbox':
|
|
697
|
+
return <CheckboxRow {...rowProps} />;
|
|
698
|
+
case 'json':
|
|
699
|
+
return <JsonRow {...rowProps} />;
|
|
700
|
+
default:
|
|
701
|
+
return <TextRow {...rowProps} />;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** A `border`/`align` combo row — reads all its member props, composes the
|
|
706
|
+
* widget's structured value, and writes every member back on change. */
|
|
707
|
+
function ComboRow({ adapter, id, row }: ComboRowProps): React.ReactElement | null {
|
|
708
|
+
const store = useEditorStore();
|
|
709
|
+
const commit = (path: string, value: unknown): void => {
|
|
710
|
+
adapter.inspector?.set(id, path, value);
|
|
711
|
+
store.notifyIngestEdit();
|
|
712
|
+
};
|
|
713
|
+
const removeStyle = (path: string): void => {
|
|
714
|
+
adapter.inspector?.remove?.(id, path);
|
|
715
|
+
store.notifyIngestEdit();
|
|
716
|
+
};
|
|
717
|
+
if (row.kind === 'border') {
|
|
718
|
+
// Read each side's longhands with uniform shorthand fallback when unavailable.
|
|
719
|
+
const fallback: BorderSide = {
|
|
720
|
+
width: numericValue(adapter.inspector?.get(id, row.width.path)),
|
|
721
|
+
style: String(adapter.inspector?.get(id, row.style.path) ?? 'solid'),
|
|
722
|
+
color: String(adapter.inspector?.get(id, row.color.path) ?? '#000000'),
|
|
723
|
+
};
|
|
724
|
+
const readSide = (suffix: 'Top' | 'Right' | 'Bottom' | 'Left'): BorderSide => {
|
|
725
|
+
const w = numericStyleValue(adapter.inspector?.get(id, `style.border${suffix}Width`));
|
|
726
|
+
const st = adapter.inspector?.get(id, `style.border${suffix}Style`);
|
|
727
|
+
const c = adapter.inspector?.get(id, `style.border${suffix}Color`);
|
|
728
|
+
// Normalize computed side colors before a later uniform commit.
|
|
729
|
+
const cHex = typeof c === 'string' ? cssColorToHex(c) : undefined;
|
|
730
|
+
return {
|
|
731
|
+
width: w ?? fallback.width,
|
|
732
|
+
style: typeof st === 'string' && st ? st : fallback.style,
|
|
733
|
+
color: cHex ?? fallback.color,
|
|
734
|
+
};
|
|
735
|
+
};
|
|
736
|
+
const value: BorderValue = {
|
|
737
|
+
top: readSide('Top'),
|
|
738
|
+
right: readSide('Right'),
|
|
739
|
+
bottom: readSide('Bottom'),
|
|
740
|
+
left: readSide('Left'),
|
|
741
|
+
};
|
|
742
|
+
// Uniform borders write the three shorthand descriptors; independent
|
|
743
|
+
// side values write their CSS longhands.
|
|
744
|
+
const sideSuffixes = ['Top', 'Right', 'Bottom', 'Left'] as const;
|
|
745
|
+
const writeBack = (v: BorderValue): void => {
|
|
746
|
+
if (isUniformBorder(v)) {
|
|
747
|
+
commit(row.width.path, v.top.width);
|
|
748
|
+
commit(row.style.path, v.top.style);
|
|
749
|
+
commit(row.color.path, v.top.color);
|
|
750
|
+
// Clear side longhands so the uniform shorthand takes effect.
|
|
751
|
+
for (const suffix of sideSuffixes) {
|
|
752
|
+
removeStyle(`style.border${suffix}Width`);
|
|
753
|
+
removeStyle(`style.border${suffix}Style`);
|
|
754
|
+
removeStyle(`style.border${suffix}Color`);
|
|
755
|
+
}
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
const sides: ReadonlyArray<{
|
|
759
|
+
suffix: 'Top' | 'Right' | 'Bottom' | 'Left';
|
|
760
|
+
side: BorderSide;
|
|
761
|
+
}> = sideSuffixes.map((suffix) => ({
|
|
762
|
+
suffix,
|
|
763
|
+
side: v[suffix.toLowerCase() as 'top' | 'right' | 'bottom' | 'left'],
|
|
764
|
+
}));
|
|
765
|
+
for (const { suffix, side } of sides) {
|
|
766
|
+
commit(`style.border${suffix}Width`, side.width);
|
|
767
|
+
commit(`style.border${suffix}Style`, side.style);
|
|
768
|
+
commit(`style.border${suffix}Color`, side.color);
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
return (
|
|
772
|
+
<Field label="Border" testId="react-prop-border">
|
|
773
|
+
<BorderEditor value={value} onChange={writeBack} onChangeEnd={writeBack} />
|
|
774
|
+
</Field>
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
const value: AlignmentValue = {
|
|
778
|
+
justify:
|
|
779
|
+
(adapter.inspector?.get(id, row.justify.path) as AlignmentValue['justify']) ?? 'flex-start',
|
|
780
|
+
align: (adapter.inspector?.get(id, row.align.path) as AlignmentValue['align']) ?? 'flex-start',
|
|
781
|
+
};
|
|
782
|
+
return (
|
|
783
|
+
<Field label="Align" testId="react-prop-align">
|
|
784
|
+
<AlignmentGrid
|
|
785
|
+
value={value}
|
|
786
|
+
onChange={(v) => {
|
|
787
|
+
commit(row.justify.path, v.justify);
|
|
788
|
+
commit(row.align.path, v.align);
|
|
789
|
+
}}
|
|
790
|
+
/>
|
|
791
|
+
</Field>
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function rowKey(row: PropertyRow): string {
|
|
796
|
+
if (row.kind === 'single') return row.prop.path;
|
|
797
|
+
if (row.kind === 'border') return `border:${row.width.path}`;
|
|
798
|
+
return `align:${row.justify.path}`;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function compactScrub(
|
|
802
|
+
adapter: AuthoringAdapter,
|
|
803
|
+
id: string,
|
|
804
|
+
prop: PropertyDescriptor | undefined,
|
|
805
|
+
label: string,
|
|
806
|
+
commit: (path: string, value: unknown) => void,
|
|
807
|
+
): React.ReactElement | null {
|
|
808
|
+
if (!prop) return null;
|
|
809
|
+
const unit = UNITLESS_NUMBER_PROPS.has(propNameFromPath(prop.path)) ? '' : 'px';
|
|
810
|
+
const testId = testIdFor(prop.path);
|
|
811
|
+
return (
|
|
812
|
+
<ScrubbableInput
|
|
813
|
+
key={prop.path}
|
|
814
|
+
testId={testId}
|
|
815
|
+
label={label}
|
|
816
|
+
value={numericValue(adapter.inspector?.get(id, prop.path))}
|
|
817
|
+
unit={unit}
|
|
818
|
+
disabled={prop.readonly}
|
|
819
|
+
onChange={(value) => commit(prop.path, value)}
|
|
820
|
+
onChangeEnd={(value) => commit(prop.path, value)}
|
|
821
|
+
style={{ flex: 1, minWidth: 0 }}
|
|
822
|
+
/>
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function SemanticLayout({
|
|
827
|
+
adapter,
|
|
828
|
+
id,
|
|
829
|
+
props,
|
|
830
|
+
fonts,
|
|
831
|
+
showConstraints,
|
|
832
|
+
onToggleConstraints,
|
|
833
|
+
}: {
|
|
834
|
+
adapter: AuthoringAdapter;
|
|
835
|
+
id: string;
|
|
836
|
+
props: readonly PropertyDescriptor[];
|
|
837
|
+
fonts: readonly string[];
|
|
838
|
+
showConstraints: boolean;
|
|
839
|
+
onToggleConstraints: () => void;
|
|
840
|
+
}): React.ReactElement {
|
|
841
|
+
const store = useEditorStore();
|
|
842
|
+
const byName = new Map(props.map((prop) => [propNameFromPath(prop.path), prop] as const));
|
|
843
|
+
const commit = (path: string, value: unknown): void => {
|
|
844
|
+
adapter.inspector?.set(id, path, value);
|
|
845
|
+
store.notifyIngestEdit();
|
|
846
|
+
};
|
|
847
|
+
const display = String(
|
|
848
|
+
adapter.inspector?.get(id, byName.get('display')?.path ?? 'style.display') ?? '',
|
|
849
|
+
);
|
|
850
|
+
const isFlex = display === 'flex' || display === 'inline-flex';
|
|
851
|
+
const isGrid = display === 'grid' || display === 'inline-grid';
|
|
852
|
+
const displayProp = byName.get('display');
|
|
853
|
+
const gapProp = byName.get('gap');
|
|
854
|
+
const alignRows = buildPropertyRows(
|
|
855
|
+
props.filter((prop) => ['justifyContent', 'alignItems'].includes(propNameFromPath(prop.path))),
|
|
856
|
+
);
|
|
857
|
+
const ordinary = ['flexDirection', 'flexWrap', 'overflow']
|
|
858
|
+
.map((name) => byName.get(name))
|
|
859
|
+
.filter((prop): prop is PropertyDescriptor => !!prop);
|
|
860
|
+
return (
|
|
861
|
+
<div style={{ padding: '2px 8px 7px' }}>
|
|
862
|
+
<div style={{ display: 'flex', gap: spaceVar[3] }}>
|
|
863
|
+
{compactScrub(adapter, id, byName.get('width'), 'W', commit)}
|
|
864
|
+
{compactScrub(adapter, id, byName.get('height'), 'H', commit)}
|
|
865
|
+
</div>
|
|
866
|
+
{showConstraints && (
|
|
867
|
+
<div data-testid="react-layout-constraints" style={{ marginTop: 5 }}>
|
|
868
|
+
<div style={{ display: 'flex', gap: spaceVar[3] }}>
|
|
869
|
+
{compactScrub(adapter, id, byName.get('minWidth'), 'Min W', commit)}
|
|
870
|
+
{compactScrub(adapter, id, byName.get('minHeight'), 'Min H', commit)}
|
|
871
|
+
</div>
|
|
872
|
+
<div style={{ display: 'flex', gap: spaceVar[3], marginTop: spaceVar[2] }}>
|
|
873
|
+
{compactScrub(adapter, id, byName.get('maxWidth'), 'Max W', commit)}
|
|
874
|
+
{compactScrub(adapter, id, byName.get('maxHeight'), 'Max H', commit)}
|
|
875
|
+
</div>
|
|
876
|
+
</div>
|
|
877
|
+
)}
|
|
878
|
+
<Button
|
|
879
|
+
type="button"
|
|
880
|
+
variant="ghost"
|
|
881
|
+
size="compact"
|
|
882
|
+
data-testid="react-layout-toggle-constraints"
|
|
883
|
+
onClick={onToggleConstraints}
|
|
884
|
+
style={{ display: 'flex', margin: '2px auto 5px' }}
|
|
885
|
+
>
|
|
886
|
+
{showConstraints ? '− constraints' : '··· constraints'}
|
|
887
|
+
</Button>
|
|
888
|
+
<div
|
|
889
|
+
style={{
|
|
890
|
+
display: 'grid',
|
|
891
|
+
gridTemplateColumns: 'minmax(0, 1.35fr) minmax(0, 1fr)',
|
|
892
|
+
gap: spaceVar[3],
|
|
893
|
+
}}
|
|
894
|
+
>
|
|
895
|
+
{displayProp && (
|
|
896
|
+
<Select
|
|
897
|
+
data-testid={testIdFor(displayProp.path)}
|
|
898
|
+
value={display}
|
|
899
|
+
disabled={displayProp.readonly}
|
|
900
|
+
data-dynamic={displayProp.readonly || undefined}
|
|
901
|
+
onChange={(event) => commit(displayProp.path, event.target.value)}
|
|
902
|
+
style={{ width: '100%' }}
|
|
903
|
+
>
|
|
904
|
+
{(displayProp.options ?? []).map((option) => (
|
|
905
|
+
<option key={String(option)} value={String(option)}>
|
|
906
|
+
{String(option)}
|
|
907
|
+
</option>
|
|
908
|
+
))}
|
|
909
|
+
</Select>
|
|
910
|
+
)}
|
|
911
|
+
{compactScrub(adapter, id, gapProp, 'Gap', commit)}
|
|
912
|
+
</div>
|
|
913
|
+
{isFlex && (
|
|
914
|
+
<div data-testid="react-layout-flex-controls" style={{ marginTop: 5 }}>
|
|
915
|
+
{ordinary
|
|
916
|
+
.filter((prop) => propNameFromPath(prop.path) !== 'overflow')
|
|
917
|
+
.map((prop) => (
|
|
918
|
+
<SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
|
|
919
|
+
))}
|
|
920
|
+
{alignRows.map((row) =>
|
|
921
|
+
row.kind === 'single' ? (
|
|
922
|
+
<SingleRow
|
|
923
|
+
key={rowKey(row)}
|
|
924
|
+
adapter={adapter}
|
|
925
|
+
id={id}
|
|
926
|
+
prop={row.prop}
|
|
927
|
+
fonts={fonts}
|
|
928
|
+
/>
|
|
929
|
+
) : (
|
|
930
|
+
<ComboRow key={rowKey(row)} adapter={adapter} id={id} row={row} />
|
|
931
|
+
),
|
|
932
|
+
)}
|
|
933
|
+
</div>
|
|
934
|
+
)}
|
|
935
|
+
{isGrid && (
|
|
936
|
+
<div data-testid="react-layout-grid-controls" style={{ marginTop: 5 }}>
|
|
937
|
+
<div style={{ display: 'flex', gap: spaceVar[3] }}>
|
|
938
|
+
{gridCountScrub(adapter, id, byName.get('gridTemplateColumns'), 'Cols', commit)}
|
|
939
|
+
{gridCountScrub(adapter, id, byName.get('gridTemplateRows'), 'Rows', commit)}
|
|
940
|
+
</div>
|
|
941
|
+
{[byName.get('gridTemplateColumns'), byName.get('gridTemplateRows')]
|
|
942
|
+
.filter((prop): prop is PropertyDescriptor => !!prop)
|
|
943
|
+
.map((prop) => (
|
|
944
|
+
<SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
|
|
945
|
+
))}
|
|
946
|
+
<div style={{ display: 'flex', gap: spaceVar[3], marginTop: spaceVar[1] }}>
|
|
947
|
+
{compactScrub(adapter, id, byName.get('rowGap'), 'Row Gap', commit)}
|
|
948
|
+
{compactScrub(adapter, id, byName.get('columnGap'), 'Col Gap', commit)}
|
|
949
|
+
</div>
|
|
950
|
+
{byName.get('gridAutoFlow') && (
|
|
951
|
+
<SingleRow
|
|
952
|
+
adapter={adapter}
|
|
953
|
+
id={id}
|
|
954
|
+
prop={byName.get('gridAutoFlow') as PropertyDescriptor}
|
|
955
|
+
fonts={fonts}
|
|
956
|
+
/>
|
|
957
|
+
)}
|
|
958
|
+
</div>
|
|
959
|
+
)}
|
|
960
|
+
{ordinary
|
|
961
|
+
.filter((prop) => propNameFromPath(prop.path) === 'overflow')
|
|
962
|
+
.map((prop) => (
|
|
963
|
+
<SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
|
|
964
|
+
))}
|
|
965
|
+
<GridItemDisclosure adapter={adapter} id={id} byName={byName} fonts={fonts} />
|
|
966
|
+
</div>
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** Count grid tracks, expanding integer repeat() groups. Auto-fit/auto-fill
|
|
971
|
+
* repeats count as one segment because their resolved count needs layout. */
|
|
972
|
+
function gridTrackCount(template: string): number {
|
|
973
|
+
const t = template.trim();
|
|
974
|
+
if (!t || t === 'none') return 0;
|
|
975
|
+
const segments: string[] = [];
|
|
976
|
+
let depth = 0;
|
|
977
|
+
let start = 0;
|
|
978
|
+
for (let i = 0; i < t.length; i++) {
|
|
979
|
+
const ch = t[i];
|
|
980
|
+
if (ch === '(' || ch === '[') depth++;
|
|
981
|
+
else if (ch === ')' || ch === ']') depth--;
|
|
982
|
+
else if (ch === ' ' && depth === 0) {
|
|
983
|
+
if (i > start) segments.push(t.slice(start, i));
|
|
984
|
+
start = i + 1;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
if (start < t.length) segments.push(t.slice(start));
|
|
988
|
+
let count = 0;
|
|
989
|
+
for (const segment of segments) {
|
|
990
|
+
const rep = /^repeat\(\s*(\d+)\s*,([\s\S]*)\)$/.exec(segment);
|
|
991
|
+
count += rep?.[1] && rep[2] !== undefined ? Number(rep[1]) * gridTrackCount(rep[2]) : 1;
|
|
992
|
+
}
|
|
993
|
+
return count;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** Figma's grid COUNT control on a CSS template: scrub/type the track count
|
|
997
|
+
* and the template becomes `repeat(N, 1fr)` — exactly what Figma's
|
|
998
|
+
* rows/columns numbers mean. A custom template (minmax, named lines) stays
|
|
999
|
+
* authorable through the template text row below; bumping the count
|
|
1000
|
+
* deliberately rewrites to N equal tracks, same as Figma. */
|
|
1001
|
+
function gridCountScrub(
|
|
1002
|
+
adapter: AuthoringAdapter,
|
|
1003
|
+
id: string,
|
|
1004
|
+
prop: PropertyDescriptor | undefined,
|
|
1005
|
+
label: string,
|
|
1006
|
+
commit: (path: string, value: unknown) => void,
|
|
1007
|
+
): React.ReactElement | null {
|
|
1008
|
+
if (!prop) return null;
|
|
1009
|
+
// Update the controlled value on change and suppress duplicate source writes.
|
|
1010
|
+
let lastWritten: string | null = null;
|
|
1011
|
+
const write = (value: number): void => {
|
|
1012
|
+
const n = Math.max(1, Math.round(value));
|
|
1013
|
+
const template = `repeat(${n}, 1fr)`;
|
|
1014
|
+
if (template === lastWritten) return;
|
|
1015
|
+
lastWritten = template;
|
|
1016
|
+
commit(prop.path, template);
|
|
1017
|
+
};
|
|
1018
|
+
return (
|
|
1019
|
+
<ScrubbableInput
|
|
1020
|
+
key={`${prop.path}-count`}
|
|
1021
|
+
testId={`${testIdFor(prop.path)}-count`}
|
|
1022
|
+
label={label}
|
|
1023
|
+
value={gridTrackCount(String(adapter.inspector?.get(id, prop.path) ?? ''))}
|
|
1024
|
+
min={1}
|
|
1025
|
+
disabled={prop.readonly}
|
|
1026
|
+
onChange={write}
|
|
1027
|
+
onChangeEnd={write}
|
|
1028
|
+
style={{ flex: 1, minWidth: 0 }}
|
|
1029
|
+
/>
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** The grid-ITEM half (placement lives on the child, not the container):
|
|
1034
|
+
* `grid-column` / `grid-row` / `justify-self` behind a ghost disclosure —
|
|
1035
|
+
* same idiom as the constraints toggle, because for most elements these
|
|
1036
|
+
* are noise, and the inspector cannot cheaply know whether the PARENT is
|
|
1037
|
+
* a grid (its reads are node-scoped). */
|
|
1038
|
+
function GridItemDisclosure({
|
|
1039
|
+
adapter,
|
|
1040
|
+
id,
|
|
1041
|
+
byName,
|
|
1042
|
+
fonts,
|
|
1043
|
+
}: {
|
|
1044
|
+
adapter: AuthoringAdapter;
|
|
1045
|
+
id: string;
|
|
1046
|
+
byName: ReadonlyMap<string, PropertyDescriptor>;
|
|
1047
|
+
fonts: readonly string[];
|
|
1048
|
+
}): React.ReactElement | null {
|
|
1049
|
+
const [open, setOpen] = useState(false);
|
|
1050
|
+
const props = ['gridColumn', 'gridRow', 'justifySelf']
|
|
1051
|
+
.map((name) => byName.get(name))
|
|
1052
|
+
.filter((prop): prop is PropertyDescriptor => !!prop);
|
|
1053
|
+
if (props.length === 0) return null;
|
|
1054
|
+
return (
|
|
1055
|
+
<>
|
|
1056
|
+
<Button
|
|
1057
|
+
type="button"
|
|
1058
|
+
variant="ghost"
|
|
1059
|
+
size="compact"
|
|
1060
|
+
data-testid="react-layout-toggle-grid-item"
|
|
1061
|
+
onClick={() => setOpen((value) => !value)}
|
|
1062
|
+
style={{ display: 'flex', margin: '2px auto 0' }}
|
|
1063
|
+
>
|
|
1064
|
+
{open ? '− grid item' : '··· grid item'}
|
|
1065
|
+
</Button>
|
|
1066
|
+
{open && (
|
|
1067
|
+
<div data-testid="react-layout-grid-item">
|
|
1068
|
+
{props.map((prop) => (
|
|
1069
|
+
<SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
|
|
1070
|
+
))}
|
|
1071
|
+
</div>
|
|
1072
|
+
)}
|
|
1073
|
+
</>
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** Split a `background-image` value into its top-level comma layers (CSS
|
|
1078
|
+
* paints the FIRST layer on top). `none`/empty ⇒ []. */
|
|
1079
|
+
function splitFillLayers(value: string): string[] {
|
|
1080
|
+
const t = value.trim();
|
|
1081
|
+
if (!t || t === 'none') return [];
|
|
1082
|
+
const layers: string[] = [];
|
|
1083
|
+
let depth = 0;
|
|
1084
|
+
let start = 0;
|
|
1085
|
+
for (let i = 0; i < t.length; i++) {
|
|
1086
|
+
const ch = t[i];
|
|
1087
|
+
if (ch === '(') depth++;
|
|
1088
|
+
else if (ch === ')') depth--;
|
|
1089
|
+
else if (ch === ',' && depth === 0) {
|
|
1090
|
+
layers.push(t.slice(start, i).trim());
|
|
1091
|
+
start = i + 1;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
layers.push(t.slice(start).trim());
|
|
1095
|
+
return layers.filter(Boolean);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/** One fill layer's URL, when the layer is an image paint. */
|
|
1099
|
+
function fillLayerUrl(layer: string): string | null {
|
|
1100
|
+
const m = /^url\((['"]?)([\s\S]*)\1\)$/.exec(layer.trim());
|
|
1101
|
+
return m?.[2] ?? null;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/** Background paints in CSS order: image/gradient layers above one solid
|
|
1105
|
+
* background color. Add, remove, and reorder rewrite the layer list. Image
|
|
1106
|
+
* layers have no independent CSS visibility or opacity property. */
|
|
1107
|
+
function SemanticFill({
|
|
1108
|
+
adapter,
|
|
1109
|
+
id,
|
|
1110
|
+
props,
|
|
1111
|
+
fonts,
|
|
1112
|
+
}: {
|
|
1113
|
+
adapter: AuthoringAdapter;
|
|
1114
|
+
id: string;
|
|
1115
|
+
props: readonly PropertyDescriptor[];
|
|
1116
|
+
fonts: readonly string[];
|
|
1117
|
+
}): React.ReactElement {
|
|
1118
|
+
const store = useEditorStore();
|
|
1119
|
+
const byName = new Map(props.map((prop) => [propNameFromPath(prop.path), prop] as const));
|
|
1120
|
+
const commit = (path: string, value: unknown): void => {
|
|
1121
|
+
adapter.inspector?.set(id, path, value);
|
|
1122
|
+
store.notifyIngestEdit();
|
|
1123
|
+
};
|
|
1124
|
+
const imageProp = byName.get('backgroundImage');
|
|
1125
|
+
const colorProp = byName.get('backgroundColor');
|
|
1126
|
+
const [expanded, setExpanded] = useState<number | null>(null);
|
|
1127
|
+
const [imageDraft, setImageDraft] = useState<string | null>(null);
|
|
1128
|
+
const raw = String(imageProp ? (adapter.inspector?.get(id, imageProp.path) ?? '') : '');
|
|
1129
|
+
const layers = splitFillLayers(raw);
|
|
1130
|
+
const write = (next: string[]): void => {
|
|
1131
|
+
if (!imageProp) return;
|
|
1132
|
+
if (next.length > 0) {
|
|
1133
|
+
commit(imageProp.path, next.join(', '));
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
// An emptied list REMOVES the member — `backgroundImage: 'none'` parked
|
|
1137
|
+
// in source is authored noise the element never had.
|
|
1138
|
+
adapter.inspector?.remove?.(id, imageProp.path);
|
|
1139
|
+
store.notifyIngestEdit();
|
|
1140
|
+
};
|
|
1141
|
+
const replaceLayer = (index: number, layer: string): void =>
|
|
1142
|
+
write(layers.map((l, j) => (j === index ? layer : l)));
|
|
1143
|
+
const removeLayer = (index: number): void => {
|
|
1144
|
+
setExpanded(null);
|
|
1145
|
+
write(layers.filter((_, j) => j !== index));
|
|
1146
|
+
};
|
|
1147
|
+
const moveLayer = (index: number, delta: number): void => {
|
|
1148
|
+
const next = [...layers];
|
|
1149
|
+
const swap = next[index + delta];
|
|
1150
|
+
if (swap === undefined || next[index] === undefined) return;
|
|
1151
|
+
next[index + delta] = next[index] as string;
|
|
1152
|
+
next[index] = swap;
|
|
1153
|
+
setExpanded(null);
|
|
1154
|
+
write(next);
|
|
1155
|
+
};
|
|
1156
|
+
// Use double-quoted URLs inside the writer's single-quoted style literals.
|
|
1157
|
+
const layerButton: React.CSSProperties = {
|
|
1158
|
+
background: 'none',
|
|
1159
|
+
border: 'none',
|
|
1160
|
+
color: THEME.textMuted,
|
|
1161
|
+
cursor: 'pointer',
|
|
1162
|
+
fontSize: fontSizeVar.base,
|
|
1163
|
+
padding: `${spaceVar[2]} ${spaceVar[3]}`,
|
|
1164
|
+
lineHeight: lineHeightVar.tight,
|
|
1165
|
+
};
|
|
1166
|
+
return (
|
|
1167
|
+
<div data-testid="react-fill-list" style={{ padding: '2px 8px 7px' }}>
|
|
1168
|
+
{layers.map((layer, index) => {
|
|
1169
|
+
const gradient = parseGradient(layer);
|
|
1170
|
+
const url = fillLayerUrl(layer);
|
|
1171
|
+
const label = gradient
|
|
1172
|
+
? `${gradient.type} gradient`
|
|
1173
|
+
: url !== null
|
|
1174
|
+
? (url.split('/').pop() ?? 'image')
|
|
1175
|
+
: layer.slice(0, 22);
|
|
1176
|
+
return (
|
|
1177
|
+
<div key={`${index}-${layer.slice(0, 24)}`} style={{ marginBottom: 3 }}>
|
|
1178
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
|
1179
|
+
<button
|
|
1180
|
+
type="button"
|
|
1181
|
+
data-testid={`react-fill-layer-${index}`}
|
|
1182
|
+
title={layer}
|
|
1183
|
+
onClick={() => setExpanded(expanded === index ? null : index)}
|
|
1184
|
+
style={{
|
|
1185
|
+
display: 'flex',
|
|
1186
|
+
alignItems: 'center',
|
|
1187
|
+
gap: spaceVar[3],
|
|
1188
|
+
flex: 1,
|
|
1189
|
+
minWidth: 0,
|
|
1190
|
+
background: 'none',
|
|
1191
|
+
border: 'none',
|
|
1192
|
+
cursor: 'pointer',
|
|
1193
|
+
padding: `${spaceVar[1]} 0`,
|
|
1194
|
+
color: THEME.text,
|
|
1195
|
+
fontSize: fontSizeVar.base,
|
|
1196
|
+
textAlign: 'left',
|
|
1197
|
+
}}
|
|
1198
|
+
>
|
|
1199
|
+
<span
|
|
1200
|
+
style={{
|
|
1201
|
+
width: 16,
|
|
1202
|
+
height: 16,
|
|
1203
|
+
flexShrink: 0,
|
|
1204
|
+
borderRadius: radius.sm,
|
|
1205
|
+
border: `1px solid ${THEME.border}`,
|
|
1206
|
+
background: layer,
|
|
1207
|
+
backgroundSize: 'cover',
|
|
1208
|
+
}}
|
|
1209
|
+
/>
|
|
1210
|
+
<span
|
|
1211
|
+
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
|
1212
|
+
>
|
|
1213
|
+
{label}
|
|
1214
|
+
</span>
|
|
1215
|
+
</button>
|
|
1216
|
+
{index > 0 && (
|
|
1217
|
+
<button
|
|
1218
|
+
type="button"
|
|
1219
|
+
data-testid={`react-fill-up-${index}`}
|
|
1220
|
+
title="Raise this fill"
|
|
1221
|
+
onClick={() => moveLayer(index, -1)}
|
|
1222
|
+
style={layerButton}
|
|
1223
|
+
>
|
|
1224
|
+
↑
|
|
1225
|
+
</button>
|
|
1226
|
+
)}
|
|
1227
|
+
{index < layers.length - 1 && (
|
|
1228
|
+
<button
|
|
1229
|
+
type="button"
|
|
1230
|
+
data-testid={`react-fill-down-${index}`}
|
|
1231
|
+
title="Lower this fill"
|
|
1232
|
+
onClick={() => moveLayer(index, 1)}
|
|
1233
|
+
style={layerButton}
|
|
1234
|
+
>
|
|
1235
|
+
↓
|
|
1236
|
+
</button>
|
|
1237
|
+
)}
|
|
1238
|
+
<button
|
|
1239
|
+
type="button"
|
|
1240
|
+
data-testid={`react-fill-remove-${index}`}
|
|
1241
|
+
title="Remove this fill (CSS has no hidden-layer state — removing is Figma's eye)"
|
|
1242
|
+
onClick={() => removeLayer(index)}
|
|
1243
|
+
style={layerButton}
|
|
1244
|
+
>
|
|
1245
|
+
✕
|
|
1246
|
+
</button>
|
|
1247
|
+
</div>
|
|
1248
|
+
{expanded === index && gradient && (
|
|
1249
|
+
<GradientEditor
|
|
1250
|
+
testId={`react-fill-gradient-${index}`}
|
|
1251
|
+
value={gradient}
|
|
1252
|
+
onChange={(g) => replaceLayer(index, composeGradient(g))}
|
|
1253
|
+
onChangeEnd={(g) => replaceLayer(index, composeGradient(g))}
|
|
1254
|
+
/>
|
|
1255
|
+
)}
|
|
1256
|
+
{expanded === index && url !== null && (
|
|
1257
|
+
<TextInput
|
|
1258
|
+
data-testid={`react-fill-url-${index}`}
|
|
1259
|
+
defaultValue={url}
|
|
1260
|
+
placeholder="image url"
|
|
1261
|
+
onBlur={(event) => replaceLayer(index, `url("${event.target.value.trim()}")`)}
|
|
1262
|
+
onKeyDown={(event) => {
|
|
1263
|
+
if (event.key === 'Enter')
|
|
1264
|
+
replaceLayer(index, `url("${event.currentTarget.value.trim()}")`);
|
|
1265
|
+
}}
|
|
1266
|
+
style={{ width: '100%', marginTop: spaceVar[1] }}
|
|
1267
|
+
/>
|
|
1268
|
+
)}
|
|
1269
|
+
</div>
|
|
1270
|
+
);
|
|
1271
|
+
})}
|
|
1272
|
+
{imageDraft !== null && (
|
|
1273
|
+
<TextInput
|
|
1274
|
+
data-testid="react-fill-image-draft"
|
|
1275
|
+
value={imageDraft}
|
|
1276
|
+
placeholder="image url — Enter to add"
|
|
1277
|
+
autoFocus
|
|
1278
|
+
onChange={(event) => setImageDraft(event.target.value)}
|
|
1279
|
+
onKeyDown={(event) => {
|
|
1280
|
+
if (event.key === 'Enter' && imageDraft.trim()) {
|
|
1281
|
+
write([`url("${imageDraft.trim()}")`, ...layers]);
|
|
1282
|
+
setImageDraft(null);
|
|
1283
|
+
}
|
|
1284
|
+
if (event.key === 'Escape') setImageDraft(null);
|
|
1285
|
+
}}
|
|
1286
|
+
style={{ width: '100%', marginBottom: 3 }}
|
|
1287
|
+
/>
|
|
1288
|
+
)}
|
|
1289
|
+
<div style={{ display: 'flex', gap: spaceVar[2] }}>
|
|
1290
|
+
<Button
|
|
1291
|
+
type="button"
|
|
1292
|
+
variant="ghost"
|
|
1293
|
+
size="compact"
|
|
1294
|
+
data-testid="react-fill-add-gradient"
|
|
1295
|
+
onClick={() => {
|
|
1296
|
+
write([composeGradient(DEFAULT_GRADIENT), ...layers]);
|
|
1297
|
+
setExpanded(0);
|
|
1298
|
+
}}
|
|
1299
|
+
>
|
|
1300
|
+
+ gradient
|
|
1301
|
+
</Button>
|
|
1302
|
+
<Button
|
|
1303
|
+
type="button"
|
|
1304
|
+
variant="ghost"
|
|
1305
|
+
size="compact"
|
|
1306
|
+
data-testid="react-fill-add-image"
|
|
1307
|
+
onClick={() => setImageDraft('')}
|
|
1308
|
+
>
|
|
1309
|
+
+ image
|
|
1310
|
+
</Button>
|
|
1311
|
+
</div>
|
|
1312
|
+
{layers.some((layer) => fillLayerUrl(layer) !== null) &&
|
|
1313
|
+
['backgroundSize', 'backgroundPosition', 'backgroundRepeat']
|
|
1314
|
+
.map((name) => byName.get(name))
|
|
1315
|
+
.filter((prop): prop is PropertyDescriptor => !!prop)
|
|
1316
|
+
.map((prop) => (
|
|
1317
|
+
<SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
|
|
1318
|
+
))}
|
|
1319
|
+
{colorProp && <SingleRow adapter={adapter} id={id} prop={colorProp} fonts={fonts} />}
|
|
1320
|
+
</div>
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function SemanticPosition({
|
|
1325
|
+
adapter,
|
|
1326
|
+
id,
|
|
1327
|
+
props,
|
|
1328
|
+
fonts,
|
|
1329
|
+
}: {
|
|
1330
|
+
adapter: AuthoringAdapter;
|
|
1331
|
+
id: string;
|
|
1332
|
+
props: readonly PropertyDescriptor[];
|
|
1333
|
+
fonts: readonly string[];
|
|
1334
|
+
}): React.ReactElement {
|
|
1335
|
+
const store = useEditorStore();
|
|
1336
|
+
const byName = new Map(props.map((prop) => [propNameFromPath(prop.path), prop] as const));
|
|
1337
|
+
const position = byName.get('position');
|
|
1338
|
+
const positionValue = String(
|
|
1339
|
+
position ? (adapter.inspector?.get(id, position.path) ?? 'static') : 'static',
|
|
1340
|
+
);
|
|
1341
|
+
const commit = (path: string, value: unknown): void => {
|
|
1342
|
+
adapter.inspector?.set(id, path, value);
|
|
1343
|
+
store.notifyIngestEdit();
|
|
1344
|
+
};
|
|
1345
|
+
const offsets = ['top', 'right', 'bottom', 'left'];
|
|
1346
|
+
const itemProps = ['zIndex', 'flexGrow', 'flexShrink', 'flexBasis', 'alignSelf']
|
|
1347
|
+
.map((name) => byName.get(name))
|
|
1348
|
+
.filter((prop): prop is PropertyDescriptor => !!prop);
|
|
1349
|
+
return (
|
|
1350
|
+
<div style={{ padding: '2px 8px 7px' }}>
|
|
1351
|
+
{position && (
|
|
1352
|
+
<div
|
|
1353
|
+
data-testid={testIdFor(position.path)}
|
|
1354
|
+
style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: spaceVar[1] }}
|
|
1355
|
+
>
|
|
1356
|
+
{(position.options ?? []).map((option) => {
|
|
1357
|
+
const value = String(option);
|
|
1358
|
+
const active = value === positionValue;
|
|
1359
|
+
return (
|
|
1360
|
+
<Button
|
|
1361
|
+
key={value}
|
|
1362
|
+
type="button"
|
|
1363
|
+
variant={active ? 'primary' : 'secondary'}
|
|
1364
|
+
size="compact"
|
|
1365
|
+
aria-pressed={active}
|
|
1366
|
+
title={value}
|
|
1367
|
+
disabled={position.readonly}
|
|
1368
|
+
onClick={() => commit(position.path, value)}
|
|
1369
|
+
>
|
|
1370
|
+
{value.slice(0, 3)}
|
|
1371
|
+
</Button>
|
|
1372
|
+
);
|
|
1373
|
+
})}
|
|
1374
|
+
</div>
|
|
1375
|
+
)}
|
|
1376
|
+
{positionValue !== 'static' && (
|
|
1377
|
+
<div
|
|
1378
|
+
data-testid="react-position-offsets"
|
|
1379
|
+
style={{
|
|
1380
|
+
display: 'grid',
|
|
1381
|
+
gridTemplateColumns: '1fr 1fr',
|
|
1382
|
+
gap: spaceVar[2],
|
|
1383
|
+
marginTop: spaceVar[3],
|
|
1384
|
+
}}
|
|
1385
|
+
>
|
|
1386
|
+
{offsets.map((name) =>
|
|
1387
|
+
compactScrub(adapter, id, byName.get(name), name[0]!.toUpperCase(), commit),
|
|
1388
|
+
)}
|
|
1389
|
+
</div>
|
|
1390
|
+
)}
|
|
1391
|
+
{itemProps.length > 0 && (
|
|
1392
|
+
<div style={{ borderTop: `1px solid ${THEME.border}`, marginTop: 7, paddingTop: 3 }}>
|
|
1393
|
+
<div style={{ color: THEME.textMuted, fontSize: fontSizeVar.xs, padding: '2px 0 1px' }}>
|
|
1394
|
+
STACKING & FLEX ITEM
|
|
1395
|
+
</div>
|
|
1396
|
+
{itemProps.map((prop) => (
|
|
1397
|
+
<SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
|
|
1398
|
+
))}
|
|
1399
|
+
</div>
|
|
1400
|
+
)}
|
|
1401
|
+
</div>
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
function SemanticSpacing({
|
|
1406
|
+
adapter,
|
|
1407
|
+
id,
|
|
1408
|
+
props,
|
|
1409
|
+
}: {
|
|
1410
|
+
adapter: AuthoringAdapter;
|
|
1411
|
+
id: string;
|
|
1412
|
+
props: readonly PropertyDescriptor[];
|
|
1413
|
+
}): React.ReactElement {
|
|
1414
|
+
const store = useEditorStore();
|
|
1415
|
+
const byName = new Map(props.map((prop) => [propNameFromPath(prop.path), prop] as const));
|
|
1416
|
+
const commit = (path: string, value: unknown): void => {
|
|
1417
|
+
adapter.inspector?.set(id, path, value);
|
|
1418
|
+
store.notifyIngestEdit();
|
|
1419
|
+
};
|
|
1420
|
+
// Nested rings identify margin and padding sides by their position.
|
|
1421
|
+
const slot = (name: string): React.ReactElement => {
|
|
1422
|
+
const prop = byName.get(name);
|
|
1423
|
+
if (!prop) return <span key={name} />;
|
|
1424
|
+
return (
|
|
1425
|
+
<ScrubbableInput
|
|
1426
|
+
key={prop.path}
|
|
1427
|
+
testId={testIdFor(prop.path)}
|
|
1428
|
+
label=""
|
|
1429
|
+
value={numericValue(adapter.inspector?.get(id, prop.path))}
|
|
1430
|
+
unit="px"
|
|
1431
|
+
disabled={prop.readonly}
|
|
1432
|
+
onChange={(value) => commit(prop.path, value)}
|
|
1433
|
+
onChangeEnd={(value) => commit(prop.path, value)}
|
|
1434
|
+
style={{ width: 46, minWidth: 0, flex: 'none' }}
|
|
1435
|
+
/>
|
|
1436
|
+
);
|
|
1437
|
+
};
|
|
1438
|
+
const ringLabel = (text: string): React.ReactElement => (
|
|
1439
|
+
<span
|
|
1440
|
+
style={{
|
|
1441
|
+
position: 'absolute',
|
|
1442
|
+
top: 2,
|
|
1443
|
+
left: 6,
|
|
1444
|
+
fontSize: 8,
|
|
1445
|
+
letterSpacing: '.08em',
|
|
1446
|
+
color: THEME.textMuted,
|
|
1447
|
+
pointerEvents: 'none',
|
|
1448
|
+
}}
|
|
1449
|
+
>
|
|
1450
|
+
{text}
|
|
1451
|
+
</span>
|
|
1452
|
+
);
|
|
1453
|
+
const centered: React.CSSProperties = {
|
|
1454
|
+
display: 'flex',
|
|
1455
|
+
alignItems: 'center',
|
|
1456
|
+
justifyContent: 'center',
|
|
1457
|
+
};
|
|
1458
|
+
return (
|
|
1459
|
+
<div
|
|
1460
|
+
style={{ padding: `${spaceVar[1]} ${spaceVar[4]} ${spaceVar[4]}` }}
|
|
1461
|
+
data-testid="react-spacing-box"
|
|
1462
|
+
>
|
|
1463
|
+
<div
|
|
1464
|
+
style={{
|
|
1465
|
+
position: 'relative',
|
|
1466
|
+
border: `1px solid ${THEME.border}`,
|
|
1467
|
+
borderRadius: 4,
|
|
1468
|
+
padding: '11px 4px 3px',
|
|
1469
|
+
}}
|
|
1470
|
+
>
|
|
1471
|
+
{ringLabel('MARGIN')}
|
|
1472
|
+
<div style={centered}>{slot('marginTop')}</div>
|
|
1473
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: spaceVar[1] }}>
|
|
1474
|
+
{slot('marginLeft')}
|
|
1475
|
+
<div
|
|
1476
|
+
style={{
|
|
1477
|
+
position: 'relative',
|
|
1478
|
+
flex: 1,
|
|
1479
|
+
minWidth: 0,
|
|
1480
|
+
border: `1px dashed ${THEME.border}`,
|
|
1481
|
+
borderRadius: 4,
|
|
1482
|
+
padding: '11px 4px 3px',
|
|
1483
|
+
}}
|
|
1484
|
+
>
|
|
1485
|
+
{ringLabel('PADDING')}
|
|
1486
|
+
<div style={centered}>{slot('paddingTop')}</div>
|
|
1487
|
+
<div
|
|
1488
|
+
style={{
|
|
1489
|
+
display: 'flex',
|
|
1490
|
+
alignItems: 'center',
|
|
1491
|
+
justifyContent: 'space-between',
|
|
1492
|
+
gap: spaceVar[1],
|
|
1493
|
+
}}
|
|
1494
|
+
>
|
|
1495
|
+
{slot('paddingLeft')}
|
|
1496
|
+
{slot('paddingRight')}
|
|
1497
|
+
</div>
|
|
1498
|
+
<div style={centered}>{slot('paddingBottom')}</div>
|
|
1499
|
+
</div>
|
|
1500
|
+
{slot('marginRight')}
|
|
1501
|
+
</div>
|
|
1502
|
+
<div style={centered}>{slot('marginBottom')}</div>
|
|
1503
|
+
</div>
|
|
1504
|
+
</div>
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* ONE group's body — the widget-mapped rows for one descriptor group, with no
|
|
1510
|
+
* chrome of its own: the group IS an identified `InspectionSection` now
|
|
1511
|
+
* (see `reactInspectorSections`), so its title, icon, order and collapse
|
|
1512
|
+
* affordance belong to the projection rendering it, and repeating them here
|
|
1513
|
+
* would title the same block twice.
|
|
1514
|
+
*/
|
|
1515
|
+
function GroupBody({
|
|
1516
|
+
name,
|
|
1517
|
+
groupId,
|
|
1518
|
+
props,
|
|
1519
|
+
adapter,
|
|
1520
|
+
id,
|
|
1521
|
+
fonts,
|
|
1522
|
+
}: {
|
|
1523
|
+
name: string;
|
|
1524
|
+
groupId: string;
|
|
1525
|
+
props: readonly PropertyDescriptor[];
|
|
1526
|
+
adapter: AuthoringAdapter;
|
|
1527
|
+
id: string;
|
|
1528
|
+
fonts: readonly string[];
|
|
1529
|
+
}): React.ReactElement {
|
|
1530
|
+
const [showConstraints, setShowConstraints] = useState(false);
|
|
1531
|
+
const rows = buildPropertyRows(props);
|
|
1532
|
+
return (
|
|
1533
|
+
<div data-testid={`react-inspector-group-${groupId}`}>
|
|
1534
|
+
{name === 'Layout' && (
|
|
1535
|
+
<SemanticLayout
|
|
1536
|
+
adapter={adapter}
|
|
1537
|
+
id={id}
|
|
1538
|
+
props={props}
|
|
1539
|
+
fonts={fonts}
|
|
1540
|
+
showConstraints={showConstraints}
|
|
1541
|
+
onToggleConstraints={() => setShowConstraints((value) => !value)}
|
|
1542
|
+
/>
|
|
1543
|
+
)}
|
|
1544
|
+
{name === 'Position' && (
|
|
1545
|
+
<SemanticPosition adapter={adapter} id={id} props={props} fonts={fonts} />
|
|
1546
|
+
)}
|
|
1547
|
+
{name === 'Spacing' && <SemanticSpacing adapter={adapter} id={id} props={props} />}
|
|
1548
|
+
{name === 'Fill' && <SemanticFill adapter={adapter} id={id} props={props} fonts={fonts} />}
|
|
1549
|
+
{name !== 'Layout' &&
|
|
1550
|
+
name !== 'Position' &&
|
|
1551
|
+
name !== 'Spacing' &&
|
|
1552
|
+
name !== 'Fill' &&
|
|
1553
|
+
rows.map((row) =>
|
|
1554
|
+
row.kind === 'single' ? (
|
|
1555
|
+
<SingleRow key={rowKey(row)} adapter={adapter} id={id} prop={row.prop} fonts={fonts} />
|
|
1556
|
+
) : (
|
|
1557
|
+
<ComboRow key={rowKey(row)} adapter={adapter} id={id} row={row} />
|
|
1558
|
+
),
|
|
1559
|
+
)}
|
|
1560
|
+
</div>
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function BreadcrumbBar({
|
|
1565
|
+
adapter,
|
|
1566
|
+
node,
|
|
1567
|
+
}: {
|
|
1568
|
+
adapter: AuthoringAdapter;
|
|
1569
|
+
node: EditorNode;
|
|
1570
|
+
}): React.ReactElement {
|
|
1571
|
+
const chain: EditorNode[] = [];
|
|
1572
|
+
let current: EditorNode | null = node;
|
|
1573
|
+
let guard = 0;
|
|
1574
|
+
while (current && guard++ < 100) {
|
|
1575
|
+
chain.unshift(current);
|
|
1576
|
+
current = current.parentId ? adapter.hierarchy.node(current.parentId) : null;
|
|
1577
|
+
}
|
|
1578
|
+
return (
|
|
1579
|
+
<nav
|
|
1580
|
+
aria-label="Selection ancestry"
|
|
1581
|
+
data-testid="react-inspector-breadcrumbs"
|
|
1582
|
+
style={{
|
|
1583
|
+
display: 'flex',
|
|
1584
|
+
alignItems: 'center',
|
|
1585
|
+
gap: 3,
|
|
1586
|
+
padding: `${spaceVar[3]} ${spaceVar[4]}`,
|
|
1587
|
+
overflowX: 'auto',
|
|
1588
|
+
borderBottom: `1px solid ${THEME.border}`,
|
|
1589
|
+
whiteSpace: 'nowrap',
|
|
1590
|
+
}}
|
|
1591
|
+
>
|
|
1592
|
+
{chain.map((entry, index) => (
|
|
1593
|
+
<span key={entry.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
|
1594
|
+
{index > 0 && <span style={{ color: THEME.textMuted }}>/</span>}
|
|
1595
|
+
<Button
|
|
1596
|
+
type="button"
|
|
1597
|
+
variant="ghost"
|
|
1598
|
+
size="compact"
|
|
1599
|
+
aria-current={entry.id === node.id ? 'page' : undefined}
|
|
1600
|
+
title={entry.secondaryLabel ?? entry.label}
|
|
1601
|
+
onClick={() => setAuthoringSelection(adapter, [entry.id])}
|
|
1602
|
+
>
|
|
1603
|
+
{entry.label}
|
|
1604
|
+
</Button>
|
|
1605
|
+
</span>
|
|
1606
|
+
))}
|
|
1607
|
+
</nav>
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function StoryActions({
|
|
1612
|
+
owner,
|
|
1613
|
+
node,
|
|
1614
|
+
}: {
|
|
1615
|
+
owner: ReactRootAuthoringAdapter;
|
|
1616
|
+
node: EditorNode;
|
|
1617
|
+
}): React.ReactElement {
|
|
1618
|
+
const args = owner.portableArgs(node.id);
|
|
1619
|
+
// The CSF WRITE half: save-the-current-args as a new story, rename, delete
|
|
1620
|
+
// — inline inputs, never window.prompt (banned), refusals rendered where
|
|
1621
|
+
// the gesture happened.
|
|
1622
|
+
const [mode, setMode] = useState<'idle' | 'save-as' | 'rename' | 'confirm-delete'>('idle');
|
|
1623
|
+
const [draftName, setDraftName] = useState('');
|
|
1624
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
1625
|
+
const run = async (op: Parameters<ReactRootAuthoringAdapter['writeStory']>[1]): Promise<void> => {
|
|
1626
|
+
const result = await owner.writeStory(node.id, op);
|
|
1627
|
+
setNotice(result.changed ? null : (result.error ?? 'refused'));
|
|
1628
|
+
if (result.changed) {
|
|
1629
|
+
setMode('idle');
|
|
1630
|
+
setDraftName('');
|
|
1631
|
+
}
|
|
1632
|
+
};
|
|
1633
|
+
return (
|
|
1634
|
+
<div
|
|
1635
|
+
data-testid="react-story-controls-header"
|
|
1636
|
+
style={{ borderBottom: `1px solid ${THEME.border}` }}
|
|
1637
|
+
>
|
|
1638
|
+
<div
|
|
1639
|
+
style={{
|
|
1640
|
+
display: 'flex',
|
|
1641
|
+
alignItems: 'center',
|
|
1642
|
+
justifyContent: 'space-between',
|
|
1643
|
+
gap: spaceVar[4],
|
|
1644
|
+
padding: '7px 8px',
|
|
1645
|
+
}}
|
|
1646
|
+
>
|
|
1647
|
+
<div style={{ minWidth: 0 }}>
|
|
1648
|
+
<div style={{ color: THEME.text, fontSize: fontSizeVar.md, fontWeight: 600 }}>
|
|
1649
|
+
{node.label}
|
|
1650
|
+
</div>
|
|
1651
|
+
<div style={{ color: THEME.textMuted, fontSize: fontSizeVar.sm }}>
|
|
1652
|
+
{args ? `${Object.keys(args).length} args · session controls` : 'Portable CSF story'}
|
|
1653
|
+
</div>
|
|
1654
|
+
</div>
|
|
1655
|
+
<Button
|
|
1656
|
+
type="button"
|
|
1657
|
+
variant="secondary"
|
|
1658
|
+
size="compact"
|
|
1659
|
+
data-testid="react-story-reset-args"
|
|
1660
|
+
onClick={() => owner.resetPortableArgs(node.id)}
|
|
1661
|
+
>
|
|
1662
|
+
Reset
|
|
1663
|
+
</Button>
|
|
1664
|
+
</div>
|
|
1665
|
+
<div
|
|
1666
|
+
style={{
|
|
1667
|
+
display: 'flex',
|
|
1668
|
+
gap: spaceVar[2],
|
|
1669
|
+
padding: `0 ${spaceVar[4]} ${spaceVar[3]}`,
|
|
1670
|
+
flexWrap: 'wrap',
|
|
1671
|
+
}}
|
|
1672
|
+
>
|
|
1673
|
+
<Button
|
|
1674
|
+
type="button"
|
|
1675
|
+
variant="ghost"
|
|
1676
|
+
size="compact"
|
|
1677
|
+
data-testid="react-story-save-as"
|
|
1678
|
+
title="Write the CURRENT session args as a new story export in this file"
|
|
1679
|
+
onClick={() => {
|
|
1680
|
+
setNotice(null);
|
|
1681
|
+
setDraftName('');
|
|
1682
|
+
setMode(mode === 'save-as' ? 'idle' : 'save-as');
|
|
1683
|
+
}}
|
|
1684
|
+
>
|
|
1685
|
+
Save as…
|
|
1686
|
+
</Button>
|
|
1687
|
+
<Button
|
|
1688
|
+
type="button"
|
|
1689
|
+
variant="ghost"
|
|
1690
|
+
size="compact"
|
|
1691
|
+
data-testid="react-story-rename"
|
|
1692
|
+
onClick={() => {
|
|
1693
|
+
setNotice(null);
|
|
1694
|
+
setDraftName('');
|
|
1695
|
+
setMode(mode === 'rename' ? 'idle' : 'rename');
|
|
1696
|
+
}}
|
|
1697
|
+
>
|
|
1698
|
+
Rename…
|
|
1699
|
+
</Button>
|
|
1700
|
+
{mode === 'confirm-delete' ? (
|
|
1701
|
+
<Button
|
|
1702
|
+
type="button"
|
|
1703
|
+
variant="secondary"
|
|
1704
|
+
size="compact"
|
|
1705
|
+
data-testid="react-story-delete-confirm"
|
|
1706
|
+
onClick={() => void run({ kind: 'delete' })}
|
|
1707
|
+
>
|
|
1708
|
+
Delete story?
|
|
1709
|
+
</Button>
|
|
1710
|
+
) : (
|
|
1711
|
+
<Button
|
|
1712
|
+
type="button"
|
|
1713
|
+
variant="ghost"
|
|
1714
|
+
size="compact"
|
|
1715
|
+
data-testid="react-story-delete"
|
|
1716
|
+
onClick={() => {
|
|
1717
|
+
setNotice(null);
|
|
1718
|
+
setMode('confirm-delete');
|
|
1719
|
+
}}
|
|
1720
|
+
>
|
|
1721
|
+
Delete
|
|
1722
|
+
</Button>
|
|
1723
|
+
)}
|
|
1724
|
+
</div>
|
|
1725
|
+
{(mode === 'save-as' || mode === 'rename') && (
|
|
1726
|
+
<div
|
|
1727
|
+
style={{
|
|
1728
|
+
display: 'flex',
|
|
1729
|
+
gap: spaceVar[2],
|
|
1730
|
+
padding: `0 ${spaceVar[4]} ${spaceVar[4]}`,
|
|
1731
|
+
alignItems: 'center',
|
|
1732
|
+
}}
|
|
1733
|
+
>
|
|
1734
|
+
<input
|
|
1735
|
+
data-testid="react-story-name-input"
|
|
1736
|
+
// Focus the name field when Save as or Rename opens it.
|
|
1737
|
+
// biome-ignore lint/a11y/noAutofocus: the input exists only because the user just asked for it.
|
|
1738
|
+
autoFocus
|
|
1739
|
+
value={draftName}
|
|
1740
|
+
placeholder={mode === 'save-as' ? 'NewStoryName' : `Rename ${node.label}`}
|
|
1741
|
+
onChange={(event) => setDraftName(event.target.value)}
|
|
1742
|
+
onKeyDown={(event) => {
|
|
1743
|
+
if (event.key === 'Enter' && draftName.trim()) {
|
|
1744
|
+
void run(
|
|
1745
|
+
mode === 'save-as'
|
|
1746
|
+
? { kind: 'save-as', name: draftName.trim() }
|
|
1747
|
+
: { kind: 'rename', newName: draftName.trim() },
|
|
1748
|
+
);
|
|
1749
|
+
}
|
|
1750
|
+
if (event.key === 'Escape') setMode('idle');
|
|
1751
|
+
}}
|
|
1752
|
+
style={{
|
|
1753
|
+
flex: 1,
|
|
1754
|
+
minWidth: 0,
|
|
1755
|
+
background: THEME.neutralHover,
|
|
1756
|
+
border: `1px solid ${THEME.border}`,
|
|
1757
|
+
borderRadius: 4,
|
|
1758
|
+
color: THEME.text,
|
|
1759
|
+
fontSize: fontSizeVar.base,
|
|
1760
|
+
padding: '3px 6px',
|
|
1761
|
+
}}
|
|
1762
|
+
/>
|
|
1763
|
+
<Button
|
|
1764
|
+
type="button"
|
|
1765
|
+
variant="secondary"
|
|
1766
|
+
size="compact"
|
|
1767
|
+
data-testid="react-story-name-commit"
|
|
1768
|
+
onClick={() =>
|
|
1769
|
+
void run(
|
|
1770
|
+
mode === 'save-as'
|
|
1771
|
+
? { kind: 'save-as', name: draftName.trim() }
|
|
1772
|
+
: { kind: 'rename', newName: draftName.trim() },
|
|
1773
|
+
)
|
|
1774
|
+
}
|
|
1775
|
+
>
|
|
1776
|
+
{mode === 'save-as' ? 'Save' : 'Rename'}
|
|
1777
|
+
</Button>
|
|
1778
|
+
</div>
|
|
1779
|
+
)}
|
|
1780
|
+
{notice && (
|
|
1781
|
+
<div
|
|
1782
|
+
data-testid="react-story-write-notice"
|
|
1783
|
+
style={{
|
|
1784
|
+
padding: `0 ${spaceVar[4]} ${spaceVar[4]}`,
|
|
1785
|
+
color: themeDanger(),
|
|
1786
|
+
fontSize: fontSizeVar.sm,
|
|
1787
|
+
lineHeight: lineHeightVar.normal,
|
|
1788
|
+
}}
|
|
1789
|
+
>
|
|
1790
|
+
{notice}
|
|
1791
|
+
</div>
|
|
1792
|
+
)}
|
|
1793
|
+
</div>
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
/** Class tokens, their scope-local use counts, and apply/create/remove
|
|
1798
|
+
* controls. A class backed by a first-party single-class rule is editable. */
|
|
1799
|
+
function ClassesBlock({
|
|
1800
|
+
owner,
|
|
1801
|
+
nodeId,
|
|
1802
|
+
}: {
|
|
1803
|
+
owner: ReactRootAuthoringAdapter;
|
|
1804
|
+
nodeId: string;
|
|
1805
|
+
}): React.ReactElement {
|
|
1806
|
+
const classes = owner.elementClasses(nodeId);
|
|
1807
|
+
const [draft, setDraft] = useState('');
|
|
1808
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
1809
|
+
const commit = async (): Promise<void> => {
|
|
1810
|
+
const className = draft.trim().replace(/^\./, '');
|
|
1811
|
+
if (!className) return;
|
|
1812
|
+
const result = await owner.writeNamedStyle(nodeId, { kind: 'set', className });
|
|
1813
|
+
setNotice(result.changed ? null : (result.error ?? 'refused'));
|
|
1814
|
+
if (result.changed) setDraft('');
|
|
1815
|
+
};
|
|
1816
|
+
return (
|
|
1817
|
+
<div
|
|
1818
|
+
data-testid="react-element-classes"
|
|
1819
|
+
style={{
|
|
1820
|
+
padding: `${spaceVar[3]} ${spaceVar[4]}`,
|
|
1821
|
+
borderBottom: `1px solid ${THEME.border}`,
|
|
1822
|
+
}}
|
|
1823
|
+
>
|
|
1824
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: spaceVar[2], alignItems: 'center' }}>
|
|
1825
|
+
<span
|
|
1826
|
+
style={{ fontSize: fontSizeVar.sm, color: THEME.textMuted, marginRight: spaceVar[1] }}
|
|
1827
|
+
>
|
|
1828
|
+
Style class
|
|
1829
|
+
</span>
|
|
1830
|
+
{classes.map((cls) => (
|
|
1831
|
+
<span
|
|
1832
|
+
key={cls.name}
|
|
1833
|
+
data-testid={`react-class-chip-${cls.name}`}
|
|
1834
|
+
title={
|
|
1835
|
+
cls.styledIn
|
|
1836
|
+
? `.${cls.name} — ${cls.count} element${cls.count === 1 ? '' : 's'} in this game ` +
|
|
1837
|
+
`share this style (${cls.styledIn.split('/').pop() ?? cls.styledIn})`
|
|
1838
|
+
: `.${cls.name} — utility/generated class (no first-party rule to edit)`
|
|
1839
|
+
}
|
|
1840
|
+
style={{
|
|
1841
|
+
display: 'inline-flex',
|
|
1842
|
+
alignItems: 'center',
|
|
1843
|
+
gap: 3,
|
|
1844
|
+
fontFamily: FONT_MONO,
|
|
1845
|
+
fontSize: fontSizeVar.sm,
|
|
1846
|
+
color: cls.styledIn ? THEME.text : THEME.textMuted,
|
|
1847
|
+
background: THEME.neutralHover,
|
|
1848
|
+
border: `1px solid ${THEME.border}`,
|
|
1849
|
+
borderRadius: 4,
|
|
1850
|
+
padding: '1px 4px',
|
|
1851
|
+
}}
|
|
1852
|
+
>
|
|
1853
|
+
.{cls.name}
|
|
1854
|
+
{cls.count > 1 && (
|
|
1855
|
+
<span style={{ color: THEME.textMuted, fontSize: fontSizeVar.xs }}>×{cls.count}</span>
|
|
1856
|
+
)}
|
|
1857
|
+
<button
|
|
1858
|
+
type="button"
|
|
1859
|
+
data-testid={`react-class-remove-${cls.name}`}
|
|
1860
|
+
title={`Remove .${cls.name} from this element`}
|
|
1861
|
+
onClick={() =>
|
|
1862
|
+
void owner
|
|
1863
|
+
.writeNamedStyle(nodeId, { kind: 'remove', className: cls.name })
|
|
1864
|
+
.then((result) => setNotice(result.changed ? null : (result.error ?? 'refused')))
|
|
1865
|
+
}
|
|
1866
|
+
style={{
|
|
1867
|
+
background: 'none',
|
|
1868
|
+
border: 'none',
|
|
1869
|
+
color: THEME.textMuted,
|
|
1870
|
+
cursor: 'pointer',
|
|
1871
|
+
fontSize: fontSizeVar.base,
|
|
1872
|
+
padding: '3px 5px',
|
|
1873
|
+
margin: '-3px -4px -3px -1px',
|
|
1874
|
+
lineHeight: lineHeightVar.tight,
|
|
1875
|
+
}}
|
|
1876
|
+
>
|
|
1877
|
+
✕
|
|
1878
|
+
</button>
|
|
1879
|
+
</span>
|
|
1880
|
+
))}
|
|
1881
|
+
<input
|
|
1882
|
+
data-testid="react-class-input"
|
|
1883
|
+
value={draft}
|
|
1884
|
+
placeholder={classes.length === 0 ? 'create style…' : 'add class…'}
|
|
1885
|
+
title="Type a class name — an existing first-party class is applied; a new name creates a style from this element's inline declarations"
|
|
1886
|
+
onChange={(event) => {
|
|
1887
|
+
setDraft(event.target.value);
|
|
1888
|
+
if (notice) setNotice(null);
|
|
1889
|
+
}}
|
|
1890
|
+
onKeyDown={(event) => {
|
|
1891
|
+
if (event.key === 'Enter') void commit();
|
|
1892
|
+
if (event.key === 'Escape') setDraft('');
|
|
1893
|
+
}}
|
|
1894
|
+
style={{
|
|
1895
|
+
flex: 1,
|
|
1896
|
+
minWidth: 72,
|
|
1897
|
+
background: 'transparent',
|
|
1898
|
+
border: `1px dashed ${THEME.border}`,
|
|
1899
|
+
borderRadius: 4,
|
|
1900
|
+
color: THEME.text,
|
|
1901
|
+
fontSize: fontSizeVar.sm,
|
|
1902
|
+
fontFamily: FONT_MONO,
|
|
1903
|
+
padding: '2px 5px',
|
|
1904
|
+
}}
|
|
1905
|
+
/>
|
|
1906
|
+
</div>
|
|
1907
|
+
{notice && (
|
|
1908
|
+
<div
|
|
1909
|
+
data-testid="react-class-notice"
|
|
1910
|
+
style={{
|
|
1911
|
+
paddingTop: spaceVar[2],
|
|
1912
|
+
color: themeDanger(),
|
|
1913
|
+
fontSize: fontSizeVar.sm,
|
|
1914
|
+
lineHeight: lineHeightVar.normal,
|
|
1915
|
+
}}
|
|
1916
|
+
>
|
|
1917
|
+
{notice}
|
|
1918
|
+
</div>
|
|
1919
|
+
)}
|
|
1920
|
+
<BreakpointRow />
|
|
1921
|
+
</div>
|
|
1922
|
+
);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
/** Keep the active breakpoint in session state across adapter recreation. */
|
|
1926
|
+
function BreakpointRow(): React.ReactElement {
|
|
1927
|
+
const bp = useSyncExternalStore(subscribeBreakpoint, activeBreakpoint);
|
|
1928
|
+
return (
|
|
1929
|
+
<>
|
|
1930
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 5, paddingTop: 5 }}>
|
|
1931
|
+
<span style={{ fontSize: fontSizeVar.sm, color: THEME.textMuted }}>Breakpoint</span>
|
|
1932
|
+
<Select
|
|
1933
|
+
data-testid="react-breakpoint-select"
|
|
1934
|
+
value={bp ?? ''}
|
|
1935
|
+
onChange={(event) => setActiveBreakpoint(event.target.value || null)}
|
|
1936
|
+
style={{
|
|
1937
|
+
fontSize: fontSizeVar.sm,
|
|
1938
|
+
flex: 1,
|
|
1939
|
+
...(bp ? { color: THEME.accent, borderColor: THEME.accent } : {}),
|
|
1940
|
+
}}
|
|
1941
|
+
>
|
|
1942
|
+
{BREAKPOINT_OPTIONS.map((option) => (
|
|
1943
|
+
<option key={option.label} value={option.media ?? ''}>
|
|
1944
|
+
{option.label}
|
|
1945
|
+
</option>
|
|
1946
|
+
))}
|
|
1947
|
+
</Select>
|
|
1948
|
+
</div>
|
|
1949
|
+
{bp && (
|
|
1950
|
+
<div
|
|
1951
|
+
data-testid="react-breakpoint-note"
|
|
1952
|
+
style={{
|
|
1953
|
+
paddingTop: 3,
|
|
1954
|
+
color: THEME.textMuted,
|
|
1955
|
+
fontSize: fontSizeVar.xs,
|
|
1956
|
+
lineHeight: lineHeightVar.normal,
|
|
1957
|
+
}}
|
|
1958
|
+
>
|
|
1959
|
+
Style edits write @media {bp} — previewed live by frame width.
|
|
1960
|
+
</div>
|
|
1961
|
+
)}
|
|
1962
|
+
</>
|
|
1963
|
+
);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
/** Webflow's cascade tiers as `@media` conditions. Base is the unscoped rule;
|
|
1967
|
+
* each tier is max-width, so narrower inherits from wider — the same
|
|
1968
|
+
* downward cascade Webflow teaches. A project's own additional conditions
|
|
1969
|
+
* are honored wherever they already exist in its css; this list is only
|
|
1970
|
+
* what the SELECT offers. */
|
|
1971
|
+
const BREAKPOINT_OPTIONS: ReadonlyArray<{ label: string; media: string | null }> = [
|
|
1972
|
+
{ label: 'Base', media: null },
|
|
1973
|
+
{ label: 'Tablet ≤991', media: '(max-width: 991px)' },
|
|
1974
|
+
{ label: 'Phone L ≤767', media: '(max-width: 767px)' },
|
|
1975
|
+
{ label: 'Phone ≤479', media: '(max-width: 479px)' },
|
|
1976
|
+
];
|
|
1977
|
+
|
|
1978
|
+
/** The danger tone for a story-write refusal. */
|
|
1979
|
+
function themeDanger(): string {
|
|
1980
|
+
return '#e06c75';
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
/** Copy a source file/line locator from a mapped DOM element. */
|
|
1984
|
+
function SourceLocationRow({ entry }: { entry: OidEntry }): React.ReactElement {
|
|
1985
|
+
const [copied, setCopied] = useState(false);
|
|
1986
|
+
const filePath = `${entry.file}:${entry.line}`;
|
|
1987
|
+
const short = `${entry.file.split('/').pop() ?? entry.file}:${entry.line}`;
|
|
1988
|
+
const copy = async (): Promise<void> => {
|
|
1989
|
+
try {
|
|
1990
|
+
await navigator.clipboard.writeText(filePath);
|
|
1991
|
+
setCopied(true);
|
|
1992
|
+
setTimeout(() => setCopied(false), 1500);
|
|
1993
|
+
} catch {
|
|
1994
|
+
// Clipboard API unavailable — the row still shows the path as text.
|
|
1995
|
+
}
|
|
1996
|
+
};
|
|
1997
|
+
return (
|
|
1998
|
+
<button
|
|
1999
|
+
type="button"
|
|
2000
|
+
data-testid="react-source-location"
|
|
2001
|
+
title={`${filePath} — click to copy the path`}
|
|
2002
|
+
onClick={() => void copy()}
|
|
2003
|
+
style={{
|
|
2004
|
+
fontFamily: FONT_MONO,
|
|
2005
|
+
fontSize: 'inherit',
|
|
2006
|
+
color: THEME.textMuted,
|
|
2007
|
+
cursor: 'pointer',
|
|
2008
|
+
overflow: 'hidden',
|
|
2009
|
+
textOverflow: 'ellipsis',
|
|
2010
|
+
whiteSpace: 'nowrap',
|
|
2011
|
+
minWidth: 0,
|
|
2012
|
+
background: 'none',
|
|
2013
|
+
border: 'none',
|
|
2014
|
+
padding: 0,
|
|
2015
|
+
textAlign: 'left',
|
|
2016
|
+
}}
|
|
2017
|
+
>
|
|
2018
|
+
{copied ? 'Copied' : short}
|
|
2019
|
+
</button>
|
|
2020
|
+
);
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
function LaneDisclosure({
|
|
2024
|
+
owner,
|
|
2025
|
+
node,
|
|
2026
|
+
nodeId,
|
|
2027
|
+
}: {
|
|
2028
|
+
owner: ReactRootAuthoringAdapter;
|
|
2029
|
+
node: EditorNode;
|
|
2030
|
+
nodeId: string;
|
|
2031
|
+
}): React.ReactElement {
|
|
2032
|
+
// The source adapter maps the node OID to its indexed file and line.
|
|
2033
|
+
const sourceEntry = owner.sourceLocation(nodeId);
|
|
2034
|
+
// Keep provenance in a compact row; its tooltip explains source-write scope.
|
|
2035
|
+
const short =
|
|
2036
|
+
node.role === 'story'
|
|
2037
|
+
? `Story · ${node.label}`
|
|
2038
|
+
: node.role === 'document'
|
|
2039
|
+
? `CSF · ${node.secondaryLabel ?? node.label}`
|
|
2040
|
+
: 'Source';
|
|
2041
|
+
const explain =
|
|
2042
|
+
node.role === 'story'
|
|
2043
|
+
? `${node.label} is the active portable CSF design state.`
|
|
2044
|
+
: node.role === 'document'
|
|
2045
|
+
? `${node.secondaryLabel ?? node.label} owns this root's design states.`
|
|
2046
|
+
: `Edits change ${node.label}'s source — every rendered ` +
|
|
2047
|
+
'instance updates.';
|
|
2048
|
+
return (
|
|
2049
|
+
<div
|
|
2050
|
+
data-testid="react-lane-b-disclosure"
|
|
2051
|
+
title={explain}
|
|
2052
|
+
style={{
|
|
2053
|
+
display: 'flex',
|
|
2054
|
+
alignItems: 'center',
|
|
2055
|
+
gap: spaceVar[3],
|
|
2056
|
+
padding: `${spaceVar[1]} ${spaceVar[4]} ${spaceVar[3]}`,
|
|
2057
|
+
borderBottom: `1px solid ${THEME.border}`,
|
|
2058
|
+
color: THEME.textMuted,
|
|
2059
|
+
fontSize: fontSizeVar.sm,
|
|
2060
|
+
lineHeight: lineHeightVar.normal,
|
|
2061
|
+
minWidth: 0,
|
|
2062
|
+
}}
|
|
2063
|
+
>
|
|
2064
|
+
<span style={{ flexShrink: 0 }}>{short}</span>
|
|
2065
|
+
{sourceEntry && <SourceLocationRow entry={sourceEntry} />}
|
|
2066
|
+
</div>
|
|
2067
|
+
);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
/** The `element` facet's body: where it sits (ancestry), which lane owns it,
|
|
2071
|
+
* and where its source is. The subject's own name and type live on the
|
|
2072
|
+
* shell's identity row, not here. */
|
|
2073
|
+
function ElementSection({
|
|
2074
|
+
adapter,
|
|
2075
|
+
node,
|
|
2076
|
+
nodeId,
|
|
2077
|
+
owner,
|
|
2078
|
+
}: {
|
|
2079
|
+
adapter: AuthoringAdapter;
|
|
2080
|
+
node: EditorNode;
|
|
2081
|
+
nodeId: string;
|
|
2082
|
+
owner: ReactRootAuthoringAdapter;
|
|
2083
|
+
}): React.ReactElement {
|
|
2084
|
+
return (
|
|
2085
|
+
<div data-testid="react-inspector-section">
|
|
2086
|
+
<BreadcrumbBar adapter={adapter} node={node} />
|
|
2087
|
+
<ClassesBlock owner={owner} nodeId={nodeId} />
|
|
2088
|
+
<LaneDisclosure owner={owner} node={node} nodeId={nodeId} />
|
|
2089
|
+
</div>
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
/** The Style/Layout/Position/Spacing/component-prop group bodies re-render on
|
|
2094
|
+
* the shell's own subscription: every `SingleRow`/`ComboRow` calls
|
|
2095
|
+
* `store.notifyIngestEdit()` on commit (its own `useEditorStore()`), which
|
|
2096
|
+
* re-renders the inspector tree including these sections. */
|
|
2097
|
+
function GroupSectionBody({
|
|
2098
|
+
adapter,
|
|
2099
|
+
id,
|
|
2100
|
+
name,
|
|
2101
|
+
groupId,
|
|
2102
|
+
props,
|
|
2103
|
+
}: {
|
|
2104
|
+
adapter: AuthoringAdapter;
|
|
2105
|
+
id: string;
|
|
2106
|
+
name: string;
|
|
2107
|
+
groupId: string;
|
|
2108
|
+
props: readonly PropertyDescriptor[];
|
|
2109
|
+
}): React.ReactElement {
|
|
2110
|
+
const fonts = useMemo(() => getAvailableFonts(), []);
|
|
2111
|
+
return (
|
|
2112
|
+
<GroupBody
|
|
2113
|
+
name={name}
|
|
2114
|
+
groupId={groupId}
|
|
2115
|
+
props={props}
|
|
2116
|
+
adapter={adapter}
|
|
2117
|
+
id={id}
|
|
2118
|
+
fonts={fonts}
|
|
2119
|
+
/>
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/** Produce one identified inspection section per node property group.
|
|
2124
|
+
* Shared section IDs replace the generic property presentation. */
|
|
2125
|
+
function reactInspectorSections(
|
|
2126
|
+
node: EditorNode | null,
|
|
2127
|
+
adapter: AuthoringAdapter,
|
|
2128
|
+
): readonly InspectionSection[] {
|
|
2129
|
+
const owner = resolveReactOwner(node, adapter);
|
|
2130
|
+
if (!owner || !node) return [];
|
|
2131
|
+
const nodeId = node.id;
|
|
2132
|
+
const props = (adapter.inspector?.properties(nodeId) ?? []).filter((p) => p.path !== 'name');
|
|
2133
|
+
const { ungrouped, groups } = groupProperties(props);
|
|
2134
|
+
const sections: InspectionSection[] = [
|
|
2135
|
+
{
|
|
2136
|
+
id: REACT_ELEMENT_SECTION_ID,
|
|
2137
|
+
// The facet's name, not the object's: what KIND of element this is
|
|
2138
|
+
// surfaces on the subject's identity row. This adapter sets no
|
|
2139
|
+
// `typeLabel`, so the row's kind label falls back to `node.kind` (the tag
|
|
2140
|
+
// name) — the `typeLabel ?? kind` chain in `compose.ts`'s `kindLabel`.
|
|
2141
|
+
title: 'Element',
|
|
2142
|
+
icon: faCode,
|
|
2143
|
+
order: REACT_ELEMENT_SECTION_ORDER,
|
|
2144
|
+
body: {
|
|
2145
|
+
kind: 'custom',
|
|
2146
|
+
render: () => createElement(ElementSection, { adapter, node, nodeId, owner }),
|
|
2147
|
+
// Publish class chips and the writable breakpoint for editor.inspect()
|
|
2148
|
+
// and editor.setField(). Class add/remove uses writeNamedStyle.
|
|
2149
|
+
data: {
|
|
2150
|
+
classes: owner.elementClasses(nodeId),
|
|
2151
|
+
breakpoint: activeBreakpoint(),
|
|
2152
|
+
},
|
|
2153
|
+
editable: {
|
|
2154
|
+
fields: [
|
|
2155
|
+
{
|
|
2156
|
+
path: 'element.breakpoint',
|
|
2157
|
+
label: 'Breakpoint',
|
|
2158
|
+
type: 'enum' as const,
|
|
2159
|
+
options: BREAKPOINT_OPTIONS.map((option) => option.media ?? ''),
|
|
2160
|
+
},
|
|
2161
|
+
],
|
|
2162
|
+
io: {
|
|
2163
|
+
get: (path: string) =>
|
|
2164
|
+
path === 'element.breakpoint' ? (activeBreakpoint() ?? '') : undefined,
|
|
2165
|
+
set: (path: string, value: unknown) => {
|
|
2166
|
+
if (path === 'element.breakpoint') {
|
|
2167
|
+
setActiveBreakpoint(typeof value === 'string' && value ? value : null);
|
|
2168
|
+
}
|
|
2169
|
+
},
|
|
2170
|
+
},
|
|
2171
|
+
},
|
|
2172
|
+
},
|
|
2173
|
+
},
|
|
2174
|
+
];
|
|
2175
|
+
if (node.role === 'story') {
|
|
2176
|
+
sections.push({
|
|
2177
|
+
id: REACT_STORY_SECTION_ID,
|
|
2178
|
+
title: 'Story',
|
|
2179
|
+
// The single selected story's own controls — a DIFFERENT facet from the
|
|
2180
|
+
// built-in `stories` collection/switcher (`faBookOpen`), and both can
|
|
2181
|
+
// compose on a story node. The compact card tells tabs apart by glyph
|
|
2182
|
+
// alone, so this one wears a distinct single-story glyph.
|
|
2183
|
+
icon: faBookBookmark,
|
|
2184
|
+
order: REACT_STORY_SECTION_ORDER,
|
|
2185
|
+
body: { kind: 'custom', render: () => createElement(StoryActions, { owner, node }) },
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
if (ungrouped.length > 0) {
|
|
2189
|
+
sections.push({
|
|
2190
|
+
id: PROPERTIES_SECTION_ID,
|
|
2191
|
+
title: 'Style',
|
|
2192
|
+
icon: faPalette,
|
|
2193
|
+
order: PROPERTIES_SECTION_ORDER,
|
|
2194
|
+
defaultOpen: true,
|
|
2195
|
+
body: {
|
|
2196
|
+
kind: 'custom',
|
|
2197
|
+
render: () =>
|
|
2198
|
+
createElement(GroupSectionBody, {
|
|
2199
|
+
adapter,
|
|
2200
|
+
id: nodeId,
|
|
2201
|
+
name: 'Style',
|
|
2202
|
+
groupId: 'style',
|
|
2203
|
+
props: ungrouped,
|
|
2204
|
+
}),
|
|
2205
|
+
},
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
groups.forEach((group, index) => {
|
|
2209
|
+
sections.push({
|
|
2210
|
+
id: groupSectionId(group.groupId),
|
|
2211
|
+
title: group.name,
|
|
2212
|
+
icon: groupIcon(group.name),
|
|
2213
|
+
order: GROUP_SECTION_ORDER + index,
|
|
2214
|
+
defaultOpen: true,
|
|
2215
|
+
body: {
|
|
2216
|
+
kind: 'custom',
|
|
2217
|
+
render: () =>
|
|
2218
|
+
createElement(GroupSectionBody, {
|
|
2219
|
+
adapter,
|
|
2220
|
+
id: nodeId,
|
|
2221
|
+
name: group.name,
|
|
2222
|
+
groupId: group.groupId,
|
|
2223
|
+
props: group.properties,
|
|
2224
|
+
}),
|
|
2225
|
+
},
|
|
2226
|
+
});
|
|
2227
|
+
});
|
|
2228
|
+
return sections;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
const registrationGroup = createHmrRegistrationGroup(import.meta.hot, 'react-inspector-section');
|
|
2232
|
+
|
|
2233
|
+
/** Register this surface's Inspector sections through its HMR registration group.
|
|
2234
|
+
* The editor-ui service calls this during activation; importing the module alone
|
|
2235
|
+
* leaves the registry unchanged. */
|
|
2236
|
+
export function ensureReactInspectorSectionRegistered(): void {
|
|
2237
|
+
registrationGroup.ensure((track) => {
|
|
2238
|
+
track(
|
|
2239
|
+
registerInspectorSections({
|
|
2240
|
+
match: isDomSelection,
|
|
2241
|
+
sections: reactInspectorSections,
|
|
2242
|
+
// These blocks own the node's properties; omit the generic descriptor grid.
|
|
2243
|
+
claims: [PROPERTIES_SECTION_ID],
|
|
2244
|
+
}),
|
|
2245
|
+
);
|
|
2246
|
+
});
|
|
2247
|
+
}
|