@jxsuite/studio 0.10.2 → 0.11.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/README.md +82 -0
- package/dist/studio.js +1001 -147
- package/dist/studio.js.map +28 -18
- package/package.json +3 -2
- package/src/browse/browse.js +27 -3
- package/src/canvas/canvas-live-render.js +133 -15
- package/src/canvas/canvas-render.js +2 -1
- package/src/panels/canvas-dnd.js +132 -50
- package/src/panels/overlays.js +15 -3
- package/src/panels/panel-events.js +10 -0
- package/src/panels/properties-panel.js +299 -5
- package/src/panels/stylebook-panel.js +31 -2
- package/src/settings/content-types-editor.js +22 -1
- package/src/settings/schema-field-ui.js +57 -4
- package/src/site-context.js +105 -0
- package/src/store.js +9 -1
- package/src/ui/spectrum.js +2 -0
- package/src/view.js +3 -0
|
@@ -35,6 +35,9 @@ import {
|
|
|
35
35
|
import { isCustomElementDoc, collectCssParts } from "./signals-panel.js";
|
|
36
36
|
import { mediaDisplayName } from "./shared.js";
|
|
37
37
|
import { getCssInitialMap } from "./style-utils.js";
|
|
38
|
+
import { renderMediaPicker } from "../ui/media-picker.js";
|
|
39
|
+
import { getEffectiveLayoutPath, invalidateLayoutCache } from "../site-context.js";
|
|
40
|
+
import { getPlatform } from "../platform.js";
|
|
38
41
|
import htmlMeta from "../../data/html-meta.json";
|
|
39
42
|
|
|
40
43
|
/**
|
|
@@ -245,9 +248,12 @@ function renderFrontmatterOnlyPanel() {
|
|
|
245
248
|
return html`<div class="empty-state">No frontmatter. Select an element to inspect.</div>`;
|
|
246
249
|
}
|
|
247
250
|
|
|
251
|
+
const pageT = renderPageSection(S.document || {});
|
|
252
|
+
|
|
248
253
|
return html`
|
|
249
254
|
<div class="style-sidebar">
|
|
250
255
|
<sp-accordion allow-multiple size="s">
|
|
256
|
+
${pageT}
|
|
251
257
|
<sp-accordion-item label=${col ? `Frontmatter (${col.name})` : "Frontmatter"} open>
|
|
252
258
|
<div class="style-section-body">
|
|
253
259
|
${fields.map((f) => renderFmFieldRow(f.field, f.entry, f.value, requiredFields))}
|
|
@@ -336,6 +342,72 @@ function renderFmFieldRow(
|
|
|
336
342
|
});
|
|
337
343
|
}
|
|
338
344
|
|
|
345
|
+
if (entry.format === "image") {
|
|
346
|
+
return renderFieldRow({
|
|
347
|
+
prop: field,
|
|
348
|
+
label: displayLabel,
|
|
349
|
+
hasValue: hasVal,
|
|
350
|
+
onClear,
|
|
351
|
+
widget: renderMediaPicker(field, value, (v) =>
|
|
352
|
+
update(updateFrontmatter(getState(), field, v || undefined)),
|
|
353
|
+
),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (entry.type === "array" && entry.items?.format === "image") {
|
|
358
|
+
const images = Array.isArray(value) ? value : [];
|
|
359
|
+
return renderFieldRow({
|
|
360
|
+
prop: field,
|
|
361
|
+
label: displayLabel,
|
|
362
|
+
hasValue: hasVal,
|
|
363
|
+
onClear,
|
|
364
|
+
widget: html`
|
|
365
|
+
<div class="gallery-picker">
|
|
366
|
+
<div class="gallery-picker-strip">
|
|
367
|
+
${images.map(
|
|
368
|
+
(img, i) => html`
|
|
369
|
+
<div class="gallery-picker-item">
|
|
370
|
+
<img src=${img} alt="" class="gallery-picker-thumb" />
|
|
371
|
+
<sp-action-button
|
|
372
|
+
size="xs"
|
|
373
|
+
quiet
|
|
374
|
+
title="Remove"
|
|
375
|
+
@click=${() => {
|
|
376
|
+
const next = images.filter((_, idx) => idx !== i);
|
|
377
|
+
update(updateFrontmatter(getState(), field, next.length ? next : undefined));
|
|
378
|
+
}}
|
|
379
|
+
>
|
|
380
|
+
<sp-icon-close slot="icon"></sp-icon-close>
|
|
381
|
+
</sp-action-button>
|
|
382
|
+
</div>
|
|
383
|
+
`,
|
|
384
|
+
)}
|
|
385
|
+
</div>
|
|
386
|
+
${renderMediaPicker(`${field}:add`, "", (v) => {
|
|
387
|
+
if (!v) return;
|
|
388
|
+
const next = [...images, v];
|
|
389
|
+
update(updateFrontmatter(getState(), field, next));
|
|
390
|
+
})}
|
|
391
|
+
</div>
|
|
392
|
+
`,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (entry.$ref) {
|
|
397
|
+
const targetName = entry.$ref.replace("#/contentTypes/", "");
|
|
398
|
+
const targetDef = projectState?.projectConfig?.contentTypes?.[targetName];
|
|
399
|
+
const picker = renderReferencePicker(field, value, targetName, targetDef, (v) =>
|
|
400
|
+
update(updateFrontmatter(getState(), field, v || undefined)),
|
|
401
|
+
);
|
|
402
|
+
return renderFieldRow({
|
|
403
|
+
prop: field,
|
|
404
|
+
label: displayLabel,
|
|
405
|
+
hasValue: hasVal,
|
|
406
|
+
onClear,
|
|
407
|
+
widget: picker,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
339
411
|
if (entry.type === "number") {
|
|
340
412
|
return renderFieldRow({
|
|
341
413
|
prop: field,
|
|
@@ -374,6 +446,78 @@ function renderFmFieldRow(
|
|
|
374
446
|
});
|
|
375
447
|
}
|
|
376
448
|
|
|
449
|
+
// ─── Reference picker ────────────────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
/** @type {Map<string, { slug: string; title: string }[]>} */
|
|
452
|
+
const refEntriesCache = new Map();
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Render a reference field as a picker of entries from the target content type.
|
|
456
|
+
*
|
|
457
|
+
* @param {string} field
|
|
458
|
+
* @param {any} value
|
|
459
|
+
* @param {string} targetName
|
|
460
|
+
* @param {any} targetDef
|
|
461
|
+
* @param {(val: any) => void} onCommit
|
|
462
|
+
*/
|
|
463
|
+
function renderReferencePicker(field, value, targetName, targetDef, onCommit) {
|
|
464
|
+
if (!targetDef?.source) {
|
|
465
|
+
return html`<sp-textfield
|
|
466
|
+
size="s"
|
|
467
|
+
placeholder="slug"
|
|
468
|
+
.value=${live(value || "")}
|
|
469
|
+
@input=${debouncedStyleCommit(`fm:${field}`, 400, (/** @type {any} */ e) =>
|
|
470
|
+
onCommit(e.target.value),
|
|
471
|
+
)}
|
|
472
|
+
></sp-textfield>`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const cacheKey = targetName;
|
|
476
|
+
if (!refEntriesCache.has(cacheKey)) {
|
|
477
|
+
loadRefEntries(targetName, targetDef);
|
|
478
|
+
}
|
|
479
|
+
const entries = refEntriesCache.get(cacheKey) || [];
|
|
480
|
+
|
|
481
|
+
return html`
|
|
482
|
+
<sp-picker
|
|
483
|
+
size="s"
|
|
484
|
+
label=${targetName}
|
|
485
|
+
.value=${live(value || "")}
|
|
486
|
+
@change=${(/** @type {any} */ e) => onCommit(e.target.value || undefined)}
|
|
487
|
+
>
|
|
488
|
+
<sp-menu-item value="">— none —</sp-menu-item>
|
|
489
|
+
${entries.map(
|
|
490
|
+
(ent) => html`<sp-menu-item value=${ent.slug}>${ent.title || ent.slug}</sp-menu-item>`,
|
|
491
|
+
)}
|
|
492
|
+
</sp-picker>
|
|
493
|
+
`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function loadRefEntries(/** @type {string} */ targetName, /** @type {any} */ targetDef) {
|
|
497
|
+
const platform = getPlatform();
|
|
498
|
+
const sourceDir = targetDef.source.replace(/^\.\//, "").split("/**")[0].split("/*")[0];
|
|
499
|
+
try {
|
|
500
|
+
const listing = await platform.listDirectory(sourceDir);
|
|
501
|
+
const entries = [];
|
|
502
|
+
for (const item of listing) {
|
|
503
|
+
if (item.type === "directory") continue;
|
|
504
|
+
const slug = item.name.replace(/\.[^.]+$/, "");
|
|
505
|
+
let title = slug;
|
|
506
|
+
try {
|
|
507
|
+
const content = await platform.readFile(item.path);
|
|
508
|
+
const match = content.match(/^---[\s\S]*?title:\s*(.+?)[\r\n]/m);
|
|
509
|
+
if (match) title = match[1].trim().replace(/^["']|["']$/g, "");
|
|
510
|
+
} catch {}
|
|
511
|
+
entries.push({ slug, title });
|
|
512
|
+
}
|
|
513
|
+
refEntriesCache.set(targetName, entries);
|
|
514
|
+
} catch {}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function invalidateRefCache() {
|
|
518
|
+
refEntriesCache.clear();
|
|
519
|
+
}
|
|
520
|
+
|
|
377
521
|
// ─── Sub-templates ──────────────────────────────────────────────────────────
|
|
378
522
|
|
|
379
523
|
/** Repeater fields template */
|
|
@@ -840,6 +984,149 @@ function mediaBreakpointRowTemplate(/** @type {any} */ name, /** @type {any} */
|
|
|
840
984
|
`;
|
|
841
985
|
}
|
|
842
986
|
|
|
987
|
+
// ─── Layout picker ──────────────────────────────────────────────────────────
|
|
988
|
+
|
|
989
|
+
/** @type {{ name: string; path: string }[] | null} */
|
|
990
|
+
let layoutEntries = null;
|
|
991
|
+
|
|
992
|
+
async function loadLayoutEntries() {
|
|
993
|
+
try {
|
|
994
|
+
const platform = getPlatform();
|
|
995
|
+
const listing = await platform.listDirectory("layouts");
|
|
996
|
+
layoutEntries = listing
|
|
997
|
+
.filter((/** @type {any} */ f) => f.type === "file" && f.name.endsWith(".json"))
|
|
998
|
+
.map((/** @type {any} */ f) => ({
|
|
999
|
+
name: f.name.replace(/\.json$/, ""),
|
|
1000
|
+
path: `./layouts/${f.name}`,
|
|
1001
|
+
}));
|
|
1002
|
+
} catch {
|
|
1003
|
+
layoutEntries = [];
|
|
1004
|
+
}
|
|
1005
|
+
renderOnly("rightPanel");
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
export function invalidateLayoutPickerCache() {
|
|
1009
|
+
layoutEntries = null;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function isPageDocument(/** @type {any} */ documentPath) {
|
|
1013
|
+
if (!documentPath || !projectState?.isSiteProject) return false;
|
|
1014
|
+
return documentPath.startsWith("pages/") || documentPath.startsWith("./pages/");
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function renderPageSection(/** @type {any} */ node) {
|
|
1018
|
+
const S = getState();
|
|
1019
|
+
if (!isPageDocument(S.documentPath)) return nothing;
|
|
1020
|
+
|
|
1021
|
+
if (layoutEntries === null) {
|
|
1022
|
+
loadLayoutEntries();
|
|
1023
|
+
return nothing;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const currentLayout = node.$layout;
|
|
1027
|
+
const defaultLayout = projectState?.projectConfig?.defaults?.layout;
|
|
1028
|
+
const effectivePath = getEffectiveLayoutPath(currentLayout);
|
|
1029
|
+
const displayValue =
|
|
1030
|
+
currentLayout === false ? "__none__" : currentLayout ? currentLayout : "__default__";
|
|
1031
|
+
|
|
1032
|
+
return html`
|
|
1033
|
+
<sp-accordion-item label="Page" open>
|
|
1034
|
+
<div class="style-section-body">
|
|
1035
|
+
<div class="style-row" data-prop="$layout">
|
|
1036
|
+
<div class="style-row-label">
|
|
1037
|
+
${currentLayout !== undefined
|
|
1038
|
+
? html`<span
|
|
1039
|
+
class="set-dot"
|
|
1040
|
+
title="Reset to default"
|
|
1041
|
+
@click=${(/** @type {any} */ e) => {
|
|
1042
|
+
e.stopPropagation();
|
|
1043
|
+
update(updateProperty(getState(), [], "$layout", undefined));
|
|
1044
|
+
}}
|
|
1045
|
+
></span>`
|
|
1046
|
+
: nothing}
|
|
1047
|
+
<sp-field-label size="s">Layout</sp-field-label>
|
|
1048
|
+
</div>
|
|
1049
|
+
<sp-picker
|
|
1050
|
+
size="s"
|
|
1051
|
+
value=${displayValue}
|
|
1052
|
+
@change=${(/** @type {any} */ e) => {
|
|
1053
|
+
const val = e.target.value;
|
|
1054
|
+
if (val === "__default__") {
|
|
1055
|
+
update(updateProperty(getState(), [], "$layout", undefined));
|
|
1056
|
+
} else if (val === "__none__") {
|
|
1057
|
+
update(updateProperty(getState(), [], "$layout", false));
|
|
1058
|
+
} else {
|
|
1059
|
+
update(updateProperty(getState(), [], "$layout", val));
|
|
1060
|
+
}
|
|
1061
|
+
invalidateLayoutCache();
|
|
1062
|
+
}}
|
|
1063
|
+
>
|
|
1064
|
+
<sp-menu-item value="__default__"
|
|
1065
|
+
>Default${defaultLayout
|
|
1066
|
+
? ` (${defaultLayout.replace(/^\.\/layouts\//, "").replace(/\.json$/, "")})`
|
|
1067
|
+
: ""}</sp-menu-item
|
|
1068
|
+
>
|
|
1069
|
+
<sp-menu-item value="__none__">None</sp-menu-item>
|
|
1070
|
+
<sp-menu-divider></sp-menu-divider>
|
|
1071
|
+
${layoutEntries.map(
|
|
1072
|
+
(/** @type {any} */ l) =>
|
|
1073
|
+
html`<sp-menu-item value=${l.path}>${l.name}</sp-menu-item>`,
|
|
1074
|
+
)}
|
|
1075
|
+
</sp-picker>
|
|
1076
|
+
</div>
|
|
1077
|
+
${effectivePath
|
|
1078
|
+
? html`<div style="font-size:10px;color:var(--fg-dim);padding:2px 0;font-style:italic">
|
|
1079
|
+
Wraps page content via <slot> distribution
|
|
1080
|
+
</div>`
|
|
1081
|
+
: nothing}
|
|
1082
|
+
</div>
|
|
1083
|
+
</sp-accordion-item>
|
|
1084
|
+
`;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// ─── Layout selection panel ─────────────────────────────────────────────────
|
|
1088
|
+
|
|
1089
|
+
function renderLayoutSelectionPanel(/** @type {any} */ ctx) {
|
|
1090
|
+
const { el, layoutPath } = view.layoutSelection;
|
|
1091
|
+
const tagName = el?.tagName?.toLowerCase() || "element";
|
|
1092
|
+
const className = el?.className || "";
|
|
1093
|
+
const displayPath = layoutPath || "layout";
|
|
1094
|
+
|
|
1095
|
+
return html`
|
|
1096
|
+
<div class="style-sidebar">
|
|
1097
|
+
<sp-accordion allow-multiple size="s">
|
|
1098
|
+
<sp-accordion-item label="Layout Element" open>
|
|
1099
|
+
<div class="style-section-body">
|
|
1100
|
+
<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px">
|
|
1101
|
+
<span
|
|
1102
|
+
style="font-size:9px;padding:2px 6px;background:var(--spectrum-purple-600);color:white;border-radius:3px;text-transform:uppercase;letter-spacing:0.5px"
|
|
1103
|
+
>Layout</span
|
|
1104
|
+
>
|
|
1105
|
+
<code style="font-size:12px;font-family:monospace"><${tagName}></code>
|
|
1106
|
+
</div>
|
|
1107
|
+
${className
|
|
1108
|
+
? html`<div class="style-row">
|
|
1109
|
+
<div class="style-row-label">
|
|
1110
|
+
<sp-field-label size="s">Class</sp-field-label>
|
|
1111
|
+
</div>
|
|
1112
|
+
<span style="font-size:11px;color:var(--fg-dim);word-break:break-all"
|
|
1113
|
+
>${className}</span
|
|
1114
|
+
>
|
|
1115
|
+
</div>`
|
|
1116
|
+
: nothing}
|
|
1117
|
+
<div style="font-size:10px;color:var(--fg-dim);padding:4px 0;font-style:italic">
|
|
1118
|
+
This element is part of the page layout. Edit it by opening the layout file.
|
|
1119
|
+
</div>
|
|
1120
|
+
<span class="kv-add" @click=${() => ctx.navigateToComponent(displayPath)}
|
|
1121
|
+
>Open Layout →</span
|
|
1122
|
+
>
|
|
1123
|
+
</div>
|
|
1124
|
+
</sp-accordion-item>
|
|
1125
|
+
</sp-accordion>
|
|
1126
|
+
</div>
|
|
1127
|
+
`;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
843
1130
|
// ─── Main entry point ───────────────────────────────────────────────────────
|
|
844
1131
|
|
|
845
1132
|
/**
|
|
@@ -850,6 +1137,11 @@ function mediaBreakpointRowTemplate(/** @type {any} */ name, /** @type {any} */
|
|
|
850
1137
|
export function renderPropertiesPanelTemplate(ctx) {
|
|
851
1138
|
const S = getState();
|
|
852
1139
|
|
|
1140
|
+
// Layout element selected — show read-only info with link to open layout
|
|
1141
|
+
if (view.layoutSelection) {
|
|
1142
|
+
return renderLayoutSelectionPanel(ctx);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
853
1145
|
if (!S.selection) {
|
|
854
1146
|
if (S.mode === "content") {
|
|
855
1147
|
return renderFrontmatterOnlyPanel();
|
|
@@ -1323,15 +1615,17 @@ export function renderPropertiesPanelTemplate(ctx) {
|
|
|
1323
1615
|
})()
|
|
1324
1616
|
: nothing;
|
|
1325
1617
|
|
|
1618
|
+
const pageT = isRoot ? renderPageSection(node) : nothing;
|
|
1619
|
+
|
|
1326
1620
|
// ── Assemble ──
|
|
1327
1621
|
const tpl = html`
|
|
1328
1622
|
<div class="style-sidebar">
|
|
1329
1623
|
<sp-accordion allow-multiple size="s">
|
|
1330
|
-
${
|
|
1331
|
-
${isMapNode ? nothing :
|
|
1332
|
-
${isMapNode ? nothing :
|
|
1333
|
-
${isMapNode ? nothing :
|
|
1334
|
-
${isMapNode ? nothing : cssPartsT}
|
|
1624
|
+
${pageT} ${frontmatterT} ${isMapNode ? repeaterT : elemT}
|
|
1625
|
+
${isMapNode ? nothing : observedAttrsT} ${isMapNode ? nothing : switchT}
|
|
1626
|
+
${isMapNode ? nothing : compPropsT} ${isMapNode ? nothing : attrSectionTemplates}
|
|
1627
|
+
${isMapNode ? nothing : customSectionT} ${isMapNode ? nothing : mediaT}
|
|
1628
|
+
${isMapNode ? nothing : cssPropsT} ${isMapNode ? nothing : cssPartsT}
|
|
1335
1629
|
</sp-accordion>
|
|
1336
1630
|
</div>
|
|
1337
1631
|
`;
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
projectState,
|
|
20
20
|
} from "../store.js";
|
|
21
21
|
import { view } from "../view.js";
|
|
22
|
-
import { defineElement } from "@jxsuite/runtime";
|
|
22
|
+
import { defineElement, setSkipServerFunctions } from "@jxsuite/runtime";
|
|
23
23
|
import { componentRegistry } from "../files/components.js";
|
|
24
24
|
import { getEffectiveStyle, getEffectiveMedia } from "../site-context.js";
|
|
25
25
|
import { parseMediaEntries, activeBreakpointsForWidth } from "../utils/canvas-media.js";
|
|
@@ -351,6 +351,7 @@ export function buildStylebookElement(entry, rootStyle, activeBreakpoints, paren
|
|
|
351
351
|
}
|
|
352
352
|
}
|
|
353
353
|
if (activeBreakpoints) {
|
|
354
|
+
// Check media overrides nested inside the tag style (selector wraps media)
|
|
354
355
|
for (const [key, val] of Object.entries(tagStyle)) {
|
|
355
356
|
if (!key.startsWith("@") || typeof val !== "object") continue;
|
|
356
357
|
const mediaName = key.slice(1);
|
|
@@ -367,6 +368,27 @@ export function buildStylebookElement(entry, rootStyle, activeBreakpoints, paren
|
|
|
367
368
|
}
|
|
368
369
|
}
|
|
369
370
|
}
|
|
371
|
+
// Check top-level @media keys for tag-specific overrides (media wraps selector)
|
|
372
|
+
if (activeBreakpoints) {
|
|
373
|
+
const selector = compoundSelector || `& ${entry.tag}`;
|
|
374
|
+
for (const [key, val] of Object.entries(rootStyle)) {
|
|
375
|
+
if (!key.startsWith("@") || typeof val !== "object") continue;
|
|
376
|
+
const mediaName = key.slice(1);
|
|
377
|
+
if (mediaName === "--") continue;
|
|
378
|
+
if (activeBreakpoints.has(mediaName)) {
|
|
379
|
+
const mediaTagStyle = /** @type {any} */ (val)[selector];
|
|
380
|
+
if (mediaTagStyle && typeof mediaTagStyle === "object") {
|
|
381
|
+
for (const [prop, v] of Object.entries(mediaTagStyle)) {
|
|
382
|
+
if (typeof v === "string" || typeof v === "number") {
|
|
383
|
+
try {
|
|
384
|
+
/** @type {any} */ (el.style)[prop] = v;
|
|
385
|
+
} catch {}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
370
392
|
if (entry.children) {
|
|
371
393
|
for (const child of entry.children) {
|
|
372
394
|
el.appendChild(buildStylebookElement(child, rootStyle, activeBreakpoints, entry.tag));
|
|
@@ -382,6 +404,7 @@ export function buildStylebookElement(entry, rootStyle, activeBreakpoints, paren
|
|
|
382
404
|
* @returns {Promise<HTMLElement>}
|
|
383
405
|
*/
|
|
384
406
|
export async function renderComponentPreview(comp) {
|
|
407
|
+
setSkipServerFunctions(true);
|
|
385
408
|
try {
|
|
386
409
|
if (comp.source === "npm") {
|
|
387
410
|
if (!customElements.get(comp.tagName)) {
|
|
@@ -416,7 +439,13 @@ export async function renderComponentPreview(comp) {
|
|
|
416
439
|
*/
|
|
417
440
|
function hasTagStyle(rootStyle, tag) {
|
|
418
441
|
const s = rootStyle[`& ${tag}`];
|
|
419
|
-
|
|
442
|
+
if (s && typeof s === "object" && Object.keys(s).length > 0) return true;
|
|
443
|
+
const selector = `& ${tag}`;
|
|
444
|
+
for (const [key, val] of Object.entries(rootStyle)) {
|
|
445
|
+
if (!key.startsWith("@") || typeof val !== "object") continue;
|
|
446
|
+
if (/** @type {any} */ (val)[selector]) return true;
|
|
447
|
+
}
|
|
448
|
+
return false;
|
|
420
449
|
}
|
|
421
450
|
|
|
422
451
|
/**
|
|
@@ -163,6 +163,20 @@ function handleChangeType(fieldName, newType, rerender) {
|
|
|
163
163
|
saveProjectConfig();
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* @param {string} fieldName
|
|
168
|
+
* @param {string} target
|
|
169
|
+
* @param {() => void} rerender
|
|
170
|
+
*/
|
|
171
|
+
function handleChangeRefTarget(fieldName, target, rerender) {
|
|
172
|
+
const schema = getSelectedSchema();
|
|
173
|
+
if (!schema?.properties) return;
|
|
174
|
+
|
|
175
|
+
schema.properties[fieldName] = { $ref: `#/contentTypes/${target}` };
|
|
176
|
+
rerender();
|
|
177
|
+
saveProjectConfig();
|
|
178
|
+
}
|
|
179
|
+
|
|
166
180
|
// ─── Nested field handlers ───────────────────────────────────────────────────
|
|
167
181
|
|
|
168
182
|
/**
|
|
@@ -367,6 +381,7 @@ export function renderContentTypesEditor(container) {
|
|
|
367
381
|
onToggleRequired: (n) => handleToggleRequired(n, rerender),
|
|
368
382
|
onRename: (oldN, newN) => handleRenameField(oldN, newN, rerender),
|
|
369
383
|
onChangeType: (n, t) => handleChangeType(n, t, rerender),
|
|
384
|
+
onChangeRefTarget: (n, target) => handleChangeRefTarget(n, target, rerender),
|
|
370
385
|
onAddNestedField: (p, s) => handleAddNestedField(p, s, rerender),
|
|
371
386
|
onDeleteNested: (p, c) => handleDeleteNested(p, c, rerender),
|
|
372
387
|
onToggleNestedRequired: (p, c) => handleToggleNestedRequired(p, c, rerender),
|
|
@@ -375,7 +390,13 @@ export function renderContentTypesEditor(container) {
|
|
|
375
390
|
};
|
|
376
391
|
|
|
377
392
|
const fieldCards = Object.entries(properties).map(([name, def]) =>
|
|
378
|
-
fieldCardTpl(
|
|
393
|
+
fieldCardTpl(
|
|
394
|
+
name,
|
|
395
|
+
/** @type {any} */ (def),
|
|
396
|
+
required.includes(name),
|
|
397
|
+
handlers,
|
|
398
|
+
contentTypeNames,
|
|
399
|
+
),
|
|
379
400
|
);
|
|
380
401
|
|
|
381
402
|
editorTpl = html`
|
|
@@ -5,7 +5,17 @@
|
|
|
5
5
|
|
|
6
6
|
import { html, nothing } from "lit-html";
|
|
7
7
|
|
|
8
|
-
export const FIELD_TYPES = [
|
|
8
|
+
export const FIELD_TYPES = [
|
|
9
|
+
"string",
|
|
10
|
+
"number",
|
|
11
|
+
"boolean",
|
|
12
|
+
"array",
|
|
13
|
+
"object",
|
|
14
|
+
"date",
|
|
15
|
+
"image",
|
|
16
|
+
"gallery",
|
|
17
|
+
"reference",
|
|
18
|
+
];
|
|
9
19
|
|
|
10
20
|
/**
|
|
11
21
|
* @typedef {{
|
|
@@ -14,6 +24,7 @@ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "d
|
|
|
14
24
|
* required?: string[];
|
|
15
25
|
* items?: any;
|
|
16
26
|
* format?: string;
|
|
27
|
+
* $ref?: string;
|
|
17
28
|
* }} SchemaProperty
|
|
18
29
|
*/
|
|
19
30
|
|
|
@@ -23,6 +34,7 @@ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "d
|
|
|
23
34
|
* onToggleRequired: (name: string) => void;
|
|
24
35
|
* onRename: (oldName: string, newName: string) => void;
|
|
25
36
|
* onChangeType: (name: string, newType: string) => void;
|
|
37
|
+
* onChangeRefTarget?: (name: string, target: string) => void;
|
|
26
38
|
* onAddNestedField?: (
|
|
27
39
|
* parentName: string,
|
|
28
40
|
* state: { name: string; type: string; required: boolean },
|
|
@@ -34,6 +46,20 @@ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "d
|
|
|
34
46
|
* }} FieldHandlers
|
|
35
47
|
*/
|
|
36
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Detect the studio field type from a JSON Schema property definition.
|
|
51
|
+
*
|
|
52
|
+
* @param {SchemaProperty} schema
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function detectFieldType(schema) {
|
|
56
|
+
if (schema.$ref) return "reference";
|
|
57
|
+
if (schema.format === "image") return "image";
|
|
58
|
+
if (schema.format === "date") return "date";
|
|
59
|
+
if (schema.type === "array" && schema.items?.format === "image") return "gallery";
|
|
60
|
+
return schema.type || "string";
|
|
61
|
+
}
|
|
62
|
+
|
|
37
63
|
/**
|
|
38
64
|
* Render a single schema field as an inline-editable form row.
|
|
39
65
|
*
|
|
@@ -41,13 +67,16 @@ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "d
|
|
|
41
67
|
* @param {SchemaProperty} fieldSchema — JSON Schema property definition
|
|
42
68
|
* @param {boolean} isRequired
|
|
43
69
|
* @param {FieldHandlers} handlers
|
|
70
|
+
* @param {string[]} [contentTypeNames] - Available content type names for reference target picker
|
|
44
71
|
* @returns {any}
|
|
45
72
|
*/
|
|
46
|
-
export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers) {
|
|
47
|
-
const type = fieldSchema
|
|
73
|
+
export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers, contentTypeNames = []) {
|
|
74
|
+
const type = detectFieldType(fieldSchema);
|
|
48
75
|
const isNested = type === "object";
|
|
76
|
+
const isRef = type === "reference";
|
|
49
77
|
const nestedProps = fieldSchema.properties || {};
|
|
50
78
|
const nestedRequired = fieldSchema.required || [];
|
|
79
|
+
const refTarget = fieldSchema.$ref ? fieldSchema.$ref.replace("#/contentTypes/", "") : "";
|
|
51
80
|
|
|
52
81
|
return html`
|
|
53
82
|
<div class="schema-field-card">
|
|
@@ -87,6 +116,23 @@ export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers) {
|
|
|
87
116
|
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
88
117
|
</sp-action-button>
|
|
89
118
|
</div>
|
|
119
|
+
${isRef && contentTypeNames.length
|
|
120
|
+
? html`
|
|
121
|
+
<div class="schema-field-ref-target">
|
|
122
|
+
<sp-picker
|
|
123
|
+
size="s"
|
|
124
|
+
label="Target"
|
|
125
|
+
value=${refTarget}
|
|
126
|
+
@change=${(/** @type {any} */ e) => {
|
|
127
|
+
if (handlers.onChangeRefTarget)
|
|
128
|
+
handlers.onChangeRefTarget(fieldName, e.target.value);
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
${contentTypeNames.map((n) => html`<sp-menu-item value=${n}>${n}</sp-menu-item>`)}
|
|
132
|
+
</sp-picker>
|
|
133
|
+
</div>
|
|
134
|
+
`
|
|
135
|
+
: nothing}
|
|
90
136
|
${isNested
|
|
91
137
|
? html`
|
|
92
138
|
<div class="schema-field-nested">
|
|
@@ -119,7 +165,7 @@ export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers) {
|
|
|
119
165
|
* @returns {any}
|
|
120
166
|
*/
|
|
121
167
|
function nestedFieldCardTpl(parentName, childName, childSchema, isRequired, handlers) {
|
|
122
|
-
const type = childSchema
|
|
168
|
+
const type = detectFieldType(childSchema);
|
|
123
169
|
|
|
124
170
|
return html`
|
|
125
171
|
<div class="schema-field-card schema-field-card--nested">
|
|
@@ -300,6 +346,12 @@ export function schemaForType(type) {
|
|
|
300
346
|
return { type: "object", properties: {}, required: [] };
|
|
301
347
|
case "date":
|
|
302
348
|
return { type: "string", format: "date" };
|
|
349
|
+
case "image":
|
|
350
|
+
return { type: "string", format: "image" };
|
|
351
|
+
case "gallery":
|
|
352
|
+
return { type: "array", items: { type: "string", format: "image" } };
|
|
353
|
+
case "reference":
|
|
354
|
+
return { $ref: "#/contentTypes/" };
|
|
303
355
|
default:
|
|
304
356
|
return { type: "string" };
|
|
305
357
|
}
|
|
@@ -314,6 +366,7 @@ export function schemaForType(type) {
|
|
|
314
366
|
*/
|
|
315
367
|
export function yamlDefault(type, format) {
|
|
316
368
|
if (format === "date") return new Date().toISOString().split("T")[0];
|
|
369
|
+
if (format === "image") return '""';
|
|
317
370
|
switch (type) {
|
|
318
371
|
case "boolean":
|
|
319
372
|
return "false";
|