windcraft-editor 0.0.0-stage → 0.1.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.
Files changed (60) hide show
  1. package/README.md +49 -2
  2. package/core/board-layout.js +71 -0
  3. package/core/class-groups.js +210 -0
  4. package/core/design-spec.js +303 -0
  5. package/core/exporters.js +156 -0
  6. package/core/pages.js +39 -0
  7. package/core/prop-tailwind.js +158 -0
  8. package/core/split-sections.js +99 -0
  9. package/core/theme.js +87 -0
  10. package/design/agent.js +2118 -0
  11. package/design/classes-section.js +103 -0
  12. package/design/code-editor.js +293 -0
  13. package/design/code-view.js +158 -0
  14. package/design/color-picker.js +107 -0
  15. package/design/config.js +5 -0
  16. package/design/controls.js +132 -0
  17. package/design/css-props.js +111 -0
  18. package/design/design-tab.js +120 -0
  19. package/design/device-frame.js +40 -0
  20. package/design/editor/add-menu.js +151 -0
  21. package/design/editor/api.js +47 -0
  22. package/design/editor/base.js +55 -0
  23. package/design/editor/code-panel.js +206 -0
  24. package/design/editor/color-ui.js +135 -0
  25. package/design/editor/designs.js +606 -0
  26. package/design/editor/docs.js +121 -0
  27. package/design/editor/frame-handles.js +223 -0
  28. package/design/editor/frame-messages.js +195 -0
  29. package/design/editor/inspector.js +461 -0
  30. package/design/editor/layers.js +61 -0
  31. package/design/editor/pages-panel.js +161 -0
  32. package/design/editor/panel-resize.js +47 -0
  33. package/design/editor/plane.js +132 -0
  34. package/design/editor/present.js +52 -0
  35. package/design/editor/toolbar.js +267 -0
  36. package/design/editor.js +47 -0
  37. package/design/figma-sections.js +440 -0
  38. package/design/float-toolbar.js +163 -0
  39. package/design/group-controls.js +52 -0
  40. package/design/host.js +22 -0
  41. package/design/panel-model.js +130 -0
  42. package/design/panel-state.js +40 -0
  43. package/design/panel.js +212 -0
  44. package/design/presenter.js +312 -0
  45. package/design/prop-sections.js +18 -0
  46. package/design/selection-overlay.js +93 -0
  47. package/design/store.js +29 -0
  48. package/design/style-sections.js +315 -0
  49. package/design/tailwind-classes.js +65 -0
  50. package/embed-frame.js +43 -0
  51. package/embed.html +15 -0
  52. package/embed.js +60 -0
  53. package/index.js +62 -0
  54. package/package.json +50 -5
  55. package/playground/component-source.js +124 -0
  56. package/styles.css +402 -0
  57. package/template.js +247 -0
  58. package/vendor/codemirror.js +24 -0
  59. package/vendor/preact-htm.js +1 -0
  60. package/vendor/prettier.js +171 -0
@@ -0,0 +1,107 @@
1
+ // The editor's own colour picker. The browser's picker opens wherever it likes, often on top of the element being coloured;
2
+ // this one is placed beside the selection, so the change can be watched as it happens. It only reports colours: the caller
3
+ // decides where they go (editor.js feeds them to the same colour fields, so live updates and undo work as before).
4
+
5
+ const clamp = (n, a, b) => Math.min(b, Math.max(a, n));
6
+
7
+ function hsvToHex(h, s, v) {
8
+ const f = (n) => { const k = (n + h / 60) % 6; return v - v * s * Math.max(0, Math.min(k, 4 - k, 1)); };
9
+ return "#" + [f(5), f(3), f(1)].map((x) => Math.round(x * 255).toString(16).padStart(2, "0")).join("");
10
+ }
11
+ function hexToHsv(hex) {
12
+ const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || "").trim());
13
+ if (!m) return null;
14
+ const n = parseInt(m[1], 16), r = (n >> 16 & 255) / 255, g = (n >> 8 & 255) / 255, b = (n & 255) / 255;
15
+ const max = Math.max(r, g, b), d = max - Math.min(r, g, b);
16
+ let h = 0;
17
+ if (d) h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
18
+ return { h: (h * 60 + 360) % 360, s: max ? d / max : 0, v: max };
19
+ }
20
+
21
+ const DEFAULT_W = 232, DEFAULT_H = 268, GAP = 12;
22
+ let box = null, state = null;
23
+
24
+ function build() {
25
+ box = document.createElement("div");
26
+ box.className = "cpick";
27
+ box.innerHTML = `<div class="cp-sv"><div class="cp-white"></div><div class="cp-black"></div><i class="cp-dot"></i></div>
28
+ <div class="cp-hue"><i class="cp-knob"></i></div>
29
+ <div class="cp-row"><span class="cp-prev"></span><input class="cp-hex" spellcheck="false" maxlength="7" aria-label="Hex colour">${window.EyeDropper ? '<button type="button" class="cp-drop" title="Pick a colour from the screen"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m2 22 1-1h3l9-9M3 21v-3l9-9m3-3 3-3 3 3-3 3 1 1-2 2-6-6 2-2Z"/></svg></button>' : ""}</div>`;
30
+ document.body.appendChild(box);
31
+ // presses inside keep focus where it is (the colour field it feeds stays the active one), except in the hex field
32
+ box.addEventListener("mousedown", (e) => { if (!e.target.closest(".cp-hex")) e.preventDefault(); });
33
+ const drag = (sel, move) => {
34
+ const area = box.querySelector(sel);
35
+ area.addEventListener("pointerdown", (e) => {
36
+ area.setPointerCapture(e.pointerId);
37
+ move(e, area.getBoundingClientRect());
38
+ const mv = (ev) => move(ev, area.getBoundingClientRect());
39
+ const up = () => { area.removeEventListener("pointermove", mv); area.removeEventListener("pointerup", up); };
40
+ area.addEventListener("pointermove", mv);
41
+ area.addEventListener("pointerup", up);
42
+ });
43
+ };
44
+ drag(".cp-sv", (e, r) => { state.s = clamp((e.clientX - r.left) / r.width, 0, 1); state.v = 1 - clamp((e.clientY - r.top) / r.height, 0, 1); emit(); });
45
+ drag(".cp-hue", (e, r) => { state.h = clamp((e.clientX - r.left) / r.width, 0, 1) * 359.9; emit(); });
46
+ const hex = box.querySelector(".cp-hex");
47
+ const typed = () => { const v = hexToHsv(hex.value.startsWith("#") ? hex.value : "#" + hex.value); if (v) { Object.assign(state, v); emit(); } else paint(); };
48
+ hex.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); typed(); close(); } if (e.key === "Escape") { e.preventDefault(); close(); } });
49
+ hex.addEventListener("change", typed);
50
+ const drop = box.querySelector(".cp-drop");
51
+ if (drop) drop.addEventListener("click", async () => { try { const r = await new window.EyeDropper().open(); const v = hexToHsv(r.sRGBHex); if (v) { Object.assign(state, v); emit(); } } catch { /* cancelled */ } });
52
+ // a press anywhere else, or Escape, closes it
53
+ document.addEventListener("pointerdown", (e) => { if (state && !box.contains(e.target) && !(state.opener && state.opener.contains(e.target))) close(); }, true);
54
+ document.addEventListener("keydown", (e) => { if (state && e.key === "Escape" && !box.contains(document.activeElement)) close(); }, true);
55
+ // a click inside the design happens in its own frame and never reaches this document: the frame taking focus closes it too
56
+ window.addEventListener("blur", () => setTimeout(() => { if (state && document.activeElement && document.activeElement.tagName === "IFRAME") close(); }, 0));
57
+ }
58
+
59
+ function paint() {
60
+ const { h, s, v } = state, hex = hsvToHex(h, s, v);
61
+ box.querySelector(".cp-sv").style.background = `hsl(${h} 100% 50%)`;
62
+ Object.assign(box.querySelector(".cp-dot").style, { left: `${s * 100}%`, top: `${(1 - v) * 100}%`, background: hex });
63
+ Object.assign(box.querySelector(".cp-knob").style, { left: `${(h / 360) * 100}%`, background: `hsl(${h} 100% 50%)` });
64
+ box.querySelector(".cp-prev").style.background = hex;
65
+ const field = box.querySelector(".cp-hex");
66
+ if (document.activeElement !== field) field.value = hex.toUpperCase();
67
+ return hex;
68
+ }
69
+ function emit() { const hex = paint(); if (hex !== state.last) { state.last = hex; state.onInput(hex); } }
70
+
71
+ /** Beside `near` (the selection), on whichever side has room inside `bounds`; failing that, where it covers the least of it. */
72
+ export function placeBeside(near, bounds, from, W = DEFAULT_W, H = DEFAULT_H) {
73
+ const fits = (x, y) => x >= bounds.left && y >= bounds.top && x + W <= bounds.right && y + H <= bounds.bottom;
74
+ const midY = clamp(near.top, bounds.top, bounds.bottom - H), midX = clamp(near.left, bounds.left, bounds.right - W);
75
+ const spots = [[near.right + GAP, midY], [near.left - GAP - W, midY], [midX, near.bottom + GAP], [midX, near.top - GAP - H]];
76
+ const ok = spots.find(([x, y]) => fits(x, y));
77
+ if (ok) return ok;
78
+ // the element fills the view: the corner of the view farthest from the swatch that opened it
79
+ const x = from && from.left > (bounds.left + bounds.right) / 2 ? bounds.left + GAP : bounds.right - W - GAP;
80
+ return [clamp(x, 0, innerWidth - W), clamp(bounds.top + GAP, 0, innerHeight - H)];
81
+ }
82
+
83
+ /**
84
+ * Opens the picker. value: "#rrggbb"; near: the selected element's rect on screen; bounds: the area to stay in; opener: the
85
+ * swatch pressed (pressing it again doesn't close it underneath); onInput(hex) while picking; onClose(hex) once it closes.
86
+ */
87
+ export function openColorPicker({ value, near, bounds, opener, onInput, onClose }) {
88
+ if (!box) build();
89
+ if (state) close();
90
+ state = { ...(hexToHsv(value) || { h: 0, s: 0, v: 0 }), last: null, opener, onInput, onClose };
91
+ state.last = hsvToHex(state.h, state.s, state.v);
92
+ paint();
93
+ const [x, y] = placeBeside(near || opener.getBoundingClientRect(), bounds || { left: 0, top: 0, right: innerWidth, bottom: innerHeight }, opener && opener.getBoundingClientRect());
94
+ Object.assign(box.style, { left: `${x}px`, top: `${y}px`, display: "block" });
95
+ }
96
+
97
+ export function closeColorPicker() { if (state) close(); }
98
+
99
+ function close() {
100
+ const s = state;
101
+ state = null;
102
+ box.style.display = "none";
103
+ s.onClose(s.last);
104
+ }
105
+
106
+ /** The swatch the open picker belongs to, or null. */
107
+ export const colorPickerOpener = () => (state ? state.opener : null);
@@ -0,0 +1,5 @@
1
+ // What mountEditor() hands the editor before its code loads: the design and what surrounds it (see index.js).
2
+ // data the design to open
3
+ // ui what of the editor is shown (see index.js): { panels, nav, tools }
4
+ // hooks what the editor tells the app around it: { chrome() after every change, details(fields) when a first save needs a name }
5
+ export const config = /** @type {{ data: any, ui: { panels?: boolean, nav?: boolean, tools?: string[] | null }, hooks: { chrome?: () => void, details?: (fields: object) => void }, leaving?: boolean }} */ ({ ui: {}, hooks: {} });
@@ -0,0 +1,132 @@
1
+ // @ts-check
2
+ // The building blocks of the Properties panel (see panel.js for the shell): text fields, dropdowns, button groups, sections
3
+ // and a colour field. Each keeps what is being typed in its own state and reports changes through callbacks; none reads the
4
+ // page or the editor's state. Their classes are the ones canvas.handlebars styles.
5
+ import { html, useState, useRef } from "../vendor/preact-htm.js";
6
+ import { HUES, SHADES, SPECIAL_COLORS } from "@windcraft/core/class-groups.js";
7
+
8
+ /**
9
+ * What a typed-in field needs: it shows `value` until it is focused, keeps what is typed in its own state (a redraw with the
10
+ * old value must not undo a keystroke), and hands the text over: `onInput(text)` on every keystroke, `onCommit(text)` on Enter
11
+ * (a single line) or when it is left. Esc gives up. Whether the value changes is up to the caller: a refused entry shows the
12
+ * old value again. Enter and Esc act at once rather than through blur, which a window without focus never sends. Leaving the
13
+ * field hands the text over too, unless `blurCommits` is false (a box that only means something on Enter).
14
+ */
15
+ function useTyped(value, onCommit, onInput, multiline, blurCommits = true) {
16
+ const [draft, setDraft] = useState(/** @type {string | null} */ (null));
17
+ const typed = useRef(/** @type {string | null} */ (null)); // the same text as `draft`, readable at once (a key can arrive before the redraw)
18
+ const type = (/** @type {string} */ text, /** @type {boolean} */ live) => { typed.current = text; setDraft(text); if (live && onInput) onInput(text); };
19
+ const finish = (/** @type {boolean} */ commit) => {
20
+ const text = typed.current;
21
+ typed.current = null;
22
+ setDraft(null);
23
+ if (commit && text !== null && text !== String(value ?? "") && onCommit) onCommit(text);
24
+ };
25
+ return {
26
+ shown: draft ?? String(value ?? ""),
27
+ onFocus: (/** @type {any} */ e) => type(e.target.value, false),
28
+ onInput: (/** @type {any} */ e) => type(e.target.value, true),
29
+ onBlur: () => finish(blurCommits),
30
+ onKeyDown: (/** @type {any} */ e) => {
31
+ if (e.key === "Enter" && !multiline) { finish(true); e.target.blur(); }
32
+ else if (e.key === "Escape") { finish(false); e.target.blur(); }
33
+ },
34
+ };
35
+ }
36
+
37
+ /**
38
+ * A text input typed in freely (see useTyped).
39
+ * @param {{ value?: string | number | null, onCommit?: (text: string) => void, onInput?: (text: string) => void, placeholder?: string, list?: string, title?: string, disabled?: boolean, blurCommits?: boolean, class?: string }} props
40
+ */
41
+ export function TextInput({ value, onCommit, onInput, placeholder, list, title, disabled, blurCommits, class: cls = "fi" }) {
42
+ const t = useTyped(value, onCommit, onInput, false, blurCommits !== false);
43
+ return html`<input class=${cls} value=${t.shown} placeholder=${placeholder || undefined} list=${list} title=${title} disabled=${disabled || undefined} autocomplete="off"
44
+ onFocus=${t.onFocus} onInput=${t.onInput} onBlur=${t.onBlur} onKeyDown=${t.onKeyDown} />`;
45
+ }
46
+
47
+ /**
48
+ * A multi-line text box typed in freely (see useTyped); Enter is a new line.
49
+ * @param {{ value?: string | null, onCommit?: (text: string) => void, onInput?: (text: string) => void, placeholder?: string, rows?: number, spellcheck?: boolean, class?: string }} props
50
+ */
51
+ export function TextArea({ value, onCommit, onInput, placeholder, rows = 3, spellcheck, class: cls = "fi area" }) {
52
+ const t = useTyped(value, onCommit, onInput, true);
53
+ return html`<textarea class=${cls} rows=${rows} placeholder=${placeholder || undefined} spellcheck=${spellcheck} value=${t.shown}
54
+ onFocus=${t.onFocus} onInput=${t.onInput} onBlur=${t.onBlur} onKeyDown=${t.onKeyDown}></textarea>`;
55
+ }
56
+
57
+ /**
58
+ * A dropdown. `options` are [value, label]; `onChange(value)` when another is picked. A `blank` option ("-") comes first when
59
+ * given. `groups` ([label, options][]) instead of `options` draws <optgroup>s.
60
+ * @param {{ value?: string, options?: string[][], groups?: [string, string[][]][], onChange: (value: string) => void, blank?: string, title?: string, class?: string }} props
61
+ */
62
+ export function SelectInput({ value, options, groups, onChange, blank, title, class: cls = "fi" }) {
63
+ const opt = (/** @type {string[]} */ [v, l]) => html`<option value=${v} selected=${value === v}>${l}</option>`;
64
+ return html`<select class=${cls} title=${title} onChange=${(e) => onChange(e.target.value)}>
65
+ ${blank !== undefined && html`<option value="" selected=${!value}>${blank}</option>`}
66
+ ${options && options.map(opt)}
67
+ ${groups && groups.map(([label, list]) => html`<optgroup label=${label}>${list.map(opt)}</optgroup>`)}
68
+ </select>`;
69
+ }
70
+
71
+ /**
72
+ * A row of buttons where one can be on. `items` are [value, label, title]; the title is the label when it is left out, and none
73
+ * when it is null. Clicking the one that is on sends "" (off), another sends its value.
74
+ * @param {{ value?: string, items: (string | null)[][], onPick: (value: string) => void, off?: boolean }} props `off`: clicking the one that is on sends its value again (a fixed choice, not a toggle)
75
+ */
76
+ export function Segmented({ value, items, onPick, off }) {
77
+ return html`<div class="seg2">${items.map(([v, l, t]) => html`<button type="button" title=${t === null ? undefined : t || l} class=${value === v ? "on" : ""}
78
+ onClick=${() => onPick(value === v && !off ? "" : /** @type {string} */ (v))}>${l}</button>`)}</div>`;
79
+ }
80
+
81
+ /** A labelled row: <div class="row">. */
82
+ export const Row = ({ label, title, children }) => html`<div class="row" title=${title}><span class="lbl">${label}</span>${children}</div>`;
83
+ /** A labelled row whose control is an input, so the label is clickable: <label class="row"> (or a small <label class="cell">). `extra`: more classes (opacity-50 for a dimmed one). */
84
+ export const Field = ({ label, title, cell, extra, children }) => html`<label class=${[cell ? "cell" : "row", extra].filter(Boolean).join(" ")} title=${title}><span class=${cell ? "clbl" : "lbl"}>${label}</span>${children}</label>`;
85
+ /** Two or four controls side by side. */
86
+ export const Two = ({ children }) => html`<div class="two">${children}</div>`;
87
+ export const Four = ({ children }) => html`<div class="four">${children}</div>`;
88
+
89
+ /**
90
+ * A collapsible section with a header; a dot shows that something in it is set. `open` is what is shown, `onToggle` flips it.
91
+ * (It has no data-sec: the editor's delegated handler for the sections still in HTML would toggle it a second time.)
92
+ * @param {{ title: string, open: boolean, set?: boolean, onToggle: () => void, children?: any }} props
93
+ */
94
+ export function Section({ title, open, set, onToggle, children }) {
95
+ return html`<div class="sec"><button type="button" class="sech" onClick=${onToggle}>
96
+ <span class="car">${open ? "▾" : "▸"}</span><span class="t">${title}</span>${set && html`<i class="dot"></i>`}</button>
97
+ ${open && html`<div class="secb">${children}</div>`}</div>`;
98
+ }
99
+
100
+ /**
101
+ * A colour as a row: a swatch that opens the palette, the colour's name (typed), its opacity, and (when open) the palette:
102
+ * the special colours, a custom colour picker, None, and every hue and shade.
103
+ * @param {{ label: string, name: string, opacity: string, swatchClass?: string, swatchStyle?: string, customValue?: string, paletteOpen: boolean, onPalette: () => void, onName: (text: string) => void, onOpacity: (text: string) => void, onPick: (value: string) => void, onCustom: (hex: string, done: boolean, input: HTMLInputElement | null) => void, customKey?: string }} props
104
+ */
105
+ export function ColorField({ label, name, opacity, swatchClass, swatchStyle, customValue, paletteOpen, onPalette, onName, onOpacity, onPick, onCustom, customKey }) {
106
+ const cell = (/** @type {string} */ v, /** @type {string} */ cls) => html`<button type="button" title=${v} class=${`sw2 ${cls} ${name === v ? "on" : ""}`} onClick=${() => onPick(v)}></button>`;
107
+ return html`
108
+ <div class="crow"><span class="lbl">${label}</span>
109
+ <button type="button" class=${`sw ${swatchClass || ""}`} style=${swatchStyle || undefined} title="Pick a color" onClick=${onPalette}></button>
110
+ <${TextInput} class="fi grow" list="dl-colors" value=${name} placeholder="none" onCommit=${onName} />
111
+ <${TextInput} class="fi op" value=${opacity} placeholder="100" title="Opacity %" onCommit=${onOpacity} /><span class="pct">%</span></div>
112
+ ${paletteOpen && html`<div class="pal">
113
+ <div class="palrow">${SPECIAL_COLORS.map((c) => cell(c, c === "black" ? "bg-black" : c === "white" ? "bg-white" : "swatch-t"))}
114
+ <label class="sw2 custom" title="Custom color"><${ColorInput} class="" value=${customValue || "#111"} hook=${{ "data-ccustom": customKey }}
115
+ onLive=${(hex) => onCustom(hex, false, null)} onDone=${(hex, input) => onCustom(hex, true, input)} /></label>
116
+ <button type="button" class="clear" onClick=${() => onPick("")}>None</button></div>
117
+ ${HUES.filter((h) => h !== "brand").map((h) => html`<div class="palrow">${SHADES.map((s) => cell(`${h}-${s}`, `bg-${h}-${s}`))}</div>`)}
118
+ </div>`}`;
119
+ }
120
+
121
+ /**
122
+ * A colour input. While it is being dragged it shows what is picked (the redraws that follow each change carry the old value
123
+ * until the page reports the new one). `onLive(hex)` on every move, `onDone(hex, input)` when it is released or closed.
124
+ * `hook` is extra attributes, such as { "data-propcolor": prop }, which the editor's colour picker uses to find the field again.
125
+ * @param {{ value: string, title?: string, class?: string, hook?: Record<string, string>, onLive: (hex: string) => void, onDone: (hex: string, input: HTMLInputElement) => void }} props
126
+ */
127
+ export function ColorInput({ value, title, class: cls = "cinp", hook, onLive, onDone }) {
128
+ const [draft, setDraft] = useState(/** @type {string | null} */ (null));
129
+ return html`<input type="color" class=${cls} value=${draft ?? value} title=${title} ...${hook || {}}
130
+ onInput=${(e) => { setDraft(e.target.value); onLive(e.target.value); }}
131
+ onChange=${(e) => { setDraft(null); onDone(e.target.value, e.target); }} />`;
132
+ }
@@ -0,0 +1,111 @@
1
+ // Property-based style panel: works on ANY element, whatever styled it (Tailwind, a stylesheet,
2
+ // Bootstrap, inline styles). It reads computed CSS and writes inline styles, which always win.
3
+ const sel = (o) => ({ t: "select", o });
4
+ const FLEX = ["flex-start", "center", "flex-end", "space-between", "space-around", "space-evenly"];
5
+
6
+ export const CSS_SECTIONS = [
7
+ ["css-layout", "Layout", [
8
+ { p: "display", l: "Display", ...sel(["block", "inline", "inline-block", "flex", "inline-flex", "grid", "inline-grid", "none", "contents"]) },
9
+ { p: "position", l: "Position", ...sel(["static", "relative", "absolute", "fixed", "sticky"]) },
10
+ { p: "overflow", l: "Overflow", ...sel(["visible", "hidden", "auto", "scroll", "clip"]) },
11
+ { p: "z-index", l: "Z-index", t: "text" },
12
+ { p: "flex-direction", l: "Direction", ...sel(["row", "row-reverse", "column", "column-reverse"]) },
13
+ { p: "flex-wrap", l: "Wrap", ...sel(["nowrap", "wrap", "wrap-reverse"]) },
14
+ { p: "justify-content", l: "Justify", ...sel(["normal", ...FLEX, "stretch"]) },
15
+ { p: "align-items", l: "Align", ...sel(["normal", "stretch", "flex-start", "center", "flex-end", "baseline"]) },
16
+ { p: "gap", l: "Gap", t: "text" },
17
+ { p: "grid-template-columns", l: "Grid cols", t: "text" },
18
+ ]],
19
+ ["css-size", "Size", [
20
+ { p: "width", l: "Width", t: "text" }, { p: "height", l: "Height", t: "text" },
21
+ { p: "min-width", l: "Min W", t: "text" }, { p: "max-width", l: "Max W", t: "text" },
22
+ { p: "min-height", l: "Min H", t: "text" }, { p: "max-height", l: "Max H", t: "text" },
23
+ { p: "aspect-ratio", l: "Ratio", t: "text" },
24
+ ]],
25
+ ["css-spacing", "Spacing", "box"],
26
+ ["css-offsets", "Position offsets", [
27
+ { p: "top", l: "Top", t: "text" }, { p: "right", l: "Right", t: "text" }, { p: "bottom", l: "Bottom", t: "text" }, { p: "left", l: "Left", t: "text" },
28
+ ]],
29
+ ["css-type", "Typography", [
30
+ { p: "font-family", l: "Family", t: "text", list: "dl-fonts" },
31
+ { p: "font-size", l: "Size", t: "text" },
32
+ { p: "font-weight", l: "Weight", ...sel(["100", "200", "300", "400", "500", "600", "700", "800", "900"]) },
33
+ { p: "line-height", l: "Line", t: "text" },
34
+ { p: "letter-spacing", l: "Tracking", t: "text" },
35
+ { p: "text-align", l: "Align", t: "seg", o: ["left", "center", "right", "justify"] },
36
+ { p: "text-decoration-line", l: "Decor", ...sel(["none", "underline", "line-through", "overline"]) },
37
+ { p: "text-transform", l: "Case", ...sel(["none", "uppercase", "lowercase", "capitalize"]) },
38
+ { p: "font-style", l: "Style", ...sel(["normal", "italic"]) },
39
+ { p: "white-space", l: "Wrap", ...sel(["normal", "nowrap", "pre", "pre-wrap", "pre-line"]) },
40
+ { p: "color", l: "Color", t: "color" },
41
+ ]],
42
+ ["css-fill", "Fill", [
43
+ { p: "background-color", l: "Color", t: "color" },
44
+ { p: "background-image", l: "Image", t: "text" },
45
+ { p: "background-size", l: "Size", ...sel(["auto", "cover", "contain"]) },
46
+ { p: "background-position", l: "Position", t: "text" },
47
+ { p: "background-repeat", l: "Repeat", ...sel(["repeat", "no-repeat", "repeat-x", "repeat-y"]) },
48
+ ]],
49
+ ["css-border", "Border and radius", [
50
+ { p: "border-top-width", l: "Width", t: "text" },
51
+ { p: "border-top-style", l: "Style", ...sel(["none", "solid", "dashed", "dotted", "double"]) },
52
+ { p: "border-top-color", l: "Color", t: "color" },
53
+ { p: "border-radius", l: "Radius", t: "text" },
54
+ { p: "outline", l: "Outline", t: "text" },
55
+ ]],
56
+ ["css-effects", "Effects", [
57
+ { p: "box-shadow", l: "Shadow", t: "text" },
58
+ { p: "opacity", l: "Opacity", t: "text" },
59
+ { p: "filter", l: "Filter", t: "text" },
60
+ { p: "backdrop-filter", l: "Backdrop", t: "text" },
61
+ { p: "transform", l: "Transform", t: "text" },
62
+ { p: "transition", l: "Transition", t: "text" },
63
+ { p: "cursor", l: "Cursor", ...sel(["auto", "default", "pointer", "text", "move", "not-allowed", "grab", "crosshair"]) },
64
+ { p: "mix-blend-mode", l: "Blend", ...sel(["normal", "multiply", "screen", "overlay", "darken", "lighten", "difference"]) },
65
+ { p: "pointer-events", l: "Pointer", ...sel(["auto", "none"]) },
66
+ { p: "object-fit", l: "Object fit", ...sel(["fill", "contain", "cover", "none", "scale-down"]) },
67
+ ]],
68
+ ];
69
+
70
+ export const BOX_PROPS = ["margin-top", "margin-right", "margin-bottom", "margin-left", "padding-top", "padding-right", "padding-bottom", "padding-left"];
71
+
72
+ /** What the floating bar's Border, Radius and Spacing popovers read, side by side and corner by corner. */
73
+ export const TOOLBAR_PROPS = [
74
+ ...["top", "right", "bottom", "left"].flatMap((d) => [`border-${d}-width`, `border-${d}-style`, `border-${d}-color`]),
75
+ "border-top-left-radius", "border-top-right-radius", "border-bottom-right-radius", "border-bottom-left-radius",
76
+ ];
77
+
78
+ /** Every property the panel can show, sent to the in-frame agent so it can report computed values. */
79
+ export const CSS_PROP_LIST = [...new Set([
80
+ "align-self", "flex-grow", "flex-shrink", "flex-basis", "column-gap", "row-gap", "scale", "justify-items", "grid-template-rows", // Position, Layout, item (figma-sections.js)
81
+ "outline-style", "outline-offset", "user-select", "visibility", "object-position", "text-overflow", "border-top-left-radius", // style-sections.js
82
+ ...CSS_SECTIONS.flatMap(([, , c]) => (c === "box" ? [] : c.map((x) => x.p))),
83
+ ...BOX_PROPS,
84
+ ...TOOLBAR_PROPS,
85
+ ])];
86
+
87
+ const LENGTH = new Set(["width", "height", "min-width", "max-width", "min-height", "max-height", "top", "right", "bottom", "left", "gap", "font-size", "line-height", "letter-spacing", "border-top-width", "border-radius", ...BOX_PROPS]);
88
+
89
+ /** A bare number means pixels for length properties (24 -> 24px). */
90
+ export function normalizeValue(prop, value) {
91
+ const v = String(value).trim();
92
+ if (LENGTH.has(prop) && /^-?\d*\.?\d+$/.test(v) && Number(v) !== 0) return `${v}px`;
93
+ return v;
94
+ }
95
+
96
+ export function rgbToHex(value) {
97
+ const m = String(value).match(/^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?\s*\)$/);
98
+ if (!m) return /^#[0-9a-f]{6}$/i.test(value) ? value : "";
99
+ if (m[4] !== undefined && Number(m[4]) === 0) return "";
100
+ return "#" + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, "0")).join("");
101
+ }
102
+
103
+ /** "color: red; padding: 4px" -> { color: "red", padding: "4px" } */
104
+ export function parseInline(style) {
105
+ const out = {};
106
+ String(style || "").split(";").forEach((d) => {
107
+ const i = d.indexOf(":");
108
+ if (i > 0) out[d.slice(0, i).trim().toLowerCase()] = d.slice(i + 1).trim();
109
+ });
110
+ return out;
111
+ }
@@ -0,0 +1,120 @@
1
+ // The top of the Design tab as components: where the element is (crumbs), what tag it is, what you can do with it, how edits
2
+ // are written (legend, breakpoint and state), and what it says (content). They read the panel's state (breakpoint, state, the
3
+ // frame's breakpoint) through useUi() and act through `actions`, which the editor provides:
4
+ // select(path) · move(dir) · duplicate() · remove() · setTag(tag) · setBreakpoint(bp) · setState(st) · setText(text) · setAttr(name, value)
5
+ import { html, useState } from "../vendor/preact-htm.js";
6
+ import { BREAKPOINT_WIDTH, parseToken } from "@windcraft/core/class-groups.js";
7
+ import { TAG_KINDS, variant } from "./panel-model.js";
8
+ import { useUi } from "./panel-state.js";
9
+ import { TextInput, TextArea, SelectInput, Field } from "./controls.js";
10
+ import { I, IconBtn } from "./figma-sections.js";
11
+
12
+ const Crumbs = ({ s, actions }) => html`<div class="crumbs">${s.crumbs.map((c, i) => html`${i > 0 && html`<span class="sl">/</span>`}<button type="button" class=${i === s.crumbs.length - 1 ? "cur" : ""} onClick=${() => actions.select(c.path || [])}>${c.tag}</button>`)}</div>`;
13
+
14
+ /**
15
+ * The element's tag (only tags that stay valid where it sits and for what it holds, grouped by kind) and what can be done with
16
+ * it: move earlier / later among its siblings, duplicate, delete. One compact row under the crumbs.
17
+ */
18
+ const ElementBar = ({ s, actions }) => {
19
+ const opts = s.tagOptions || [];
20
+ const many = (s.count || 1) > 1;
21
+ const groups = TAG_KINDS.map(([label, re]) => [label, opts.filter((t) => re.test(t)).map((t) => [t, `<${t}>`])]).filter(([, list]) => list.length);
22
+ const canTag = opts.length > 1 && !many;
23
+ if (s.tag === "body") return null;
24
+ return html`<div class="ebar">
25
+ <label class=${`nf etag ${canTag ? "" : "off"}`} title=${canTag ? "Change the tag (only tags that stay valid here are listed)" : "This tag can't change here"}>
26
+ <span class="nfl">Tag</span>${canTag
27
+ ? html`<${SelectInput} class="nfs wide" value=${s.tag} groups=${groups} onChange=${actions.setTag} />`
28
+ : html`<span class="nfi etagv">${many ? `${s.count} selected` : `<${s.tag}>`}</span>`}</label>
29
+ <div class="ibg">
30
+ <${IconBtn} icon=${I.up} title="Move earlier (before the previous sibling)" onClick=${() => actions.move(-1)} />
31
+ <${IconBtn} icon=${I.down} title="Move later (after the next sibling)" onClick=${() => actions.move(1)} />
32
+ <${IconBtn} icon=${I.copy} title="Duplicate (Ctrl/Cmd+D)" onClick=${actions.duplicate} />
33
+ <button type="button" class="ib danger" title="Delete (Del)" onClick=${actions.remove}>${I.trash}</button>
34
+ </div></div>`;
35
+ };
36
+
37
+ /** A key to the edge on fields: blue for a Tailwind class, amber for CSS. */
38
+ const Legend = () => html`<p class="legend"><span class="edge tw"></span>Tailwind <span class="edge css"></span>CSS</p>`;
39
+
40
+ /**
41
+ * Where edits go, as a frontend developer thinks of it: Screen (all sizes, or from a breakpoint up: md:) and State (default, or
42
+ * hover:, focus:...). With All and Default an edit is a plain class. A dot marks a breakpoint or state this element already has
43
+ * classes for; picking a breakpoint also takes the frame to its width, so what is edited is what is seen.
44
+ */
45
+ const SCREENS = [["", "All"], ["sm", "sm"], ["md", "md"], ["lg", "lg"], ["xl", "xl"], ["2xl", "2xl"]];
46
+ const MAIN_STATES = [["", "Default"], ["hover", "Hover"], ["focus", "Focus"]]; // the rest in the + menu; any this element uses gets a chip
47
+ /** Every state Tailwind has a prefix for, grouped as the + menu shows them: [prefix, label, when it applies]. */
48
+ const STATE_GROUPS = [
49
+ ["Interaction", [["hover", "Hover", "on hover"], ["focus", "Focus", "when focused"], ["focus-visible", "Focus visible", "when focused from the keyboard"], ["focus-within", "Focus within", "when something inside is focused"], ["active", "Active", "while pressed"], ["visited", "Visited", "once the link was visited"]]],
50
+ ["Form", [["disabled", "Disabled", "when disabled"], ["checked", "Checked", "when checked"], ["required", "Required", "when required"], ["invalid", "Invalid", "when its value is invalid"], ["placeholder-shown", "Placeholder shown", "while the placeholder shows"], ["read-only", "Read only", "when read-only"]]],
51
+ ["Children", [["first", "First", "on the first child"], ["last", "Last", "on the last child"], ["odd", "Odd", "on odd children"], ["even", "Even", "on even children"], ["only", "Only", "on an only child"], ["empty", "Empty", "when it has nothing inside"]]],
52
+ ["Parent and sibling", [["group-hover", "Group hover", "when its .group parent is hovered"], ["group-focus", "Group focus", "when its .group parent is focused"], ["peer-hover", "Peer hover", "when the .peer before it is hovered"], ["peer-focus", "Peer focus", "when the .peer before it is focused"], ["peer-checked", "Peer checked", "when the .peer before it is checked"]]],
53
+ ["Parts", [["placeholder", "Placeholder", "its placeholder text"], ["before", "Before", "its ::before"], ["after", "After", "its ::after"], ["selection", "Selection", "selected text in it"], ["marker", "Marker", "its list markers"], ["file", "File button", "its file-input button"]]],
54
+ ["Theme and media", [["dark", "Dark", "in dark mode"], ["print", "Print", "when printed"], ["motion-reduce", "Reduced motion", "when motion is reduced"]]],
55
+ ];
56
+ const STATE_INFO = Object.fromEntries(STATE_GROUPS.flatMap(([, list]) => list.map(([v, l, w]) => [v, { label: l, when: w }])));
57
+ const STATE_WORDS = Object.fromEntries(Object.entries(STATE_INFO).map(([v, i]) => [v, i.when]));
58
+ // the states the canvas can show on the selected element (the rest apply on their own: disabled, first, dark...)
59
+ const SHOWABLE = new Set(["hover", "focus", "focus-visible", "focus-within", "active", "group-hover", "group-focus", "peer-hover", "peer-focus"]);
60
+
61
+ /** The + menu: every state, grouped, the ones this element uses marked; picking one makes it the state edits go to. */
62
+ function StateMenu({ used, current, onPick }) {
63
+ const [open, setOpen] = useState(false);
64
+ return html`<div class="vadd">
65
+ <button type="button" class=${`vchip vplus ${open ? "on" : ""}`} title="More states (disabled, first, dark, group-hover…)" onClick=${() => setOpen(!open)}>+</button>
66
+ ${open && html`<div class="vpop" onMouseLeave=${() => setOpen(false)}>
67
+ ${STATE_GROUPS.map(([g, list]) => html`<p class="vpg">${g}</p><div class="vpl">${list.map(([v, l, w]) => html`<button type="button"
68
+ class=${`vpi ${current === v ? "on" : ""}`} title=${`${w} (${v}:)`} onClick=${() => { setOpen(false); onPick(v); }}>${l}${used.has(v) && html`<i></i>`}</button>`)}</div>`)}
69
+ </div>`}</div>`;
70
+ }
71
+
72
+ const VariantBar = ({ s, actions }) => {
73
+ const ui = useUi(), prefix = variant(ui);
74
+ // the breakpoints and states this element has classes for
75
+ const used = { bp: new Set(), st: new Set() };
76
+ String(s.classes || "").split(/\s+/).filter(Boolean).forEach((t) => {
77
+ const v = parseToken(t).variant.split(":").filter(Boolean);
78
+ v.forEach((x) => (BREAKPOINT_WIDTH[x] !== undefined ? used.bp.add(x) : used.st.add(x)));
79
+ });
80
+ const chip = (on, dot, label, title, onClick) => html`<button type="button" class=${`vchip ${on ? "on" : ""}`} title=${title} onClick=${onClick}>${label}${dot && html`<i></i>`}</button>`;
81
+ // states beyond the four main ones that this element uses, or that is picked: a chip of their own, one click away
82
+ const others = [...new Set([...used.st, ui.st].filter((x) => x && !MAIN_STATES.some(([m]) => m === x)))];
83
+ const where = [ui.bp && `from ${BREAKPOINT_WIDTH[ui.bp]}px wide`, ui.st && STATE_WORDS[ui.st]].filter(Boolean).join(", ");
84
+ const hidden = ui.bp && ui.frameW && ui.frameW < BREAKPOINT_WIDTH[ui.bp]; // the frame is narrower: these classes don't show there
85
+ return html`<div class="vbar2">
86
+ <div class="vrow"><span class="vl">Screen</span><div class="vchips">${SCREENS.map(([v, l]) => chip(ui.bp === v, v && used.bp.has(v), l,
87
+ v ? `${l}: ${BREAKPOINT_WIDTH[v]}px and wider (${v}:)${used.bp.has(v) ? " · this element has classes here" : ""}` : "Every screen size (plain classes)",
88
+ () => actions.setBreakpoint(ui.bp === v ? "" : v)))}</div></div>
89
+ <div class="vrow vtop"><span class="vl">State</span><div class="vstates">
90
+ <div class="vchips">${MAIN_STATES.map(([v, l]) => chip(ui.st === v, v && used.st.has(v), l,
91
+ v ? `${STATE_WORDS[v]} (${v}:)${used.st.has(v) ? " · this element has classes here" : ""}` : "Its normal look (no state)", () => actions.setState(ui.st === v ? "" : v)))}
92
+ <${StateMenu} used=${used.st} current=${ui.st} onPick=${(v) => actions.setState(v)} /></div>
93
+ ${others.length > 0 && html`<div class="vchips vextra">${others.map((v) => chip(ui.st === v, used.st.has(v), (STATE_INFO[v] || {}).label || v,
94
+ `${STATE_WORDS[v] || v} (${v}:)${used.st.has(v) ? " · this element has classes here" : ""}`, () => actions.setState(ui.st === v ? "" : v)))}</div>`}
95
+ </div></div>
96
+ ${prefix && html`<p class="vnote on">Editing <code>${prefix}</code> styles: ${where}.${ui.st && SHOWABLE.has(ui.st) ? " The selected element shows this state on the canvas." : ""} Fields show this element's ${prefix} values; faint ones are inherited.
97
+ ${hidden ? html` The frame is narrower than ${BREAKPOINT_WIDTH[ui.bp]}px, so they don't show in it.` : null}
98
+ <button type="button" onClick=${() => { actions.setBreakpoint(""); actions.setState(""); }}>Back to all</button></p>`}
99
+ </div>`;
100
+ };
101
+
102
+ /** The element's text and the attributes that matter for its kind; each applies as you type. */
103
+ const Content = ({ s, actions }) => {
104
+ const fields = [];
105
+ if (s.tag === "img") fields.push(["src", "Image URL"], ["alt", "Alt text"]);
106
+ if (s.tag === "a") fields.push(["href", "Link"], ["target", "Target"]);
107
+ if (["input", "textarea"].includes(s.tag)) fields.push(["placeholder", "Placeholder"], ["type", "Type"], ["value", "Value"]);
108
+ const text = s.leaf && !["img", "input", "hr", "br"].includes(s.tag);
109
+ if (!text && !fields.length) return null;
110
+ return html`<div class="sec"><div class="secb nopad"><p class="sub first">Content</p>
111
+ ${text && html`<${TextArea} value=${s.text} placeholder="Text" onInput=${actions.setText} />`}
112
+ ${fields.map(([a, l]) => html`<${Field} label=${l}><${TextInput} value=${s.attrs[a] || ""} onInput=${(v) => actions.setAttr(a, v)} /></${Field}>`)}
113
+ </div></div>`;
114
+ };
115
+
116
+ /** Everything above the Design tab's sections. */
117
+ export function DesignTop({ s, actions }) {
118
+ return html`<${Crumbs} s=${s} actions=${actions} /><${ElementBar} s=${s} actions=${actions} /><${Legend} />
119
+ <${VariantBar} s=${s} actions=${actions} /><${Content} s=${s} actions=${actions} />`;
120
+ }
@@ -0,0 +1,40 @@
1
+ // A phone and a tablet drawn around a page at the device's real screen size: bezel, notch or camera, side buttons. Used by the
2
+ // prototype viewer and the gallery's device switch. Desktop has no frame.
3
+ export const DEVICES = {
4
+ mobile: { w: 390, h: 844, bz: 14, r: 56, screenR: 44, ring: "#cdbfd8", notch: true },
5
+ tablet: { w: 768, h: 1024, bz: 22, r: 42, screenR: 22, ring: "#d4d4d8", notch: false },
6
+ };
7
+
8
+ /**
9
+ * The CSS of each part, at full size (the caller scales the shell, e.g. `transform: scale(k)`). Positions are inside the shell,
10
+ * whose size is `w` by `h`; the screen is `dev.w` by `dev.h`.
11
+ */
12
+ export function deviceParts(dev, opts = {}) {
13
+ const w = dev.w + 2 * dev.bz, h = dev.h + 2 * dev.bz;
14
+ return {
15
+ w, h,
16
+ shell: `position:absolute;left:0;top:0;width:${w}px;height:${h}px;transform-origin:0 0;background:#161618;border-radius:${dev.r}px;box-shadow:0 0 0 3px ${dev.ring},inset 0 0 0 2px #2b2b2f,${opts.shadow || "0 28px 70px rgba(0,0,0,.55)"};`,
17
+ screen: `position:absolute;left:${dev.bz}px;top:${dev.bz}px;width:${dev.w}px;height:${dev.h}px;border-radius:${dev.screenR}px;overflow:hidden;background:#fff;`,
18
+ frame: `position:absolute;left:0;top:0;width:${dev.w}px;height:${dev.h}px;border:0;background:#fff;`,
19
+ notch: dev.notch ? `display:block;position:absolute;z-index:2;pointer-events:none;top:${dev.bz}px;left:${w / 2 - 63}px;width:126px;height:30px;background:#161618;border-radius:0 0 20px 20px;box-shadow:inset 0 -1px 0 #2b2b2f;` : "display:none",
20
+ cam: dev.notch
21
+ ? `display:block;position:absolute;z-index:3;pointer-events:none;top:${dev.bz + 9}px;left:${w / 2 + 30}px;width:11px;height:11px;border-radius:50%;background:#0b1220;box-shadow:inset 0 0 0 2px #2a3344;`
22
+ : `display:block;position:absolute;pointer-events:none;top:${dev.bz / 2 - 3}px;left:${w / 2 - 3}px;width:6px;height:6px;border-radius:50%;background:#2a3344;`,
23
+ // a mute switch and two volume keys on the left, power on the right (a phone only); the strip is a little wider than the shell
24
+ side: dev.notch
25
+ ? `display:block;position:absolute;left:-8px;right:-8px;top:0;bottom:0;pointer-events:none;background:linear-gradient(${dev.ring},${dev.ring}) 0 100px/4px 26px no-repeat,linear-gradient(${dev.ring},${dev.ring}) 0 160px/4px 52px no-repeat,linear-gradient(${dev.ring},${dev.ring}) 0 226px/4px 52px no-repeat,linear-gradient(${dev.ring},${dev.ring}) ${w + 11}px 190px/4px 88px no-repeat;`
26
+ : "display:none",
27
+ };
28
+ }
29
+
30
+ /** The frame as elements (`opts.shadow`: its drop shadow), for a page that is not the prototype viewer: `{ root, screen }` — put an iframe (or anything) in `screen`. */
31
+ export function buildDeviceFrame(kind, scale = 1, opts = {}) {
32
+ const dev = DEVICES[kind], p = deviceParts(dev, opts);
33
+ const make = (css) => { const e = document.createElement("div"); e.style.cssText = css; return e; };
34
+ const root = make(`position:relative;width:${p.w * scale}px;height:${p.h * scale}px;margin:16px auto;`);
35
+ const shell = make(p.shell + `transform:scale(${scale});`);
36
+ const screen = make(p.screen);
37
+ shell.append(screen, make(p.notch), make(p.cam), make(p.side));
38
+ root.appendChild(shell);
39
+ return { root, screen, dev, parts: p };
40
+ }