@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.
Files changed (40) hide show
  1. package/dist/studio.js +50941 -34749
  2. package/dist/studio.js.map +461 -345
  3. package/package.json +46 -35
  4. package/src/browse/browse.js +414 -0
  5. package/src/editor/context-menu.js +48 -1
  6. package/src/editor/convert-to-component.js +208 -0
  7. package/src/editor/inline-edit.js +33 -6
  8. package/src/editor/shortcuts.js +6 -1
  9. package/src/files/components.js +4 -2
  10. package/src/files/file-ops.js +102 -54
  11. package/src/files/files.js +22 -8
  12. package/src/markdown/md-convert.js +309 -11
  13. package/src/panels/activity-bar.js +3 -0
  14. package/src/panels/head-panel.js +576 -0
  15. package/src/panels/overlays.js +133 -0
  16. package/src/panels/right-panel.js +130 -0
  17. package/src/panels/shared.js +41 -0
  18. package/src/panels/signals-panel.js +95 -94
  19. package/src/panels/statusbar.js +15 -1
  20. package/src/panels/toolbar.js +223 -0
  21. package/src/platforms/devserver.js +58 -16
  22. package/src/settings/collections-editor.js +428 -0
  23. package/src/settings/defs-editor.js +418 -0
  24. package/src/settings/schema-field-ui.js +329 -0
  25. package/src/state.js +99 -2
  26. package/src/store.js +112 -41
  27. package/src/studio.js +1551 -1565
  28. package/src/ui/button-group.js +91 -0
  29. package/src/ui/color-selector.js +299 -0
  30. package/src/ui/field-row.js +47 -0
  31. package/src/ui/media-picker.js +172 -0
  32. package/src/ui/panel-resize.js +96 -0
  33. package/src/ui/spectrum.js +36 -2
  34. package/src/ui/unit-selector.js +106 -0
  35. package/src/ui/{jx-styled-combobox.js → value-selector.js} +7 -7
  36. package/src/ui/widgets.js +106 -0
  37. package/src/utils/canvas-media.js +151 -0
  38. package/src/utils/inherited-style.js +54 -0
  39. package/src/utils/studio-utils.js +32 -0
  40. 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
+ }