@jxsuite/studio 0.1.0 → 0.5.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 +47638 -33445
- package/dist/studio.js.map +449 -344
- package/package.json +45 -34
- package/src/browse/browse.js +414 -0
- package/src/editor/context-menu.js +48 -1
- package/src/editor/convert-to-component.js +208 -0
- package/src/editor/inline-edit.js +33 -6
- package/src/editor/shortcuts.js +6 -1
- package/src/files/components.js +4 -2
- package/src/files/file-ops.js +102 -54
- package/src/files/files.js +22 -8
- package/src/markdown/md-convert.js +309 -11
- package/src/panels/activity-bar.js +3 -0
- package/src/panels/head-panel.js +576 -0
- package/src/panels/overlays.js +125 -0
- package/src/panels/right-panel.js +104 -0
- package/src/panels/shared.js +41 -0
- package/src/panels/signals-panel.js +95 -94
- package/src/panels/toolbar.js +217 -0
- package/src/platforms/devserver.js +58 -16
- package/src/settings/collections-editor.js +428 -0
- package/src/settings/defs-editor.js +418 -0
- package/src/settings/schema-field-ui.js +329 -0
- package/src/state.js +99 -2
- package/src/store.js +77 -41
- package/src/studio.js +1523 -1375
- package/src/ui/button-group.js +91 -0
- package/src/ui/color-selector.js +299 -0
- package/src/ui/field-row.js +47 -0
- package/src/ui/media-picker.js +172 -0
- package/src/ui/panel-resize.js +96 -0
- package/src/ui/spectrum.js +36 -2
- package/src/ui/unit-selector.js +106 -0
- package/src/ui/{jx-styled-combobox.js → value-selector.js} +7 -7
- package/src/ui/widgets.js +106 -0
- package/src/utils/inherited-style.js +54 -0
- package/src/utils/studio-utils.js +32 -0
- package/src/view.js +45 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Button-group.js — Action group + overflow picker widget.
|
|
3
|
+
*
|
|
4
|
+
* Renders a compact button group for enum values with an optional overflow picker for additional
|
|
5
|
+
* options that don't fit in the button bar.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { html, nothing } from "lit-html";
|
|
9
|
+
import { abbreviateValue, kebabToLabel } from "../utils/studio-utils.js";
|
|
10
|
+
import icons from "./icons.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render a button group widget with optional overflow menu.
|
|
14
|
+
*
|
|
15
|
+
* @param {any} entry — css-meta entry with $buttonValues, enum, $icons
|
|
16
|
+
* @param {string} prop — property key (for menu ID namespace)
|
|
17
|
+
* @param {any} value — current value
|
|
18
|
+
* @param {(val: string) => void} onChange — commit callback
|
|
19
|
+
* @returns {any}
|
|
20
|
+
*/
|
|
21
|
+
export function renderButtonGroup(
|
|
22
|
+
/** @type {any} */ entry,
|
|
23
|
+
/** @type {any} */ prop,
|
|
24
|
+
/** @type {any} */ value,
|
|
25
|
+
/** @type {any} */ onChange,
|
|
26
|
+
) {
|
|
27
|
+
const values = entry.$buttonValues || entry.enum || [];
|
|
28
|
+
/** @type {Record<string, any>} */
|
|
29
|
+
const iconMap = entry.$icons || {};
|
|
30
|
+
const extra =
|
|
31
|
+
entry.$buttonValues && entry.enum && entry.enum.length > entry.$buttonValues.length
|
|
32
|
+
? entry.enum.filter((/** @type {any} */ v) => !entry.$buttonValues.includes(v))
|
|
33
|
+
: [];
|
|
34
|
+
|
|
35
|
+
const menuId = `style-btngrp-${prop}`;
|
|
36
|
+
const hasExtra = extra.length > 0;
|
|
37
|
+
const extraSelected = hasExtra && extra.includes(value);
|
|
38
|
+
|
|
39
|
+
return html`
|
|
40
|
+
<div class="button-group-combo ${hasExtra ? "has-overflow" : ""}">
|
|
41
|
+
<sp-action-group size="s" compact>
|
|
42
|
+
${values.map(
|
|
43
|
+
(/** @type {any} */ v) => html`
|
|
44
|
+
<sp-action-button
|
|
45
|
+
size="s"
|
|
46
|
+
value=${v}
|
|
47
|
+
title=${v}
|
|
48
|
+
?selected=${v === value}
|
|
49
|
+
@click=${() => onChange(v === value ? "" : v)}
|
|
50
|
+
>
|
|
51
|
+
${
|
|
52
|
+
/** @type {any} */ (iconMap)[v] &&
|
|
53
|
+
/** @type {any} */ (icons)[/** @type {any} */ (iconMap)[v]]
|
|
54
|
+
? /** @type {any} */ (icons)[/** @type {any} */ (iconMap)[v]]
|
|
55
|
+
: abbreviateValue(v)
|
|
56
|
+
}
|
|
57
|
+
</sp-action-button>
|
|
58
|
+
`,
|
|
59
|
+
)}
|
|
60
|
+
</sp-action-group>
|
|
61
|
+
${hasExtra
|
|
62
|
+
? html`
|
|
63
|
+
<sp-picker-button
|
|
64
|
+
size="s"
|
|
65
|
+
id=${menuId}
|
|
66
|
+
class=${extraSelected ? "has-selection" : ""}
|
|
67
|
+
></sp-picker-button>
|
|
68
|
+
<sp-overlay trigger="${menuId}@click" placement="bottom-end" type="auto">
|
|
69
|
+
<sp-popover>
|
|
70
|
+
<sp-menu
|
|
71
|
+
@change=${(/** @type {any} */ e) => {
|
|
72
|
+
if (e.target.value) onChange(e.target.value);
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<sp-menu-item value="__none__">—</sp-menu-item>
|
|
76
|
+
${extra.map((/** @type {any} */ v) => {
|
|
77
|
+
const label = v.includes("-")
|
|
78
|
+
? kebabToLabel(v)
|
|
79
|
+
: v.replace(/^./, (/** @type {any} */ c) => c.toUpperCase());
|
|
80
|
+
return html`<sp-menu-item value=${v} ?selected=${v === value}
|
|
81
|
+
>${label}</sp-menu-item
|
|
82
|
+
>`;
|
|
83
|
+
})}
|
|
84
|
+
</sp-menu>
|
|
85
|
+
</sp-popover>
|
|
86
|
+
</sp-overlay>
|
|
87
|
+
`
|
|
88
|
+
: nothing}
|
|
89
|
+
</div>
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Color-selector.js — Color input widget with swatch, text field, and popover picker.
|
|
3
|
+
*
|
|
4
|
+
* Uses sp-overlay trigger pattern for positioning (same as unit-selector and value-selector). The
|
|
5
|
+
* popover content is a JxColorPopover LitElement that reactively syncs color between the area,
|
|
6
|
+
* slider, and text field via a single `color` property.
|
|
7
|
+
*
|
|
8
|
+
* When the value is a var(--color-*) reference matching a defined color variable, the input
|
|
9
|
+
* switches to picker mode showing the title-cased variable name (e.g. "Primary Blue") with a
|
|
10
|
+
* swatch, similar to jx-value-selector's dual-mode behavior.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { LitElement, html, nothing } from "lit";
|
|
14
|
+
import { html as litHtml } from "lit-html";
|
|
15
|
+
import { live } from "lit-html/directives/live.js";
|
|
16
|
+
import { getState, debouncedStyleCommit } from "../store.js";
|
|
17
|
+
import { getEffectiveStyle } from "../site-context.js";
|
|
18
|
+
import { kebabToLabel } from "../utils/studio-utils.js";
|
|
19
|
+
|
|
20
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** Extract --color-* CSS custom properties from the effective (site + document) style. */
|
|
23
|
+
function getColorVars() {
|
|
24
|
+
const S = getState();
|
|
25
|
+
const style = getEffectiveStyle(S?.document?.style);
|
|
26
|
+
if (!style) return [];
|
|
27
|
+
const vars = [];
|
|
28
|
+
for (const [k, v] of Object.entries(style)) {
|
|
29
|
+
if (k.startsWith("--color") && (typeof v === "string" || typeof v === "number")) {
|
|
30
|
+
vars.push({ name: k, value: String(v) });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return vars;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Convert a color variable name to a title-case label. Strips the "--color-" prefix and converts
|
|
38
|
+
* kebab to title case. e.g. "--color-primary-blue" → "Primary Blue"
|
|
39
|
+
*/
|
|
40
|
+
function varToLabel(/** @type {string} */ name) {
|
|
41
|
+
return kebabToLabel(name.replace(/^--color-?/, ""));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolve a color value for display — if it's a var() reference, look up the actual color. */
|
|
45
|
+
function resolveColorForDisplay(/** @type {any} */ val) {
|
|
46
|
+
if (!val) return "transparent";
|
|
47
|
+
const m = val.match(/^var\((--[^)]+)\)$/);
|
|
48
|
+
if (m) {
|
|
49
|
+
const S = getState();
|
|
50
|
+
const style = getEffectiveStyle(S?.document?.style);
|
|
51
|
+
const resolved = style?.[m[1]];
|
|
52
|
+
if (typeof resolved === "string") return resolved;
|
|
53
|
+
return "transparent";
|
|
54
|
+
}
|
|
55
|
+
return val;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function safeColor(/** @type {any} */ val) {
|
|
59
|
+
if (!val) return "transparent";
|
|
60
|
+
return resolveColorForDisplay(val);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Normalize a color string to include # prefix for hex values. */
|
|
64
|
+
function normalizeHex(/** @type {string} */ c) {
|
|
65
|
+
if (!c) return c;
|
|
66
|
+
if (c.startsWith("var(") || c.startsWith("rgb") || c.startsWith("hsl")) return c;
|
|
67
|
+
return c.replace(/^#?/, "#");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check if a value is a var() reference that matches a defined color variable.
|
|
72
|
+
*
|
|
73
|
+
* @param {any} value
|
|
74
|
+
* @param {{ name: string; value: string }[]} colorVars
|
|
75
|
+
*/
|
|
76
|
+
function matchesColorVar(value, colorVars) {
|
|
77
|
+
if (!value || typeof value !== "string") return null;
|
|
78
|
+
const m = value.match(/^var\((--[^)]+)\)$/);
|
|
79
|
+
if (!m) return null;
|
|
80
|
+
return colorVars.find((cv) => cv.name === m[1]) || null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── JxColorPopover LitElement ──────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/** @typedef {{ name: string; value: string }} ColorVar */
|
|
86
|
+
|
|
87
|
+
export class JxColorPopover extends LitElement {
|
|
88
|
+
static properties = {
|
|
89
|
+
color: { type: String },
|
|
90
|
+
displayColor: { type: String, attribute: false },
|
|
91
|
+
colorVars: { attribute: false },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
constructor() {
|
|
95
|
+
super();
|
|
96
|
+
/** @type {string} */ this.color = "";
|
|
97
|
+
/** @type {string} */ this.displayColor = "#000000";
|
|
98
|
+
/** @type {ColorVar[]} */ this.colorVars = [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** No shadow DOM — render directly into light DOM for Spectrum theming */
|
|
102
|
+
createRenderRoot() {
|
|
103
|
+
return this;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @param {Map<string, any>} changed */
|
|
107
|
+
willUpdate(changed) {
|
|
108
|
+
if (changed.has("color")) {
|
|
109
|
+
const raw = resolveColorForDisplay(this.color);
|
|
110
|
+
if (!raw || raw === "transparent") {
|
|
111
|
+
this.displayColor = "#000000";
|
|
112
|
+
} else if (raw.startsWith("#") || raw.startsWith("rgb") || raw.startsWith("hsl")) {
|
|
113
|
+
this.displayColor = raw;
|
|
114
|
+
} else {
|
|
115
|
+
this.displayColor = `#${raw}`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_handleArea(/** @type {any} */ e) {
|
|
121
|
+
const color = normalizeHex(String(e.target.color));
|
|
122
|
+
this.displayColor = color;
|
|
123
|
+
this.color = color;
|
|
124
|
+
this.dispatchEvent(new CustomEvent("color-change", { detail: color, bubbles: true }));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_handleSlider(/** @type {any} */ e) {
|
|
128
|
+
const color = normalizeHex(String(e.target.color));
|
|
129
|
+
this.displayColor = color;
|
|
130
|
+
this.color = color;
|
|
131
|
+
this.dispatchEvent(new CustomEvent("color-change", { detail: color, bubbles: true }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
_handleText(/** @type {any} */ e) {
|
|
135
|
+
const val = e.target.value.trim();
|
|
136
|
+
if (!val) return;
|
|
137
|
+
this.displayColor = val;
|
|
138
|
+
this.color = val;
|
|
139
|
+
this.dispatchEvent(new CustomEvent("color-change", { detail: val, bubbles: true }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_handleSwatch(/** @type {any} */ e, /** @type {string} */ varName) {
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
const varRef = `var(${varName})`;
|
|
145
|
+
this.color = varRef;
|
|
146
|
+
this.dispatchEvent(new CustomEvent("color-change", { detail: varRef, bubbles: true }));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
render() {
|
|
150
|
+
return html`
|
|
151
|
+
<div class="color-popover-inner">
|
|
152
|
+
<sp-color-area
|
|
153
|
+
style="width:200px; height:150px; --mod-colorarea-width:200px; --mod-colorarea-height:150px"
|
|
154
|
+
.color=${this.displayColor}
|
|
155
|
+
@input=${this._handleArea}
|
|
156
|
+
></sp-color-area>
|
|
157
|
+
<sp-color-slider
|
|
158
|
+
style="width:200px; --mod-colorslider-length:200px"
|
|
159
|
+
.color=${this.displayColor}
|
|
160
|
+
@input=${this._handleSlider}
|
|
161
|
+
></sp-color-slider>
|
|
162
|
+
<sp-textfield
|
|
163
|
+
size="s"
|
|
164
|
+
style="width:200px"
|
|
165
|
+
.value=${live(this.color || "")}
|
|
166
|
+
placeholder="#000000"
|
|
167
|
+
@change=${this._handleText}
|
|
168
|
+
></sp-textfield>
|
|
169
|
+
${this.colorVars.length > 0
|
|
170
|
+
? html`
|
|
171
|
+
<sp-divider size="s"></sp-divider>
|
|
172
|
+
<span class="color-popover-swatches-label">Color Tokens</span>
|
|
173
|
+
<sp-swatch-group size="xs" border="light" rounding="none">
|
|
174
|
+
${this.colorVars.map(
|
|
175
|
+
(cv) => html`
|
|
176
|
+
<sp-swatch
|
|
177
|
+
color=${cv.value}
|
|
178
|
+
.value=${cv.name}
|
|
179
|
+
title=${cv.name}
|
|
180
|
+
@click=${(/** @type {any} */ e) => this._handleSwatch(e, cv.name)}
|
|
181
|
+
></sp-swatch>
|
|
182
|
+
`,
|
|
183
|
+
)}
|
|
184
|
+
</sp-swatch-group>
|
|
185
|
+
`
|
|
186
|
+
: nothing}
|
|
187
|
+
</div>
|
|
188
|
+
`;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── Color input widget ─────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Render a color selector: swatch + text field + overlay popover. Uses sp-overlay trigger pattern
|
|
196
|
+
* (same as unit-selector and value-selector).
|
|
197
|
+
*
|
|
198
|
+
* When value is a var(--color-*) matching a defined variable, switches to picker mode showing
|
|
199
|
+
* title-cased label with swatch (e.g. "Primary Blue").
|
|
200
|
+
*
|
|
201
|
+
* @param {string} prop — property key (for debounce namespace)
|
|
202
|
+
* @param {any} value — current color value
|
|
203
|
+
* @param {(color: string) => void} onChange — commit callback
|
|
204
|
+
* @returns {any}
|
|
205
|
+
*/
|
|
206
|
+
export function renderColorSelector(
|
|
207
|
+
/** @type {any} */ prop,
|
|
208
|
+
/** @type {any} */ value,
|
|
209
|
+
/** @type {any} */ onChange,
|
|
210
|
+
) {
|
|
211
|
+
const colorVars = getColorVars();
|
|
212
|
+
const matchedVar = matchesColorVar(value, colorVars);
|
|
213
|
+
const triggerId = `color-trigger-${prop}`;
|
|
214
|
+
const pickerTriggerId = `color-picker-${prop}`;
|
|
215
|
+
|
|
216
|
+
// ─── Picker mode: value matches a defined color variable ───
|
|
217
|
+
if (matchedVar) {
|
|
218
|
+
return litHtml`
|
|
219
|
+
<div class="style-input-color">
|
|
220
|
+
<sp-swatch
|
|
221
|
+
size="s"
|
|
222
|
+
rounding="none"
|
|
223
|
+
border="light"
|
|
224
|
+
color=${matchedVar.value}
|
|
225
|
+
id=${triggerId}
|
|
226
|
+
></sp-swatch>
|
|
227
|
+
<sp-overlay trigger="${triggerId}@click" placement="bottom-start" type="auto">
|
|
228
|
+
<sp-popover style="padding:12px">
|
|
229
|
+
<jx-color-popover
|
|
230
|
+
.color=${value || ""}
|
|
231
|
+
.colorVars=${colorVars}
|
|
232
|
+
@color-change=${(/** @type {CustomEvent} */ e) => onChange(e.detail)}
|
|
233
|
+
></jx-color-popover>
|
|
234
|
+
</sp-popover>
|
|
235
|
+
</sp-overlay>
|
|
236
|
+
<sp-picker
|
|
237
|
+
id=${pickerTriggerId}
|
|
238
|
+
size="s"
|
|
239
|
+
style="flex:1; min-width:0"
|
|
240
|
+
.value=${`var(${matchedVar.name})`}
|
|
241
|
+
@change=${(/** @type {any} */ e) => {
|
|
242
|
+
e.stopPropagation();
|
|
243
|
+
onChange(e.target.value);
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
${colorVars.map(
|
|
247
|
+
(cv) => litHtml`
|
|
248
|
+
<sp-menu-item value=${`var(${cv.name})`}>
|
|
249
|
+
<sp-swatch
|
|
250
|
+
slot="icon"
|
|
251
|
+
size="xs"
|
|
252
|
+
rounding="none"
|
|
253
|
+
border="light"
|
|
254
|
+
color=${cv.value}
|
|
255
|
+
></sp-swatch>
|
|
256
|
+
${varToLabel(cv.name)}
|
|
257
|
+
</sp-menu-item>
|
|
258
|
+
`,
|
|
259
|
+
)}
|
|
260
|
+
</sp-picker>
|
|
261
|
+
</div>
|
|
262
|
+
`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ─── Text mode: custom color value or empty ───
|
|
266
|
+
return litHtml`
|
|
267
|
+
<div class="style-input-color" id=${triggerId}>
|
|
268
|
+
<sp-swatch
|
|
269
|
+
size="s"
|
|
270
|
+
rounding="none"
|
|
271
|
+
border="light"
|
|
272
|
+
color=${safeColor(value)}
|
|
273
|
+
></sp-swatch>
|
|
274
|
+
<sp-textfield
|
|
275
|
+
size="s"
|
|
276
|
+
style="flex:1; min-width:0"
|
|
277
|
+
.value=${live(value || "")}
|
|
278
|
+
@click=${(/** @type {Event} */ e) => e.stopPropagation()}
|
|
279
|
+
@input=${debouncedStyleCommit(`color:${prop}`, 400, (/** @type {any} */ e) => {
|
|
280
|
+
onChange(e.target.value.trim());
|
|
281
|
+
})}
|
|
282
|
+
></sp-textfield>
|
|
283
|
+
<sp-overlay trigger="${triggerId}@click" placement="bottom-start" type="auto">
|
|
284
|
+
<sp-popover style="padding:12px">
|
|
285
|
+
<jx-color-popover
|
|
286
|
+
.color=${value || ""}
|
|
287
|
+
.colorVars=${colorVars}
|
|
288
|
+
@color-change=${(/** @type {CustomEvent} */ e) => onChange(e.detail)}
|
|
289
|
+
></jx-color-popover>
|
|
290
|
+
</sp-popover>
|
|
291
|
+
</sp-overlay>
|
|
292
|
+
</div>
|
|
293
|
+
`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Whether any color popover is currently open. */
|
|
297
|
+
export function isColorPopoverOpen() {
|
|
298
|
+
return !!document.querySelector(".style-input-color sp-overlay[open]");
|
|
299
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field-row.js — Universal field row layout for all Studio panels.
|
|
3
|
+
*
|
|
4
|
+
* Renders the consistent pattern: indicator dot + label + widget slot. Used by style panel,
|
|
5
|
+
* attributes panel, frontmatter panel, signals panel, etc.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { html, nothing } from "lit-html";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Render a universal field row with indicator dot, label, and widget.
|
|
12
|
+
*
|
|
13
|
+
* @param {{
|
|
14
|
+
* prop: string;
|
|
15
|
+
* label: string;
|
|
16
|
+
* hasValue: boolean;
|
|
17
|
+
* onClear?: () => void;
|
|
18
|
+
* widget: any;
|
|
19
|
+
* span?: number;
|
|
20
|
+
* warning?: boolean;
|
|
21
|
+
* }} opts
|
|
22
|
+
* @returns {any}
|
|
23
|
+
*/
|
|
24
|
+
export function renderFieldRow({ prop, label, hasValue, onClear, widget, span, warning }) {
|
|
25
|
+
return html`
|
|
26
|
+
<div
|
|
27
|
+
class=${"style-row" + (warning ? " style-row--warning" : "")}
|
|
28
|
+
data-prop=${prop}
|
|
29
|
+
style=${span === 2 ? "grid-column: 1 / -1" : ""}
|
|
30
|
+
>
|
|
31
|
+
<div class="style-row-label">
|
|
32
|
+
${hasValue && onClear
|
|
33
|
+
? html`<span
|
|
34
|
+
class="set-dot"
|
|
35
|
+
title="Clear ${prop}"
|
|
36
|
+
@click=${(/** @type {any} */ e) => {
|
|
37
|
+
e.stopPropagation();
|
|
38
|
+
onClear();
|
|
39
|
+
}}
|
|
40
|
+
></span>`
|
|
41
|
+
: nothing}
|
|
42
|
+
<sp-field-label size="s" title=${prop}>${label}</sp-field-label>
|
|
43
|
+
</div>
|
|
44
|
+
${widget}
|
|
45
|
+
</div>
|
|
46
|
+
`;
|
|
47
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media Picker — combobox-style widget for selecting project media files.
|
|
3
|
+
*
|
|
4
|
+
* Shows an editable text input for manual URL entry combined with a dropdown of available media
|
|
5
|
+
* files from the project's public/ directory, with thumbnail previews for images.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { html, nothing } from "lit-html";
|
|
9
|
+
import { live } from "lit-html/directives/live.js";
|
|
10
|
+
import { getPlatform } from "../platform.js";
|
|
11
|
+
import { debouncedStyleCommit } from "../store.js";
|
|
12
|
+
|
|
13
|
+
// ─── Media file cache ────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
16
|
+
".jpg",
|
|
17
|
+
".jpeg",
|
|
18
|
+
".png",
|
|
19
|
+
".gif",
|
|
20
|
+
".svg",
|
|
21
|
+
".webp",
|
|
22
|
+
".avif",
|
|
23
|
+
".ico",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const MEDIA_EXTENSIONS = new Set([
|
|
27
|
+
...IMAGE_EXTENSIONS,
|
|
28
|
+
".mp4",
|
|
29
|
+
".webm",
|
|
30
|
+
".mp3",
|
|
31
|
+
".wav",
|
|
32
|
+
".ogg",
|
|
33
|
+
".pdf",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** @type {{ path: string; name: string; isImage: boolean }[]} */
|
|
37
|
+
let mediaCache = [];
|
|
38
|
+
let mediaCacheLoaded = false;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Recursively collect media files from a directory.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} dir
|
|
44
|
+
* @param {ReturnType<typeof getPlatform>} platform
|
|
45
|
+
* @returns {Promise<{ path: string; name: string; isImage: boolean }[]>}
|
|
46
|
+
*/
|
|
47
|
+
async function collectMedia(dir, platform) {
|
|
48
|
+
/** @type {{ path: string; name: string; isImage: boolean }[]} */
|
|
49
|
+
const results = [];
|
|
50
|
+
try {
|
|
51
|
+
const entries = await platform.listDirectory(dir);
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (entry.type === "directory") {
|
|
54
|
+
const sub = await collectMedia(entry.path, platform);
|
|
55
|
+
results.push(...sub);
|
|
56
|
+
} else {
|
|
57
|
+
const dot = entry.name.lastIndexOf(".");
|
|
58
|
+
const ext = dot > 0 ? entry.name.slice(dot).toLowerCase() : "";
|
|
59
|
+
if (MEDIA_EXTENSIONS.has(ext)) {
|
|
60
|
+
results.push({
|
|
61
|
+
path: `/${entry.path}`,
|
|
62
|
+
name: entry.name,
|
|
63
|
+
isImage: IMAGE_EXTENSIONS.has(ext),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// Directory may not exist
|
|
70
|
+
}
|
|
71
|
+
return results;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function loadMediaCache() {
|
|
75
|
+
if (mediaCacheLoaded) return;
|
|
76
|
+
const platform = getPlatform();
|
|
77
|
+
mediaCache = await collectMedia("public", platform);
|
|
78
|
+
// Strip "public/" prefix so paths match production (public/ contents served at root)
|
|
79
|
+
for (const m of mediaCache) {
|
|
80
|
+
m.path = m.path.replace(/^\/public\//, "/");
|
|
81
|
+
}
|
|
82
|
+
mediaCacheLoaded = true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Force media cache reload (e.g. after upload). */
|
|
86
|
+
export function invalidateMediaCache() {
|
|
87
|
+
mediaCache = [];
|
|
88
|
+
mediaCacheLoaded = false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Render ──────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Render the media picker widget for src-type attributes.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} prop — attribute name (e.g. "src")
|
|
97
|
+
* @param {any} value — current attribute value
|
|
98
|
+
* @param {(val: any) => void} onCommit — commit callback
|
|
99
|
+
* @returns {any}
|
|
100
|
+
*/
|
|
101
|
+
export function renderMediaPicker(prop, value, onCommit) {
|
|
102
|
+
// Kick off async load (won't block render)
|
|
103
|
+
loadMediaCache();
|
|
104
|
+
|
|
105
|
+
const currentValue = value || "";
|
|
106
|
+
const isImage = IMAGE_EXTENSIONS.has(
|
|
107
|
+
currentValue.slice(currentValue.lastIndexOf(".")).toLowerCase(),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Filter media options based on current input
|
|
111
|
+
const query = currentValue.toLowerCase();
|
|
112
|
+
const filtered = query
|
|
113
|
+
? mediaCache.filter(
|
|
114
|
+
(m) => m.path.toLowerCase().includes(query) || m.name.toLowerCase().includes(query),
|
|
115
|
+
)
|
|
116
|
+
: mediaCache;
|
|
117
|
+
|
|
118
|
+
// Limit displayed options
|
|
119
|
+
const options = filtered.slice(0, 20);
|
|
120
|
+
|
|
121
|
+
return html`
|
|
122
|
+
<div class="media-picker">
|
|
123
|
+
${isImage && currentValue
|
|
124
|
+
? html`<img class="media-picker-thumb" src=${currentValue} alt="" />`
|
|
125
|
+
: nothing}
|
|
126
|
+
<sp-textfield
|
|
127
|
+
size="s"
|
|
128
|
+
placeholder="/image.jpg"
|
|
129
|
+
.value=${live(currentValue)}
|
|
130
|
+
@input=${debouncedStyleCommit(`media:${prop}`, 400, (/** @type {any} */ e) =>
|
|
131
|
+
onCommit(e.target.value),
|
|
132
|
+
)}
|
|
133
|
+
@focus=${() => loadMediaCache()}
|
|
134
|
+
></sp-textfield>
|
|
135
|
+
${mediaCache.length > 0
|
|
136
|
+
? html`
|
|
137
|
+
<overlay-trigger placement="bottom-end">
|
|
138
|
+
<sp-action-button size="xs" quiet slot="trigger" title="Browse media">
|
|
139
|
+
<sp-icon-image slot="icon"></sp-icon-image>
|
|
140
|
+
</sp-action-button>
|
|
141
|
+
<sp-popover slot="click-content" class="media-picker-popover">
|
|
142
|
+
<sp-menu
|
|
143
|
+
@change=${(/** @type {any} */ e) => {
|
|
144
|
+
onCommit(e.target.value);
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
${options.map(
|
|
148
|
+
(m) => html`
|
|
149
|
+
<sp-menu-item value=${m.path}>
|
|
150
|
+
${m.isImage
|
|
151
|
+
? html`<img
|
|
152
|
+
slot="icon"
|
|
153
|
+
src=${m.path}
|
|
154
|
+
alt=""
|
|
155
|
+
style="width:24px;height:24px;object-fit:cover;border-radius:2px"
|
|
156
|
+
/>`
|
|
157
|
+
: nothing}
|
|
158
|
+
${m.name}
|
|
159
|
+
</sp-menu-item>
|
|
160
|
+
`,
|
|
161
|
+
)}
|
|
162
|
+
${filtered.length > 20
|
|
163
|
+
? html`<sp-menu-item disabled>...${filtered.length - 20} more</sp-menu-item>`
|
|
164
|
+
: nothing}
|
|
165
|
+
</sp-menu>
|
|
166
|
+
</sp-popover>
|
|
167
|
+
</overlay-trigger>
|
|
168
|
+
`
|
|
169
|
+
: nothing}
|
|
170
|
+
</div>
|
|
171
|
+
`;
|
|
172
|
+
}
|