@jxsuite/studio 0.7.0 → 0.9.0
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/dist/studio.js +125373 -124927
- package/dist/studio.js.map +87 -73
- package/package.json +2 -2
- package/src/canvas/canvas-utils.js +313 -0
- package/src/editor/component-inline-edit.js +316 -0
- package/src/editor/content-inline-edit.js +220 -0
- package/src/editor/insertion-helper.js +301 -0
- package/src/editor/slash-menu.js +111 -37
- package/src/panels/block-action-bar.js +436 -0
- package/src/panels/canvas-dnd.js +165 -0
- package/src/panels/dnd.js +332 -0
- package/src/panels/editors.js +196 -0
- package/src/panels/left-panel.js +3 -2
- package/src/panels/panel-events.js +263 -0
- package/src/panels/preview-render.js +103 -0
- package/src/panels/properties-panel.js +1340 -0
- package/src/panels/pseudo-preview.js +64 -0
- package/src/panels/right-panel.js +2 -1
- package/src/panels/shared.js +74 -0
- package/src/panels/stylebook-panel.js +1052 -0
- package/src/studio.js +121 -4874
- package/src/utils/edit-display.js +197 -0
|
@@ -0,0 +1,1340 @@
|
|
|
1
|
+
/** Properties panel — inspector for element attributes, component props, media, and frontmatter. */
|
|
2
|
+
|
|
3
|
+
import { html, nothing } from "lit-html";
|
|
4
|
+
import { live } from "lit-html/directives/live.js";
|
|
5
|
+
import {
|
|
6
|
+
getState,
|
|
7
|
+
selectNode,
|
|
8
|
+
getNodeAtPath,
|
|
9
|
+
update,
|
|
10
|
+
updateProperty,
|
|
11
|
+
updateAttribute,
|
|
12
|
+
updateProp,
|
|
13
|
+
updateMedia,
|
|
14
|
+
updateFrontmatter,
|
|
15
|
+
addSwitchCase,
|
|
16
|
+
removeSwitchCase,
|
|
17
|
+
renameSwitchCase,
|
|
18
|
+
debouncedStyleCommit,
|
|
19
|
+
renderOnly,
|
|
20
|
+
updateUi,
|
|
21
|
+
projectState,
|
|
22
|
+
} from "../store.js";
|
|
23
|
+
import { view } from "../view.js";
|
|
24
|
+
import { componentRegistry } from "../files/components.js";
|
|
25
|
+
import { widgetForType } from "./style-inputs.js";
|
|
26
|
+
import { renderFieldRow } from "../ui/field-row.js";
|
|
27
|
+
import {
|
|
28
|
+
attrLabel,
|
|
29
|
+
inferInputType,
|
|
30
|
+
findCollectionSchema,
|
|
31
|
+
friendlyNameToVar,
|
|
32
|
+
camelToLabel,
|
|
33
|
+
parseCemType,
|
|
34
|
+
} from "../utils/studio-utils.js";
|
|
35
|
+
import { isCustomElementDoc, collectCssParts } from "./signals-panel.js";
|
|
36
|
+
import { mediaDisplayName } from "./shared.js";
|
|
37
|
+
import { getCssInitialMap } from "./style-utils.js";
|
|
38
|
+
import htmlMeta from "../../data/html-meta.json";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Convert a human-friendly name like "Tablet" to a $media key "--tablet"
|
|
42
|
+
*
|
|
43
|
+
* @param {any} name
|
|
44
|
+
*/
|
|
45
|
+
function friendlyNameToMedia(name) {
|
|
46
|
+
return friendlyNameToVar(name, "--");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Check if a selection path is inside a $map template (contains [..., "children", "map", ...]). */
|
|
50
|
+
function isInsideMapTemplate(/** @type {any} */ path) {
|
|
51
|
+
if (!path) return false;
|
|
52
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
53
|
+
if (path[i] === "children" && path[i + 1] === "map") return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Field row with binding toggle — allows switching between static value and signal binding.
|
|
60
|
+
* rawValue can be a string/bool (static) or { $ref: "..." } (bound).
|
|
61
|
+
*/
|
|
62
|
+
function bindableFieldRow(
|
|
63
|
+
/** @type {any} */ label,
|
|
64
|
+
/** @type {any} */ type,
|
|
65
|
+
/** @type {any} */ rawValue,
|
|
66
|
+
/** @type {any} */ onChange,
|
|
67
|
+
/** @type {any} */ filterFn = null,
|
|
68
|
+
/** @type {any} */ extraSignals = null,
|
|
69
|
+
) {
|
|
70
|
+
const S = getState();
|
|
71
|
+
const defs = S.document.state || {};
|
|
72
|
+
const isBound = typeof rawValue === "object" && rawValue !== null && rawValue.$ref;
|
|
73
|
+
|
|
74
|
+
const signalDefs = Object.entries(defs).filter(([, d]) =>
|
|
75
|
+
filterFn ? filterFn(d) : !d.$handler && d.$prototype !== "Function",
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/** @type {any} */
|
|
79
|
+
let debounce;
|
|
80
|
+
const onInput = (/** @type {any} */ e) => {
|
|
81
|
+
clearTimeout(debounce);
|
|
82
|
+
debounce = setTimeout(() => onChange(e.target.value), 400);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const staticVal = isBound ? "" : (rawValue ?? "");
|
|
86
|
+
const staticTpl =
|
|
87
|
+
type === "textarea"
|
|
88
|
+
? html`<sp-textfield multiline size="s" value=${staticVal} @input=${onInput}></sp-textfield>`
|
|
89
|
+
: type === "checkbox"
|
|
90
|
+
? html`<sp-checkbox
|
|
91
|
+
?checked=${!!staticVal}
|
|
92
|
+
@change=${(/** @type {any} */ e) => onChange(e.target.checked)}
|
|
93
|
+
></sp-checkbox>`
|
|
94
|
+
: html`<sp-textfield size="s" value=${staticVal} @input=${onInput}></sp-textfield>`;
|
|
95
|
+
|
|
96
|
+
const boundTpl = html`
|
|
97
|
+
<sp-picker
|
|
98
|
+
size="s"
|
|
99
|
+
quiet
|
|
100
|
+
placeholder="— select signal —"
|
|
101
|
+
value=${isBound && rawValue.$ref ? rawValue.$ref : nothing}
|
|
102
|
+
@change=${(/** @type {any} */ e) => {
|
|
103
|
+
if (e.target.value) onChange({ $ref: e.target.value });
|
|
104
|
+
else onChange(undefined);
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
${signalDefs.map(
|
|
108
|
+
([defName]) => html`<sp-menu-item value=${`#/state/${defName}`}>${defName}</sp-menu-item>`,
|
|
109
|
+
)}
|
|
110
|
+
${extraSignals
|
|
111
|
+
? html`
|
|
112
|
+
<sp-menu-divider></sp-menu-divider>
|
|
113
|
+
${extraSignals.map(
|
|
114
|
+
(/** @type {any} */ sig) =>
|
|
115
|
+
html`<sp-menu-item value=${sig.value}>${sig.label}</sp-menu-item>`,
|
|
116
|
+
)}
|
|
117
|
+
`
|
|
118
|
+
: nothing}
|
|
119
|
+
</sp-picker>
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
const onToggle = () => {
|
|
123
|
+
if (isBound) {
|
|
124
|
+
const ref = rawValue.$ref;
|
|
125
|
+
const defName = ref.startsWith("#/state/") ? ref.slice(8) : ref;
|
|
126
|
+
const def = defs[defName];
|
|
127
|
+
let staticVal = "";
|
|
128
|
+
if (def && def.default !== undefined)
|
|
129
|
+
staticVal =
|
|
130
|
+
typeof def.default === "object" ? JSON.stringify(def.default) : String(def.default);
|
|
131
|
+
onChange(staticVal || undefined);
|
|
132
|
+
} else {
|
|
133
|
+
if (signalDefs.length > 0) {
|
|
134
|
+
onChange({ $ref: `#/state/${signalDefs[0][0]}` });
|
|
135
|
+
} else if (extraSignals?.length > 0) {
|
|
136
|
+
onChange({ $ref: extraSignals[0].value });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return html`
|
|
142
|
+
<div class="field-row">
|
|
143
|
+
<sp-field-label size="s">${label}</sp-field-label>
|
|
144
|
+
${isBound ? boundTpl : staticTpl}
|
|
145
|
+
<sp-action-button
|
|
146
|
+
size="xs"
|
|
147
|
+
quiet
|
|
148
|
+
title=${isBound ? "Unbind (switch to static)" : "Bind to signal"}
|
|
149
|
+
@click=${onToggle}
|
|
150
|
+
>${isBound ? "\u26A1" : "\u2194"}</sp-action-button
|
|
151
|
+
>
|
|
152
|
+
</div>
|
|
153
|
+
`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Key-value pair row for styles / attributes */
|
|
157
|
+
function kvRow(
|
|
158
|
+
/** @type {any} */ key,
|
|
159
|
+
/** @type {any} */ value,
|
|
160
|
+
/** @type {any} */ onChange,
|
|
161
|
+
/** @type {any} */ onDelete,
|
|
162
|
+
/** @type {any} */ datalistId = null,
|
|
163
|
+
) {
|
|
164
|
+
/** @type {any} */
|
|
165
|
+
let debounceTimer;
|
|
166
|
+
let currentKey = key;
|
|
167
|
+
let currentVal = value;
|
|
168
|
+
const commit = () => {
|
|
169
|
+
clearTimeout(debounceTimer);
|
|
170
|
+
debounceTimer = setTimeout(() => onChange(currentKey, currentVal), 400);
|
|
171
|
+
};
|
|
172
|
+
const placeholder = datalistId === "css-props" ? getCssInitialMap().get(key) || "" : "";
|
|
173
|
+
return html`
|
|
174
|
+
<div class="kv-row">
|
|
175
|
+
<sp-textfield
|
|
176
|
+
size="s"
|
|
177
|
+
class="kv-key"
|
|
178
|
+
value=${key}
|
|
179
|
+
@input=${(/** @type {any} */ e) => {
|
|
180
|
+
currentKey = e.target.value;
|
|
181
|
+
commit();
|
|
182
|
+
}}
|
|
183
|
+
@change=${datalistId === "css-props"
|
|
184
|
+
? (/** @type {any} */ e) => {
|
|
185
|
+
const el = e.target.closest(".kv-row")?.querySelector(".kv-val");
|
|
186
|
+
if (el) el.setAttribute("placeholder", getCssInitialMap().get(e.target.value) || "");
|
|
187
|
+
}
|
|
188
|
+
: nothing}
|
|
189
|
+
></sp-textfield>
|
|
190
|
+
<sp-textfield
|
|
191
|
+
size="s"
|
|
192
|
+
class="kv-val"
|
|
193
|
+
value=${value}
|
|
194
|
+
placeholder=${placeholder}
|
|
195
|
+
@input=${(/** @type {any} */ e) => {
|
|
196
|
+
currentVal = e.target.value;
|
|
197
|
+
commit();
|
|
198
|
+
}}
|
|
199
|
+
></sp-textfield>
|
|
200
|
+
<sp-action-button size="xs" quiet @click=${onDelete}>
|
|
201
|
+
<sp-icon-close slot="icon"></sp-icon-close>
|
|
202
|
+
</sp-action-button>
|
|
203
|
+
</div>
|
|
204
|
+
`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── Frontmatter ────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
/** Frontmatter-only panel shown in content mode when no element is selected */
|
|
210
|
+
function renderFrontmatterOnlyPanel() {
|
|
211
|
+
const S = getState();
|
|
212
|
+
const fm = S.content?.frontmatter || {};
|
|
213
|
+
const col = findCollectionSchema(S.documentPath, projectState?.projectConfig);
|
|
214
|
+
const schemaProps = col?.schema?.properties;
|
|
215
|
+
const requiredFields = new Set(col?.schema?.required || []);
|
|
216
|
+
|
|
217
|
+
/** @type {{ field: string; entry: any; value: any }[]} */
|
|
218
|
+
const fields = [];
|
|
219
|
+
if (schemaProps) {
|
|
220
|
+
for (const [field, fieldSchema] of Object.entries(
|
|
221
|
+
/** @type {Record<string, any>} */ (schemaProps),
|
|
222
|
+
)) {
|
|
223
|
+
fields.push({ field, entry: fieldSchema, value: fm[field] });
|
|
224
|
+
}
|
|
225
|
+
for (const [field, value] of Object.entries(fm)) {
|
|
226
|
+
if (!schemaProps[field]) {
|
|
227
|
+
fields.push({
|
|
228
|
+
field,
|
|
229
|
+
entry: { type: typeof value === "boolean" ? "boolean" : "string" },
|
|
230
|
+
value,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
for (const [field, value] of Object.entries(fm)) {
|
|
236
|
+
fields.push({
|
|
237
|
+
field,
|
|
238
|
+
entry: { type: typeof value === "boolean" ? "boolean" : "string" },
|
|
239
|
+
value,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (fields.length === 0 && !schemaProps) {
|
|
245
|
+
return html`<div class="empty-state">No frontmatter. Select an element to inspect.</div>`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return html`
|
|
249
|
+
<div class="style-sidebar">
|
|
250
|
+
<sp-accordion allow-multiple size="s">
|
|
251
|
+
<sp-accordion-item label=${col ? `Frontmatter (${col.name})` : "Frontmatter"} open>
|
|
252
|
+
<div class="style-section-body">
|
|
253
|
+
${fields.map((f) => renderFmFieldRow(f.field, f.entry, f.value, requiredFields))}
|
|
254
|
+
</div>
|
|
255
|
+
</sp-accordion-item>
|
|
256
|
+
</sp-accordion>
|
|
257
|
+
</div>
|
|
258
|
+
`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Render a single frontmatter field row (shared between both panels) */
|
|
262
|
+
function renderFmFieldRow(
|
|
263
|
+
/** @type {string} */ field,
|
|
264
|
+
/** @type {any} */ entry,
|
|
265
|
+
/** @type {any} */ value,
|
|
266
|
+
/** @type {Set<string>} */ requiredFields,
|
|
267
|
+
) {
|
|
268
|
+
const S = getState();
|
|
269
|
+
const isRequired = requiredFields.has(field);
|
|
270
|
+
const label = field.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase());
|
|
271
|
+
const displayLabel = label + (isRequired ? " *" : "");
|
|
272
|
+
const hasVal = value !== undefined && value !== "" && value !== false;
|
|
273
|
+
const onClear = () => update(updateFrontmatter(S, field, undefined));
|
|
274
|
+
|
|
275
|
+
if (entry.type === "boolean") {
|
|
276
|
+
return renderFieldRow({
|
|
277
|
+
prop: field,
|
|
278
|
+
label: displayLabel,
|
|
279
|
+
hasValue: hasVal,
|
|
280
|
+
onClear,
|
|
281
|
+
widget: html`
|
|
282
|
+
<sp-checkbox
|
|
283
|
+
size="s"
|
|
284
|
+
.checked=${live(!!value)}
|
|
285
|
+
@change=${(/** @type {any} */ e) =>
|
|
286
|
+
update(updateFrontmatter(getState(), field, e.target.checked || undefined))}
|
|
287
|
+
></sp-checkbox>
|
|
288
|
+
`,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (entry.type === "array") {
|
|
293
|
+
const display = Array.isArray(value) ? value.join(", ") : value || "";
|
|
294
|
+
return renderFieldRow({
|
|
295
|
+
prop: field,
|
|
296
|
+
label: displayLabel,
|
|
297
|
+
hasValue: hasVal,
|
|
298
|
+
onClear,
|
|
299
|
+
widget: html`
|
|
300
|
+
<sp-textfield
|
|
301
|
+
size="s"
|
|
302
|
+
placeholder="comma, separated"
|
|
303
|
+
.value=${live(display)}
|
|
304
|
+
@input=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
|
|
305
|
+
const arr = e.target.value
|
|
306
|
+
? e.target.value
|
|
307
|
+
.split(",")
|
|
308
|
+
.map((/** @type {string} */ s) => s.trim())
|
|
309
|
+
.filter(Boolean)
|
|
310
|
+
: undefined;
|
|
311
|
+
update(updateFrontmatter(getState(), field, arr));
|
|
312
|
+
})}
|
|
313
|
+
></sp-textfield>
|
|
314
|
+
`,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (Array.isArray(entry.enum)) {
|
|
319
|
+
return renderFieldRow({
|
|
320
|
+
prop: field,
|
|
321
|
+
label: displayLabel,
|
|
322
|
+
hasValue: hasVal,
|
|
323
|
+
onClear,
|
|
324
|
+
widget: html`
|
|
325
|
+
<sp-picker
|
|
326
|
+
size="s"
|
|
327
|
+
.value=${live(value || "")}
|
|
328
|
+
@change=${(/** @type {any} */ e) =>
|
|
329
|
+
update(updateFrontmatter(getState(), field, e.target.value || undefined))}
|
|
330
|
+
>
|
|
331
|
+
${entry.enum.map(
|
|
332
|
+
(/** @type {string} */ opt) => html`<sp-menu-item value=${opt}>${opt}</sp-menu-item>`,
|
|
333
|
+
)}
|
|
334
|
+
</sp-picker>
|
|
335
|
+
`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (entry.type === "number") {
|
|
340
|
+
return renderFieldRow({
|
|
341
|
+
prop: field,
|
|
342
|
+
label: displayLabel,
|
|
343
|
+
hasValue: hasVal,
|
|
344
|
+
onClear,
|
|
345
|
+
widget: html`
|
|
346
|
+
<sp-number-field
|
|
347
|
+
size="s"
|
|
348
|
+
hide-stepper
|
|
349
|
+
.value=${live(value !== undefined ? Number(value) : undefined)}
|
|
350
|
+
@change=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
|
|
351
|
+
const v = e.target.value;
|
|
352
|
+
update(updateFrontmatter(getState(), field, isNaN(v) ? undefined : Number(v)));
|
|
353
|
+
})}
|
|
354
|
+
></sp-number-field>
|
|
355
|
+
`,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return renderFieldRow({
|
|
360
|
+
prop: field,
|
|
361
|
+
label: displayLabel,
|
|
362
|
+
hasValue: hasVal,
|
|
363
|
+
onClear,
|
|
364
|
+
widget: html`
|
|
365
|
+
<sp-textfield
|
|
366
|
+
size="s"
|
|
367
|
+
placeholder=${entry.format === "date" ? "YYYY-MM-DD" : ""}
|
|
368
|
+
.value=${live(value || "")}
|
|
369
|
+
@input=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) => {
|
|
370
|
+
update(updateFrontmatter(getState(), field, e.target.value || undefined));
|
|
371
|
+
})}
|
|
372
|
+
></sp-textfield>
|
|
373
|
+
`,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ─── Sub-templates ──────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
/** Repeater fields template */
|
|
380
|
+
function renderRepeaterFieldsTemplate(
|
|
381
|
+
/** @type {any} */ node,
|
|
382
|
+
/** @type {any} */ path,
|
|
383
|
+
/** @type {any} */ _mapSignals,
|
|
384
|
+
) {
|
|
385
|
+
const S = getState();
|
|
386
|
+
return html`
|
|
387
|
+
${bindableFieldRow("Items", "text", node.items, (/** @type {any} */ v) =>
|
|
388
|
+
update(updateProperty(getState(), path, "items", v)),
|
|
389
|
+
)}
|
|
390
|
+
${node.filter
|
|
391
|
+
? bindableFieldRow("Filter", "text", node.filter, (/** @type {any} */ v) =>
|
|
392
|
+
update(updateProperty(getState(), path, "filter", v || undefined)),
|
|
393
|
+
)
|
|
394
|
+
: nothing}
|
|
395
|
+
${node.sort
|
|
396
|
+
? bindableFieldRow("Sort", "text", node.sort, (/** @type {any} */ v) =>
|
|
397
|
+
update(updateProperty(getState(), path, "sort", v || undefined)),
|
|
398
|
+
)
|
|
399
|
+
: nothing}
|
|
400
|
+
<div style="display:flex;gap:8px;margin-top:4px">
|
|
401
|
+
${!node.filter
|
|
402
|
+
? html`<span
|
|
403
|
+
class="kv-add"
|
|
404
|
+
@click=${() => update(updateProperty(getState(), path, "filter", { $ref: "#/state/" }))}
|
|
405
|
+
>+ Add filter</span
|
|
406
|
+
>`
|
|
407
|
+
: nothing}
|
|
408
|
+
${!node.sort
|
|
409
|
+
? html`<span
|
|
410
|
+
class="kv-add"
|
|
411
|
+
@click=${() => update(updateProperty(getState(), path, "sort", { $ref: "#/state/" }))}
|
|
412
|
+
>+ Add sort</span
|
|
413
|
+
>`
|
|
414
|
+
: nothing}
|
|
415
|
+
</div>
|
|
416
|
+
${node.map
|
|
417
|
+
? html`
|
|
418
|
+
<sp-action-button
|
|
419
|
+
size="s"
|
|
420
|
+
style="margin-top:8px;width:100%"
|
|
421
|
+
@click=${() => update(selectNode(S, [...path, "map"]))}
|
|
422
|
+
>Edit template →</sp-action-button
|
|
423
|
+
>
|
|
424
|
+
`
|
|
425
|
+
: nothing}
|
|
426
|
+
`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Switch fields template */
|
|
430
|
+
function renderSwitchFieldsTemplate(
|
|
431
|
+
/** @type {any} */ node,
|
|
432
|
+
/** @type {any} */ path,
|
|
433
|
+
/** @type {any} */ mapSignals,
|
|
434
|
+
) {
|
|
435
|
+
const caseNames = Object.keys(node.cases || {});
|
|
436
|
+
return html`
|
|
437
|
+
${bindableFieldRow(
|
|
438
|
+
"Expression",
|
|
439
|
+
"text",
|
|
440
|
+
node.$switch,
|
|
441
|
+
(/** @type {any} */ v) => update(updateProperty(getState(), path, "$switch", v)),
|
|
442
|
+
null,
|
|
443
|
+
mapSignals,
|
|
444
|
+
)}
|
|
445
|
+
<div
|
|
446
|
+
style="font-size:11px;font-weight:600;color:var(--fg-dim);margin:8px 0 4px;text-transform:uppercase;letter-spacing:0.05em"
|
|
447
|
+
>
|
|
448
|
+
Cases
|
|
449
|
+
</div>
|
|
450
|
+
${caseNames.map((caseName) => {
|
|
451
|
+
/** @type {any} */
|
|
452
|
+
let debounce;
|
|
453
|
+
return html`
|
|
454
|
+
<div class="field-row" style="display:flex;align-items:center;gap:4px;margin-bottom:3px">
|
|
455
|
+
<input
|
|
456
|
+
class="field-input"
|
|
457
|
+
value=${caseName}
|
|
458
|
+
style="flex:1"
|
|
459
|
+
@input=${(/** @type {any} */ e) => {
|
|
460
|
+
clearTimeout(debounce);
|
|
461
|
+
debounce = setTimeout(() => {
|
|
462
|
+
if (e.target.value && e.target.value !== caseName)
|
|
463
|
+
update(renameSwitchCase(getState(), path, caseName, e.target.value));
|
|
464
|
+
}, 500);
|
|
465
|
+
}}
|
|
466
|
+
/>
|
|
467
|
+
<span
|
|
468
|
+
class="bind-toggle"
|
|
469
|
+
title="Edit case"
|
|
470
|
+
style="cursor:pointer"
|
|
471
|
+
@click=${(/** @type {any} */ e) => {
|
|
472
|
+
e.stopPropagation();
|
|
473
|
+
update(selectNode(getState(), [...path, "cases", caseName]));
|
|
474
|
+
}}
|
|
475
|
+
>→</span
|
|
476
|
+
>
|
|
477
|
+
<span
|
|
478
|
+
style="cursor:pointer;color:var(--danger);font-size:11px"
|
|
479
|
+
@click=${(/** @type {any} */ e) => {
|
|
480
|
+
e.stopPropagation();
|
|
481
|
+
update(removeSwitchCase(getState(), path, caseName));
|
|
482
|
+
}}
|
|
483
|
+
>✕</span
|
|
484
|
+
>
|
|
485
|
+
</div>
|
|
486
|
+
`;
|
|
487
|
+
})}
|
|
488
|
+
<span
|
|
489
|
+
class="kv-add"
|
|
490
|
+
@click=${() => {
|
|
491
|
+
update(addSwitchCase(getState(), path, `case${caseNames.length + 1}`));
|
|
492
|
+
}}
|
|
493
|
+
>+ Add case</span
|
|
494
|
+
>
|
|
495
|
+
`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Component props fields template */
|
|
499
|
+
function renderComponentPropsFieldsTemplate(
|
|
500
|
+
/** @type {any} */ node,
|
|
501
|
+
/** @type {any} */ path,
|
|
502
|
+
/** @type {any} */ mapSignals,
|
|
503
|
+
/** @type {(path: string) => void} */ navigateToComponent,
|
|
504
|
+
) {
|
|
505
|
+
const S = getState();
|
|
506
|
+
const comp = componentRegistry.find((c) => c.tagName === node.tagName);
|
|
507
|
+
if (!comp) return html`<div class="empty-state">Component not found</div>`;
|
|
508
|
+
const isNpm = comp.source === "npm";
|
|
509
|
+
const currentVals = isNpm ? node.attributes || {} : node.$props || {};
|
|
510
|
+
const updateFn = isNpm
|
|
511
|
+
? (/** @type {string} */ name, /** @type {any} */ v) =>
|
|
512
|
+
update(updateAttribute(getState(), path, name, v === "" ? undefined : v))
|
|
513
|
+
: (/** @type {string} */ name, /** @type {any} */ v) =>
|
|
514
|
+
update(updateProp(getState(), path, name, v));
|
|
515
|
+
|
|
516
|
+
const defs = S.document.state || {};
|
|
517
|
+
const signalDefs = Object.entries(defs).filter(
|
|
518
|
+
([, d]) => !d.$handler && d.$prototype !== "Function",
|
|
519
|
+
);
|
|
520
|
+
const extraSignals = mapSignals;
|
|
521
|
+
|
|
522
|
+
return html`
|
|
523
|
+
${comp.props.map((/** @type {any} */ prop) => {
|
|
524
|
+
const rawValue = currentVals[prop.name];
|
|
525
|
+
const isBound = typeof rawValue === "object" && rawValue !== null && rawValue.$ref;
|
|
526
|
+
const hasVal = rawValue !== undefined && rawValue !== null;
|
|
527
|
+
const parsed = parseCemType(prop.type);
|
|
528
|
+
const onChange = (/** @type {any} */ v) => updateFn(prop.name, v);
|
|
529
|
+
|
|
530
|
+
const clearProp = (/** @type {any} */ e) => {
|
|
531
|
+
e.stopPropagation();
|
|
532
|
+
updateFn(prop.name, undefined);
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const onToggleBind = () => {
|
|
536
|
+
if (isBound) {
|
|
537
|
+
const ref = rawValue.$ref;
|
|
538
|
+
const defName = ref.startsWith("#/state/") ? ref.slice(8) : ref;
|
|
539
|
+
const def = defs[defName];
|
|
540
|
+
let staticVal = "";
|
|
541
|
+
if (def && def.default !== undefined)
|
|
542
|
+
staticVal =
|
|
543
|
+
typeof def.default === "object" ? JSON.stringify(def.default) : String(def.default);
|
|
544
|
+
onChange(staticVal || undefined);
|
|
545
|
+
} else {
|
|
546
|
+
if (signalDefs.length > 0) {
|
|
547
|
+
onChange({ $ref: `#/state/${signalDefs[0][0]}` });
|
|
548
|
+
} else if (extraSignals?.length > 0) {
|
|
549
|
+
onChange({ $ref: extraSignals[0].value });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const boundTpl = html`
|
|
555
|
+
<sp-picker
|
|
556
|
+
size="s"
|
|
557
|
+
quiet
|
|
558
|
+
placeholder="— select signal —"
|
|
559
|
+
value=${isBound && rawValue.$ref ? rawValue.$ref : nothing}
|
|
560
|
+
@change=${(/** @type {any} */ e) => {
|
|
561
|
+
if (e.target.value) onChange({ $ref: e.target.value });
|
|
562
|
+
else onChange(undefined);
|
|
563
|
+
}}
|
|
564
|
+
>
|
|
565
|
+
${signalDefs.map(
|
|
566
|
+
([defName]) =>
|
|
567
|
+
html`<sp-menu-item value=${`#/state/${defName}`}>${defName}</sp-menu-item>`,
|
|
568
|
+
)}
|
|
569
|
+
${extraSignals
|
|
570
|
+
? html`
|
|
571
|
+
<sp-menu-divider></sp-menu-divider>
|
|
572
|
+
${extraSignals.map(
|
|
573
|
+
(/** @type {any} */ sig) =>
|
|
574
|
+
html`<sp-menu-item value=${sig.value}>${sig.label}</sp-menu-item>`,
|
|
575
|
+
)}
|
|
576
|
+
`
|
|
577
|
+
: nothing}
|
|
578
|
+
</sp-picker>
|
|
579
|
+
`;
|
|
580
|
+
|
|
581
|
+
/** @type {any} */
|
|
582
|
+
let debounce;
|
|
583
|
+
const staticVal = isBound ? "" : (rawValue ?? "");
|
|
584
|
+
/** @type {any} */
|
|
585
|
+
let widgetTpl;
|
|
586
|
+
if (parsed.kind === "boolean") {
|
|
587
|
+
widgetTpl = html`<sp-checkbox
|
|
588
|
+
size="s"
|
|
589
|
+
.checked=${live(!!staticVal)}
|
|
590
|
+
@change=${(/** @type {any} */ e) => onChange(e.target.checked || undefined)}
|
|
591
|
+
></sp-checkbox>`;
|
|
592
|
+
} else if (parsed.kind === "number") {
|
|
593
|
+
widgetTpl = html`<sp-number-field
|
|
594
|
+
size="s"
|
|
595
|
+
value=${staticVal}
|
|
596
|
+
@input=${(/** @type {any} */ e) => {
|
|
597
|
+
clearTimeout(debounce);
|
|
598
|
+
debounce = setTimeout(() => onChange(e.target.value), 400);
|
|
599
|
+
}}
|
|
600
|
+
></sp-number-field>`;
|
|
601
|
+
} else if (parsed.kind === "combobox") {
|
|
602
|
+
const options = /** @type {string[]} */ (/** @type {any} */ (parsed).options);
|
|
603
|
+
widgetTpl = html`<jx-value-selector
|
|
604
|
+
.value=${String(staticVal)}
|
|
605
|
+
size="s"
|
|
606
|
+
placeholder="—"
|
|
607
|
+
.options=${options.map((o) => ({ value: o, label: camelToLabel(o) }))}
|
|
608
|
+
@change=${(/** @type {any} */ e) => onChange(e.detail?.value ?? e.target.value)}
|
|
609
|
+
></jx-value-selector>`;
|
|
610
|
+
} else {
|
|
611
|
+
widgetTpl = html`<sp-textfield
|
|
612
|
+
size="s"
|
|
613
|
+
value=${staticVal}
|
|
614
|
+
@input=${(/** @type {any} */ e) => {
|
|
615
|
+
clearTimeout(debounce);
|
|
616
|
+
debounce = setTimeout(() => onChange(e.target.value), 400);
|
|
617
|
+
}}
|
|
618
|
+
></sp-textfield>`;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return html`
|
|
622
|
+
<div class="style-row" data-prop=${prop.name}>
|
|
623
|
+
<div class="style-row-label">
|
|
624
|
+
${hasVal
|
|
625
|
+
? html`<span class="set-dot" title="Clear ${prop.name}" @click=${clearProp}></span>`
|
|
626
|
+
: nothing}
|
|
627
|
+
<sp-field-label size="s" title=${prop.description || prop.name}
|
|
628
|
+
>${camelToLabel(prop.name)}</sp-field-label
|
|
629
|
+
>
|
|
630
|
+
<sp-action-button
|
|
631
|
+
size="xs"
|
|
632
|
+
quiet
|
|
633
|
+
title=${isBound ? "Unbind (switch to static)" : "Bind to signal"}
|
|
634
|
+
@click=${onToggleBind}
|
|
635
|
+
>${isBound ? "\u26A1" : "\u2194"}</sp-action-button
|
|
636
|
+
>
|
|
637
|
+
</div>
|
|
638
|
+
${isBound ? boundTpl : widgetTpl}
|
|
639
|
+
</div>
|
|
640
|
+
`;
|
|
641
|
+
})}
|
|
642
|
+
${comp.props.length === 0 ? html`<div class="empty-state">No props defined</div>` : nothing}
|
|
643
|
+
${comp.path
|
|
644
|
+
? html`<span class="kv-add" @click=${() => navigateToComponent(comp.path)}
|
|
645
|
+
>→ Edit definition</span
|
|
646
|
+
>`
|
|
647
|
+
: nothing}
|
|
648
|
+
`;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Custom attrs fields template */
|
|
652
|
+
function renderCustomAttrsFieldsTemplate(
|
|
653
|
+
/** @type {any} */ node,
|
|
654
|
+
/** @type {any} */ path,
|
|
655
|
+
/** @type {any} */ attrs,
|
|
656
|
+
/** @type {any} */ knownAttrNames,
|
|
657
|
+
) {
|
|
658
|
+
const customAttrs = Object.entries(attrs).filter(([k]) => !knownAttrNames.has(k));
|
|
659
|
+
return html`
|
|
660
|
+
${customAttrs.map(([attr, val]) =>
|
|
661
|
+
kvRow(
|
|
662
|
+
attr,
|
|
663
|
+
String(val),
|
|
664
|
+
(/** @type {any} */ newAttr, /** @type {any} */ newVal) => {
|
|
665
|
+
const S = getState();
|
|
666
|
+
if (newAttr !== attr) {
|
|
667
|
+
let s = updateAttribute(S, path, attr, undefined);
|
|
668
|
+
s = updateAttribute(s, path, newAttr, newVal);
|
|
669
|
+
update(s);
|
|
670
|
+
} else {
|
|
671
|
+
update(updateAttribute(S, path, attr, newVal));
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
() => update(updateAttribute(getState(), path, attr, undefined)),
|
|
675
|
+
),
|
|
676
|
+
)}
|
|
677
|
+
<span class="kv-add" @click=${() => update(updateAttribute(getState(), path, "data-", ""))}
|
|
678
|
+
>+ Add attribute</span
|
|
679
|
+
>
|
|
680
|
+
`;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ─── Media breakpoints ──────────────────────────────────────────────────────
|
|
684
|
+
|
|
685
|
+
/** Media breakpoint fields template */
|
|
686
|
+
function renderMediaFieldsTemplate(/** @type {any} */ node) {
|
|
687
|
+
const media = node.$media || {};
|
|
688
|
+
/** @type {any} */
|
|
689
|
+
let baseDebounce;
|
|
690
|
+
const breakpoints = Object.entries(media).filter(([k]) => k !== "--");
|
|
691
|
+
|
|
692
|
+
return html`
|
|
693
|
+
<div class="kv-row" style="align-items:center">
|
|
694
|
+
<span class="field-label" style="width:auto;margin-right:4px">Base width</span>
|
|
695
|
+
<input
|
|
696
|
+
class="field-input"
|
|
697
|
+
style="width:70px;flex:none"
|
|
698
|
+
placeholder="320px"
|
|
699
|
+
value=${media["--"] || ""}
|
|
700
|
+
@input=${(/** @type {any} */ e) => {
|
|
701
|
+
clearTimeout(baseDebounce);
|
|
702
|
+
baseDebounce = setTimeout(() => {
|
|
703
|
+
const val = e.target.value.trim();
|
|
704
|
+
update(updateMedia(getState(), "--", val || undefined));
|
|
705
|
+
}, 400);
|
|
706
|
+
}}
|
|
707
|
+
/>
|
|
708
|
+
${media["--"]
|
|
709
|
+
? html`<span class="kv-del" @click=${() => update(updateMedia(getState(), "--", undefined))}
|
|
710
|
+
>✕</span
|
|
711
|
+
>`
|
|
712
|
+
: nothing}
|
|
713
|
+
</div>
|
|
714
|
+
|
|
715
|
+
${breakpoints.map(([name, query]) => mediaBreakpointRowTemplate(name, query))}
|
|
716
|
+
|
|
717
|
+
<div>
|
|
718
|
+
<span
|
|
719
|
+
class="kv-add"
|
|
720
|
+
style=${view.showAddBreakpointForm ? "display:none" : ""}
|
|
721
|
+
@click=${(/** @type {any} */ _e) => {
|
|
722
|
+
view.showAddBreakpointForm = true;
|
|
723
|
+
renderOnly("rightPanel");
|
|
724
|
+
}}
|
|
725
|
+
>+ Add breakpoint</span
|
|
726
|
+
>
|
|
727
|
+
${view.showAddBreakpointForm
|
|
728
|
+
? html`
|
|
729
|
+
<div style="margin-top:4px">
|
|
730
|
+
<div style="display:flex;gap:4px;margin-bottom:3px;align-items:center">
|
|
731
|
+
<input
|
|
732
|
+
class="field-input"
|
|
733
|
+
placeholder="Name (e.g. Tablet)"
|
|
734
|
+
style="flex:1"
|
|
735
|
+
@input=${(/** @type {any} */ e) => {
|
|
736
|
+
view.addBreakpointPreview = friendlyNameToMedia(e.target.value) || "";
|
|
737
|
+
renderOnly("rightPanel");
|
|
738
|
+
}}
|
|
739
|
+
/>
|
|
740
|
+
<span
|
|
741
|
+
style="font-size:10px;color:var(--fg-dim);font-family:'SF Mono','Fira Code',monospace;white-space:nowrap"
|
|
742
|
+
>${view.addBreakpointPreview}</span
|
|
743
|
+
>
|
|
744
|
+
</div>
|
|
745
|
+
<div style="display:flex;gap:4px;margin-bottom:3px;align-items:center">
|
|
746
|
+
<input class="field-input add-bp-query" value="(min-width: 768px)" style="flex:1" />
|
|
747
|
+
</div>
|
|
748
|
+
<div style="display:flex;gap:4px">
|
|
749
|
+
<button
|
|
750
|
+
class="kv-add"
|
|
751
|
+
style="padding:2px 10px;cursor:pointer"
|
|
752
|
+
@click=${(/** @type {any} */ e) => {
|
|
753
|
+
const wrap = e.target.closest("div").parentElement;
|
|
754
|
+
const nameVal = wrap.querySelector("input")?.value;
|
|
755
|
+
const queryVal = wrap.querySelector(".add-bp-query")?.value?.trim();
|
|
756
|
+
const key = friendlyNameToMedia(nameVal);
|
|
757
|
+
if (key && queryVal) {
|
|
758
|
+
view.showAddBreakpointForm = false;
|
|
759
|
+
view.addBreakpointPreview = "";
|
|
760
|
+
update(updateMedia(getState(), key, queryVal));
|
|
761
|
+
}
|
|
762
|
+
}}
|
|
763
|
+
>
|
|
764
|
+
Add
|
|
765
|
+
</button>
|
|
766
|
+
<button
|
|
767
|
+
class="kv-add"
|
|
768
|
+
style="padding:2px 10px;cursor:pointer;color:var(--fg-dim)"
|
|
769
|
+
@click=${() => {
|
|
770
|
+
view.showAddBreakpointForm = false;
|
|
771
|
+
view.addBreakpointPreview = "";
|
|
772
|
+
renderOnly("rightPanel");
|
|
773
|
+
}}
|
|
774
|
+
>
|
|
775
|
+
Cancel
|
|
776
|
+
</button>
|
|
777
|
+
</div>
|
|
778
|
+
</div>
|
|
779
|
+
`
|
|
780
|
+
: nothing}
|
|
781
|
+
</div>
|
|
782
|
+
`;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** Single media breakpoint row template */
|
|
786
|
+
function mediaBreakpointRowTemplate(/** @type {any} */ name, /** @type {any} */ query) {
|
|
787
|
+
/** @type {any} */
|
|
788
|
+
let debounceTimer;
|
|
789
|
+
let currentRawLabel = name;
|
|
790
|
+
return html`
|
|
791
|
+
<div style="margin-bottom:6px;padding:4px 0;border-bottom:1px solid var(--border)">
|
|
792
|
+
<div style="display:flex;align-items:center;gap:4px;margin-bottom:2px">
|
|
793
|
+
<input
|
|
794
|
+
class="field-input"
|
|
795
|
+
value=${mediaDisplayName(name)}
|
|
796
|
+
style="flex:1;font-weight:600;font-size:12px"
|
|
797
|
+
@input=${(/** @type {any} */ e) => {
|
|
798
|
+
const newKey = friendlyNameToMedia(e.target.value);
|
|
799
|
+
currentRawLabel = newKey || "";
|
|
800
|
+
const rawEl = e.target.parentElement?.querySelector(".bp-raw-label");
|
|
801
|
+
if (rawEl) rawEl.textContent = currentRawLabel;
|
|
802
|
+
clearTimeout(debounceTimer);
|
|
803
|
+
debounceTimer = setTimeout(() => {
|
|
804
|
+
if (newKey && newKey !== name) {
|
|
805
|
+
const queryEl = e.target
|
|
806
|
+
.closest("div[style]")
|
|
807
|
+
?.parentElement?.querySelector(".bp-query-input");
|
|
808
|
+
const S = getState();
|
|
809
|
+
let s = updateMedia(S, name, undefined);
|
|
810
|
+
s = updateMedia(s, newKey, queryEl?.value || query);
|
|
811
|
+
update(s);
|
|
812
|
+
}
|
|
813
|
+
}, 600);
|
|
814
|
+
}}
|
|
815
|
+
/>
|
|
816
|
+
<span
|
|
817
|
+
class="bp-raw-label"
|
|
818
|
+
style="font-size:10px;color:var(--fg-dim);font-family:'SF Mono','Fira Code',monospace;white-space:nowrap"
|
|
819
|
+
>${name}</span
|
|
820
|
+
>
|
|
821
|
+
<span class="kv-del" @click=${() => update(updateMedia(getState(), name, undefined))}
|
|
822
|
+
>✕</span
|
|
823
|
+
>
|
|
824
|
+
</div>
|
|
825
|
+
<div style="display:flex;gap:4px;align-items:center">
|
|
826
|
+
<input
|
|
827
|
+
class="field-input bp-query-input"
|
|
828
|
+
value=${query}
|
|
829
|
+
style="flex:1"
|
|
830
|
+
@input=${(/** @type {any} */ e) => {
|
|
831
|
+
clearTimeout(debounceTimer);
|
|
832
|
+
debounceTimer = setTimeout(
|
|
833
|
+
() => update(updateMedia(getState(), name, e.target.value)),
|
|
834
|
+
400,
|
|
835
|
+
);
|
|
836
|
+
}}
|
|
837
|
+
/>
|
|
838
|
+
</div>
|
|
839
|
+
</div>
|
|
840
|
+
`;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ─── Main entry point ───────────────────────────────────────────────────────
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Properties panel — lit-html template with accordion sections.
|
|
847
|
+
*
|
|
848
|
+
* @param {{ navigateToComponent: (path: string) => void }} ctx
|
|
849
|
+
*/
|
|
850
|
+
export function renderPropertiesPanelTemplate(ctx) {
|
|
851
|
+
const S = getState();
|
|
852
|
+
|
|
853
|
+
if (!S.selection) {
|
|
854
|
+
if (S.mode === "content") {
|
|
855
|
+
return renderFrontmatterOnlyPanel();
|
|
856
|
+
}
|
|
857
|
+
return html`<div class="empty-state">Select an element to inspect</div>`;
|
|
858
|
+
}
|
|
859
|
+
const node = getNodeAtPath(S.document, S.selection);
|
|
860
|
+
if (!node) return html`<div class="empty-state">Node not found</div>`;
|
|
861
|
+
|
|
862
|
+
const path = S.selection;
|
|
863
|
+
const isMapNode = node.$prototype === "Array";
|
|
864
|
+
const isMapParent =
|
|
865
|
+
node.children && typeof node.children === "object" && node.children.$prototype === "Array";
|
|
866
|
+
const isSwitchNode = !!node.$switch;
|
|
867
|
+
const isCustomInstance = (node.tagName || "").includes("-");
|
|
868
|
+
const isRoot = path.length === 0;
|
|
869
|
+
const tagName = node.tagName || "div";
|
|
870
|
+
const attrs = node.attributes || {};
|
|
871
|
+
|
|
872
|
+
const mapSignals = isInsideMapTemplate(path)
|
|
873
|
+
? [
|
|
874
|
+
{ value: "$map/item", label: "$map/item" },
|
|
875
|
+
{ value: "$map/index", label: "$map/index" },
|
|
876
|
+
]
|
|
877
|
+
: null;
|
|
878
|
+
|
|
879
|
+
function renderAttrRow(
|
|
880
|
+
/** @type {any} */ attr,
|
|
881
|
+
/** @type {any} */ entry,
|
|
882
|
+
/** @type {any} */ value,
|
|
883
|
+
) {
|
|
884
|
+
const type = inferInputType(entry);
|
|
885
|
+
const hasVal = value !== undefined && value !== "";
|
|
886
|
+
|
|
887
|
+
if (entry.type === "boolean") {
|
|
888
|
+
return renderFieldRow({
|
|
889
|
+
prop: attr,
|
|
890
|
+
label: attrLabel(entry, attr),
|
|
891
|
+
hasValue: hasVal,
|
|
892
|
+
onClear: () => update(updateAttribute(getState(), path, attr, undefined)),
|
|
893
|
+
widget: html`
|
|
894
|
+
<sp-checkbox
|
|
895
|
+
size="s"
|
|
896
|
+
.checked=${live(!!value)}
|
|
897
|
+
@change=${(/** @type {any} */ e) =>
|
|
898
|
+
update(updateAttribute(getState(), path, attr, e.target.checked || undefined))}
|
|
899
|
+
>
|
|
900
|
+
</sp-checkbox>
|
|
901
|
+
`,
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return renderFieldRow({
|
|
906
|
+
prop: attr,
|
|
907
|
+
label: attrLabel(entry, attr),
|
|
908
|
+
hasValue: hasVal,
|
|
909
|
+
onClear: () => update(updateAttribute(getState(), path, attr, undefined)),
|
|
910
|
+
widget: widgetForType(type, entry, attr, value || "", (/** @type {any} */ v) =>
|
|
911
|
+
update(updateAttribute(getState(), path, attr, v || undefined)),
|
|
912
|
+
),
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// ── Collect applicable attributes from html-meta ──
|
|
917
|
+
const applicableAttrs = /** @type {Record<string, any>} */ ({});
|
|
918
|
+
for (const [attr, entry] of /** @type {[string, any][]} */ (Object.entries(htmlMeta.$defs))) {
|
|
919
|
+
if (!entry.$elements || entry.$elements.includes(tagName)) {
|
|
920
|
+
applicableAttrs[attr] = entry;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const attrSections = /** @type {Record<string, any[]>} */ ({});
|
|
925
|
+
for (const sec of htmlMeta.$sections) attrSections[sec.key] = [];
|
|
926
|
+
for (const [attr, entry] of Object.entries(applicableAttrs)) {
|
|
927
|
+
const secKey = entry.$section;
|
|
928
|
+
if (attrSections[secKey]) attrSections[secKey].push({ name: attr, entry });
|
|
929
|
+
}
|
|
930
|
+
for (const sec of htmlMeta.$sections) {
|
|
931
|
+
attrSections[sec.key].sort(
|
|
932
|
+
(/** @type {any} */ a, /** @type {any} */ b) => a.entry.$order - b.entry.$order,
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const knownAttrNames = new Set(Object.keys(applicableAttrs));
|
|
937
|
+
if (isCustomInstance) {
|
|
938
|
+
const comp = componentRegistry.find((c) => c.tagName === node.tagName);
|
|
939
|
+
if (comp) for (const p of comp.props) knownAttrNames.add(p.name);
|
|
940
|
+
}
|
|
941
|
+
const customAttrs = Object.entries(attrs).filter(([k]) => !knownAttrNames.has(k));
|
|
942
|
+
|
|
943
|
+
const autoOpen = new Set();
|
|
944
|
+
for (const [attr] of Object.entries(attrs)) {
|
|
945
|
+
const entry = applicableAttrs[attr];
|
|
946
|
+
if (entry) autoOpen.add(entry.$section);
|
|
947
|
+
}
|
|
948
|
+
if (customAttrs.length > 0) autoOpen.add("__custom");
|
|
949
|
+
|
|
950
|
+
function isSectionOpen(/** @type {any} */ key) {
|
|
951
|
+
if (S.ui.inspectorSections[key] !== undefined) return S.ui.inspectorSections[key];
|
|
952
|
+
return autoOpen.has(key);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function toggleSection(/** @type {any} */ key) {
|
|
956
|
+
const current = isSectionOpen(key);
|
|
957
|
+
updateUi("inspectorSections", { ...S.ui.inspectorSections, [key]: !current });
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// ── Build section templates ─────────────────────────────────────────
|
|
961
|
+
|
|
962
|
+
const elemT = html`
|
|
963
|
+
<sp-accordion-item
|
|
964
|
+
label="Element"
|
|
965
|
+
?open=${isSectionOpen("__element") !== false}
|
|
966
|
+
@sp-accordion-item-toggle=${() => toggleSection("__element")}
|
|
967
|
+
>
|
|
968
|
+
<div class="style-section-body">
|
|
969
|
+
<div class="style-row" data-prop="tagName">
|
|
970
|
+
<div class="style-row-label">
|
|
971
|
+
<sp-field-label size="s">Tag</sp-field-label>
|
|
972
|
+
</div>
|
|
973
|
+
<sp-textfield
|
|
974
|
+
size="s"
|
|
975
|
+
.value=${live(tagName)}
|
|
976
|
+
autocomplete="off"
|
|
977
|
+
list="tag-names"
|
|
978
|
+
@input=${debouncedStyleCommit("prop:tagName", 400, (/** @type {any} */ e) => {
|
|
979
|
+
update(updateProperty(getState(), path, "tagName", e.target.value || undefined));
|
|
980
|
+
})}
|
|
981
|
+
></sp-textfield>
|
|
982
|
+
</div>
|
|
983
|
+
<div class="style-row" data-prop="$id">
|
|
984
|
+
<div class="style-row-label">
|
|
985
|
+
${node.$id
|
|
986
|
+
? html`<span
|
|
987
|
+
class="set-dot"
|
|
988
|
+
title="Clear $id"
|
|
989
|
+
@click=${(/** @type {any} */ e) => {
|
|
990
|
+
e.stopPropagation();
|
|
991
|
+
update(updateProperty(getState(), path, "$id", undefined));
|
|
992
|
+
}}
|
|
993
|
+
></span>`
|
|
994
|
+
: nothing}
|
|
995
|
+
<sp-field-label size="s">ID</sp-field-label>
|
|
996
|
+
</div>
|
|
997
|
+
<sp-textfield
|
|
998
|
+
size="s"
|
|
999
|
+
.value=${live(node.$id || "")}
|
|
1000
|
+
@input=${debouncedStyleCommit("prop:$id", 400, (/** @type {any} */ e) => {
|
|
1001
|
+
update(updateProperty(getState(), path, "$id", e.target.value || undefined));
|
|
1002
|
+
})}
|
|
1003
|
+
></sp-textfield>
|
|
1004
|
+
</div>
|
|
1005
|
+
<div class="style-row" data-prop="className">
|
|
1006
|
+
<div class="style-row-label">
|
|
1007
|
+
${node.className
|
|
1008
|
+
? html`<span
|
|
1009
|
+
class="set-dot"
|
|
1010
|
+
title="Clear class"
|
|
1011
|
+
@click=${(/** @type {any} */ e) => {
|
|
1012
|
+
e.stopPropagation();
|
|
1013
|
+
update(updateProperty(getState(), path, "className", undefined));
|
|
1014
|
+
}}
|
|
1015
|
+
></span>`
|
|
1016
|
+
: nothing}
|
|
1017
|
+
<sp-field-label size="s">Class</sp-field-label>
|
|
1018
|
+
</div>
|
|
1019
|
+
<sp-textfield
|
|
1020
|
+
size="s"
|
|
1021
|
+
.value=${live(node.className || "")}
|
|
1022
|
+
@input=${debouncedStyleCommit("prop:className", 400, (/** @type {any} */ e) => {
|
|
1023
|
+
update(updateProperty(getState(), path, "className", e.target.value || undefined));
|
|
1024
|
+
})}
|
|
1025
|
+
></sp-textfield>
|
|
1026
|
+
</div>
|
|
1027
|
+
${!Array.isArray(node.children) || node.children.length === 0
|
|
1028
|
+
? html`
|
|
1029
|
+
<div class="style-row" data-prop="textContent">
|
|
1030
|
+
<div class="style-row-label">
|
|
1031
|
+
${node.textContent !== undefined
|
|
1032
|
+
? html`<span
|
|
1033
|
+
class="set-dot"
|
|
1034
|
+
title="Clear text"
|
|
1035
|
+
@click=${(/** @type {any} */ e) => {
|
|
1036
|
+
e.stopPropagation();
|
|
1037
|
+
update(updateProperty(getState(), path, "textContent", undefined));
|
|
1038
|
+
}}
|
|
1039
|
+
></span>`
|
|
1040
|
+
: nothing}
|
|
1041
|
+
<sp-field-label size="s">Text Content</sp-field-label>
|
|
1042
|
+
</div>
|
|
1043
|
+
<sp-textfield
|
|
1044
|
+
size="s"
|
|
1045
|
+
multiline
|
|
1046
|
+
.value=${live(
|
|
1047
|
+
typeof node.textContent === "string"
|
|
1048
|
+
? node.textContent
|
|
1049
|
+
: (node.textContent ?? ""),
|
|
1050
|
+
)}
|
|
1051
|
+
@input=${debouncedStyleCommit("prop:textContent", 400, (/** @type {any} */ e) => {
|
|
1052
|
+
update(
|
|
1053
|
+
updateProperty(getState(), path, "textContent", e.target.value || undefined),
|
|
1054
|
+
);
|
|
1055
|
+
})}
|
|
1056
|
+
></sp-textfield>
|
|
1057
|
+
</div>
|
|
1058
|
+
`
|
|
1059
|
+
: nothing}
|
|
1060
|
+
<div class="style-row" data-prop="hidden">
|
|
1061
|
+
<div class="style-row-label">
|
|
1062
|
+
${node.hidden
|
|
1063
|
+
? html`<span
|
|
1064
|
+
class="set-dot"
|
|
1065
|
+
title="Clear hidden"
|
|
1066
|
+
@click=${(/** @type {any} */ e) => {
|
|
1067
|
+
e.stopPropagation();
|
|
1068
|
+
update(updateProperty(getState(), path, "hidden", undefined));
|
|
1069
|
+
}}
|
|
1070
|
+
></span>`
|
|
1071
|
+
: nothing}
|
|
1072
|
+
<sp-field-label size="s">Hidden</sp-field-label>
|
|
1073
|
+
</div>
|
|
1074
|
+
<sp-checkbox
|
|
1075
|
+
size="s"
|
|
1076
|
+
.checked=${live(!!node.hidden)}
|
|
1077
|
+
@change=${(/** @type {any} */ e) =>
|
|
1078
|
+
update(updateProperty(getState(), path, "hidden", e.target.checked || undefined))}
|
|
1079
|
+
>
|
|
1080
|
+
</sp-checkbox>
|
|
1081
|
+
</div>
|
|
1082
|
+
${isMapParent
|
|
1083
|
+
? html`
|
|
1084
|
+
<div style="font-size:10px;color:var(--fg-dim);padding:4px 0;font-style:italic">
|
|
1085
|
+
Children: Repeater (select in layers to configure)
|
|
1086
|
+
</div>
|
|
1087
|
+
`
|
|
1088
|
+
: nothing}
|
|
1089
|
+
</div>
|
|
1090
|
+
</sp-accordion-item>
|
|
1091
|
+
`;
|
|
1092
|
+
|
|
1093
|
+
const repeaterT = isMapNode
|
|
1094
|
+
? html`
|
|
1095
|
+
<sp-accordion-item label="Repeater" open>
|
|
1096
|
+
<div class="style-section-body">
|
|
1097
|
+
${renderRepeaterFieldsTemplate(node, path, mapSignals)}
|
|
1098
|
+
</div>
|
|
1099
|
+
</sp-accordion-item>
|
|
1100
|
+
`
|
|
1101
|
+
: nothing;
|
|
1102
|
+
|
|
1103
|
+
const switchT = isSwitchNode
|
|
1104
|
+
? html`
|
|
1105
|
+
<sp-accordion-item label="Switch" open>
|
|
1106
|
+
<div class="style-section-body">
|
|
1107
|
+
${renderSwitchFieldsTemplate(node, path, mapSignals)}
|
|
1108
|
+
</div>
|
|
1109
|
+
</sp-accordion-item>
|
|
1110
|
+
`
|
|
1111
|
+
: nothing;
|
|
1112
|
+
|
|
1113
|
+
const observedAttrsT =
|
|
1114
|
+
isCustomElementDoc(S) && isRoot
|
|
1115
|
+
? (() => {
|
|
1116
|
+
const state = S.document.state || {};
|
|
1117
|
+
const entries = Object.entries(state).filter(([, d]) => d.attribute);
|
|
1118
|
+
return html`
|
|
1119
|
+
<sp-accordion-item label="Observed Attributes" ?open=${isSectionOpen("__observed")}>
|
|
1120
|
+
<div class="style-section-body">
|
|
1121
|
+
${entries.length === 0
|
|
1122
|
+
? html`<div class="empty-state">
|
|
1123
|
+
No attributes declared. Set "attribute" on a state entry.
|
|
1124
|
+
</div>`
|
|
1125
|
+
: entries.map(
|
|
1126
|
+
([key, d]) => html`
|
|
1127
|
+
<div
|
|
1128
|
+
style="display:flex;gap:6px;align-items:center;padding:2px 0;font-size:11px"
|
|
1129
|
+
>
|
|
1130
|
+
<code style="font-family:monospace;color:var(--accent)"
|
|
1131
|
+
>${d.attribute}</code
|
|
1132
|
+
>
|
|
1133
|
+
<span style="color:var(--fg-dim)"> → </span>
|
|
1134
|
+
<span>${key}</span>
|
|
1135
|
+
${d.type
|
|
1136
|
+
? html`<span style="margin-left:auto;color:var(--fg-dim);font-size:10px"
|
|
1137
|
+
>${d.type}</span
|
|
1138
|
+
>`
|
|
1139
|
+
: nothing}
|
|
1140
|
+
${d.reflects
|
|
1141
|
+
? html`<span
|
|
1142
|
+
style="font-size:9px;background:var(--bg-hover);padding:1px 4px;border-radius:3px"
|
|
1143
|
+
>reflects</span
|
|
1144
|
+
>`
|
|
1145
|
+
: nothing}
|
|
1146
|
+
</div>
|
|
1147
|
+
`,
|
|
1148
|
+
)}
|
|
1149
|
+
</div>
|
|
1150
|
+
</sp-accordion-item>
|
|
1151
|
+
`;
|
|
1152
|
+
})()
|
|
1153
|
+
: nothing;
|
|
1154
|
+
|
|
1155
|
+
const compPropsT = isCustomInstance
|
|
1156
|
+
? html`
|
|
1157
|
+
<sp-accordion-item label="Component Props" open>
|
|
1158
|
+
<div class="style-section-body">
|
|
1159
|
+
${renderComponentPropsFieldsTemplate(node, path, mapSignals, ctx.navigateToComponent)}
|
|
1160
|
+
</div>
|
|
1161
|
+
</sp-accordion-item>
|
|
1162
|
+
`
|
|
1163
|
+
: nothing;
|
|
1164
|
+
|
|
1165
|
+
const attrSectionTemplates = htmlMeta.$sections
|
|
1166
|
+
.filter((sec) => attrSections[sec.key].length > 0)
|
|
1167
|
+
.map((sec) => {
|
|
1168
|
+
const sectionAttrs = attrSections[sec.key];
|
|
1169
|
+
const hasAnySet = sectionAttrs.some((/** @type {any} */ a) => attrs[a.name] !== undefined);
|
|
1170
|
+
return html`
|
|
1171
|
+
<sp-accordion-item
|
|
1172
|
+
label=${sec.label}
|
|
1173
|
+
?open=${isSectionOpen(sec.key)}
|
|
1174
|
+
@sp-accordion-item-toggle=${() => toggleSection(sec.key)}
|
|
1175
|
+
>
|
|
1176
|
+
${hasAnySet
|
|
1177
|
+
? html`<span slot="heading" class="set-dot set-dot--section"></span>`
|
|
1178
|
+
: nothing}
|
|
1179
|
+
<div class="style-section-body">
|
|
1180
|
+
${sectionAttrs.map((/** @type {any} */ a) =>
|
|
1181
|
+
renderAttrRow(a.name, a.entry, attrs[a.name]),
|
|
1182
|
+
)}
|
|
1183
|
+
</div>
|
|
1184
|
+
</sp-accordion-item>
|
|
1185
|
+
`;
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
const customSectionT =
|
|
1189
|
+
customAttrs.length > 0 || Object.keys(attrs).length > 0
|
|
1190
|
+
? html`
|
|
1191
|
+
<sp-accordion-item
|
|
1192
|
+
label="Custom"
|
|
1193
|
+
?open=${isSectionOpen("__custom")}
|
|
1194
|
+
@sp-accordion-item-toggle=${() => toggleSection("__custom")}
|
|
1195
|
+
>
|
|
1196
|
+
${customAttrs.length > 0
|
|
1197
|
+
? html`<span slot="heading" class="set-dot set-dot--section"></span>`
|
|
1198
|
+
: nothing}
|
|
1199
|
+
<div class="style-section-body">
|
|
1200
|
+
${renderCustomAttrsFieldsTemplate(node, path, attrs, knownAttrNames)}
|
|
1201
|
+
</div>
|
|
1202
|
+
</sp-accordion-item>
|
|
1203
|
+
`
|
|
1204
|
+
: nothing;
|
|
1205
|
+
|
|
1206
|
+
const mediaT = isRoot
|
|
1207
|
+
? html`
|
|
1208
|
+
<sp-accordion-item
|
|
1209
|
+
label="Media"
|
|
1210
|
+
?open=${isSectionOpen("__media")}
|
|
1211
|
+
@sp-accordion-item-toggle=${() => toggleSection("__media")}
|
|
1212
|
+
>
|
|
1213
|
+
<div class="style-section-body">${renderMediaFieldsTemplate(node)}</div>
|
|
1214
|
+
</sp-accordion-item>
|
|
1215
|
+
`
|
|
1216
|
+
: nothing;
|
|
1217
|
+
|
|
1218
|
+
const cssPropsT =
|
|
1219
|
+
isCustomElementDoc(S) && isRoot
|
|
1220
|
+
? (() => {
|
|
1221
|
+
const style = node.style || {};
|
|
1222
|
+
const cssProps = Object.entries(style).filter(([k]) => k.startsWith("--"));
|
|
1223
|
+
if (cssProps.length === 0) return nothing;
|
|
1224
|
+
return html`
|
|
1225
|
+
<sp-accordion-item
|
|
1226
|
+
label="CSS Properties"
|
|
1227
|
+
?open=${isSectionOpen("__cssprops")}
|
|
1228
|
+
@sp-accordion-item-toggle=${() => toggleSection("__cssprops")}
|
|
1229
|
+
>
|
|
1230
|
+
<div class="style-section-body">
|
|
1231
|
+
${cssProps.map(
|
|
1232
|
+
([prop, val]) => html`
|
|
1233
|
+
<div
|
|
1234
|
+
style="display:flex;gap:6px;align-items:center;padding:2px 0;font-size:11px"
|
|
1235
|
+
>
|
|
1236
|
+
<code style="font-family:monospace;color:var(--accent)">${prop}</code>
|
|
1237
|
+
<span style="margin-left:auto;color:var(--fg-dim)">${String(val)}</span>
|
|
1238
|
+
</div>
|
|
1239
|
+
`,
|
|
1240
|
+
)}
|
|
1241
|
+
</div>
|
|
1242
|
+
</sp-accordion-item>
|
|
1243
|
+
`;
|
|
1244
|
+
})()
|
|
1245
|
+
: nothing;
|
|
1246
|
+
|
|
1247
|
+
const cssPartsT =
|
|
1248
|
+
isCustomElementDoc(S) && isRoot
|
|
1249
|
+
? (() => {
|
|
1250
|
+
const parts = collectCssParts(S.document);
|
|
1251
|
+
if (parts.length === 0) return nothing;
|
|
1252
|
+
return html`
|
|
1253
|
+
<sp-accordion-item
|
|
1254
|
+
label="CSS Parts"
|
|
1255
|
+
?open=${isSectionOpen("__cssparts")}
|
|
1256
|
+
@sp-accordion-item-toggle=${() => toggleSection("__cssparts")}
|
|
1257
|
+
>
|
|
1258
|
+
<div class="style-section-body">
|
|
1259
|
+
${parts.map(
|
|
1260
|
+
(p) => html`
|
|
1261
|
+
<div
|
|
1262
|
+
style="display:flex;gap:6px;align-items:center;padding:2px 0;font-size:11px"
|
|
1263
|
+
>
|
|
1264
|
+
<code style="font-family:monospace;color:var(--accent)">${p.name}</code>
|
|
1265
|
+
<span style="color:var(--fg-dim)"><${p.tag}></span>
|
|
1266
|
+
</div>
|
|
1267
|
+
`,
|
|
1268
|
+
)}
|
|
1269
|
+
</div>
|
|
1270
|
+
</sp-accordion-item>
|
|
1271
|
+
`;
|
|
1272
|
+
})()
|
|
1273
|
+
: nothing;
|
|
1274
|
+
|
|
1275
|
+
const frontmatterT =
|
|
1276
|
+
S.mode === "content"
|
|
1277
|
+
? (() => {
|
|
1278
|
+
const fm = S.content?.frontmatter || {};
|
|
1279
|
+
const col = findCollectionSchema(S.documentPath, projectState?.projectConfig);
|
|
1280
|
+
const schemaProps = col?.schema?.properties;
|
|
1281
|
+
const requiredFields = new Set(col?.schema?.required || []);
|
|
1282
|
+
|
|
1283
|
+
/** @type {{ field: string; entry: any; value: any }[]} */
|
|
1284
|
+
const fields = [];
|
|
1285
|
+
if (schemaProps) {
|
|
1286
|
+
for (const [field, fieldSchema] of Object.entries(
|
|
1287
|
+
/** @type {Record<string, any>} */ (schemaProps),
|
|
1288
|
+
)) {
|
|
1289
|
+
fields.push({ field, entry: fieldSchema, value: fm[field] });
|
|
1290
|
+
}
|
|
1291
|
+
for (const [field, value] of Object.entries(fm)) {
|
|
1292
|
+
if (!schemaProps[field]) {
|
|
1293
|
+
fields.push({
|
|
1294
|
+
field,
|
|
1295
|
+
entry: { type: typeof value === "boolean" ? "boolean" : "string" },
|
|
1296
|
+
value,
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
} else {
|
|
1301
|
+
for (const [field, value] of Object.entries(fm)) {
|
|
1302
|
+
fields.push({
|
|
1303
|
+
field,
|
|
1304
|
+
entry: { type: typeof value === "boolean" ? "boolean" : "string" },
|
|
1305
|
+
value,
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
if (fields.length === 0 && !schemaProps) return nothing;
|
|
1311
|
+
|
|
1312
|
+
return html`
|
|
1313
|
+
<sp-accordion-item
|
|
1314
|
+
label=${col ? `Frontmatter (${col.name})` : "Frontmatter"}
|
|
1315
|
+
?open=${isSectionOpen("__frontmatter") !== false}
|
|
1316
|
+
@sp-accordion-item-toggle=${() => toggleSection("__frontmatter")}
|
|
1317
|
+
>
|
|
1318
|
+
<div class="style-section-body">
|
|
1319
|
+
${fields.map((f) => renderFmFieldRow(f.field, f.entry, f.value, requiredFields))}
|
|
1320
|
+
</div>
|
|
1321
|
+
</sp-accordion-item>
|
|
1322
|
+
`;
|
|
1323
|
+
})()
|
|
1324
|
+
: nothing;
|
|
1325
|
+
|
|
1326
|
+
// ── Assemble ──
|
|
1327
|
+
const tpl = html`
|
|
1328
|
+
<div class="style-sidebar">
|
|
1329
|
+
<sp-accordion allow-multiple size="s">
|
|
1330
|
+
${frontmatterT} ${isMapNode ? repeaterT : elemT} ${isMapNode ? nothing : observedAttrsT}
|
|
1331
|
+
${isMapNode ? nothing : switchT} ${isMapNode ? nothing : compPropsT}
|
|
1332
|
+
${isMapNode ? nothing : attrSectionTemplates} ${isMapNode ? nothing : customSectionT}
|
|
1333
|
+
${isMapNode ? nothing : mediaT} ${isMapNode ? nothing : cssPropsT}
|
|
1334
|
+
${isMapNode ? nothing : cssPartsT}
|
|
1335
|
+
</sp-accordion>
|
|
1336
|
+
</div>
|
|
1337
|
+
`;
|
|
1338
|
+
|
|
1339
|
+
return tpl;
|
|
1340
|
+
}
|