@jxsuite/studio 0.6.2 → 0.7.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 +151433 -141131
- package/dist/studio.js.map +84 -18
- package/package.json +2 -2
- package/src/markdown/md-convert.js +18 -16
- package/src/panels/activity-bar.js +22 -0
- package/src/panels/elements-panel.js +148 -0
- package/src/panels/git-panel.js +280 -0
- package/src/panels/layers-panel.js +270 -0
- package/src/panels/left-panel.js +141 -0
- package/src/panels/right-panel.js +3 -2
- package/src/panels/style-inputs.js +176 -0
- package/src/panels/style-panel.js +651 -0
- package/src/panels/style-utils.js +193 -0
- package/src/panels/stylebook-layers-panel.js +103 -0
- package/src/platforms/devserver.js +113 -0
- package/src/state.js +7 -0
- package/src/studio.js +38 -1490
- package/src/ui/spectrum.js +4 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Style panel — CSS property editor with media breakpoint tabs, selector dropdown, section
|
|
3
|
+
* accordion, shorthand expand/compress, and filter.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { html, nothing } from "lit-html";
|
|
7
|
+
import { live } from "lit-html/directives/live.js";
|
|
8
|
+
import { ifDefined } from "lit-html/directives/if-defined.js";
|
|
9
|
+
import {
|
|
10
|
+
getState,
|
|
11
|
+
update,
|
|
12
|
+
updateUi,
|
|
13
|
+
getNodeAtPath,
|
|
14
|
+
updateStyle,
|
|
15
|
+
updateMediaStyle,
|
|
16
|
+
updateNestedStyle,
|
|
17
|
+
updateMediaNestedStyle,
|
|
18
|
+
COMMON_SELECTORS,
|
|
19
|
+
isNestedSelector,
|
|
20
|
+
debouncedStyleCommit,
|
|
21
|
+
} from "../store.js";
|
|
22
|
+
import { inferInputType, propLabel } from "../utils/studio-utils.js";
|
|
23
|
+
import { renderFieldRow } from "../ui/field-row.js";
|
|
24
|
+
import { parseMediaEntries } from "../utils/canvas-media.js";
|
|
25
|
+
import { getEffectiveMedia } from "../site-context.js";
|
|
26
|
+
import { computeInheritedStyle } from "../utils/inherited-style.js";
|
|
27
|
+
import { mediaDisplayName } from "./shared.js";
|
|
28
|
+
import {
|
|
29
|
+
cssMeta,
|
|
30
|
+
getCssInitialMap,
|
|
31
|
+
allConditionsPass,
|
|
32
|
+
autoOpenSections,
|
|
33
|
+
getLonghands,
|
|
34
|
+
expandShorthand,
|
|
35
|
+
compressShorthand,
|
|
36
|
+
expandBorderSide,
|
|
37
|
+
compressBorderSide,
|
|
38
|
+
} from "./style-utils.js";
|
|
39
|
+
import { widgetForType } from "./style-inputs.js";
|
|
40
|
+
|
|
41
|
+
// ─── Row renderers ──────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
function renderStyleRow(
|
|
44
|
+
/** @type {any} */ entry,
|
|
45
|
+
/** @type {any} */ prop,
|
|
46
|
+
/** @type {any} */ value,
|
|
47
|
+
/** @type {any} */ onCommit,
|
|
48
|
+
/** @type {any} */ onDelete,
|
|
49
|
+
/** @type {any} */ isWarning,
|
|
50
|
+
/** @type {any} */ gridMode,
|
|
51
|
+
/** @type {any} */ inheritedValue,
|
|
52
|
+
) {
|
|
53
|
+
const type = inferInputType(entry);
|
|
54
|
+
const hasVal = value !== undefined && value !== "";
|
|
55
|
+
const placeholder = !hasVal && inheritedValue ? String(inheritedValue) : "";
|
|
56
|
+
return renderFieldRow({
|
|
57
|
+
prop,
|
|
58
|
+
label: propLabel(entry, prop),
|
|
59
|
+
hasValue: hasVal,
|
|
60
|
+
onClear: onDelete,
|
|
61
|
+
widget: widgetForType(type, entry, prop, value, onCommit, { placeholder }),
|
|
62
|
+
span: gridMode && entry.$span === 2 ? 2 : undefined,
|
|
63
|
+
warning: isWarning,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {any} shortProp @param {any} entry @param {any} style @param {any} commitFn
|
|
69
|
+
* @param {any} _deleteFn @param {Record<string, any>} inherited
|
|
70
|
+
*/
|
|
71
|
+
function renderShorthandRow(shortProp, entry, style, commitFn, _deleteFn, inherited = {}) {
|
|
72
|
+
const S = getState();
|
|
73
|
+
const longhands = getLonghands(shortProp);
|
|
74
|
+
const shortVal = style[shortProp];
|
|
75
|
+
const hasLonghands = longhands.some((/** @type {any} */ l) => style[l.name] !== undefined);
|
|
76
|
+
const isExpanded = S.ui.styleShorthands[shortProp] ?? hasLonghands;
|
|
77
|
+
const hasAnyVal =
|
|
78
|
+
shortVal !== undefined || longhands.some((/** @type {any} */ l) => style[l.name] !== undefined);
|
|
79
|
+
|
|
80
|
+
return html`
|
|
81
|
+
<div class="style-row" data-prop=${shortProp}>
|
|
82
|
+
<div class="style-row-label">
|
|
83
|
+
${hasAnyVal
|
|
84
|
+
? html`<span
|
|
85
|
+
class="set-dot"
|
|
86
|
+
title="Clear ${shortProp}"
|
|
87
|
+
@click=${(/** @type {any} */ e) => {
|
|
88
|
+
e.stopPropagation();
|
|
89
|
+
let s = getState();
|
|
90
|
+
if (shortVal !== undefined) s = commitFn(s, shortProp, undefined);
|
|
91
|
+
for (const l of longhands) {
|
|
92
|
+
if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
|
|
93
|
+
}
|
|
94
|
+
update(s);
|
|
95
|
+
}}
|
|
96
|
+
></span>`
|
|
97
|
+
: nothing}
|
|
98
|
+
<sp-field-label size="s" title=${shortProp}>${propLabel(entry, shortProp)}</sp-field-label>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="style-shorthand-header">
|
|
101
|
+
<sp-textfield
|
|
102
|
+
size="s"
|
|
103
|
+
.value=${live(shortVal || "")}
|
|
104
|
+
placeholder=${!shortVal && hasLonghands
|
|
105
|
+
? longhands.map((/** @type {any} */ l) => style[l.name] || "0").join(" ")
|
|
106
|
+
: !shortVal && inherited[shortProp]
|
|
107
|
+
? inherited[shortProp]
|
|
108
|
+
: !shortVal && longhands.some((/** @type {any} */ l) => inherited[l.name])
|
|
109
|
+
? longhands.map((/** @type {any} */ l) => inherited[l.name] || "0").join(" ")
|
|
110
|
+
: ""}
|
|
111
|
+
@input=${debouncedStyleCommit(`short:${shortProp}`, 400, (/** @type {any} */ e) => {
|
|
112
|
+
let s = getState();
|
|
113
|
+
for (const l of longhands) {
|
|
114
|
+
if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
|
|
115
|
+
}
|
|
116
|
+
s = commitFn(s, shortProp, e.target.value || undefined);
|
|
117
|
+
update(s);
|
|
118
|
+
})}
|
|
119
|
+
></sp-textfield>
|
|
120
|
+
<sp-action-button
|
|
121
|
+
size="xs"
|
|
122
|
+
quiet
|
|
123
|
+
@click=${(/** @type {any} */ e) => {
|
|
124
|
+
e.stopPropagation();
|
|
125
|
+
updateUi("styleShorthands", {
|
|
126
|
+
...getState().ui.styleShorthands,
|
|
127
|
+
[shortProp]: !isExpanded,
|
|
128
|
+
});
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
${isExpanded
|
|
132
|
+
? html`<sp-icon-chevron-down slot="icon"></sp-icon-chevron-down>`
|
|
133
|
+
: html`<sp-icon-chevron-right slot="icon"></sp-icon-chevron-right>`}
|
|
134
|
+
</sp-action-button>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
${isExpanded
|
|
138
|
+
? (() => {
|
|
139
|
+
const isBorderSide = entry.$shorthandType === "border-side";
|
|
140
|
+
const expanded = shortVal
|
|
141
|
+
? isBorderSide
|
|
142
|
+
? expandBorderSide(shortVal)
|
|
143
|
+
: expandShorthand(shortVal, longhands.length)
|
|
144
|
+
: null;
|
|
145
|
+
const compress = isBorderSide ? compressBorderSide : compressShorthand;
|
|
146
|
+
const emptyVal = isBorderSide ? "" : "0";
|
|
147
|
+
return longhands.map(
|
|
148
|
+
(/** @type {any} */ { name, entry: lEntry }, /** @type {any} */ idx) => {
|
|
149
|
+
const lVal = style[name] ?? (expanded ? expanded[idx] : "");
|
|
150
|
+
return html`
|
|
151
|
+
<div class="style-row style-row--child" data-prop=${name}>
|
|
152
|
+
<div class="style-row-label">
|
|
153
|
+
${lVal !== undefined && lVal !== ""
|
|
154
|
+
? html`<span
|
|
155
|
+
class="set-dot"
|
|
156
|
+
title="Clear ${name}"
|
|
157
|
+
@click=${(/** @type {any} */ e) => {
|
|
158
|
+
e.stopPropagation();
|
|
159
|
+
const vals = longhands.map(
|
|
160
|
+
(/** @type {any} */ l, /** @type {any} */ i) =>
|
|
161
|
+
i === idx
|
|
162
|
+
? emptyVal
|
|
163
|
+
: (style[l.name] ?? (expanded ? expanded[i] : emptyVal)),
|
|
164
|
+
);
|
|
165
|
+
let s = getState();
|
|
166
|
+
for (const l of longhands) {
|
|
167
|
+
if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
|
|
168
|
+
}
|
|
169
|
+
s = commitFn(s, shortProp, compress(vals));
|
|
170
|
+
update(s);
|
|
171
|
+
}}
|
|
172
|
+
></span>`
|
|
173
|
+
: nothing}
|
|
174
|
+
<sp-field-label size="s" title=${name}
|
|
175
|
+
>${propLabel(lEntry, name)}</sp-field-label
|
|
176
|
+
>
|
|
177
|
+
</div>
|
|
178
|
+
${widgetForType(
|
|
179
|
+
inferInputType(lEntry),
|
|
180
|
+
lEntry,
|
|
181
|
+
name,
|
|
182
|
+
lVal,
|
|
183
|
+
(/** @type {any} */ newVal) => {
|
|
184
|
+
const vals = longhands.map((/** @type {any} */ l, /** @type {any} */ i) =>
|
|
185
|
+
i === idx
|
|
186
|
+
? newVal || emptyVal
|
|
187
|
+
: (style[l.name] ?? (expanded ? expanded[i] : emptyVal)),
|
|
188
|
+
);
|
|
189
|
+
let s = getState();
|
|
190
|
+
for (const l of longhands) {
|
|
191
|
+
if (style[l.name] !== undefined) s = commitFn(s, l.name, undefined);
|
|
192
|
+
}
|
|
193
|
+
s = commitFn(s, shortProp, compress(vals));
|
|
194
|
+
update(s);
|
|
195
|
+
},
|
|
196
|
+
{ placeholder: !lVal && inherited[name] ? String(inherited[name]) : "" },
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
`;
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
})()
|
|
203
|
+
: nothing}
|
|
204
|
+
`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── Main template ──────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @param {any} node
|
|
211
|
+
* @param {any} activeMediaTab
|
|
212
|
+
* @param {any} activeSelector
|
|
213
|
+
*/
|
|
214
|
+
function styleSidebarTemplate(node, activeMediaTab, activeSelector) {
|
|
215
|
+
const S = getState();
|
|
216
|
+
const style = node.style || {};
|
|
217
|
+
const { sizeBreakpoints } = parseMediaEntries(getEffectiveMedia(S.document.$media));
|
|
218
|
+
const mediaNames = sizeBreakpoints.map((bp) => bp.name);
|
|
219
|
+
const activeTab = activeMediaTab;
|
|
220
|
+
|
|
221
|
+
// ── Media tabs template ──────────────────────────────────────────────────
|
|
222
|
+
const mediaTabsT =
|
|
223
|
+
mediaNames.length > 0
|
|
224
|
+
? html`
|
|
225
|
+
<sp-tabs
|
|
226
|
+
size="s"
|
|
227
|
+
selected=${activeTab || "base"}
|
|
228
|
+
@change=${(/** @type {any} */ e) => {
|
|
229
|
+
const val = e.target.selected;
|
|
230
|
+
const newMedia = val === "base" ? null : val;
|
|
231
|
+
if (newMedia !== S.ui.activeMedia) {
|
|
232
|
+
updateUi("activeMedia", newMedia);
|
|
233
|
+
}
|
|
234
|
+
}}
|
|
235
|
+
>
|
|
236
|
+
<sp-tab label="Base" value="base"></sp-tab>
|
|
237
|
+
${mediaNames.map(
|
|
238
|
+
(name) => html` <sp-tab label=${mediaDisplayName(name)} value=${name}></sp-tab> `,
|
|
239
|
+
)}
|
|
240
|
+
</sp-tabs>
|
|
241
|
+
`
|
|
242
|
+
: nothing;
|
|
243
|
+
|
|
244
|
+
// ── Selector dropdown ──────────────────────────────────────────────────────
|
|
245
|
+
const contextStyle = activeTab ? style[`@${activeTab}`] || {} : style;
|
|
246
|
+
const existingSelectors = Object.keys(contextStyle).filter(isNestedSelector);
|
|
247
|
+
const existingSet = new Set(existingSelectors);
|
|
248
|
+
const commonSet = new Set(COMMON_SELECTORS);
|
|
249
|
+
const extraSelectors = existingSelectors.filter((s) => !commonSet.has(s));
|
|
250
|
+
if (activeSelector && !commonSet.has(activeSelector) && !existingSet.has(activeSelector)) {
|
|
251
|
+
extraSelectors.unshift(activeSelector);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const _selectorVal = activeSelector || "__base__";
|
|
255
|
+
const selectorT = html`
|
|
256
|
+
<sp-picker
|
|
257
|
+
size="s"
|
|
258
|
+
class="selector-select"
|
|
259
|
+
quiet
|
|
260
|
+
.value=${live(_selectorVal)}
|
|
261
|
+
@change=${(/** @type {any} */ e) => {
|
|
262
|
+
const val = e.target.value;
|
|
263
|
+
if (val === "__add_custom__") {
|
|
264
|
+
requestAnimationFrame(() => {
|
|
265
|
+
e.target.value = activeSelector || "__base__";
|
|
266
|
+
});
|
|
267
|
+
const picker = e.target;
|
|
268
|
+
const bar = picker.closest(".style-toolbar");
|
|
269
|
+
picker.style.display = "none";
|
|
270
|
+
const inp = document.createElement("input");
|
|
271
|
+
inp.type = "text";
|
|
272
|
+
inp.className = "selector-custom-input";
|
|
273
|
+
inp.placeholder = ":hover, .child, &.active, [attr]";
|
|
274
|
+
bar.appendChild(inp);
|
|
275
|
+
inp.focus();
|
|
276
|
+
let done = false;
|
|
277
|
+
const finish = (/** @type {any} */ accept) => {
|
|
278
|
+
if (done) return;
|
|
279
|
+
done = true;
|
|
280
|
+
const v = inp.value.trim();
|
|
281
|
+
inp.remove();
|
|
282
|
+
picker.style.display = "";
|
|
283
|
+
if (accept && v && isNestedSelector(v)) {
|
|
284
|
+
updateUi("activeSelector", v);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
inp.addEventListener("keydown", (ev) => {
|
|
288
|
+
if (ev.key === "Enter") finish(true);
|
|
289
|
+
else if (ev.key === "Escape") finish(false);
|
|
290
|
+
});
|
|
291
|
+
inp.addEventListener("blur", () => finish(inp.value.trim().length > 0));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const newSelector = val === "__base__" ? null : val;
|
|
295
|
+
updateUi("activeSelector", newSelector);
|
|
296
|
+
}}
|
|
297
|
+
>
|
|
298
|
+
<sp-menu-item value="__base__">(base)</sp-menu-item>
|
|
299
|
+
<sp-menu-divider></sp-menu-divider>
|
|
300
|
+
${COMMON_SELECTORS.map(
|
|
301
|
+
(s) => html`
|
|
302
|
+
<sp-menu-item value=${s}>${existingSet.has(s) ? `${s} \u25CF` : s}</sp-menu-item>
|
|
303
|
+
`,
|
|
304
|
+
)}
|
|
305
|
+
${extraSelectors.length > 0
|
|
306
|
+
? html`
|
|
307
|
+
<sp-menu-divider></sp-menu-divider>
|
|
308
|
+
${extraSelectors.map((s) => html` <sp-menu-item value=${s}>${s} ●</sp-menu-item> `)}
|
|
309
|
+
`
|
|
310
|
+
: nothing}
|
|
311
|
+
<sp-menu-divider></sp-menu-divider>
|
|
312
|
+
<sp-menu-item value="__add_custom__">+ Add custom…</sp-menu-item>
|
|
313
|
+
</sp-picker>
|
|
314
|
+
`;
|
|
315
|
+
|
|
316
|
+
// ── Combined toolbar (media tabs + selector) ───────────────────────────────
|
|
317
|
+
const toolbarT = html`
|
|
318
|
+
<div class="style-toolbar">
|
|
319
|
+
<div class="style-toolbar-tabs">${mediaTabsT}</div>
|
|
320
|
+
${selectorT}
|
|
321
|
+
</div>
|
|
322
|
+
`;
|
|
323
|
+
|
|
324
|
+
// ── Filter bar ─────────────────────────────────────────────────────────────
|
|
325
|
+
const filterBarT = html`
|
|
326
|
+
<div class="style-filter-bar">
|
|
327
|
+
<sp-textfield
|
|
328
|
+
size="s"
|
|
329
|
+
class="style-filter-input"
|
|
330
|
+
placeholder="Filter properties…"
|
|
331
|
+
.value=${live(S.ui.styleFilter || "")}
|
|
332
|
+
@input=${(/** @type {any} */ e) => updateUi("styleFilter", e.target.value)}
|
|
333
|
+
></sp-textfield>
|
|
334
|
+
<sp-action-button
|
|
335
|
+
size="xs"
|
|
336
|
+
class="style-filter-toggle"
|
|
337
|
+
?selected=${S.ui.styleFilterActive}
|
|
338
|
+
@click=${() => updateUi("styleFilterActive", !S.ui.styleFilterActive)}
|
|
339
|
+
>
|
|
340
|
+
Active
|
|
341
|
+
</sp-action-button>
|
|
342
|
+
</div>
|
|
343
|
+
`;
|
|
344
|
+
|
|
345
|
+
// ── Determine the active style object ──────────────────────────────────────
|
|
346
|
+
/** @type {Record<string, any>} */
|
|
347
|
+
let activeStyle;
|
|
348
|
+
/** @type {any} */
|
|
349
|
+
let commitStyle;
|
|
350
|
+
if (activeSelector && activeTab && mediaNames.length > 0) {
|
|
351
|
+
activeStyle = (style[`@${activeTab}`] || {})[activeSelector] || {};
|
|
352
|
+
commitStyle = (/** @type {any} */ s, /** @type {any} */ prop, /** @type {any} */ val) =>
|
|
353
|
+
updateMediaNestedStyle(s, S.selection, activeTab, activeSelector, prop, val);
|
|
354
|
+
} else if (activeSelector) {
|
|
355
|
+
activeStyle = style[activeSelector] || {};
|
|
356
|
+
commitStyle = (/** @type {any} */ s, /** @type {any} */ prop, /** @type {any} */ val) =>
|
|
357
|
+
updateNestedStyle(s, S.selection, activeSelector, prop, val);
|
|
358
|
+
} else if (activeTab !== null && mediaNames.length > 0) {
|
|
359
|
+
activeStyle = {};
|
|
360
|
+
for (const [p, v] of Object.entries(style[`@${activeTab}`] || {})) {
|
|
361
|
+
if (typeof v !== "object") activeStyle[p] = v;
|
|
362
|
+
}
|
|
363
|
+
commitStyle = (/** @type {any} */ s, /** @type {any} */ prop, /** @type {any} */ val) =>
|
|
364
|
+
updateMediaStyle(s, S.selection, activeTab, prop, val);
|
|
365
|
+
} else {
|
|
366
|
+
activeStyle = {};
|
|
367
|
+
for (const [p, v] of Object.entries(style)) {
|
|
368
|
+
if (typeof v !== "object") activeStyle[p] = v;
|
|
369
|
+
}
|
|
370
|
+
commitStyle = (/** @type {any} */ s, /** @type {any} */ prop, /** @type {any} */ val) =>
|
|
371
|
+
updateStyle(s, S.selection, prop, val);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── Compute inherited style from higher breakpoints ──────────────────────
|
|
375
|
+
/** @type {Record<string, any>} */
|
|
376
|
+
const inheritedStyle = computeInheritedStyle(style, mediaNames, activeTab, activeSelector);
|
|
377
|
+
|
|
378
|
+
// Auto-open sections that have properties
|
|
379
|
+
const newSections = autoOpenSections({ style: activeStyle }, S.ui.styleSections);
|
|
380
|
+
if (JSON.stringify(newSections) !== JSON.stringify(S.ui.styleSections)) {
|
|
381
|
+
updateUi("styleSections", newSections);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Partition properties into sections
|
|
385
|
+
const sectionProps = /** @type {Record<string, any[]>} */ ({});
|
|
386
|
+
for (const sec of cssMeta.$sections) sectionProps[sec.key] = [];
|
|
387
|
+
|
|
388
|
+
for (const [prop, entry] of /** @type {[string, any][]} */ (Object.entries(cssMeta.$defs))) {
|
|
389
|
+
if (typeof entry.$shorthand === "string") continue;
|
|
390
|
+
const sec = entry.$section || "other";
|
|
391
|
+
sectionProps[sec].push({ prop, entry });
|
|
392
|
+
}
|
|
393
|
+
for (const sec of cssMeta.$sections) {
|
|
394
|
+
sectionProps[sec.key].sort(
|
|
395
|
+
(/** @type {any} */ a, /** @type {any} */ b) => a.entry.$order - b.entry.$order,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const otherProps = [];
|
|
400
|
+
for (const prop of Object.keys(activeStyle)) {
|
|
401
|
+
if (!(/** @type {Record<string, any>} */ (cssMeta.$defs)[prop])) otherProps.push(prop);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── Filter state ─────────────────────────────────────────────────────────
|
|
405
|
+
const filterText = (S.ui.styleFilter || "").toLowerCase();
|
|
406
|
+
const filterActive = S.ui.styleFilterActive;
|
|
407
|
+
const isFiltering = filterText.length > 0 || filterActive;
|
|
408
|
+
|
|
409
|
+
// ── Section templates ────────────────────────────────────────────────────
|
|
410
|
+
const sectionTemplates = cssMeta.$sections
|
|
411
|
+
.filter((sec) => sec.key !== "other")
|
|
412
|
+
.map((sec) => {
|
|
413
|
+
const entries = sectionProps[sec.key];
|
|
414
|
+
|
|
415
|
+
const sectionActiveProps = entries.filter((/** @type {any} */ { prop, entry }) => {
|
|
416
|
+
if (activeStyle[prop] !== undefined) return true;
|
|
417
|
+
if (inferInputType(entry) === "shorthand") {
|
|
418
|
+
return getLonghands(prop).some(
|
|
419
|
+
(/** @type {any} */ l) => activeStyle[l.name] !== undefined,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
return false;
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const rows = [];
|
|
426
|
+
for (const { prop, entry } of entries) {
|
|
427
|
+
const val = activeStyle[prop];
|
|
428
|
+
const hasVal = val !== undefined;
|
|
429
|
+
const condMet = allConditionsPass(entry, activeStyle);
|
|
430
|
+
const type = inferInputType(entry);
|
|
431
|
+
if (!hasVal && !condMet) continue;
|
|
432
|
+
|
|
433
|
+
if (filterText) {
|
|
434
|
+
const label = propLabel(entry, prop).toLowerCase();
|
|
435
|
+
if (!prop.includes(filterText) && !label.includes(filterText)) continue;
|
|
436
|
+
}
|
|
437
|
+
if (filterActive) {
|
|
438
|
+
if (type === "shorthand") {
|
|
439
|
+
const longhands = getLonghands(prop);
|
|
440
|
+
const hasAnySet =
|
|
441
|
+
hasVal || longhands.some((/** @type {any} */ l) => activeStyle[l.name] !== undefined);
|
|
442
|
+
if (!hasAnySet) continue;
|
|
443
|
+
} else if (!hasVal) continue;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (type === "shorthand") {
|
|
447
|
+
const longhands = getLonghands(prop);
|
|
448
|
+
const hasAny =
|
|
449
|
+
hasVal || longhands.some((/** @type {any} */ l) => activeStyle[l.name] !== undefined);
|
|
450
|
+
if (!hasAny && !condMet) continue;
|
|
451
|
+
rows.push(
|
|
452
|
+
renderShorthandRow(prop, entry, activeStyle, commitStyle, () => {}, inheritedStyle),
|
|
453
|
+
);
|
|
454
|
+
} else {
|
|
455
|
+
const isWarning = hasVal && !condMet;
|
|
456
|
+
if (hasVal || condMet) {
|
|
457
|
+
rows.push(
|
|
458
|
+
renderStyleRow(
|
|
459
|
+
entry,
|
|
460
|
+
prop,
|
|
461
|
+
val ?? "",
|
|
462
|
+
(/** @type {any} */ newVal) =>
|
|
463
|
+
update(commitStyle(getState(), prop, newVal || undefined)),
|
|
464
|
+
() => update(commitStyle(getState(), prop, undefined)),
|
|
465
|
+
isWarning,
|
|
466
|
+
sec.$layout === "grid",
|
|
467
|
+
inheritedStyle[prop],
|
|
468
|
+
),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (isFiltering && rows.length === 0) return nothing;
|
|
475
|
+
const isOpen = isFiltering ? true : (S.ui.styleSections[sec.key] ?? false);
|
|
476
|
+
|
|
477
|
+
return html`
|
|
478
|
+
<sp-accordion-item
|
|
479
|
+
label=${sec.label}
|
|
480
|
+
.open=${isOpen}
|
|
481
|
+
@sp-accordion-item-toggle=${(/** @type {any} */ e) => {
|
|
482
|
+
updateUi("styleSections", { ...getState().ui.styleSections, [sec.key]: e.target.open });
|
|
483
|
+
}}
|
|
484
|
+
>
|
|
485
|
+
${sectionActiveProps.length > 0
|
|
486
|
+
? html`
|
|
487
|
+
<span slot="heading" style="display:flex;align-items:center;gap:6px">
|
|
488
|
+
${sec.label}
|
|
489
|
+
<span
|
|
490
|
+
class="set-dot set-dot--section"
|
|
491
|
+
title="Clear all ${sec.label.toLowerCase()} properties"
|
|
492
|
+
@click=${(/** @type {any} */ e) => {
|
|
493
|
+
e.stopPropagation();
|
|
494
|
+
e.preventDefault();
|
|
495
|
+
let s = getState();
|
|
496
|
+
for (const { prop, entry } of sectionActiveProps) {
|
|
497
|
+
if (activeStyle[prop] !== undefined) s = commitStyle(s, prop, undefined);
|
|
498
|
+
if (inferInputType(entry) === "shorthand") {
|
|
499
|
+
for (const l of getLonghands(prop)) {
|
|
500
|
+
if (activeStyle[l.name] !== undefined)
|
|
501
|
+
s = commitStyle(s, l.name, undefined);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
update(s);
|
|
506
|
+
}}
|
|
507
|
+
></span>
|
|
508
|
+
</span>
|
|
509
|
+
`
|
|
510
|
+
: nothing}
|
|
511
|
+
<div class=${sec.$layout === "grid" ? "style-section-body--grid" : ""}>${rows}</div>
|
|
512
|
+
</sp-accordion-item>
|
|
513
|
+
`;
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// ── Custom section ─────────────────────────────────────────────────────────
|
|
517
|
+
const cssInitialMap = getCssInitialMap();
|
|
518
|
+
const customIsOpen = S.ui.styleSections.other ?? otherProps.length > 0;
|
|
519
|
+
const customSectionT = html`
|
|
520
|
+
<sp-accordion-item
|
|
521
|
+
label="Custom"
|
|
522
|
+
.open=${customIsOpen}
|
|
523
|
+
@sp-accordion-item-toggle=${(/** @type {any} */ e) => {
|
|
524
|
+
updateUi("styleSections", { ...getState().ui.styleSections, other: e.target.open });
|
|
525
|
+
}}
|
|
526
|
+
>
|
|
527
|
+
<div>
|
|
528
|
+
${otherProps.map(
|
|
529
|
+
(prop) => html`
|
|
530
|
+
<div class="kv-row">
|
|
531
|
+
<sp-textfield
|
|
532
|
+
size="s"
|
|
533
|
+
class="kv-key"
|
|
534
|
+
.value=${live(prop)}
|
|
535
|
+
@change=${(/** @type {any} */ e) => {
|
|
536
|
+
const newProp = e.target.value.trim();
|
|
537
|
+
if (newProp && newProp !== prop) {
|
|
538
|
+
let s = commitStyle(getState(), prop, undefined);
|
|
539
|
+
s = commitStyle(s, newProp, String(activeStyle[prop]));
|
|
540
|
+
update(s);
|
|
541
|
+
}
|
|
542
|
+
}}
|
|
543
|
+
></sp-textfield>
|
|
544
|
+
<sp-textfield
|
|
545
|
+
size="s"
|
|
546
|
+
class="kv-val"
|
|
547
|
+
.value=${live(String(activeStyle[prop]))}
|
|
548
|
+
placeholder=${ifDefined(cssInitialMap.get(prop))}
|
|
549
|
+
@input=${debouncedStyleCommit(`custom:${prop}`, 400, (/** @type {any} */ e) => {
|
|
550
|
+
update(commitStyle(getState(), prop, e.target.value));
|
|
551
|
+
})}
|
|
552
|
+
></sp-textfield>
|
|
553
|
+
<sp-action-button
|
|
554
|
+
size="xs"
|
|
555
|
+
quiet
|
|
556
|
+
@click=${() => update(commitStyle(getState(), prop, undefined))}
|
|
557
|
+
>
|
|
558
|
+
<sp-icon-close slot="icon"></sp-icon-close>
|
|
559
|
+
</sp-action-button>
|
|
560
|
+
</div>
|
|
561
|
+
`,
|
|
562
|
+
)}
|
|
563
|
+
<div style="display:flex;gap:4px;padding-top:4px">
|
|
564
|
+
<sp-textfield
|
|
565
|
+
size="s"
|
|
566
|
+
placeholder="Property name…"
|
|
567
|
+
style="flex:1"
|
|
568
|
+
@keydown=${(/** @type {any} */ e) => {
|
|
569
|
+
if (e.key === "Enter") {
|
|
570
|
+
e.preventDefault();
|
|
571
|
+
const prop = e.target.value.trim();
|
|
572
|
+
if (prop) {
|
|
573
|
+
const initial = cssInitialMap.get(prop) || "";
|
|
574
|
+
update(commitStyle(getState(), prop, initial || ""));
|
|
575
|
+
e.target.value = "";
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}}
|
|
579
|
+
></sp-textfield>
|
|
580
|
+
</div>
|
|
581
|
+
</div>
|
|
582
|
+
</sp-accordion-item>
|
|
583
|
+
`;
|
|
584
|
+
|
|
585
|
+
return html`
|
|
586
|
+
<div class="style-sidebar">
|
|
587
|
+
${toolbarT} ${filterBarT}
|
|
588
|
+
<sp-accordion allow-multiple size="s"> ${sectionTemplates} ${customSectionT} </sp-accordion>
|
|
589
|
+
</div>
|
|
590
|
+
`;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ─── Entry point ────────────────────────────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Top-level Style panel — returns a lit-html template.
|
|
597
|
+
*
|
|
598
|
+
* @param {{ getCanvasMode: () => string }} ctx
|
|
599
|
+
* @returns {import("lit-html").TemplateResult}
|
|
600
|
+
*/
|
|
601
|
+
export function renderStylePanelTemplate(ctx) {
|
|
602
|
+
const S = getState();
|
|
603
|
+
if (ctx.getCanvasMode() === "settings" && S.ui.stylebookSelection) {
|
|
604
|
+
const node = S.document;
|
|
605
|
+
if (!node) return html`<div class="empty-state">No document loaded</div>`;
|
|
606
|
+
return html`
|
|
607
|
+
<div class="stylebook-style-header">Styling: <${S.ui.stylebookSelection}></div>
|
|
608
|
+
${styleSidebarTemplate(node, S.ui.activeMedia, S.ui.activeSelector)}
|
|
609
|
+
`;
|
|
610
|
+
}
|
|
611
|
+
if (!S.selection) return html`<div class="empty-state">Select an element to style</div>`;
|
|
612
|
+
const node = getNodeAtPath(S.document, S.selection);
|
|
613
|
+
if (!node) return html`<div class="empty-state">Select an element to style</div>`;
|
|
614
|
+
return styleSidebarTemplate(node, S.ui.activeMedia, S.ui.activeSelector);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Single property input row (generic field row helper) */
|
|
618
|
+
export function _fieldRow(
|
|
619
|
+
/** @type {any} */ label,
|
|
620
|
+
/** @type {any} */ type,
|
|
621
|
+
/** @type {any} */ value,
|
|
622
|
+
/** @type {any} */ onChange,
|
|
623
|
+
/** @type {any} */ _datalistId,
|
|
624
|
+
) {
|
|
625
|
+
/** @type {any} */
|
|
626
|
+
let debounceTimer;
|
|
627
|
+
const onInput = (/** @type {any} */ e) => {
|
|
628
|
+
clearTimeout(debounceTimer);
|
|
629
|
+
debounceTimer = setTimeout(() => onChange(e.target.value), 400);
|
|
630
|
+
};
|
|
631
|
+
const inputTpl =
|
|
632
|
+
type === "textarea"
|
|
633
|
+
? html`<sp-textfield
|
|
634
|
+
multiline
|
|
635
|
+
size="s"
|
|
636
|
+
value=${value ?? ""}
|
|
637
|
+
@input=${onInput}
|
|
638
|
+
></sp-textfield>`
|
|
639
|
+
: type === "checkbox"
|
|
640
|
+
? html`<sp-checkbox
|
|
641
|
+
?checked=${!!value}
|
|
642
|
+
@change=${(/** @type {any} */ e) => onChange(e.target.checked)}
|
|
643
|
+
></sp-checkbox>`
|
|
644
|
+
: html`<sp-textfield size="s" value=${value ?? ""} @input=${onInput}></sp-textfield>`;
|
|
645
|
+
return html`
|
|
646
|
+
<div class="field-row">
|
|
647
|
+
<sp-field-label size="s">${label}</sp-field-label>
|
|
648
|
+
${inputTpl}
|
|
649
|
+
</div>
|
|
650
|
+
`;
|
|
651
|
+
}
|