@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,96 @@
1
+ /**
2
+ * Panel-resize.js — Draggable resize handles for left and right sidebars.
3
+ *
4
+ * Self-initializing module. Import it and the resize handles become interactive. Persists widths to
5
+ * localStorage so they survive page reloads.
6
+ */
7
+
8
+ const STORAGE_KEY = "jx-studio-panel-widths";
9
+ const MIN_WIDTH = 160;
10
+ const MAX_RATIO = 0.5; // max 50% of viewport
11
+ const DEFAULT_LEFT = 240;
12
+ const DEFAULT_RIGHT = 280;
13
+
14
+ const root = document.documentElement;
15
+
16
+ // ─── Restore saved widths ────────────────────────────────────────────────────
17
+
18
+ try {
19
+ const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
20
+ if (saved.left) root.style.setProperty("--panel-w-left", `${saved.left}px`);
21
+ if (saved.right) root.style.setProperty("--panel-w-right", `${saved.right}px`);
22
+ } catch {
23
+ // ignore
24
+ }
25
+
26
+ // ─── Setup handles ───────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * @param {HTMLElement} handle
30
+ * @param {string} cssVar
31
+ * @param {"left" | "right"} side
32
+ * @param {number} defaultWidth
33
+ */
34
+ function setupHandle(handle, cssVar, side, defaultWidth) {
35
+ /** @type {{ startX: number; startWidth: number } | null} */
36
+ let drag = null;
37
+
38
+ handle.addEventListener("pointerdown", (e) => {
39
+ e.preventDefault();
40
+ try {
41
+ handle.setPointerCapture(e.pointerId);
42
+ } catch {
43
+ /* synthetic events */
44
+ }
45
+ handle.classList.add("dragging");
46
+ document.body.style.userSelect = "none";
47
+
48
+ const current = parseInt(getComputedStyle(root).getPropertyValue(cssVar)) || defaultWidth;
49
+ drag = { startX: e.clientX, startWidth: current };
50
+ });
51
+
52
+ handle.addEventListener("pointermove", (e) => {
53
+ if (!drag) return;
54
+ const delta = side === "left" ? e.clientX - drag.startX : drag.startX - e.clientX;
55
+ const maxWidth = window.innerWidth * MAX_RATIO;
56
+ const newWidth = Math.round(Math.min(maxWidth, Math.max(MIN_WIDTH, drag.startWidth + delta)));
57
+ root.style.setProperty(cssVar, `${newWidth}px`);
58
+ });
59
+
60
+ handle.addEventListener("pointerup", (e) => {
61
+ if (!drag) return;
62
+ drag = null;
63
+ try {
64
+ handle.releasePointerCapture(e.pointerId);
65
+ } catch {
66
+ /* synthetic events */
67
+ }
68
+ handle.classList.remove("dragging");
69
+ document.body.style.userSelect = "";
70
+ persistWidths();
71
+ });
72
+
73
+ handle.addEventListener("dblclick", () => {
74
+ root.style.setProperty(cssVar, `${defaultWidth}px`);
75
+ persistWidths();
76
+ });
77
+ }
78
+
79
+ function persistWidths() {
80
+ const left = parseInt(getComputedStyle(root).getPropertyValue("--panel-w-left")) || DEFAULT_LEFT;
81
+ const right =
82
+ parseInt(getComputedStyle(root).getPropertyValue("--panel-w-right")) || DEFAULT_RIGHT;
83
+ try {
84
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ left, right }));
85
+ } catch {
86
+ // storage full or unavailable
87
+ }
88
+ }
89
+
90
+ // ─── Initialize ──────────────────────────────────────────────────────────────
91
+
92
+ const resizeLeft = document.getElementById("resize-left");
93
+ const resizeRight = document.getElementById("resize-right");
94
+
95
+ if (resizeLeft) setupHandle(resizeLeft, "--panel-w-left", "left", DEFAULT_LEFT);
96
+ if (resizeRight) setupHandle(resizeRight, "--panel-w-right", "right", DEFAULT_RIGHT);
@@ -37,10 +37,22 @@ import { Switch as SpSwitch } from "@spectrum-web-components/switch/src/Switch.j
37
37
  import { Divider } from "@spectrum-web-components/divider/src/Divider.js";
38
38
  import { Tooltip } from "@spectrum-web-components/tooltip/src/Tooltip.js";
39
39
  import { Overlay } from "@spectrum-web-components/overlay/src/Overlay.js";
40
+ import { OverlayTrigger } from "@spectrum-web-components/overlay/src/OverlayTrigger.js";
41
+ import { Dialog } from "@spectrum-web-components/dialog/src/Dialog.js";
42
+ import { DialogWrapper } from "@spectrum-web-components/dialog/src/DialogWrapper.js";
43
+ import { HelpText } from "@spectrum-web-components/help-text/src/HelpText.js";
44
+ import { Button } from "@spectrum-web-components/button/src/Button.js";
45
+ import { Underlay } from "@spectrum-web-components/underlay/src/Underlay.js";
40
46
  import { PickerButton } from "@spectrum-web-components/picker-button/src/PickerButton.js";
41
47
  import { Accordion } from "@spectrum-web-components/accordion/src/Accordion.js";
42
48
  import { AccordionItem } from "@spectrum-web-components/accordion/src/AccordionItem.js";
43
49
  import { ActionBar } from "@spectrum-web-components/action-bar/src/ActionBar.js";
50
+ import { Table } from "@spectrum-web-components/table/src/Table.js";
51
+ import { TableHead } from "@spectrum-web-components/table/src/TableHead.js";
52
+ import { TableHeadCell } from "@spectrum-web-components/table/src/TableHeadCell.js";
53
+ import { TableBody } from "@spectrum-web-components/table/src/TableBody.js";
54
+ import { TableRow } from "@spectrum-web-components/table/src/TableRow.js";
55
+ import { TableCell } from "@spectrum-web-components/table/src/TableCell.js";
44
56
 
45
57
  // Icons
46
58
  import { IconFolder } from "@spectrum-web-components/icons-workflow/src/elements/IconFolder.js";
@@ -49,8 +61,10 @@ import { IconDocument } from "@spectrum-web-components/icons-workflow/src/elemen
49
61
  import { IconFileCode } from "@spectrum-web-components/icons-workflow/src/elements/IconFileCode.js";
50
62
  import { IconFileTxt } from "@spectrum-web-components/icons-workflow/src/elements/IconFileTxt.js";
51
63
  import { IconImage } from "@spectrum-web-components/icons-workflow/src/elements/IconImage.js";
64
+ import { IconFileSingleWebPage } from "@spectrum-web-components/icons-workflow/src/elements/IconFileSingleWebPage.js";
52
65
  import { IconRefresh } from "@spectrum-web-components/icons-workflow/src/elements/IconRefresh.js";
53
66
  import { IconAdd } from "@spectrum-web-components/icons-workflow/src/elements/IconAdd.js";
67
+ import { IconUpload } from "@spectrum-web-components/icons-workflow/src/elements/IconUpload.js";
54
68
  import { IconLayers } from "@spectrum-web-components/icons-workflow/src/elements/IconLayers.js";
55
69
  import { IconViewGrid } from "@spectrum-web-components/icons-workflow/src/elements/IconViewGrid.js";
56
70
  import { IconBrackets } from "@spectrum-web-components/icons-workflow/src/elements/IconBrackets.js";
@@ -69,6 +83,7 @@ import { IconExport } from "@spectrum-web-components/icons-workflow/src/elements
69
83
  import { IconPreview } from "@spectrum-web-components/icons-workflow/src/elements/IconPreview.js";
70
84
  import { IconCode } from "@spectrum-web-components/icons-workflow/src/elements/IconCode.js";
71
85
  import { IconBrush } from "@spectrum-web-components/icons-workflow/src/elements/IconBrush.js";
86
+ import { IconGears } from "@spectrum-web-components/icons-workflow/src/elements/IconGears.js";
72
87
  import { IconBack } from "@spectrum-web-components/icons-workflow/src/elements/IconBack.js";
73
88
  import { IconProperties } from "@spectrum-web-components/icons-workflow/src/elements/IconProperties.js";
74
89
  import { IconEvent } from "@spectrum-web-components/icons-workflow/src/elements/IconEvent.js";
@@ -103,6 +118,7 @@ import { IconBox } from "@spectrum-web-components/icons-workflow/src/elements/Ic
103
118
  import { IconVisibility } from "@spectrum-web-components/icons-workflow/src/elements/IconVisibility.js";
104
119
  import { IconVisibilityOff } from "@spectrum-web-components/icons-workflow/src/elements/IconVisibilityOff.js";
105
120
  import { IconArtboard } from "@spectrum-web-components/icons-workflow/src/elements/IconArtboard.js";
121
+ import { IconViewList } from "@spectrum-web-components/icons-workflow/src/elements/IconViewList.js";
106
122
 
107
123
  // Inline formatting icons
108
124
  import { IconTextBold } from "@spectrum-web-components/icons-workflow/src/elements/IconTextBold.js";
@@ -114,7 +130,8 @@ import { IconTextSubscript } from "@spectrum-web-components/icons-workflow/src/e
114
130
  import { IconLink } from "@spectrum-web-components/icons-workflow/src/elements/IconLink.js";
115
131
 
116
132
  // Custom studio components
117
- import { JxStyledCombobox } from "./jx-styled-combobox.js";
133
+ import { JxValueSelector } from "./value-selector.js";
134
+ import { JxColorPopover } from "./color-selector.js";
118
135
 
119
136
  // UI icons (used internally by Spectrum components like accordion, picker, combobox)
120
137
  import { IconChevron100 } from "@spectrum-web-components/icons-ui/src/elements/IconChevron100.js";
@@ -151,18 +168,32 @@ const components = [
151
168
  ["sp-divider", Divider],
152
169
  ["sp-tooltip", Tooltip],
153
170
  ["sp-overlay", Overlay],
171
+ ["overlay-trigger", OverlayTrigger],
172
+ ["sp-dialog", Dialog],
173
+ ["sp-dialog-wrapper", DialogWrapper],
174
+ ["sp-help-text", HelpText],
175
+ ["sp-button", Button],
176
+ ["sp-underlay", Underlay],
154
177
  ["sp-picker-button", PickerButton],
155
178
  ["sp-accordion", Accordion],
156
179
  ["sp-accordion-item", AccordionItem],
157
180
  ["sp-action-bar", ActionBar],
181
+ ["sp-table", Table],
182
+ ["sp-table-head", TableHead],
183
+ ["sp-table-head-cell", TableHeadCell],
184
+ ["sp-table-body", TableBody],
185
+ ["sp-table-row", TableRow],
186
+ ["sp-table-cell", TableCell],
158
187
  ["sp-icon-folder", IconFolder],
159
188
  ["sp-icon-folder-open", IconFolderOpen],
160
189
  ["sp-icon-document", IconDocument],
161
190
  ["sp-icon-file-code", IconFileCode],
162
191
  ["sp-icon-file-txt", IconFileTxt],
163
192
  ["sp-icon-image", IconImage],
193
+ ["sp-icon-file-single-web-page", IconFileSingleWebPage],
164
194
  ["sp-icon-refresh", IconRefresh],
165
195
  ["sp-icon-add", IconAdd],
196
+ ["sp-icon-upload", IconUpload],
166
197
  ["sp-icon-layers", IconLayers],
167
198
  ["sp-icon-view-grid", IconViewGrid],
168
199
  ["sp-icon-brackets", IconBrackets],
@@ -181,6 +212,7 @@ const components = [
181
212
  ["sp-icon-preview", IconPreview],
182
213
  ["sp-icon-code", IconCode],
183
214
  ["sp-icon-brush", IconBrush],
215
+ ["sp-icon-gears", IconGears],
184
216
  ["sp-icon-back", IconBack],
185
217
  ["sp-icon-properties", IconProperties],
186
218
  ["sp-icon-event", IconEvent],
@@ -213,6 +245,7 @@ const components = [
213
245
  ["sp-icon-visibility", IconVisibility],
214
246
  ["sp-icon-visibility-off", IconVisibilityOff],
215
247
  ["sp-icon-artboard", IconArtboard],
248
+ ["sp-icon-view-list", IconViewList],
216
249
  ["sp-icon-text-bold", IconTextBold],
217
250
  ["sp-icon-text-italic", IconTextItalic],
218
251
  ["sp-icon-text-underline", IconTextUnderline],
@@ -223,7 +256,8 @@ const components = [
223
256
  // UI icons (internal component chrome)
224
257
  ["sp-icon-chevron100", IconChevron100],
225
258
  // Custom studio components
226
- ["jx-styled-combobox", JxStyledCombobox],
259
+ ["jx-value-selector", JxValueSelector],
260
+ ["jx-color-popover", JxColorPopover],
227
261
  ];
228
262
 
229
263
  for (const [tag, ctor] of /** @type {[string, CustomElementConstructor][]} */ (components)) {
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Unit-selector.js — Number + unit picker widget.
3
+ *
4
+ * Renders a text field for numeric input paired with a unit picker dropdown. Handles keywords
5
+ * (auto, inherit, etc.) alongside numeric+unit values.
6
+ */
7
+
8
+ import { html, nothing } from "lit-html";
9
+ import { live } from "lit-html/directives/live.js";
10
+ import { classMap } from "lit-html/directives/class-map.js";
11
+ import { debouncedStyleCommit } from "../store.js";
12
+
13
+ export const UNIT_RE = /^(-?[\d.]+)(px|rem|em|%|vw|vh|svw|svh|dvh|ms|s|fr|ch|ex|deg)?$/;
14
+
15
+ /**
16
+ * Render a number + unit selector widget.
17
+ *
18
+ * @param {any} entry — css-meta entry with $units and $keywords arrays
19
+ * @param {string} prop — property key (for debounce namespace)
20
+ * @param {any} value — current value (e.g. "12px", "auto", "")
21
+ * @param {(val: string) => void} onChange — commit callback
22
+ * @returns {any}
23
+ */
24
+ export function renderUnitSelector(
25
+ /** @type {any} */ entry,
26
+ /** @type {any} */ prop,
27
+ /** @type {any} */ value,
28
+ /** @type {any} */ onChange,
29
+ /** @type {string} */ placeholder = "",
30
+ ) {
31
+ const units = entry.$units || [];
32
+ const keywords = entry.$keywords || [];
33
+ const strVal = String(value ?? "");
34
+ const match = strVal.match(UNIT_RE);
35
+ const isKeyword = !match && strVal !== "" && keywords.includes(strVal);
36
+ const isNumericVal = (/** @type {any} */ v) => /^-?\d*\.?\d*$/.test(v);
37
+
38
+ const currentUnit = isKeyword ? units[0] || "" : match ? match[2] || "" : units[0] || "";
39
+ let displayValue;
40
+ if (isKeyword) displayValue = strVal;
41
+ else if (match) displayValue = match[1];
42
+ else if (strVal !== "") {
43
+ const num = parseFloat(strVal);
44
+ displayValue = isNaN(num) ? strVal : String(num);
45
+ } else displayValue = "";
46
+
47
+ const isExpression = isKeyword || (displayValue !== "" && !isNumericVal(displayValue));
48
+ const hasUnits = units.length > 0 || keywords.length > 0;
49
+ const btnId = `style-unit-${prop}`;
50
+
51
+ return html`
52
+ <div class="style-input-number-unit">
53
+ <div class=${classMap({ "input-group": true, "is-expression": isExpression })}>
54
+ <sp-textfield
55
+ size="s"
56
+ placeholder=${placeholder || "0"}
57
+ .value=${live(displayValue)}
58
+ @input=${debouncedStyleCommit(`nui:${prop}`, 400, (/** @type {any} */ e) => {
59
+ const val = (e.target.value ?? "").trim();
60
+ if (val === "") {
61
+ onChange("");
62
+ return;
63
+ }
64
+ if (isNumericVal(val)) onChange(units.length > 0 ? val + currentUnit : val);
65
+ else onChange(val);
66
+ })}
67
+ ></sp-textfield>
68
+ ${hasUnits
69
+ ? html`
70
+ <sp-picker-button id=${btnId} size="s">
71
+ <span slot="label">${currentUnit || units[0] || ""}</span>
72
+ </sp-picker-button>
73
+ <sp-overlay trigger="${btnId}@click" placement="bottom-end" offset="4">
74
+ <sp-popover style="min-width: var(--spectrum-component-width-900, 64px)">
75
+ <sp-menu
76
+ label="CSS unit"
77
+ @change=${(/** @type {any} */ e) => {
78
+ const chosen = e.target.value;
79
+ if (keywords.includes(chosen)) {
80
+ onChange(chosen);
81
+ } else if (units.includes(chosen)) {
82
+ const curMatch = String(value ?? "").match(UNIT_RE);
83
+ const numPart = curMatch ? curMatch[1] : "";
84
+ if (numPart) onChange(numPart + chosen);
85
+ }
86
+ }}
87
+ >
88
+ ${units.map(
89
+ (/** @type {any} */ u) => html`<sp-menu-item value=${u}>${u}</sp-menu-item>`,
90
+ )}
91
+ ${keywords.length > 0 && units.length > 0
92
+ ? html`<sp-menu-divider></sp-menu-divider>`
93
+ : nothing}
94
+ ${keywords.map(
95
+ (/** @type {any} */ kw) =>
96
+ html`<sp-menu-item value=${kw}>${kw}</sp-menu-item>`,
97
+ )}
98
+ </sp-menu>
99
+ </sp-popover>
100
+ </sp-overlay>
101
+ `
102
+ : nothing}
103
+ </div>
104
+ </div>
105
+ `;
106
+ }
@@ -1,15 +1,15 @@
1
1
  /**
2
- * Jx-styled-combobox — Dual-mode styled combobox custom element.
2
+ * Value Selector — Dual-mode styled combobox custom element.
3
3
  *
4
4
  * Renders as sp-picker when the current value matches a predefined option, or as a textfield +
5
5
  * dropdown overlay (manual combobox) when it doesn't. Both modes share identical styled menu items,
6
6
  * ensuring visual consistency.
7
7
  *
8
- * Usage: html`<jx-styled-combobox size="s" .value=${"italic"} placeholder="normal" .options=${[{
8
+ * Usage: html`<jx-value-selector size="s" .value=${"italic"} placeholder="normal" .options=${[{
9
9
  * value: "italic", label: "Italic", style: "font-style: italic" }]} @change=${handler}
10
10
  * @input=${handler}
11
11
  *
12
- * > </jx-styled-combobox>`
12
+ * > </jx-value-selector>`
13
13
  *
14
14
  * Options format: { value: string, label: string, style?: string } — menu item { divider: true } —
15
15
  * menu divider
@@ -20,7 +20,7 @@ import { live } from "lit/directives/live.js";
20
20
 
21
21
  /** @typedef {{ value: string; label: string; style?: string } | { divider: true }} ComboOption */
22
22
 
23
- export class JxStyledCombobox extends LitElement {
23
+ export class JxValueSelector extends LitElement {
24
24
  static properties = {
25
25
  value: { type: String },
26
26
  placeholder: { type: String },
@@ -71,14 +71,14 @@ export class JxStyledCombobox extends LitElement {
71
71
 
72
72
  /** Picker mode: sp-picker @change handler */
73
73
  _handlePickerChange(/** @type {any} */ e) {
74
- e.stopPropagation(); // prevent sp-picker's raw event from reaching consumer
74
+ e.stopPropagation();
75
75
  this.value = e.target.value;
76
76
  this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
77
77
  }
78
78
 
79
79
  /** Combobox mode: sp-menu @change handler */
80
80
  _handleMenuChange(/** @type {any} */ e) {
81
- e.stopPropagation(); // prevent sp-menu's raw event from reaching consumer
81
+ e.stopPropagation();
82
82
  if (!e.target.value) return;
83
83
  this.value = e.target.value;
84
84
  this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
@@ -86,7 +86,7 @@ export class JxStyledCombobox extends LitElement {
86
86
 
87
87
  /** Combobox mode: textfield @input handler */
88
88
  _handleInput(/** @type {any} */ e) {
89
- e.stopPropagation(); // prevent sp-textfield's raw event from reaching consumer
89
+ e.stopPropagation();
90
90
  this.value = e.target.value;
91
91
  this.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
92
92
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Widgets.js — Widget type dispatcher and simple widget renderers.
3
+ *
4
+ * This module provides `widgetForType()` which dispatches to the appropriate widget based on the
5
+ * inferred type from css-meta/schema entries, plus the simpler widget renderers (text, number,
6
+ * select/combobox).
7
+ *
8
+ * Complex widgets are imported from their dedicated modules: - renderColorSelector →
9
+ * ui/color-selector.js - renderUnitSelector → ui/unit-selector.js - renderButtonGroup →
10
+ * ui/button-group.js
11
+ */
12
+
13
+ import { html } from "lit-html";
14
+ import { live } from "lit-html/directives/live.js";
15
+ import { ifDefined } from "lit-html/directives/if-defined.js";
16
+ import { debouncedStyleCommit } from "../store.js";
17
+ import { renderColorSelector } from "./color-selector.js";
18
+ import { renderUnitSelector } from "./unit-selector.js";
19
+ import { renderButtonGroup } from "./button-group.js";
20
+ import { renderMediaPicker } from "./media-picker.js";
21
+
22
+ /**
23
+ * Render a plain text input widget.
24
+ *
25
+ * @param {string} prop
26
+ * @param {any} value
27
+ * @param {(val: string) => void} onChange
28
+ * @param {string} [placeholder]
29
+ * @returns {any}
30
+ */
31
+ export function renderTextInput(prop, value, onChange, placeholder = "") {
32
+ return html`
33
+ <sp-textfield
34
+ size="s"
35
+ placeholder=${placeholder}
36
+ .value=${live(value || "")}
37
+ @input=${debouncedStyleCommit(`text:${prop}`, 400, (/** @type {any} */ e) =>
38
+ onChange(e.target.value),
39
+ )}
40
+ ></sp-textfield>
41
+ `;
42
+ }
43
+
44
+ /**
45
+ * Render a number input widget (sp-number-field).
46
+ *
47
+ * @param {any} entry
48
+ * @param {string} prop
49
+ * @param {any} value
50
+ * @param {(val: any) => void} onChange
51
+ * @returns {any}
52
+ */
53
+ export function renderNumberInput(entry, prop, value, onChange, placeholder = "") {
54
+ return html`
55
+ <sp-number-field
56
+ size="s"
57
+ hide-stepper
58
+ .value=${live(value !== undefined && value !== "" ? Number(value) : undefined)}
59
+ placeholder=${placeholder}
60
+ min=${ifDefined(entry.minimum)}
61
+ max=${ifDefined(entry.maximum)}
62
+ step=${ifDefined(entry.maximum !== undefined && entry.maximum <= 1 ? 0.1 : undefined)}
63
+ @change=${debouncedStyleCommit(`num:${prop}`, 400, (/** @type {any} */ e) => {
64
+ const v = e.target.value;
65
+ if (v === undefined || isNaN(v)) onChange("");
66
+ else onChange(Number(v));
67
+ })}
68
+ ></sp-number-field>
69
+ `;
70
+ }
71
+
72
+ /**
73
+ * Dispatch to the appropriate widget based on inferred type.
74
+ *
75
+ * @param {string} type — one of: button-group, color, number-unit, number, select, combobox, text
76
+ * @param {any} entry — css-meta or schema entry
77
+ * @param {string} prop — property key
78
+ * @param {any} value — current value
79
+ * @param {(val: any) => void} onCommit — commit callback
80
+ * @param {{ placeholder?: string; renderSelect?: Function; renderCombobox?: Function }} [opts]
81
+ * @returns {any}
82
+ */
83
+ export function widgetForType(type, entry, prop, value, onCommit, opts = {}) {
84
+ switch (type) {
85
+ case "button-group":
86
+ return renderButtonGroup(entry, prop, value, onCommit);
87
+ case "color":
88
+ return renderColorSelector(prop, value, onCommit);
89
+ case "number-unit":
90
+ return renderUnitSelector(entry, prop, value, onCommit, opts.placeholder);
91
+ case "number":
92
+ return renderNumberInput(entry, prop, value, onCommit, opts.placeholder);
93
+ case "media":
94
+ return renderMediaPicker(prop, value, onCommit);
95
+ case "select":
96
+ // Allow caller to override select rendering (e.g. for typography preview)
97
+ if (opts.renderSelect) return opts.renderSelect(entry, prop, value, onCommit);
98
+ return renderTextInput(prop, value, onCommit, opts.placeholder);
99
+ case "combobox":
100
+ // Allow caller to override combobox rendering (e.g. for font family)
101
+ if (opts.renderCombobox) return opts.renderCombobox(entry, prop, value, onCommit);
102
+ return renderTextInput(prop, value, onCommit, opts.placeholder);
103
+ default:
104
+ return renderTextInput(prop, value, onCommit, opts.placeholder);
105
+ }
106
+ }
@@ -0,0 +1,151 @@
1
+ /** Canvas media/breakpoint utilities — pure functions extracted for testability. */
2
+
3
+ /**
4
+ * Classify $media entries into size breakpoints (get a canvas each) and feature queries (rendered
5
+ * as toolbar toggles).
6
+ *
7
+ * @param {Record<string, string> | null | undefined} mediaDef
8
+ * @returns {{
9
+ * sizeBreakpoints: { name: string; query: string; width: number; type: string }[];
10
+ * featureQueries: { name: string; query: string }[];
11
+ * baseWidth: number;
12
+ * }}
13
+ */
14
+ export function parseMediaEntries(mediaDef) {
15
+ if (!mediaDef) return { sizeBreakpoints: [], featureQueries: [], baseWidth: 320 };
16
+ const sizes = [],
17
+ features = [];
18
+ let baseWidth = 320;
19
+ for (const [name, query] of Object.entries(mediaDef)) {
20
+ if (name === "--") {
21
+ const wm = String(query).match(/^(\d+)\s*px$/);
22
+ baseWidth = wm ? parseFloat(wm[1]) : 320;
23
+ continue;
24
+ }
25
+ const minMatch = query.match(/min-width:\s*([\d.]+)px/);
26
+ const maxMatch = query.match(/max-width:\s*([\d.]+)px/);
27
+ if (minMatch) sizes.push({ name, query, width: parseFloat(minMatch[1]), type: "min" });
28
+ else if (maxMatch) sizes.push({ name, query, width: parseFloat(maxMatch[1]), type: "max" });
29
+ else features.push({ name, query });
30
+ }
31
+ sizes.sort((a, b) => (a.type === "min" ? a.width - b.width : b.width - a.width));
32
+ return { sizeBreakpoints: sizes, featureQueries: features, baseWidth };
33
+ }
34
+
35
+ /**
36
+ * Compute which named breakpoints are active at a given canvas width.
37
+ *
38
+ * @param {{ name: string; width: number; type: string }[]} sizeBreakpoints
39
+ * @param {number} canvasWidth
40
+ * @returns {Set<string>}
41
+ */
42
+ export function activeBreakpointsForWidth(sizeBreakpoints, canvasWidth) {
43
+ const active = new Set();
44
+ for (const bp of sizeBreakpoints) {
45
+ if (bp.type === "min" && canvasWidth >= bp.width) active.add(bp.name);
46
+ else if (bp.type === "max" && canvasWidth <= bp.width) active.add(bp.name);
47
+ }
48
+ return active;
49
+ }
50
+
51
+ /**
52
+ * Apply styles to a canvas element, including active media overrides. Base (flat) styles applied
53
+ * first, then matching media overrides in source order.
54
+ *
55
+ * @param {HTMLElement} el
56
+ * @param {Record<string, any>} styleDef
57
+ * @param {Set<string>} activeBreakpoints
58
+ * @param {Record<string, boolean>} featureToggles
59
+ */
60
+ export function applyCanvasStyle(el, styleDef, activeBreakpoints, featureToggles) {
61
+ if (!styleDef || typeof styleDef !== "object") return;
62
+ for (const [prop, val] of Object.entries(styleDef)) {
63
+ if (typeof val === "string" || typeof val === "number") {
64
+ try {
65
+ if (prop.startsWith("--")) el.style.setProperty(prop, String(val));
66
+ else /** @type {any} */ (el.style)[prop] = val;
67
+ } catch {}
68
+ }
69
+ }
70
+ for (const [key, val] of Object.entries(styleDef)) {
71
+ if (!key.startsWith("@") || typeof val !== "object") continue;
72
+ const mediaName = key.slice(1);
73
+ if (mediaName === "--") continue;
74
+ if (activeBreakpoints.has(mediaName) || featureToggles[mediaName]) {
75
+ for (const [prop, v] of Object.entries(/** @type {any} */ (val))) {
76
+ if (typeof v === "string" || typeof v === "number") {
77
+ try {
78
+ if (prop.startsWith("--")) el.style.setProperty(prop, String(v));
79
+ else /** @type {any} */ (el.style)[prop] = v;
80
+ } catch {}
81
+ }
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Scan stylesheets for @media rules matching active breakpoints, collecting the CSS declarations
89
+ * that should be applied as inline overrides per data-jx element.
90
+ *
91
+ * Returns a Map of data-jx uid → Map of CSS property → value.
92
+ *
93
+ * @param {Iterable<CSSStyleSheet>} styleSheets
94
+ * @param {Set<string>} activeBreakpoints
95
+ * @returns {Map<string, Map<string, string>>}
96
+ */
97
+ export function collectMediaOverrides(styleSheets, activeBreakpoints) {
98
+ /** @type {Map<string, Map<string, string>>} */
99
+ const overrides = new Map();
100
+ if (!activeBreakpoints.size) return overrides;
101
+
102
+ for (const sheet of styleSheets) {
103
+ /** @type {CSSRuleList | null} */
104
+ let rules;
105
+ try {
106
+ rules = sheet.cssRules;
107
+ } catch {
108
+ continue;
109
+ }
110
+ if (!rules) continue;
111
+ for (let ri = 0; ri < rules.length; ri++) {
112
+ const rule = rules[ri];
113
+ if (!(rule instanceof CSSMediaRule)) continue;
114
+ if (!activeBreakpoints.has(rule.conditionText)) continue;
115
+ for (let mi = 0; mi < rule.cssRules.length; mi++) {
116
+ const mediaRule = rule.cssRules[mi];
117
+ if (!(mediaRule instanceof CSSStyleRule)) continue;
118
+ const selector = mediaRule.selectorText;
119
+ const jxMatch = selector.match(/\[data-jx="([^"]+)"\]/);
120
+ if (!jxMatch) continue;
121
+ const uid = jxMatch[1];
122
+ if (!overrides.has(uid)) overrides.set(uid, new Map());
123
+ const props = /** @type {Map<string, string>} */ (overrides.get(uid));
124
+ for (let i = 0; i < mediaRule.style.length; i++) {
125
+ const prop = mediaRule.style[i];
126
+ props.set(prop, mediaRule.style.getPropertyValue(prop));
127
+ }
128
+ }
129
+ }
130
+ }
131
+ return overrides;
132
+ }
133
+
134
+ /**
135
+ * Apply collected media overrides to elements within a canvas.
136
+ *
137
+ * @param {Element} canvasEl
138
+ * @param {Map<string, Map<string, string>>} overrides
139
+ */
140
+ export function applyOverridesToCanvas(canvasEl, overrides) {
141
+ for (const [uid, props] of overrides) {
142
+ const els = canvasEl.querySelectorAll(`[data-jx="${uid}"]`);
143
+ for (const el of els) {
144
+ for (const [prop, val] of props) {
145
+ try {
146
+ /** @type {HTMLElement} */ (el).style.setProperty(prop, val);
147
+ } catch {}
148
+ }
149
+ }
150
+ }
151
+ }