@jxsuite/studio 0.1.0 → 0.5.1
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 +50941 -34749
- package/dist/studio.js.map +461 -345
- package/package.json +46 -35
- 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 +133 -0
- package/src/panels/right-panel.js +130 -0
- package/src/panels/shared.js +41 -0
- package/src/panels/signals-panel.js +95 -94
- package/src/panels/statusbar.js +15 -1
- package/src/panels/toolbar.js +223 -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 +112 -41
- package/src/studio.js +1551 -1565
- 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/canvas-media.js +151 -0
- package/src/utils/inherited-style.js +54 -0
- package/src/utils/studio-utils.js +32 -0
- package/src/view.js +68 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Head panel — Page meta, OpenGraph, Google Fonts, and custom `$head` entries.
|
|
3
|
+
*
|
|
4
|
+
* Uses `renderFieldRow()` for consistent indicator-dot fields and `renderMediaPicker()` for image
|
|
5
|
+
* selection (icon, og:image).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { html } from "lit-html";
|
|
9
|
+
import { live } from "lit-html/directives/live.js";
|
|
10
|
+
import { renderFieldRow } from "../ui/field-row.js";
|
|
11
|
+
import { renderMediaPicker } from "../ui/media-picker.js";
|
|
12
|
+
import { debouncedStyleCommit } from "../store.js";
|
|
13
|
+
|
|
14
|
+
// ─── Field definitions ───────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {{
|
|
18
|
+
* label: string;
|
|
19
|
+
* attr: "name" | "property";
|
|
20
|
+
* key: string;
|
|
21
|
+
* multiline?: boolean;
|
|
22
|
+
* media?: boolean;
|
|
23
|
+
* }} MetaField
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** @type {MetaField[]} */
|
|
27
|
+
const PAGE_FIELDS = [
|
|
28
|
+
{ label: "Description", attr: "name", key: "description" },
|
|
29
|
+
{ label: "Viewport", attr: "name", key: "viewport" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** @type {MetaField[]} */
|
|
33
|
+
const OG_FIELDS = [
|
|
34
|
+
{ label: "Title", attr: "property", key: "og:title" },
|
|
35
|
+
{ label: "Description", attr: "property", key: "og:description", multiline: true },
|
|
36
|
+
{ label: "Image", attr: "property", key: "og:image", media: true },
|
|
37
|
+
{ label: "Type", attr: "property", key: "og:type" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** Set of `name`/`property` values managed by the structured forms. */
|
|
41
|
+
const MANAGED_META_KEYS = new Set([...PAGE_FIELDS, ...OG_FIELDS].map((f) => f.key));
|
|
42
|
+
|
|
43
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Find a `$head` meta entry by attribute match.
|
|
47
|
+
*
|
|
48
|
+
* @param {any[]} head
|
|
49
|
+
* @param {"name" | "property"} attr
|
|
50
|
+
* @param {string} key
|
|
51
|
+
* @returns {any | undefined}
|
|
52
|
+
*/
|
|
53
|
+
function findMetaEntry(head, attr, key) {
|
|
54
|
+
if (!head) return undefined;
|
|
55
|
+
return head.find(
|
|
56
|
+
(/** @type {any} */ e) => e?.tagName === "meta" && e?.attributes?.[attr] === key,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Find a `$head` link entry by `rel` attribute.
|
|
62
|
+
*
|
|
63
|
+
* @param {any[]} head
|
|
64
|
+
* @param {string} rel
|
|
65
|
+
* @returns {any | undefined}
|
|
66
|
+
*/
|
|
67
|
+
function findLinkEntry(head, rel) {
|
|
68
|
+
if (!head) return undefined;
|
|
69
|
+
return head.find((/** @type {any} */ e) => e?.tagName === "link" && e?.attributes?.rel === rel);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if a `$head` entry is managed by the structured forms.
|
|
74
|
+
*
|
|
75
|
+
* @param {any} entry
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
function isManagedEntry(entry) {
|
|
79
|
+
if (!entry?.tagName) return false;
|
|
80
|
+
// Managed meta tags
|
|
81
|
+
if (entry.tagName === "meta") {
|
|
82
|
+
const name = entry?.attributes?.name;
|
|
83
|
+
const prop = entry?.attributes?.property;
|
|
84
|
+
return (name && MANAGED_META_KEYS.has(name)) || (prop && MANAGED_META_KEYS.has(prop));
|
|
85
|
+
}
|
|
86
|
+
// Managed link: favicon
|
|
87
|
+
if (entry.tagName === "link" && entry?.attributes?.rel === "icon") return true;
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Upsert or remove a meta entry in `doc.$head`.
|
|
93
|
+
*
|
|
94
|
+
* @param {any} doc
|
|
95
|
+
* @param {"name" | "property"} attr
|
|
96
|
+
* @param {string} key
|
|
97
|
+
* @param {string} content
|
|
98
|
+
*/
|
|
99
|
+
function upsertMeta(doc, attr, key, content) {
|
|
100
|
+
if (!doc.$head) doc.$head = [];
|
|
101
|
+
const idx = doc.$head.findIndex(
|
|
102
|
+
(/** @type {any} */ e) => e?.tagName === "meta" && e?.attributes?.[attr] === key,
|
|
103
|
+
);
|
|
104
|
+
if (content) {
|
|
105
|
+
const entry = { tagName: "meta", attributes: { [attr]: key, content } };
|
|
106
|
+
if (idx >= 0) {
|
|
107
|
+
doc.$head[idx] = entry;
|
|
108
|
+
} else {
|
|
109
|
+
doc.$head.push(entry);
|
|
110
|
+
}
|
|
111
|
+
} else if (idx >= 0) {
|
|
112
|
+
doc.$head.splice(idx, 1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Upsert or remove a link entry in `doc.$head`.
|
|
118
|
+
*
|
|
119
|
+
* @param {any} doc
|
|
120
|
+
* @param {string} rel
|
|
121
|
+
* @param {string} href
|
|
122
|
+
*/
|
|
123
|
+
function upsertLink(doc, rel, href) {
|
|
124
|
+
if (!doc.$head) doc.$head = [];
|
|
125
|
+
const idx = doc.$head.findIndex(
|
|
126
|
+
(/** @type {any} */ e) => e?.tagName === "link" && e?.attributes?.rel === rel,
|
|
127
|
+
);
|
|
128
|
+
if (href) {
|
|
129
|
+
const entry = { tagName: "link", attributes: { rel, href } };
|
|
130
|
+
if (idx >= 0) {
|
|
131
|
+
doc.$head[idx] = entry;
|
|
132
|
+
} else {
|
|
133
|
+
doc.$head.push(entry);
|
|
134
|
+
}
|
|
135
|
+
} else if (idx >= 0) {
|
|
136
|
+
doc.$head.splice(idx, 1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get a display label for an arbitrary $head entry.
|
|
142
|
+
*
|
|
143
|
+
* @param {any} entry
|
|
144
|
+
* @returns {string}
|
|
145
|
+
*/
|
|
146
|
+
function entryLabel(entry) {
|
|
147
|
+
if (!entry?.tagName) return "unknown";
|
|
148
|
+
const a = entry.attributes ?? {};
|
|
149
|
+
if (a.name) return `<meta name="${a.name}">`;
|
|
150
|
+
if (a.property) return `<meta property="${a.property}">`;
|
|
151
|
+
if (a.rel && a.href) return `<link rel="${a.rel}">`;
|
|
152
|
+
if (a.src) return `<script src="${a.src}">`;
|
|
153
|
+
if (a.charset) return `<meta charset="${a.charset}">`;
|
|
154
|
+
return `<${entry.tagName}>`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get a display value for an arbitrary $head entry.
|
|
159
|
+
*
|
|
160
|
+
* @param {any} entry
|
|
161
|
+
* @returns {string}
|
|
162
|
+
*/
|
|
163
|
+
function entryValue(entry) {
|
|
164
|
+
const a = entry?.attributes ?? {};
|
|
165
|
+
return a.content ?? a.href ?? a.src ?? entry?.textContent ?? "";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Google Fonts helpers ────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
const GFONTS_CSS_PREFIX = "https://fonts.googleapis.com/css2?";
|
|
171
|
+
const GFONTS_PRECONNECT_ORIGINS = ["https://fonts.googleapis.com", "https://fonts.gstatic.com"];
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Check if a `$head` entry is a Google Fonts stylesheet link.
|
|
175
|
+
*
|
|
176
|
+
* @param {any} entry
|
|
177
|
+
* @returns {boolean}
|
|
178
|
+
*/
|
|
179
|
+
function isGoogleFontEntry(entry) {
|
|
180
|
+
return (
|
|
181
|
+
entry?.tagName === "link" &&
|
|
182
|
+
entry?.attributes?.rel === "stylesheet" &&
|
|
183
|
+
typeof entry?.attributes?.href === "string" &&
|
|
184
|
+
entry.attributes.href.startsWith(GFONTS_CSS_PREFIX)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Check if a `$head` entry is a Google Fonts preconnect link.
|
|
190
|
+
*
|
|
191
|
+
* @param {any} entry
|
|
192
|
+
* @returns {boolean}
|
|
193
|
+
*/
|
|
194
|
+
function isGoogleFontPreconnect(entry) {
|
|
195
|
+
return (
|
|
196
|
+
entry?.tagName === "link" &&
|
|
197
|
+
entry?.attributes?.rel === "preconnect" &&
|
|
198
|
+
GFONTS_PRECONNECT_ORIGINS.includes(entry?.attributes?.href)
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Extract the font family name from a Google Fonts CSS URL.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} href
|
|
206
|
+
* @returns {string}
|
|
207
|
+
*/
|
|
208
|
+
function extractFontFamily(href) {
|
|
209
|
+
const match = href.match(/family=([^&:]+)/);
|
|
210
|
+
if (!match) return "";
|
|
211
|
+
return decodeURIComponent(match[1].replace(/\+/g, " "));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Build a Google Fonts CSS2 URL for a family name.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} family
|
|
218
|
+
* @returns {string}
|
|
219
|
+
*/
|
|
220
|
+
function buildGoogleFontUrl(family) {
|
|
221
|
+
return `${GFONTS_CSS_PREFIX}family=${encodeURIComponent(family).replace(/%20/g, "+")}&display=swap`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Ensure preconnect links exist in `$head` for Google Fonts.
|
|
226
|
+
*
|
|
227
|
+
* @param {any} doc
|
|
228
|
+
*/
|
|
229
|
+
function ensureGoogleFontPreconnects(doc) {
|
|
230
|
+
if (!doc.$head) doc.$head = [];
|
|
231
|
+
for (const origin of GFONTS_PRECONNECT_ORIGINS) {
|
|
232
|
+
const exists = doc.$head.some(
|
|
233
|
+
(/** @type {any} */ e) =>
|
|
234
|
+
e?.tagName === "link" &&
|
|
235
|
+
e?.attributes?.rel === "preconnect" &&
|
|
236
|
+
e?.attributes?.href === origin,
|
|
237
|
+
);
|
|
238
|
+
if (!exists) {
|
|
239
|
+
/** @type {Record<string, any>} */
|
|
240
|
+
const attrs = { rel: "preconnect", href: origin };
|
|
241
|
+
if (origin === "https://fonts.gstatic.com") attrs.crossorigin = "";
|
|
242
|
+
doc.$head.push({ tagName: "link", attributes: attrs });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Remove preconnect links if no Google Font stylesheets remain.
|
|
249
|
+
*
|
|
250
|
+
* @param {any} doc
|
|
251
|
+
*/
|
|
252
|
+
function cleanupGoogleFontPreconnects(doc) {
|
|
253
|
+
if (!doc.$head) return;
|
|
254
|
+
const hasFont = doc.$head.some((/** @type {any} */ e) => isGoogleFontEntry(e));
|
|
255
|
+
if (!hasFont) {
|
|
256
|
+
doc.$head = doc.$head.filter((/** @type {any} */ e) => !isGoogleFontPreconnect(e));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── Field renderers ─────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Render a meta field row using renderFieldRow.
|
|
264
|
+
*
|
|
265
|
+
* @param {MetaField} field
|
|
266
|
+
* @param {any[]} head
|
|
267
|
+
* @param {(fn: (doc: any) => void) => void} applyMutation
|
|
268
|
+
* @returns {any}
|
|
269
|
+
*/
|
|
270
|
+
function renderMetaFieldRow(field, head, applyMutation) {
|
|
271
|
+
const entry = findMetaEntry(head, field.attr, field.key);
|
|
272
|
+
const val = entry?.attributes?.content ?? "";
|
|
273
|
+
|
|
274
|
+
if (field.media) {
|
|
275
|
+
return renderFieldRow({
|
|
276
|
+
prop: field.key,
|
|
277
|
+
label: field.label,
|
|
278
|
+
hasValue: !!val,
|
|
279
|
+
onClear: () =>
|
|
280
|
+
applyMutation((/** @type {any} */ d) => upsertMeta(d, field.attr, field.key, "")),
|
|
281
|
+
widget: renderMediaPicker(field.key, val, (/** @type {any} */ v) => {
|
|
282
|
+
applyMutation((/** @type {any} */ d) => upsertMeta(d, field.attr, field.key, v || ""));
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const widget = field.multiline
|
|
288
|
+
? html`
|
|
289
|
+
<sp-textfield
|
|
290
|
+
size="s"
|
|
291
|
+
multiline
|
|
292
|
+
.value=${live(val)}
|
|
293
|
+
placeholder="${field.label}…"
|
|
294
|
+
@input=${debouncedStyleCommit(`head:${field.key}`, 400, (/** @type {any} */ e) => {
|
|
295
|
+
const content = e.target.value?.trim() ?? "";
|
|
296
|
+
applyMutation((/** @type {any} */ d) => upsertMeta(d, field.attr, field.key, content));
|
|
297
|
+
})}
|
|
298
|
+
></sp-textfield>
|
|
299
|
+
`
|
|
300
|
+
: html`
|
|
301
|
+
<sp-textfield
|
|
302
|
+
size="s"
|
|
303
|
+
.value=${live(val)}
|
|
304
|
+
placeholder=${field.key === "viewport"
|
|
305
|
+
? "width=device-width, initial-scale=1"
|
|
306
|
+
: `${field.label}…`}
|
|
307
|
+
@input=${debouncedStyleCommit(`head:${field.key}`, 400, (/** @type {any} */ e) => {
|
|
308
|
+
const content = e.target.value?.trim() ?? "";
|
|
309
|
+
applyMutation((/** @type {any} */ d) => upsertMeta(d, field.attr, field.key, content));
|
|
310
|
+
})}
|
|
311
|
+
></sp-textfield>
|
|
312
|
+
`;
|
|
313
|
+
|
|
314
|
+
return renderFieldRow({
|
|
315
|
+
prop: field.key,
|
|
316
|
+
label: field.label,
|
|
317
|
+
hasValue: !!val,
|
|
318
|
+
onClear: () =>
|
|
319
|
+
applyMutation((/** @type {any} */ d) => upsertMeta(d, field.attr, field.key, "")),
|
|
320
|
+
widget,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ─── Template ────────────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* @param {{
|
|
328
|
+
* document: any;
|
|
329
|
+
* applyMutation: (fn: (doc: any) => void) => void;
|
|
330
|
+
* renderLeftPanel: () => void;
|
|
331
|
+
* }} ctx
|
|
332
|
+
* @returns {any}
|
|
333
|
+
*/
|
|
334
|
+
export function renderHeadTemplate({ document: doc, applyMutation, renderLeftPanel }) {
|
|
335
|
+
const head = doc.$head ?? [];
|
|
336
|
+
const title = doc.title ?? "";
|
|
337
|
+
|
|
338
|
+
// Icon (favicon) link
|
|
339
|
+
const iconEntry = findLinkEntry(head, "icon");
|
|
340
|
+
const iconHref = iconEntry?.attributes?.href ?? "";
|
|
341
|
+
|
|
342
|
+
// Custom entries not managed by structured forms, fonts, or preconnects
|
|
343
|
+
const customEntries = head.filter(
|
|
344
|
+
(/** @type {any} */ e) =>
|
|
345
|
+
!isManagedEntry(e) && !isGoogleFontEntry(e) && !isGoogleFontPreconnect(e),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
// Google Font entries
|
|
349
|
+
const fontEntries = head.filter((/** @type {any} */ e) => isGoogleFontEntry(e));
|
|
350
|
+
|
|
351
|
+
return html`
|
|
352
|
+
<div class="imports-panel">
|
|
353
|
+
<!-- Page section -->
|
|
354
|
+
<div class="imports-section">
|
|
355
|
+
<div class="imports-section-header">
|
|
356
|
+
<span class="imports-section-title">Page</span>
|
|
357
|
+
</div>
|
|
358
|
+
<div class="head-section-body">
|
|
359
|
+
${renderFieldRow({
|
|
360
|
+
prop: "title",
|
|
361
|
+
label: "Title",
|
|
362
|
+
hasValue: !!title,
|
|
363
|
+
onClear: () =>
|
|
364
|
+
applyMutation((/** @type {any} */ d) => {
|
|
365
|
+
delete d.title;
|
|
366
|
+
}),
|
|
367
|
+
widget: html`
|
|
368
|
+
<sp-textfield
|
|
369
|
+
size="s"
|
|
370
|
+
.value=${live(title)}
|
|
371
|
+
placeholder="Page title…"
|
|
372
|
+
@input=${debouncedStyleCommit("head:title", 400, (/** @type {any} */ e) => {
|
|
373
|
+
const val = e.target.value?.trim() ?? "";
|
|
374
|
+
applyMutation((/** @type {any} */ d) => {
|
|
375
|
+
if (val) d.title = val;
|
|
376
|
+
else delete d.title;
|
|
377
|
+
});
|
|
378
|
+
})}
|
|
379
|
+
></sp-textfield>
|
|
380
|
+
`,
|
|
381
|
+
})}
|
|
382
|
+
${PAGE_FIELDS.map((field) => renderMetaFieldRow(field, head, applyMutation))}
|
|
383
|
+
${renderFieldRow({
|
|
384
|
+
prop: "icon",
|
|
385
|
+
label: "Icon",
|
|
386
|
+
hasValue: !!iconHref,
|
|
387
|
+
onClear: () => applyMutation((/** @type {any} */ d) => upsertLink(d, "icon", "")),
|
|
388
|
+
widget: renderMediaPicker("icon", iconHref, (/** @type {any} */ v) => {
|
|
389
|
+
applyMutation((/** @type {any} */ d) => upsertLink(d, "icon", v || ""));
|
|
390
|
+
}),
|
|
391
|
+
})}
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
<!-- OpenGraph section -->
|
|
396
|
+
<div class="imports-section">
|
|
397
|
+
<div class="imports-section-header">
|
|
398
|
+
<span class="imports-section-title">OpenGraph</span>
|
|
399
|
+
</div>
|
|
400
|
+
<div class="head-section-body">
|
|
401
|
+
${OG_FIELDS.map((field) => renderMetaFieldRow(field, head, applyMutation))}
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
|
|
405
|
+
<!-- Google Fonts -->
|
|
406
|
+
<div class="imports-section">
|
|
407
|
+
<div class="imports-section-header">
|
|
408
|
+
<span class="imports-section-title">Google Fonts</span>
|
|
409
|
+
<span class="imports-count">${fontEntries.length}</span>
|
|
410
|
+
</div>
|
|
411
|
+
${fontEntries.length > 0
|
|
412
|
+
? html`
|
|
413
|
+
<div class="imports-list">
|
|
414
|
+
${fontEntries.map((/** @type {any} */ entry) => {
|
|
415
|
+
const family = extractFontFamily(entry.attributes.href);
|
|
416
|
+
return html`
|
|
417
|
+
<div class="import-row">
|
|
418
|
+
<span class="import-name">${family}</span>
|
|
419
|
+
<sp-action-button
|
|
420
|
+
quiet
|
|
421
|
+
size="xs"
|
|
422
|
+
title="Remove"
|
|
423
|
+
@click=${() => {
|
|
424
|
+
applyMutation((/** @type {any} */ d) => {
|
|
425
|
+
if (!d.$head) return;
|
|
426
|
+
d.$head = d.$head.filter((/** @type {any} */ e) => e !== entry);
|
|
427
|
+
cleanupGoogleFontPreconnects(d);
|
|
428
|
+
});
|
|
429
|
+
renderLeftPanel();
|
|
430
|
+
}}
|
|
431
|
+
>
|
|
432
|
+
<sp-icon-close slot="icon" size="xs"></sp-icon-close>
|
|
433
|
+
</sp-action-button>
|
|
434
|
+
</div>
|
|
435
|
+
`;
|
|
436
|
+
})}
|
|
437
|
+
</div>
|
|
438
|
+
`
|
|
439
|
+
: html`<div class="imports-empty">No fonts imported</div>`}
|
|
440
|
+
<div class="head-add-form">
|
|
441
|
+
<sp-textfield
|
|
442
|
+
placeholder="Font family name…"
|
|
443
|
+
size="s"
|
|
444
|
+
style="flex:1"
|
|
445
|
+
@keydown=${(/** @type {any} */ e) => {
|
|
446
|
+
if (e.key !== "Enter") return;
|
|
447
|
+
const family = e.target.value?.trim();
|
|
448
|
+
if (!family) return;
|
|
449
|
+
e.target.value = "";
|
|
450
|
+
applyMutation((/** @type {any} */ d) => {
|
|
451
|
+
if (!d.$head) d.$head = [];
|
|
452
|
+
ensureGoogleFontPreconnects(d);
|
|
453
|
+
d.$head.push({
|
|
454
|
+
tagName: "link",
|
|
455
|
+
attributes: { rel: "stylesheet", href: buildGoogleFontUrl(family) },
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
renderLeftPanel();
|
|
459
|
+
}}
|
|
460
|
+
></sp-textfield>
|
|
461
|
+
<sp-action-button
|
|
462
|
+
quiet
|
|
463
|
+
size="xs"
|
|
464
|
+
title="Add font"
|
|
465
|
+
@click=${(/** @type {any} */ e) => {
|
|
466
|
+
const input = e.target.closest(".head-add-form")?.querySelector("sp-textfield");
|
|
467
|
+
const family = input?.value?.trim();
|
|
468
|
+
if (!family) return;
|
|
469
|
+
input.value = "";
|
|
470
|
+
applyMutation((/** @type {any} */ d) => {
|
|
471
|
+
if (!d.$head) d.$head = [];
|
|
472
|
+
ensureGoogleFontPreconnects(d);
|
|
473
|
+
d.$head.push({
|
|
474
|
+
tagName: "link",
|
|
475
|
+
attributes: { rel: "stylesheet", href: buildGoogleFontUrl(family) },
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
renderLeftPanel();
|
|
479
|
+
}}
|
|
480
|
+
>
|
|
481
|
+
<sp-icon-add slot="icon" size="xs"></sp-icon-add>
|
|
482
|
+
</sp-action-button>
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
|
|
486
|
+
<!-- Custom $head entries -->
|
|
487
|
+
<div class="imports-section">
|
|
488
|
+
<div class="imports-section-header">
|
|
489
|
+
<span class="imports-section-title">Custom Tags</span>
|
|
490
|
+
<span class="imports-count">${customEntries.length}</span>
|
|
491
|
+
</div>
|
|
492
|
+
${customEntries.length > 0
|
|
493
|
+
? html`
|
|
494
|
+
<div class="imports-list">
|
|
495
|
+
${customEntries.map((/** @type {any} */ entry) => {
|
|
496
|
+
const label = entryLabel(entry);
|
|
497
|
+
const value = entryValue(entry);
|
|
498
|
+
return html`
|
|
499
|
+
<div class="import-row">
|
|
500
|
+
<span class="import-name" title=${value}>${label}</span>
|
|
501
|
+
<span class="import-path">${value}</span>
|
|
502
|
+
<sp-action-button
|
|
503
|
+
quiet
|
|
504
|
+
size="xs"
|
|
505
|
+
title="Remove"
|
|
506
|
+
@click=${() => {
|
|
507
|
+
applyMutation((/** @type {any} */ d) => {
|
|
508
|
+
if (!d.$head) return;
|
|
509
|
+
const idx = d.$head.indexOf(entry);
|
|
510
|
+
if (idx >= 0) d.$head.splice(idx, 1);
|
|
511
|
+
});
|
|
512
|
+
renderLeftPanel();
|
|
513
|
+
}}
|
|
514
|
+
>
|
|
515
|
+
<sp-icon-close slot="icon" size="xs"></sp-icon-close>
|
|
516
|
+
</sp-action-button>
|
|
517
|
+
</div>
|
|
518
|
+
`;
|
|
519
|
+
})}
|
|
520
|
+
</div>
|
|
521
|
+
`
|
|
522
|
+
: html`<div class="imports-empty">No custom tags</div>`}
|
|
523
|
+
|
|
524
|
+
<!-- Add custom tag form -->
|
|
525
|
+
<div class="head-add-form">
|
|
526
|
+
<sp-picker size="s" label="Tag" class="head-add-tag" value="meta">
|
|
527
|
+
<sp-menu-item value="meta">meta</sp-menu-item>
|
|
528
|
+
<sp-menu-item value="link">link</sp-menu-item>
|
|
529
|
+
<sp-menu-item value="script">script</sp-menu-item>
|
|
530
|
+
</sp-picker>
|
|
531
|
+
<sp-textfield
|
|
532
|
+
placeholder="Attribute (e.g. name)"
|
|
533
|
+
size="s"
|
|
534
|
+
class="head-add-attr"
|
|
535
|
+
></sp-textfield>
|
|
536
|
+
<sp-textfield placeholder="Value" size="s" class="head-add-val"></sp-textfield>
|
|
537
|
+
<sp-action-button
|
|
538
|
+
quiet
|
|
539
|
+
size="xs"
|
|
540
|
+
title="Add tag"
|
|
541
|
+
@click=${(/** @type {any} */ e) => {
|
|
542
|
+
const form = e.target.closest(".head-add-form");
|
|
543
|
+
const tagPicker = form?.querySelector(".head-add-tag");
|
|
544
|
+
const attrField = form?.querySelector(".head-add-attr");
|
|
545
|
+
const valField = form?.querySelector(".head-add-val");
|
|
546
|
+
const tagName = tagPicker?.value || "meta";
|
|
547
|
+
const attrKey = attrField?.value?.trim();
|
|
548
|
+
const attrVal = valField?.value?.trim();
|
|
549
|
+
if (!attrKey || !attrVal) return;
|
|
550
|
+
attrField.value = "";
|
|
551
|
+
valField.value = "";
|
|
552
|
+
|
|
553
|
+
/** @type {Record<string, any>} */
|
|
554
|
+
const entry = { tagName, attributes: {} };
|
|
555
|
+
if (tagName === "meta") {
|
|
556
|
+
entry.attributes = { name: attrKey, content: attrVal };
|
|
557
|
+
} else if (tagName === "link") {
|
|
558
|
+
entry.attributes = { rel: attrKey, href: attrVal };
|
|
559
|
+
} else if (tagName === "script") {
|
|
560
|
+
entry.attributes = { [attrKey]: attrVal };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
applyMutation((/** @type {any} */ d) => {
|
|
564
|
+
if (!d.$head) d.$head = [];
|
|
565
|
+
d.$head.push(entry);
|
|
566
|
+
});
|
|
567
|
+
renderLeftPanel();
|
|
568
|
+
}}
|
|
569
|
+
>
|
|
570
|
+
<sp-icon-add slot="icon" size="xs"></sp-icon-add>
|
|
571
|
+
</sp-action-button>
|
|
572
|
+
</div>
|
|
573
|
+
</div>
|
|
574
|
+
</div>
|
|
575
|
+
`;
|
|
576
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlays panel — renders hover/selection overlay boxes on canvas panels. Delegates block action
|
|
3
|
+
* bar rendering to studio.js via ctx callback.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { html, render as litRender, nothing } from "lit-html";
|
|
7
|
+
import { getState, canvasPanels, pathsEqual, subscribe } from "../store.js";
|
|
8
|
+
import { view } from "../view.js";
|
|
9
|
+
|
|
10
|
+
/** @type {any} */
|
|
11
|
+
let _ctx = null;
|
|
12
|
+
|
|
13
|
+
/** @type {(() => void) | null} */
|
|
14
|
+
let _unsub = null;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Mount the overlays panel.
|
|
18
|
+
*
|
|
19
|
+
* @param {any} ctx — { effectiveZoom, getCanvasMode, isEditing, renderBlockActionBar,
|
|
20
|
+
* findCanvasElement, getActivePanel }
|
|
21
|
+
*/
|
|
22
|
+
export function mount(ctx) {
|
|
23
|
+
_ctx = ctx;
|
|
24
|
+
_unsub = subscribe((change) => {
|
|
25
|
+
if (change.selection || change.hover || change.mode || change.ui || change.doc) render();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function unmount() {
|
|
30
|
+
_unsub?.();
|
|
31
|
+
_unsub = null;
|
|
32
|
+
_ctx = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {any} el
|
|
37
|
+
* @param {any} type
|
|
38
|
+
* @param {any} panel
|
|
39
|
+
*/
|
|
40
|
+
function overlayBoxDescriptor(el, type, panel) {
|
|
41
|
+
const vpRect = panel.viewport.getBoundingClientRect();
|
|
42
|
+
const elRect = el.getBoundingClientRect();
|
|
43
|
+
const scale = _ctx.effectiveZoom();
|
|
44
|
+
return {
|
|
45
|
+
cls: `overlay-box overlay-${type}`,
|
|
46
|
+
top: `${(elRect.top - vpRect.top + panel.viewport.scrollTop) / scale}px`,
|
|
47
|
+
left: `${(elRect.left - vpRect.left + panel.viewport.scrollLeft) / scale}px`,
|
|
48
|
+
width: `${elRect.width / scale}px`,
|
|
49
|
+
height: `${elRect.height / scale}px`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function render() {
|
|
54
|
+
if (!_ctx) return;
|
|
55
|
+
const S = getState();
|
|
56
|
+
const canvasMode = _ctx.getCanvasMode();
|
|
57
|
+
|
|
58
|
+
if (canvasMode !== "design" && canvasMode !== "edit" && canvasMode !== "settings") {
|
|
59
|
+
for (const p of canvasPanels) {
|
|
60
|
+
litRender(nothing, p.overlay);
|
|
61
|
+
p.overlayClk.style.pointerEvents = "none";
|
|
62
|
+
}
|
|
63
|
+
if (view.selDragCleanup) {
|
|
64
|
+
view.selDragCleanup();
|
|
65
|
+
view.selDragCleanup = null;
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (canvasMode === "settings") {
|
|
71
|
+
const enable = S.ui.stylebookTab === "elements";
|
|
72
|
+
for (const p of canvasPanels) {
|
|
73
|
+
p.overlayClk.style.pointerEvents = enable ? "" : "none";
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const p of canvasPanels) {
|
|
79
|
+
p.overlayClk.style.pointerEvents = view.componentInlineEdit || _ctx.isEditing() ? "none" : "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (view.selDragCleanup) {
|
|
83
|
+
view.selDragCleanup();
|
|
84
|
+
view.selDragCleanup = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const p of canvasPanels) {
|
|
88
|
+
/**
|
|
89
|
+
* @type {{
|
|
90
|
+
* cls: string;
|
|
91
|
+
* top: string;
|
|
92
|
+
* left: string;
|
|
93
|
+
* width: string;
|
|
94
|
+
* height: string;
|
|
95
|
+
* border?: string;
|
|
96
|
+
* }[]}
|
|
97
|
+
*/
|
|
98
|
+
const boxes = [];
|
|
99
|
+
|
|
100
|
+
if (S.hover && !pathsEqual(S.hover, S.selection)) {
|
|
101
|
+
const el = _ctx.findCanvasElement(S.hover, p.canvas);
|
|
102
|
+
if (el) boxes.push(overlayBoxDescriptor(el, "hover", p));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (S.selection && p === _ctx.getActivePanel()) {
|
|
106
|
+
const el = _ctx.findCanvasElement(S.selection, p.canvas);
|
|
107
|
+
if (el) {
|
|
108
|
+
const desc = overlayBoxDescriptor(el, "selection", p);
|
|
109
|
+
if (view.componentInlineEdit || _ctx.isEditing()) /** @type {any} */ (desc).border = "none";
|
|
110
|
+
boxes.push(desc);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
litRender(
|
|
115
|
+
html`
|
|
116
|
+
${p.dropLine}
|
|
117
|
+
${boxes.map(
|
|
118
|
+
(b) => html`
|
|
119
|
+
<div
|
|
120
|
+
class=${b.cls}
|
|
121
|
+
style="top:${b.top};left:${b.left};width:${b.width};height:${b.height}${b.border
|
|
122
|
+
? `;border:${b.border}`
|
|
123
|
+
: ""}"
|
|
124
|
+
></div>
|
|
125
|
+
`,
|
|
126
|
+
)}
|
|
127
|
+
`,
|
|
128
|
+
p.overlay,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_ctx.renderBlockActionBar();
|
|
133
|
+
}
|