@jxsuite/studio 0.17.0 → 0.18.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 +1678 -1512
- package/dist/studio.js.map +20 -16
- package/package.json +2 -2
- package/src/canvas/canvas-render.js +2 -2
- package/src/canvas/canvas-utils.js +1 -1
- package/src/files/files.js +13 -1
- package/src/panels/activity-bar.js +12 -0
- package/src/panels/left-panel.js +2 -2
- package/src/panels/overlays.js +2 -2
- package/src/panels/style-panel.js +1 -1
- package/src/panels/stylebook-panel.js +30 -529
- package/src/panels/toolbar.js +7 -2
- package/src/settings/css-vars-editor.js +307 -0
- package/src/settings/general-settings.js +94 -0
- package/src/settings/head-editor.js +184 -0
- package/src/settings/settings-modal.js +117 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS Variables editor — form-based editor for managing design tokens. Colors and fonts are NOT
|
|
3
|
+
* media-aware; sizes/spacing are media-aware.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { html, render as litRender, nothing } from "lit-html";
|
|
7
|
+
import { ref } from "lit-html/directives/ref.js";
|
|
8
|
+
import { projectState } from "../store.js";
|
|
9
|
+
import { updateSiteConfig } from "../site-context.js";
|
|
10
|
+
import { getEffectiveMedia } from "../site-context.js";
|
|
11
|
+
import { friendlyNameToVar, varDisplayName } from "../utils/studio-utils.js";
|
|
12
|
+
|
|
13
|
+
/** @param {HTMLElement} container */
|
|
14
|
+
export function renderCssVarsEditor(container) {
|
|
15
|
+
const config = projectState.projectConfig || {};
|
|
16
|
+
const rootStyle = config.style || {};
|
|
17
|
+
const media = getEffectiveMedia(config.$media);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @type {{
|
|
21
|
+
* color: [string, any][];
|
|
22
|
+
* font: [string, any][];
|
|
23
|
+
* size: [string, any][];
|
|
24
|
+
* other: [string, any][];
|
|
25
|
+
* }}
|
|
26
|
+
*/
|
|
27
|
+
const groups = { color: [], font: [], size: [], other: [] };
|
|
28
|
+
for (const [k, v] of Object.entries(rootStyle)) {
|
|
29
|
+
if (!k.startsWith("--")) continue;
|
|
30
|
+
if (typeof v !== "string" && typeof v !== "number") continue;
|
|
31
|
+
if (k.startsWith("--color")) groups.color.push([k, v]);
|
|
32
|
+
else if (k.startsWith("--font")) groups.font.push([k, v]);
|
|
33
|
+
else if (k.startsWith("--size") || k.startsWith("--spacing") || k.startsWith("--radius"))
|
|
34
|
+
groups.size.push([k, v]);
|
|
35
|
+
else groups.other.push([k, v]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const mediaNames = media ? Object.keys(media).filter((m) => m !== "--") : [];
|
|
39
|
+
|
|
40
|
+
const save = () => {
|
|
41
|
+
updateSiteConfig({ style: { ...rootStyle } });
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const updateVar = (/** @type {string} */ name, /** @type {string} */ val) => {
|
|
45
|
+
rootStyle[name] = val;
|
|
46
|
+
save();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const deleteVar = (/** @type {string} */ name) => {
|
|
50
|
+
delete rootStyle[name];
|
|
51
|
+
save();
|
|
52
|
+
renderCssVarsEditor(container);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const addVar = (
|
|
56
|
+
/** @type {string} */ prefix,
|
|
57
|
+
/** @type {string} */ friendlyName,
|
|
58
|
+
/** @type {string} */ val,
|
|
59
|
+
) => {
|
|
60
|
+
const varName = friendlyNameToVar(friendlyName, prefix);
|
|
61
|
+
if (!varName || !val) return;
|
|
62
|
+
rootStyle[varName] = val;
|
|
63
|
+
save();
|
|
64
|
+
renderCssVarsEditor(container);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const tpl = html`
|
|
68
|
+
<div class="settings-section">
|
|
69
|
+
<h3 class="settings-section-title">CSS Variables</h3>
|
|
70
|
+
|
|
71
|
+
${renderColorSection(groups.color, updateVar, deleteVar, addVar)}
|
|
72
|
+
${renderFontSection(groups.font, updateVar, deleteVar, addVar)}
|
|
73
|
+
${renderSizeSection(groups.size, updateVar, deleteVar, addVar, rootStyle, mediaNames)}
|
|
74
|
+
${groups.other.length > 0
|
|
75
|
+
? renderOtherSection(groups.other, updateVar, deleteVar, addVar, rootStyle, mediaNames)
|
|
76
|
+
: nothing}
|
|
77
|
+
</div>
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
litRender(tpl, container);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {[string, any][]} vars
|
|
85
|
+
* @param {Function} updateVar
|
|
86
|
+
* @param {Function} deleteVar
|
|
87
|
+
* @param {Function} addVar
|
|
88
|
+
*/
|
|
89
|
+
function renderColorSection(vars, updateVar, deleteVar, addVar) {
|
|
90
|
+
return html`
|
|
91
|
+
<div class="css-vars-group">
|
|
92
|
+
<h4 class="css-vars-group-title">Colors</h4>
|
|
93
|
+
${vars.map(
|
|
94
|
+
([name, val]) => html`
|
|
95
|
+
<div class="css-var-row">
|
|
96
|
+
<div class="css-var-swatch" style="background:${val}">
|
|
97
|
+
<input
|
|
98
|
+
type="color"
|
|
99
|
+
.value=${val && val.startsWith("#") ? val : "#007acc"}
|
|
100
|
+
@input=${(/** @type {any} */ e) => updateVar(name, e.target.value)}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
<span class="css-var-name">${varDisplayName(name, "--color-")}</span>
|
|
104
|
+
<sp-textfield
|
|
105
|
+
size="s"
|
|
106
|
+
.value=${String(val)}
|
|
107
|
+
@change=${(/** @type {any} */ e) => updateVar(name, e.target.value)}
|
|
108
|
+
style="flex:1;max-width:160px"
|
|
109
|
+
></sp-textfield>
|
|
110
|
+
<sp-action-button quiet size="s" @click=${() => deleteVar(name)}>
|
|
111
|
+
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
112
|
+
</sp-action-button>
|
|
113
|
+
</div>
|
|
114
|
+
`,
|
|
115
|
+
)}
|
|
116
|
+
${renderAddRow("--color-", "Primary Blue", "#007acc", addVar)}
|
|
117
|
+
</div>
|
|
118
|
+
`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {[string, any][]} vars
|
|
123
|
+
* @param {Function} updateVar
|
|
124
|
+
* @param {Function} deleteVar
|
|
125
|
+
* @param {Function} addVar
|
|
126
|
+
*/
|
|
127
|
+
function renderFontSection(vars, updateVar, deleteVar, addVar) {
|
|
128
|
+
return html`
|
|
129
|
+
<div class="css-vars-group">
|
|
130
|
+
<h4 class="css-vars-group-title">Fonts</h4>
|
|
131
|
+
${vars.map(
|
|
132
|
+
([name, val]) => html`
|
|
133
|
+
<div class="css-var-row">
|
|
134
|
+
<span class="css-var-name">${varDisplayName(name, "--font-")}</span>
|
|
135
|
+
<sp-textfield
|
|
136
|
+
size="s"
|
|
137
|
+
.value=${String(val)}
|
|
138
|
+
@change=${(/** @type {any} */ e) => updateVar(name, e.target.value)}
|
|
139
|
+
style="flex:1"
|
|
140
|
+
></sp-textfield>
|
|
141
|
+
<sp-action-button quiet size="s" @click=${() => deleteVar(name)}>
|
|
142
|
+
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
143
|
+
</sp-action-button>
|
|
144
|
+
</div>
|
|
145
|
+
<div class="css-var-font-preview" style="font-family:${val}">
|
|
146
|
+
The quick brown fox jumps over the lazy dog
|
|
147
|
+
</div>
|
|
148
|
+
`,
|
|
149
|
+
)}
|
|
150
|
+
${renderAddRow("--font-", "Body Serif", "'Georgia', serif", addVar)}
|
|
151
|
+
</div>
|
|
152
|
+
`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {[string, any][]} vars
|
|
157
|
+
* @param {Function} updateVar
|
|
158
|
+
* @param {Function} deleteVar
|
|
159
|
+
* @param {Function} addVar
|
|
160
|
+
* @param {any} rootStyle
|
|
161
|
+
* @param {string[]} mediaNames
|
|
162
|
+
*/
|
|
163
|
+
function renderSizeSection(vars, updateVar, deleteVar, addVar, rootStyle, mediaNames) {
|
|
164
|
+
return html`
|
|
165
|
+
<div class="css-vars-group">
|
|
166
|
+
<h4 class="css-vars-group-title">Sizes & Spacing</h4>
|
|
167
|
+
${vars.map(
|
|
168
|
+
([name, val]) => html`
|
|
169
|
+
<div class="css-var-row">
|
|
170
|
+
<span class="css-var-name"
|
|
171
|
+
>${varDisplayName(name, "--size-") ||
|
|
172
|
+
varDisplayName(name, "--spacing-") ||
|
|
173
|
+
varDisplayName(name, "--radius-") ||
|
|
174
|
+
name}</span
|
|
175
|
+
>
|
|
176
|
+
<sp-textfield
|
|
177
|
+
size="s"
|
|
178
|
+
.value=${String(val)}
|
|
179
|
+
@change=${(/** @type {any} */ e) => updateVar(name, e.target.value)}
|
|
180
|
+
style="max-width:120px"
|
|
181
|
+
></sp-textfield>
|
|
182
|
+
<sp-action-button quiet size="s" @click=${() => deleteVar(name)}>
|
|
183
|
+
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
184
|
+
</sp-action-button>
|
|
185
|
+
</div>
|
|
186
|
+
${mediaNames.length > 0 ? renderMediaOverrides(name, rootStyle, mediaNames) : nothing}
|
|
187
|
+
`,
|
|
188
|
+
)}
|
|
189
|
+
${renderAddRow("--size-", "Spacing Large", "32px", addVar)}
|
|
190
|
+
</div>
|
|
191
|
+
`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param {[string, any][]} vars
|
|
196
|
+
* @param {Function} updateVar
|
|
197
|
+
* @param {Function} deleteVar
|
|
198
|
+
* @param {Function} addVar
|
|
199
|
+
* @param {any} rootStyle
|
|
200
|
+
* @param {string[]} mediaNames
|
|
201
|
+
*/
|
|
202
|
+
function renderOtherSection(vars, updateVar, deleteVar, addVar, rootStyle, mediaNames) {
|
|
203
|
+
return html`
|
|
204
|
+
<div class="css-vars-group">
|
|
205
|
+
<h4 class="css-vars-group-title">Other</h4>
|
|
206
|
+
${vars.map(
|
|
207
|
+
([name, val]) => html`
|
|
208
|
+
<div class="css-var-row">
|
|
209
|
+
<span class="css-var-name">${name}</span>
|
|
210
|
+
<sp-textfield
|
|
211
|
+
size="s"
|
|
212
|
+
.value=${String(val)}
|
|
213
|
+
@change=${(/** @type {any} */ e) => updateVar(name, e.target.value)}
|
|
214
|
+
style="flex:1"
|
|
215
|
+
></sp-textfield>
|
|
216
|
+
<sp-action-button quiet size="s" @click=${() => deleteVar(name)}>
|
|
217
|
+
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
218
|
+
</sp-action-button>
|
|
219
|
+
</div>
|
|
220
|
+
${mediaNames.length > 0 ? renderMediaOverrides(name, rootStyle, mediaNames) : nothing}
|
|
221
|
+
`,
|
|
222
|
+
)}
|
|
223
|
+
${renderAddRow("--", "Custom Var", "value", addVar)}
|
|
224
|
+
</div>
|
|
225
|
+
`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @param {string} varName
|
|
230
|
+
* @param {any} rootStyle
|
|
231
|
+
* @param {string[]} mediaNames
|
|
232
|
+
*/
|
|
233
|
+
function renderMediaOverrides(varName, rootStyle, mediaNames) {
|
|
234
|
+
const overrides = [];
|
|
235
|
+
for (const mediaName of mediaNames) {
|
|
236
|
+
const mediaKey = `@${mediaName}`;
|
|
237
|
+
const mediaBlock = rootStyle[mediaKey];
|
|
238
|
+
if (mediaBlock && typeof mediaBlock === "object" && mediaBlock[varName]) {
|
|
239
|
+
overrides.push({ mediaName, value: mediaBlock[varName] });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (overrides.length === 0) return nothing;
|
|
244
|
+
|
|
245
|
+
return html`
|
|
246
|
+
<div class="css-var-media-overrides">
|
|
247
|
+
${overrides.map(
|
|
248
|
+
(o) => html`
|
|
249
|
+
<div class="css-var-media-row">
|
|
250
|
+
<span class="css-var-media-label">@${o.mediaName}</span>
|
|
251
|
+
<sp-textfield
|
|
252
|
+
size="s"
|
|
253
|
+
.value=${String(o.value)}
|
|
254
|
+
@change=${(/** @type {any} */ e) => {
|
|
255
|
+
if (!rootStyle[`@${o.mediaName}`]) rootStyle[`@${o.mediaName}`] = {};
|
|
256
|
+
rootStyle[`@${o.mediaName}`][varName] = e.target.value;
|
|
257
|
+
updateSiteConfig({ style: { ...rootStyle } });
|
|
258
|
+
}}
|
|
259
|
+
style="max-width:120px"
|
|
260
|
+
></sp-textfield>
|
|
261
|
+
</div>
|
|
262
|
+
`,
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* @param {string} prefix
|
|
270
|
+
* @param {string} placeholder
|
|
271
|
+
* @param {string} valuePlaceholder
|
|
272
|
+
* @param {Function} addVar
|
|
273
|
+
*/
|
|
274
|
+
function renderAddRow(prefix, placeholder, valuePlaceholder, addVar) {
|
|
275
|
+
/** @type {any} */
|
|
276
|
+
let nameEl = null;
|
|
277
|
+
/** @type {any} */
|
|
278
|
+
let valEl = null;
|
|
279
|
+
|
|
280
|
+
return html`
|
|
281
|
+
<div class="css-var-add-row">
|
|
282
|
+
<sp-textfield
|
|
283
|
+
size="s"
|
|
284
|
+
placeholder=${placeholder}
|
|
285
|
+
${ref((el) => {
|
|
286
|
+
if (el) nameEl = el;
|
|
287
|
+
})}
|
|
288
|
+
></sp-textfield>
|
|
289
|
+
<sp-textfield
|
|
290
|
+
size="s"
|
|
291
|
+
placeholder=${valuePlaceholder}
|
|
292
|
+
${ref((el) => {
|
|
293
|
+
if (el) valEl = el;
|
|
294
|
+
})}
|
|
295
|
+
></sp-textfield>
|
|
296
|
+
<sp-action-button
|
|
297
|
+
size="s"
|
|
298
|
+
@click=${() => {
|
|
299
|
+
if (nameEl && valEl) {
|
|
300
|
+
addVar(prefix, nameEl.value, valEl.value);
|
|
301
|
+
}
|
|
302
|
+
}}
|
|
303
|
+
>Add</sp-action-button
|
|
304
|
+
>
|
|
305
|
+
</div>
|
|
306
|
+
`;
|
|
307
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** General settings section — favicon, platform adapter, and other site-wide config. */
|
|
2
|
+
|
|
3
|
+
import { html, render as litRender, nothing } from "lit-html";
|
|
4
|
+
import { projectState } from "../store.js";
|
|
5
|
+
import { updateSiteConfig } from "../site-context.js";
|
|
6
|
+
import { getPlatform } from "../platform.js";
|
|
7
|
+
import { openFileInTab } from "../files/files.js";
|
|
8
|
+
import { closeSettingsModal } from "./settings-modal.js";
|
|
9
|
+
|
|
10
|
+
/** @param {HTMLElement} container */
|
|
11
|
+
export function renderGeneralSettings(container) {
|
|
12
|
+
const config = projectState.projectConfig || {};
|
|
13
|
+
|
|
14
|
+
const onFaviconUpload = async () => {
|
|
15
|
+
const input = document.createElement("input");
|
|
16
|
+
input.type = "file";
|
|
17
|
+
input.accept = "image/*,.ico,.svg";
|
|
18
|
+
input.onchange = async () => {
|
|
19
|
+
const file = input.files?.[0];
|
|
20
|
+
if (!file) return;
|
|
21
|
+
const platform = getPlatform();
|
|
22
|
+
await platform.uploadFile("public/favicon.ico", file);
|
|
23
|
+
await updateSiteConfig({ favicon: "/favicon.ico" });
|
|
24
|
+
renderGeneralSettings(container);
|
|
25
|
+
};
|
|
26
|
+
input.click();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const onAdapterChange = (/** @type {any} */ e) => {
|
|
30
|
+
updateSiteConfig({ adapter: e.target.value });
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const onEditGlobalStyles = () => {
|
|
34
|
+
closeSettingsModal();
|
|
35
|
+
openFileInTab("project.json");
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const currentFavicon = config.favicon;
|
|
39
|
+
|
|
40
|
+
const tpl = html`
|
|
41
|
+
<div class="settings-section">
|
|
42
|
+
<h3 class="settings-section-title">General</h3>
|
|
43
|
+
|
|
44
|
+
<div class="settings-field">
|
|
45
|
+
<label class="settings-field-label">Favicon</label>
|
|
46
|
+
<p class="settings-field-desc">Upload an image to use as the site favicon.</p>
|
|
47
|
+
<div style="display:flex;align-items:center;gap:12px">
|
|
48
|
+
${currentFavicon
|
|
49
|
+
? html`<img
|
|
50
|
+
src=${currentFavicon}
|
|
51
|
+
alt="Current favicon"
|
|
52
|
+
style="width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:4px;padding:2px"
|
|
53
|
+
/>`
|
|
54
|
+
: html`<div
|
|
55
|
+
style="width:32px;height:32px;border:1px dashed var(--border);border-radius:4px;display:flex;align-items:center;justify-content:center;color:var(--fg-dim);font-size:11px"
|
|
56
|
+
>
|
|
57
|
+
—
|
|
58
|
+
</div>`}
|
|
59
|
+
<sp-action-button size="s" @click=${onFaviconUpload}> Upload Favicon </sp-action-button>
|
|
60
|
+
${currentFavicon
|
|
61
|
+
? html`<span style="font-size:11px;color:var(--fg-dim)">${currentFavicon}</span>`
|
|
62
|
+
: nothing}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div class="settings-field">
|
|
67
|
+
<label class="settings-field-label">Platform Adapter</label>
|
|
68
|
+
<p class="settings-field-desc">Build adapter for deployment target.</p>
|
|
69
|
+
<sp-picker
|
|
70
|
+
size="s"
|
|
71
|
+
label="Platform Adapter"
|
|
72
|
+
.value=${config.adapter || "static"}
|
|
73
|
+
@change=${onAdapterChange}
|
|
74
|
+
>
|
|
75
|
+
<sp-menu-item value="static">Static</sp-menu-item>
|
|
76
|
+
<sp-menu-item value="bun">Bun</sp-menu-item>
|
|
77
|
+
<sp-menu-item value="node">Node</sp-menu-item>
|
|
78
|
+
<sp-menu-item value="cloudflare-workers">Cloudflare Workers</sp-menu-item>
|
|
79
|
+
<sp-menu-item value="cloudflare-pages">Cloudflare Pages</sp-menu-item>
|
|
80
|
+
</sp-picker>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div class="settings-field">
|
|
84
|
+
<label class="settings-field-label">Global Styles</label>
|
|
85
|
+
<p class="settings-field-desc">Edit default element styles that apply across all pages.</p>
|
|
86
|
+
<sp-action-button size="s" @click=${onEditGlobalStyles}>
|
|
87
|
+
Edit Global Styles
|
|
88
|
+
</sp-action-button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
litRender(tpl, container);
|
|
94
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Head editor — structured form for managing $head entries (link, meta, script, style tags) with
|
|
3
|
+
* Monaco editor for script/style bodies.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { html, render as litRender, nothing } from "lit-html";
|
|
7
|
+
import { projectState } from "../store.js";
|
|
8
|
+
import { updateSiteConfig } from "../site-context.js";
|
|
9
|
+
|
|
10
|
+
/** @param {HTMLElement} container */
|
|
11
|
+
export function renderHeadEditor(container) {
|
|
12
|
+
const config = projectState.projectConfig || {};
|
|
13
|
+
const headEntries = config.$head || [];
|
|
14
|
+
|
|
15
|
+
const save = () => {
|
|
16
|
+
updateSiteConfig({ $head: headEntries });
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const addEntry = (/** @type {string} */ tag) => {
|
|
20
|
+
/** @type {any} */
|
|
21
|
+
const entry = { tag };
|
|
22
|
+
if (tag === "link") {
|
|
23
|
+
entry.rel = "stylesheet";
|
|
24
|
+
entry.href = "";
|
|
25
|
+
} else if (tag === "meta") {
|
|
26
|
+
entry.name = "";
|
|
27
|
+
entry.content = "";
|
|
28
|
+
} else if (tag === "script") {
|
|
29
|
+
entry.src = "";
|
|
30
|
+
entry.body = "";
|
|
31
|
+
} else if (tag === "style") {
|
|
32
|
+
entry.body = "";
|
|
33
|
+
}
|
|
34
|
+
headEntries.push(entry);
|
|
35
|
+
save();
|
|
36
|
+
renderHeadEditor(container);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const removeEntry = (/** @type {number} */ idx) => {
|
|
40
|
+
headEntries.splice(idx, 1);
|
|
41
|
+
save();
|
|
42
|
+
renderHeadEditor(container);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const updateEntry = (
|
|
46
|
+
/** @type {number} */ idx,
|
|
47
|
+
/** @type {string} */ key,
|
|
48
|
+
/** @type {string} */ val,
|
|
49
|
+
) => {
|
|
50
|
+
headEntries[idx][key] = val;
|
|
51
|
+
save();
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const tpl = html`
|
|
55
|
+
<div class="settings-section">
|
|
56
|
+
<h3 class="settings-section-title">Head</h3>
|
|
57
|
+
<p class="settings-field-desc">
|
|
58
|
+
Manage global <head> tags — stylesheets, meta tags, scripts, and inline styles.
|
|
59
|
+
</p>
|
|
60
|
+
|
|
61
|
+
<div class="head-entries">
|
|
62
|
+
${headEntries.map(
|
|
63
|
+
(/** @type {any} */ entry, /** @type {number} */ idx) => html`
|
|
64
|
+
<div class="head-entry">
|
|
65
|
+
<div class="head-entry-header">
|
|
66
|
+
<span class="head-entry-tag"><${entry.tag}></span>
|
|
67
|
+
<sp-action-button quiet size="s" @click=${() => removeEntry(idx)}>
|
|
68
|
+
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
69
|
+
</sp-action-button>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="head-entry-fields">${renderEntryFields(entry, idx, updateEntry)}</div>
|
|
72
|
+
</div>
|
|
73
|
+
`,
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div class="head-add-actions" style="margin-top:12px;display:flex;gap:8px">
|
|
78
|
+
<sp-action-button size="s" @click=${() => addEntry("link")}> + Link </sp-action-button>
|
|
79
|
+
<sp-action-button size="s" @click=${() => addEntry("meta")}> + Meta </sp-action-button>
|
|
80
|
+
<sp-action-button size="s" @click=${() => addEntry("script")}> + Script </sp-action-button>
|
|
81
|
+
<sp-action-button size="s" @click=${() => addEntry("style")}> + Style </sp-action-button>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
litRender(tpl, container);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {any} entry
|
|
91
|
+
* @param {number} idx
|
|
92
|
+
* @param {(idx: number, key: string, val: string) => void} updateEntry
|
|
93
|
+
*/
|
|
94
|
+
function renderEntryFields(entry, idx, updateEntry) {
|
|
95
|
+
/** @type {any} */
|
|
96
|
+
let debounce;
|
|
97
|
+
const onFieldChange = (/** @type {string} */ key) => (/** @type {any} */ e) => {
|
|
98
|
+
clearTimeout(debounce);
|
|
99
|
+
debounce = setTimeout(() => {
|
|
100
|
+
updateEntry(idx, key, e.target.value);
|
|
101
|
+
}, 300);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
switch (entry.tag) {
|
|
105
|
+
case "link":
|
|
106
|
+
return html`
|
|
107
|
+
<div class="settings-field-row">
|
|
108
|
+
<sp-textfield
|
|
109
|
+
size="s"
|
|
110
|
+
label="rel"
|
|
111
|
+
.value=${entry.rel || ""}
|
|
112
|
+
@change=${onFieldChange("rel")}
|
|
113
|
+
></sp-textfield>
|
|
114
|
+
<sp-textfield
|
|
115
|
+
size="s"
|
|
116
|
+
label="href"
|
|
117
|
+
.value=${entry.href || ""}
|
|
118
|
+
@change=${onFieldChange("href")}
|
|
119
|
+
style="flex:1"
|
|
120
|
+
></sp-textfield>
|
|
121
|
+
</div>
|
|
122
|
+
`;
|
|
123
|
+
case "meta":
|
|
124
|
+
return html`
|
|
125
|
+
<div class="settings-field-row">
|
|
126
|
+
<sp-textfield
|
|
127
|
+
size="s"
|
|
128
|
+
label="name"
|
|
129
|
+
.value=${entry.name || ""}
|
|
130
|
+
@change=${onFieldChange("name")}
|
|
131
|
+
></sp-textfield>
|
|
132
|
+
<sp-textfield
|
|
133
|
+
size="s"
|
|
134
|
+
label="content"
|
|
135
|
+
.value=${entry.content || ""}
|
|
136
|
+
@change=${onFieldChange("content")}
|
|
137
|
+
style="flex:1"
|
|
138
|
+
></sp-textfield>
|
|
139
|
+
</div>
|
|
140
|
+
`;
|
|
141
|
+
case "script":
|
|
142
|
+
return html`
|
|
143
|
+
<div class="settings-field-row">
|
|
144
|
+
<sp-textfield
|
|
145
|
+
size="s"
|
|
146
|
+
label="src"
|
|
147
|
+
.value=${entry.src || ""}
|
|
148
|
+
@change=${onFieldChange("src")}
|
|
149
|
+
placeholder="URL or leave empty for inline"
|
|
150
|
+
style="flex:1"
|
|
151
|
+
></sp-textfield>
|
|
152
|
+
</div>
|
|
153
|
+
${!entry.src
|
|
154
|
+
? html`
|
|
155
|
+
<div class="head-entry-body">
|
|
156
|
+
<label class="settings-field-label">Script body</label>
|
|
157
|
+
<textarea
|
|
158
|
+
class="head-code-editor"
|
|
159
|
+
.value=${entry.body || ""}
|
|
160
|
+
@input=${onFieldChange("body")}
|
|
161
|
+
rows="6"
|
|
162
|
+
spellcheck="false"
|
|
163
|
+
></textarea>
|
|
164
|
+
</div>
|
|
165
|
+
`
|
|
166
|
+
: nothing}
|
|
167
|
+
`;
|
|
168
|
+
case "style":
|
|
169
|
+
return html`
|
|
170
|
+
<div class="head-entry-body">
|
|
171
|
+
<label class="settings-field-label">Style body</label>
|
|
172
|
+
<textarea
|
|
173
|
+
class="head-code-editor"
|
|
174
|
+
.value=${entry.body || ""}
|
|
175
|
+
@input=${onFieldChange("body")}
|
|
176
|
+
rows="8"
|
|
177
|
+
spellcheck="false"
|
|
178
|
+
></textarea>
|
|
179
|
+
</div>
|
|
180
|
+
`;
|
|
181
|
+
default:
|
|
182
|
+
return nothing;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings modal — site-wide project settings (CSS variables, definitions, content types, head,
|
|
3
|
+
* general). Modeled after VS Code / Obsidian settings panels: left sidebar nav + right content
|
|
4
|
+
* area.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { html, render as litRender } from "lit-html";
|
|
8
|
+
import { ref } from "lit-html/directives/ref.js";
|
|
9
|
+
import { renderDefsEditor } from "./defs-editor.js";
|
|
10
|
+
import { renderContentTypesEditor } from "./content-types-editor.js";
|
|
11
|
+
import { renderCssVarsEditor } from "./css-vars-editor.js";
|
|
12
|
+
import { renderHeadEditor } from "./head-editor.js";
|
|
13
|
+
import { renderGeneralSettings } from "./general-settings.js";
|
|
14
|
+
|
|
15
|
+
/** @type {HTMLElement | null} */
|
|
16
|
+
let _host = null;
|
|
17
|
+
|
|
18
|
+
/** @type {string} */
|
|
19
|
+
let _activeSection = "general";
|
|
20
|
+
|
|
21
|
+
const sections = [
|
|
22
|
+
{ key: "general", label: "General", icon: "sp-icon-properties" },
|
|
23
|
+
{ key: "head", label: "Head", icon: "sp-icon-file-single-web-page" },
|
|
24
|
+
{ key: "cssVars", label: "CSS Variables", icon: "sp-icon-brush" },
|
|
25
|
+
{ key: "definitions", label: "Definitions", icon: "sp-icon-data" },
|
|
26
|
+
{ key: "contentTypes", label: "Content Types", icon: "sp-icon-view-grid" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export function openSettingsModal() {
|
|
30
|
+
if (_host) return;
|
|
31
|
+
|
|
32
|
+
_host = document.createElement("div");
|
|
33
|
+
_host.style.display = "contents";
|
|
34
|
+
const themeRoot = document.querySelector("sp-theme") || document.body;
|
|
35
|
+
themeRoot.appendChild(_host);
|
|
36
|
+
_activeSection = "general";
|
|
37
|
+
renderModal();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function closeSettingsModal() {
|
|
41
|
+
if (!_host) return;
|
|
42
|
+
_host.remove();
|
|
43
|
+
_host = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderModal() {
|
|
47
|
+
if (!_host) return;
|
|
48
|
+
|
|
49
|
+
const onNavClick = (/** @type {string} */ key) => {
|
|
50
|
+
_activeSection = key;
|
|
51
|
+
renderModal();
|
|
52
|
+
renderActiveSection();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const tpl = html`
|
|
56
|
+
<sp-underlay open @close=${closeSettingsModal}></sp-underlay>
|
|
57
|
+
<div
|
|
58
|
+
class="settings-modal"
|
|
59
|
+
@keydown=${(/** @type {KeyboardEvent} */ e) => {
|
|
60
|
+
if (e.key === "Escape") closeSettingsModal();
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
<div class="settings-modal-header">
|
|
64
|
+
<h2 class="settings-modal-title">Settings</h2>
|
|
65
|
+
<sp-action-button quiet size="s" @click=${closeSettingsModal} title="Close">
|
|
66
|
+
<sp-icon-close slot="icon"></sp-icon-close>
|
|
67
|
+
</sp-action-button>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="settings-modal-body">
|
|
70
|
+
<nav class="settings-modal-nav">
|
|
71
|
+
${sections.map(
|
|
72
|
+
(s) => html`
|
|
73
|
+
<button
|
|
74
|
+
class="settings-nav-item${_activeSection === s.key ? " active" : ""}"
|
|
75
|
+
@click=${() => onNavClick(s.key)}
|
|
76
|
+
>
|
|
77
|
+
${s.label}
|
|
78
|
+
</button>
|
|
79
|
+
`,
|
|
80
|
+
)}
|
|
81
|
+
</nav>
|
|
82
|
+
<div
|
|
83
|
+
class="settings-modal-content"
|
|
84
|
+
${ref((el) => {
|
|
85
|
+
if (el) requestAnimationFrame(() => renderActiveSection());
|
|
86
|
+
})}
|
|
87
|
+
></div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
litRender(tpl, _host);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderActiveSection() {
|
|
96
|
+
if (!_host) return;
|
|
97
|
+
const container = _host.querySelector(".settings-modal-content");
|
|
98
|
+
if (!container) return;
|
|
99
|
+
|
|
100
|
+
switch (_activeSection) {
|
|
101
|
+
case "general":
|
|
102
|
+
renderGeneralSettings(/** @type {HTMLElement} */ (container));
|
|
103
|
+
break;
|
|
104
|
+
case "head":
|
|
105
|
+
renderHeadEditor(/** @type {HTMLElement} */ (container));
|
|
106
|
+
break;
|
|
107
|
+
case "cssVars":
|
|
108
|
+
renderCssVarsEditor(/** @type {HTMLElement} */ (container));
|
|
109
|
+
break;
|
|
110
|
+
case "definitions":
|
|
111
|
+
renderDefsEditor(/** @type {HTMLElement} */ (container));
|
|
112
|
+
break;
|
|
113
|
+
case "contentTypes":
|
|
114
|
+
renderContentTypesEditor(/** @type {HTMLElement} */ (container));
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|