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.
- package/README.md +49 -2
- package/core/board-layout.js +71 -0
- package/core/class-groups.js +210 -0
- package/core/design-spec.js +303 -0
- package/core/exporters.js +156 -0
- package/core/pages.js +39 -0
- package/core/prop-tailwind.js +158 -0
- package/core/split-sections.js +99 -0
- package/core/theme.js +87 -0
- package/design/agent.js +2118 -0
- package/design/classes-section.js +103 -0
- package/design/code-editor.js +293 -0
- package/design/code-view.js +158 -0
- package/design/color-picker.js +107 -0
- package/design/config.js +5 -0
- package/design/controls.js +132 -0
- package/design/css-props.js +111 -0
- package/design/design-tab.js +120 -0
- package/design/device-frame.js +40 -0
- package/design/editor/add-menu.js +151 -0
- package/design/editor/api.js +47 -0
- package/design/editor/base.js +55 -0
- package/design/editor/code-panel.js +206 -0
- package/design/editor/color-ui.js +135 -0
- package/design/editor/designs.js +606 -0
- package/design/editor/docs.js +121 -0
- package/design/editor/frame-handles.js +223 -0
- package/design/editor/frame-messages.js +195 -0
- package/design/editor/inspector.js +461 -0
- package/design/editor/layers.js +61 -0
- package/design/editor/pages-panel.js +161 -0
- package/design/editor/panel-resize.js +47 -0
- package/design/editor/plane.js +132 -0
- package/design/editor/present.js +52 -0
- package/design/editor/toolbar.js +267 -0
- package/design/editor.js +47 -0
- package/design/figma-sections.js +440 -0
- package/design/float-toolbar.js +163 -0
- package/design/group-controls.js +52 -0
- package/design/host.js +22 -0
- package/design/panel-model.js +130 -0
- package/design/panel-state.js +40 -0
- package/design/panel.js +212 -0
- package/design/presenter.js +312 -0
- package/design/prop-sections.js +18 -0
- package/design/selection-overlay.js +93 -0
- package/design/store.js +29 -0
- package/design/style-sections.js +315 -0
- package/design/tailwind-classes.js +65 -0
- package/embed-frame.js +43 -0
- package/embed.html +15 -0
- package/embed.js +60 -0
- package/index.js +62 -0
- package/package.json +50 -5
- package/playground/component-source.js +124 -0
- package/styles.css +402 -0
- package/template.js +247 -0
- package/vendor/codemirror.js +24 -0
- package/vendor/preact-htm.js +1 -0
- package/vendor/prettier.js +171 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// The small toolbar that floats next to whatever is selected (text, a shape, or a whole frame).
|
|
2
|
+
// It only renders markup; editor.js places it and turns its clicks into edits.
|
|
3
|
+
import { rgbToHex } from "./css-props.js";
|
|
4
|
+
|
|
5
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
6
|
+
const icon = (d) => `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
|
|
7
|
+
|
|
8
|
+
const ICONS = {
|
|
9
|
+
bold: icon('<path d="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z"/>'),
|
|
10
|
+
italic: icon('<path d="M10 5h8M6 19h8M15 5 9 19"/>'),
|
|
11
|
+
underline: icon('<path d="M7 4v7a5 5 0 0 0 10 0V4M5 20h14"/>'),
|
|
12
|
+
link: icon('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>'),
|
|
13
|
+
left: icon('<path d="M4 6h16M4 10h10M4 14h16M4 18h10"/>'),
|
|
14
|
+
center: icon('<path d="M4 6h16M7 10h10M4 14h16M7 18h10"/>'),
|
|
15
|
+
right: icon('<path d="M4 6h16M10 10h10M4 14h16M10 18h10"/>'),
|
|
16
|
+
justify: icon('<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>'),
|
|
17
|
+
radius: icon('<path d="M5 19v-7a7 7 0 0 1 7-7h7"/>'),
|
|
18
|
+
border: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4.5" width="16" height="3.4" rx="1.2"/><rect x="4" y="10.3" width="16" height="2.4" rx="1.1"/><rect x="4" y="15.6" width="16" height="1.4" rx=".7"/></svg>',
|
|
19
|
+
spacing: icon('<rect x="3.5" y="3.5" width="17" height="17" rx="3.5" stroke-dasharray="3 2.6"/><rect x="8.5" y="8.5" width="7" height="7" rx="1.6"/>'),
|
|
20
|
+
sides: icon('<rect x="4" y="4" width="16" height="16" rx="3.5" stroke-dasharray="3.4 3"/>'),
|
|
21
|
+
corners: icon('<path d="M4 9V7a3 3 0 0 1 3-3h2M15 4h2a3 3 0 0 1 3 3v2M20 15v2a3 3 0 0 1-3 3h-2M9 20H7a3 3 0 0 1-3-3v-2"/>'),
|
|
22
|
+
none: icon('<circle cx="12" cy="12" r="8"/><path d="M6.3 6.3l11.4 11.4"/>'),
|
|
23
|
+
solid: icon('<path d="M4 12h16"/>'),
|
|
24
|
+
dashed: icon('<path d="M3 12h4M10 12h4M17 12h4"/>'),
|
|
25
|
+
dotted: icon('<path d="M4 12h.01M8 12h.01M12 12h.01M16 12h.01M20 12h.01" stroke-width="2.4"/>'),
|
|
26
|
+
more: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="12" cy="19" r="1.7"/></svg>',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const TEXT_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6", "p", "span", "a", "button", "label", "li", "strong", "em", "small", "blockquote", "td", "th", "figcaption"]);
|
|
30
|
+
const SIZES = [10, 11, 12, 13, 14, 15, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 96];
|
|
31
|
+
|
|
32
|
+
const num = (v) => { const n = parseFloat(v); return Number.isFinite(n) ? Math.round(n * 10) / 10 : ""; };
|
|
33
|
+
const familyOf = (css) => {
|
|
34
|
+
const first = String(css || "").split(",")[0].replace(/["']/g, "").trim().toLowerCase();
|
|
35
|
+
if (/mono|courier|consolas|menlo/.test(first)) return "mono";
|
|
36
|
+
if (/^(georgia|times|garamond|palatino|serif|ui-serif|cambria)/.test(first)) return "serif";
|
|
37
|
+
return "sans";
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const btn = (act, svg, title, on) => `<button type="button" class="tbtn ${on ? "on" : ""}" data-ft="${act}" title="${title}">${svg}</button>`;
|
|
41
|
+
const swatch = (kind, value, title) => `<label class="swatch" title="${title}" style="background:${value || "#fff"}${value ? "" : ";background-image:linear-gradient(135deg,transparent 46%,#e5484d 46%,#e5484d 54%,transparent 54%)"}"><input type="color" data-ftcol="${kind}" value="${value || "#000000"}"></label>`;
|
|
42
|
+
|
|
43
|
+
function moreMenu(st) {
|
|
44
|
+
const abs = st.sel && st.sel.geom && st.sel.geom.abs;
|
|
45
|
+
const item = (act, label, hint = "") => `<button type="button" data-ft="${act}" class="flex h-8 w-full items-center justify-between rounded-lg px-2.5 text-left text-[12px] hover:bg-neutral-100"><span>${label}</span><span class="text-neutral-400">${hint}</span></button>`;
|
|
46
|
+
return `<div class="pop absolute right-0 top-9 z-40 w-52 p-1">
|
|
47
|
+
${item("dup", "Duplicate", "Ctrl+D")}${item("up", "Move earlier")}${item("down", "Move later")}
|
|
48
|
+
${item("front", "Bring to front", "Ctrl+Shift+]")}${item("back", "Send to back", "Ctrl+Shift+[")}
|
|
49
|
+
${item("free", abs ? "Back to page flow" : "Free position")}
|
|
50
|
+
${st.sel && st.sel.count > 1 ? item("group", "Group", "Ctrl+G") : ""}${st.sel && (st.sel.group || (abs && st.sel.leaf === false)) ? item("ungroup", "Ungroup", "Ctrl+Shift+G") : ""}
|
|
51
|
+
${item("props", "All properties")}
|
|
52
|
+
<div class="my-1 h-px bg-neutral-100"></div>${item("del", "Delete", "Del")}
|
|
53
|
+
</div>`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const more = (st) => `<span class="sep"></span><div class="relative">${btn("more", ICONS.more, "More", st.menu)}${st.menu ? moreMenu(st) : ""}</div>`;
|
|
57
|
+
|
|
58
|
+
function textBar(st) {
|
|
59
|
+
const cs = (st.sel && st.sel.computed) || {};
|
|
60
|
+
const size = num(cs["font-size"]);
|
|
61
|
+
const fam = familyOf(cs["font-family"]);
|
|
62
|
+
const color = rgbToHex(cs.color || "") || "#000000";
|
|
63
|
+
const fill = rgbToHex(cs["background-color"] || "");
|
|
64
|
+
const fmt = st.editing && st.fmt ? st.fmt : { bold: parseInt(cs["font-weight"], 10) >= 600, italic: cs["font-style"] === "italic", underline: /underline/.test(cs["text-decoration-line"] || "") };
|
|
65
|
+
const align = ["center", "right", "justify"].includes(cs["text-align"]) ? cs["text-align"] : "left";
|
|
66
|
+
const hasFill = st.sel && ["button", "a"].includes(st.sel.tag);
|
|
67
|
+
return `<select data-ftsel="family" title="Font">${[["sans", "Sans"], ["serif", "Serif"], ["mono", "Mono"]].map(([v, l]) => `<option value="${v}" ${fam === v ? "selected" : ""}>${l}</option>`).join("")}</select>
|
|
68
|
+
<input type="number" min="1" max="400" data-ftin="size" value="${size}" list="ft-sizes" title="Size">
|
|
69
|
+
<datalist id="ft-sizes">${SIZES.map((n) => `<option value="${n}"></option>`).join("")}</datalist>
|
|
70
|
+
<span class="sep"></span>${swatch("text", color, "Text color")}${hasFill ? swatch("fill", fill, "Fill color") : ""}
|
|
71
|
+
<span class="sep"></span>${btn("bold", ICONS.bold, "Bold", fmt.bold)}${btn("italic", ICONS.italic, "Italic", fmt.italic)}${btn("underline", ICONS.underline, "Underline", fmt.underline)}${btn("link", ICONS.link, "Link")}
|
|
72
|
+
<span class="sep"></span>${btn("align", ICONS[align], `Align ${align}`)}${st.editing ? "" : boxButtons(st)}${more(st)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Border, Radius and Spacing: three buttons, each opening a small panel above the bar (see popHtml)
|
|
76
|
+
const popBtn = (st, key, svg, title) => `<button type="button" class="tbtn ${st.pop === key ? "on" : ""}" data-ft="pop" data-arg="${key}" title="${title}">${svg}</button>`;
|
|
77
|
+
const boxButtons = (st) => `<span class="sep"></span>${popBtn(st, "border", ICONS.border, "Border")}${popBtn(st, "radius", ICONS.radius, "Corner radius")}${popBtn(st, "spacing", ICONS.spacing, "Padding and margin")}`;
|
|
78
|
+
|
|
79
|
+
function shapeBar(st) {
|
|
80
|
+
const cs = (st.sel && st.sel.computed) || {};
|
|
81
|
+
const fill = rgbToHex(cs["background-color"] || "");
|
|
82
|
+
const hasText = st.sel && st.sel.leaf === false ? false : true;
|
|
83
|
+
return `<span class="lbl2">Fill</span>${swatch("fill", fill, "Fill color")}${hasText ? `<span class="lbl2">Text</span>${swatch("text", rgbToHex(cs.color || "") || "#000000", "Text color")}` : ""}
|
|
84
|
+
${boxButtons(st)}${more(st)}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── The panels ───────────────────────────────────────────────────────────────
|
|
88
|
+
const px = (v) => { const n = parseFloat(v); return Number.isFinite(n) ? Math.round(n * 10) / 10 : 0; };
|
|
89
|
+
const field = (label, prop, value, min = 0) => `<label class="ffield"><span class="fl">${label}</span><input type="number" ${min === null ? "" : `min="${min}"`} step="1" data-ftp="${prop}" value="${value}"><span class="fu">px</span></label>`;
|
|
90
|
+
const sidesBtn = (key, open, svg = ICONS.sides) => `<button type="button" class="tbtn sidebtn ${open ? "on" : ""}" data-ft="side" data-arg="${key}" title="${open ? "Same on all sides" : "Each side on its own"}">${svg}</button>`;
|
|
91
|
+
/** Whether a group shows one field per side: your last choice, or automatically when the sides differ. */
|
|
92
|
+
const isOpen = (st, key, differs) => (st.sides && st.sides[key] !== undefined ? st.sides[key] : differs);
|
|
93
|
+
const same = (a) => a.every((v) => v === a[0]);
|
|
94
|
+
/** Four per-side fields in two rows, with the switch back to "all sides" at the end of the first row. */
|
|
95
|
+
const grid4 = (f, btn) => `<div class="fgrid">${f[0]}${f[1]}${btn}${f[2]}${f[3]}</div>`;
|
|
96
|
+
|
|
97
|
+
function borderPanel(st, cs) {
|
|
98
|
+
const sides = ["top", "right", "bottom", "left"];
|
|
99
|
+
const w = sides.map((d) => px(cs[`border-${d}-width`]));
|
|
100
|
+
const seen = sides.find((d, i) => w[i] > 0) || "top";
|
|
101
|
+
const kind = w.every((v) => v === 0) ? "none" : ["dashed", "dotted"].includes(cs[`border-${seen}-style`]) ? cs[`border-${seen}-style`] : "solid";
|
|
102
|
+
const hex = (rgbToHex(cs[`border-${seen}-color`] || "") || "#000000").toUpperCase();
|
|
103
|
+
const open = isOpen(st, "border", !same(w));
|
|
104
|
+
const seg = ["none", "solid", "dashed", "dotted"].map((k) => `<button type="button" data-ft="bstyle" data-arg="${k}" title="${k[0].toUpperCase() + k.slice(1)}" class="${kind === k ? "on" : ""}">${ICONS[k]}</button>`).join("");
|
|
105
|
+
const widths = open
|
|
106
|
+
? grid4(["top", "right", "bottom", "left"].map((d, i) => field(d[0].toUpperCase(), `bw-${d}`, w[i])), sidesBtn("border", true))
|
|
107
|
+
: `<div class="frow">${field(ICONS.border, "bw-all", w[0])}${sidesBtn("border", false)}</div>`;
|
|
108
|
+
return `<div class="fseg">${seg}</div>
|
|
109
|
+
<div class="frow"><label class="swatch big" style="background:${hex}"><input type="color" data-ftp="border-color" value="${hex}"></label><input type="text" class="fhex" data-ftp="border-color-hex" value="${hex}" spellcheck="false"></div>
|
|
110
|
+
${widths}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function radiusPanel(st, cs) {
|
|
114
|
+
const r = ["top-left", "top-right", "bottom-left", "bottom-right"].map((c) => px(cs[`border-${c}-radius`]));
|
|
115
|
+
const open = isOpen(st, "radius", !same(r));
|
|
116
|
+
return open
|
|
117
|
+
? grid4([["TL", "tl", 0], ["TR", "tr", 1], ["BL", "bl", 2], ["BR", "br", 3]].map(([l, k, i]) => field(l, `r-${k}`, r[i])), sidesBtn("radius", true, ICONS.corners))
|
|
118
|
+
: `<div class="frow">${field("", "r-all", r[0])}${sidesBtn("radius", false, ICONS.corners)}</div>`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function spacingPanel(st, cs) {
|
|
122
|
+
const group = (key, title, prefix, min) => {
|
|
123
|
+
const v = ["top", "right", "bottom", "left"].map((d) => px(cs[`${prefix}-${d}`]));
|
|
124
|
+
const open = isOpen(st, key, v[0] !== v[2] || v[1] !== v[3]);
|
|
125
|
+
const body = open
|
|
126
|
+
? grid4([["T", "top", 0], ["R", "right", 1], ["B", "bottom", 2], ["L", "left", 3]].map(([l, d, i]) => field(l, `${key}-${d}`, v[i], min)), sidesBtn(key, true))
|
|
127
|
+
: `<div class="frow">${field("H", `${key}-h`, v[1], min)}${field("V", `${key}-v`, v[0], min)}${sidesBtn(key, false)}</div>`;
|
|
128
|
+
return `<div class="fttl">${title}</div>${body}`;
|
|
129
|
+
};
|
|
130
|
+
return `${group("padding", "Padding", "padding", 0)}${group("margin", "Margin", "margin", null)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The open panel, if any; placed by CSS above the bar (or below it when the bar is near the top). */
|
|
134
|
+
function popHtml(st) {
|
|
135
|
+
if (!st.pop || !st.sel) return "";
|
|
136
|
+
const cs = st.sel.computed || {};
|
|
137
|
+
const body = st.pop === "border" ? borderPanel(st, cs) : st.pop === "radius" ? radiusPanel(st, cs) : spacingPanel(st, cs);
|
|
138
|
+
return `<div class="ftpop">${body}</div>`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const DEVICES = [[1280, "Desktop"], [768, "Tablet"], [390, "Mobile"]];
|
|
142
|
+
function frameBar(st) {
|
|
143
|
+
const f = st.frame;
|
|
144
|
+
const preset = DEVICES.some(([w]) => w === f.w);
|
|
145
|
+
// a Custom button appears once the frame has a size that isn't a preset, and keeps your last manual size within reach
|
|
146
|
+
const custom = f.custom || (!preset ? { w: f.w, h: f.auto ? null : f.h } : null);
|
|
147
|
+
const customBtn = custom
|
|
148
|
+
? `<button type="button" data-ftdev="custom" title="${custom.w} × ${custom.h || "auto"}" class="h-7 rounded-lg px-2 text-[12px] font-medium hover:bg-neutral-100 ${!preset ? "bg-neutral-100" : ""}">Custom</button>`
|
|
149
|
+
: "";
|
|
150
|
+
return `${DEVICES.map(([w, l]) => `<button type="button" data-ftdev="${w}" class="h-7 rounded-lg px-2 text-[12px] font-medium hover:bg-neutral-100 ${f.w === w ? "bg-neutral-100" : ""}">${l}</button>`).join("")}${customBtn}
|
|
151
|
+
<span class="sep"></span><span class="lbl2">W</span><input type="number" min="120" max="8000" data-ftin="fw" value="${f.w}"><span class="lbl2">H</span><input type="number" min="120" max="20000" data-ftin="fh" value="${f.auto ? "" : f.h}" placeholder="auto" title="Leave empty to grow with the content">`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function toolbarHtml(st) {
|
|
155
|
+
if (st.kind === "frame") return frameBar(st);
|
|
156
|
+
return (st.kind === "text" ? textBar(st) : shapeBar(st)) + popHtml(st);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const FAMILY_STACKS = {
|
|
160
|
+
sans: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
|
161
|
+
serif: 'ui-serif, Georgia, Cambria, "Times New Roman", serif',
|
|
162
|
+
mono: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
163
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The Design tab's controls for Tailwind class groups (a size, a colour, a choice, a few buttons), drawn from controls.js.
|
|
2
|
+
// What they show comes from the selection's classes at the panel's breakpoint and state (val, useUi); what they do goes
|
|
3
|
+
// through `actions`, which the editor provides:
|
|
4
|
+
// applyValue(key, value) set a group (empty clears it)
|
|
5
|
+
// applyColor(key, name, opacity?, undoKey?) set a colour group
|
|
6
|
+
// togglePalette(key) open or close a colour's palette
|
|
7
|
+
// customColor(key, hex, done, input) the custom colour picker moved (done: it was released or closed)
|
|
8
|
+
// redraw() show the stored values again
|
|
9
|
+
import { html } from "../vendor/preact-htm.js";
|
|
10
|
+
import { val, PRESETS, OPTS, splitOpacity } from "./panel-model.js";
|
|
11
|
+
import { useUi } from "./panel-state.js";
|
|
12
|
+
import { TextInput, SelectInput, Segmented, Row, Field, ColorField } from "./controls.js";
|
|
13
|
+
|
|
14
|
+
/** A free-typed value with suggestions (a size, a spacing...). `cell`: the small labelled cell instead of a full row. */
|
|
15
|
+
export function GroupCombo({ s, k, label, cell, actions }) {
|
|
16
|
+
const ui = useUi();
|
|
17
|
+
return html`<${Field} label=${label} title=${k} cell=${cell}>
|
|
18
|
+
<${TextInput} list=${`dl-${PRESETS[k] ? k : "spacing"}`} value=${val(s, k, ui)} placeholder="-" onCommit=${(text) => actions.applyValue(k, text.trim())} />
|
|
19
|
+
</${Field}>`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One of a fixed set of values, or "-" for none. */
|
|
23
|
+
export function GroupSelect({ s, k, label, actions }) {
|
|
24
|
+
const ui = useUi();
|
|
25
|
+
return html`<${Field} label=${label}>
|
|
26
|
+
<${SelectInput} blank="-" value=${val(s, k, ui)} options=${OPTS[k]} onChange=${(v) => actions.applyValue(k, v.trim())} />
|
|
27
|
+
</${Field}>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Buttons for a few values; the one that is on turns the group off when clicked again. `items`: [value, label, title]. */
|
|
31
|
+
export function GroupSeg({ s, k, label, items, actions }) {
|
|
32
|
+
const ui = useUi();
|
|
33
|
+
return html`<${Row} label=${label}><${Segmented} value=${val(s, k, ui)} items=${items} onPick=${(v) => actions.applyValue(k, v)} /></${Row}>`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A colour: swatch, name, opacity, and the palette while it is open (the panel state's palette). */
|
|
37
|
+
export function GroupColor({ s, k, label, actions }) {
|
|
38
|
+
const ui = useUi();
|
|
39
|
+
const [name, op] = splitOpacity(val(s, k, ui));
|
|
40
|
+
const named = /^(?:black|white|transparent|current|inherit)$/.test(name) || /^[a-z]+-\d{2,3}$/.test(name);
|
|
41
|
+
const arb = name.match(/^\[(.*)\]$/);
|
|
42
|
+
return html`<${ColorField} label=${label} name=${name} opacity=${op} customKey=${k}
|
|
43
|
+
swatchStyle=${name && arb ? `background:${arb[1].replace(/_/g, " ")}` : ""}
|
|
44
|
+
swatchClass=${name && named ? (/^(?:transparent|current|inherit)$/.test(name) ? "swatch-t" : `bg-${name}`) : ""}
|
|
45
|
+
customValue=${arb && /^#[0-9a-f]{6}$/i.test(arb[1]) ? arb[1] : "#111"}
|
|
46
|
+
paletteOpen=${ui.palette === k}
|
|
47
|
+
onPalette=${() => actions.togglePalette(k)}
|
|
48
|
+
onName=${(text) => actions.applyColor(k, text.trim())}
|
|
49
|
+
onOpacity=${(text) => { const base = val(s, k, ui).replace(/\/\d+$/, ""); if (base) actions.applyColor(k, base, text.trim()); else actions.redraw(); }}
|
|
50
|
+
onPick=${(v) => actions.applyColor(k, v)}
|
|
51
|
+
onCustom=${(hex, done, input) => actions.customColor(k, hex, done, input)} />`;
|
|
52
|
+
}
|
package/design/host.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Everything the editor needs from the app around it: saving, projects, board positions, the AI and the page address.
|
|
3
|
+
// The editor only calls `host`; the page that mounts it fills it in with setHost() before loading editor.js (the app's own
|
|
4
|
+
// version is app-host.js). An editor used elsewhere supplies its own.
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} Host
|
|
7
|
+
* @property {(fields: object) => Promise<any>} createComponent saves a new design (a design opened from a Figma draft sends `draftId`, and the answer may carry `figmaSource`); resolves to the saved record ({ _id, version, ... }), rejects with the reason
|
|
8
|
+
* @property {(id: string, fields: object) => Promise<any>} updateComponent changes a saved design (a new version when its code changes)
|
|
9
|
+
* @property {(id: string) => Promise<void>} [deleteComponent] deletes a saved design (a page takes its sections with it); rejects with the reason. Omit it and the editor shows no Delete page
|
|
10
|
+
* @property {(name: string) => Promise<{ id: string, name: string }>} createProject a project is the canvas that designs share
|
|
11
|
+
* @property {(projectId: string, positions: object[]) => Promise<void>} saveLayout where each design sits on the project's canvas
|
|
12
|
+
* @property {(projectId: string, positions: object[]) => void} saveLayoutOnExit the same, while the page is closing
|
|
13
|
+
* @property {(body: object, onText?: (text: string) => void) => Promise<string>} [generate] the AI drafts a page; resolves to its HTML. Omit it and the editor shows no AI box or AI tab
|
|
14
|
+
* @property {(id: string | null) => void} showDesign the address now shows this saved design (null: an unsaved one)
|
|
15
|
+
* @property {(projectId: string) => string} [projectUrl] where a project's own page is (the project name links there); omit for none
|
|
16
|
+
* @property {(where: { projectId: string, designId: string }) => string} [previewUrl] the address of the prototype page for a saved design (the play button opens it in its own tab); omit it and Preview opens over the editor instead
|
|
17
|
+
* @property {(id: string) => void} openDesign go to this design (loads the page again)
|
|
18
|
+
* @property {(id: string) => Promise<{ changed?: boolean }>} [updateFromFigma] converts a design again from the Figma data kept with it (a new version when the code changed); omit for no such button
|
|
19
|
+
*/
|
|
20
|
+
export const host = /** @type {Host} */ ({});
|
|
21
|
+
/** @param {Host} h */
|
|
22
|
+
export const setHost = (h) => { Object.assign(host, h); };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// What the Properties panel draws from: the data tables behind its controls (suggestions, options, tags), small selectors
|
|
2
|
+
// that read the selection and the panel's state (panel-state.js), and inspectorView, which says what the panel shows. The
|
|
3
|
+
// components themselves are in panel.js, design-tab.js, figma-sections.js, style-sections.js, prop-sections.js and classes-section.js.
|
|
4
|
+
import { HUES, SHADES, SPECIAL_COLORS, STEPS, SIZES, MAX_W, GROUPS, getGroup, parseToken } from "@windcraft/core/class-groups.js";
|
|
5
|
+
import { twShown } from "@windcraft/core/prop-tailwind.js";
|
|
6
|
+
import { panelStore } from "./panel-state.js";
|
|
7
|
+
|
|
8
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
9
|
+
|
|
10
|
+
const TEXT_SIZES = ["xs", "sm", "base", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl", "7xl", "8xl", "9xl"];
|
|
11
|
+
const RADII = ["none", "sm", "md", "lg", "xl", "2xl", "3xl", "full"];
|
|
12
|
+
const PCT = ["0", "5", "10", "20", "25", "30", "40", "50", "60", "70", "75", "80", "90", "95", "100"];
|
|
13
|
+
const DEGS = ["0", "1", "2", "3", "6", "12", "45", "90", "180"];
|
|
14
|
+
const MS = ["0", "75", "100", "150", "200", "300", "500", "700", "1000"];
|
|
15
|
+
|
|
16
|
+
// suggestions for the free-type inputs (any value, including [arbitrary], is accepted)
|
|
17
|
+
export const PRESETS = {
|
|
18
|
+
w: SIZES, h: SIZES, minW: ["0", "full", "min", "max", "fit"], minH: ["0", "full", "screen", "min", "max", "fit"], maxW: MAX_W, maxH: ["none", "full", "screen", "min", "max", "fit"],
|
|
19
|
+
gap: STEPS, gapX: STEPS, gapY: STEPS, spaceX: STEPS, spaceY: STEPS, z: ["0", "10", "20", "30", "40", "50", "auto"], order: ["first", "last", "none", "1", "2", "3"],
|
|
20
|
+
gridCols: ["1", "2", "3", "4", "5", "6", "12", "none"], gridRows: ["1", "2", "3", "4", "5", "6", "none"], colSpan: ["1", "2", "3", "4", "6", "full"], rowSpan: ["1", "2", "3", "full"],
|
|
21
|
+
inset: [...STEPS, "auto", "full"], top: [...STEPS, "auto", "full"], right: [...STEPS, "auto", "full"], bottom: [...STEPS, "auto", "full"], left: [...STEPS, "auto", "full"],
|
|
22
|
+
fontSize: TEXT_SIZES, leading: ["none", "tight", "snug", "normal", "relaxed", "loose", "4", "5", "6", "7", "8", "9", "10"], tracking: ["tighter", "tight", "normal", "wide", "wider", "widest"], lineClamp: ["1", "2", "3", "4", "5", "6", "none"],
|
|
23
|
+
borderWidth: ["0", "1", "2", "4", "8"], borderT: ["0", "2", "4", "8"], borderR: ["0", "2", "4", "8"], borderB: ["0", "2", "4", "8"], borderL: ["0", "2", "4", "8"],
|
|
24
|
+
radius: ["default", ...RADII], radiusTL: RADII, radiusTR: RADII, radiusBR: RADII, radiusBL: RADII, ring: ["0", "1", "2", "4", "8"],
|
|
25
|
+
opacity: PCT, brightness: ["0", "50", "75", "90", "95", "100", "105", "110", "125", "150", "200"], rotate: DEGS, scale: ["0", "50", "75", "90", "95", "100", "105", "110", "125", "150"],
|
|
26
|
+
translateX: [...STEPS, "full", "1/2"], translateY: [...STEPS, "full", "1/2"], duration: MS, delay: MS,
|
|
27
|
+
spacing: STEPS,
|
|
28
|
+
};
|
|
29
|
+
for (const k of ["p", "px", "py", "pt", "pr", "pb", "pl"]) PRESETS[k] = STEPS;
|
|
30
|
+
for (const k of ["m", "mx", "my", "mt", "mr", "mb", "ml"]) PRESETS[k] = [...STEPS, "auto"];
|
|
31
|
+
|
|
32
|
+
const en = (prefix, list) => list.map((v) => [`${prefix}${v}`, v]);
|
|
33
|
+
export const OPTS = {
|
|
34
|
+
display: ["block", "inline-block", "inline", "flex", "inline-flex", "grid", "inline-grid", "contents", "hidden"].map((v) => [v, v]),
|
|
35
|
+
position: ["static", "relative", "absolute", "fixed", "sticky"].map((v) => [v, v]),
|
|
36
|
+
overflow: en("overflow-", ["auto", "hidden", "visible", "scroll", "clip"]),
|
|
37
|
+
overflowX: en("overflow-x-", ["auto", "hidden", "visible", "scroll", "clip"]),
|
|
38
|
+
overflowY: en("overflow-y-", ["auto", "hidden", "visible", "scroll", "clip"]),
|
|
39
|
+
visibility: [["visible", "visible"], ["invisible", "invisible"]],
|
|
40
|
+
flexWrap: en("flex-", ["wrap", "nowrap", "wrap-reverse"]),
|
|
41
|
+
justify: en("justify-", ["start", "center", "end", "between", "around", "evenly", "stretch"]),
|
|
42
|
+
items: en("items-", ["start", "center", "end", "stretch", "baseline"]),
|
|
43
|
+
content: en("content-", ["start", "center", "end", "between", "around", "evenly", "stretch"]),
|
|
44
|
+
self: en("self-", ["auto", "start", "center", "end", "stretch", "baseline"]),
|
|
45
|
+
flexNum: en("flex-", ["1", "auto", "initial", "none"]),
|
|
46
|
+
objectFit: en("object-", ["contain", "cover", "fill", "none", "scale-down"]),
|
|
47
|
+
fontFamily: en("font-", ["sans", "serif", "mono"]),
|
|
48
|
+
fontWeight: en("font-", ["thin", "extralight", "light", "normal", "medium", "semibold", "bold", "extrabold", "black"]),
|
|
49
|
+
textOverflow: [["truncate", "truncate"], ["text-ellipsis", "ellipsis"], ["text-clip", "clip"]],
|
|
50
|
+
whitespace: en("whitespace-", ["normal", "nowrap", "pre", "pre-line", "pre-wrap"]),
|
|
51
|
+
bgImage: [...en("bg-gradient-to-", ["t", "tr", "r", "br", "b", "bl", "l", "tl"]).map(([v, l]) => [v, "gradient " + l]), ["bg-none", "none"]],
|
|
52
|
+
bgSize: en("bg-", ["auto", "cover", "contain"]),
|
|
53
|
+
bgPosition: en("bg-", ["center", "top", "right", "bottom", "left", "left-top", "left-bottom", "right-top", "right-bottom"]),
|
|
54
|
+
bgRepeat: en("bg-", ["repeat", "no-repeat", "repeat-x", "repeat-y"]),
|
|
55
|
+
borderStyle: en("border-", ["solid", "dashed", "dotted", "double", "none"]),
|
|
56
|
+
shadow: [["default", "default"], ...["sm", "md", "lg", "xl", "2xl", "inner", "none"].map((v) => [v, v])],
|
|
57
|
+
blur: [["default", "default"], ...["none", "sm", "md", "lg", "xl", "2xl", "3xl"].map((v) => [v, v])],
|
|
58
|
+
backdropBlur: [["default", "default"], ...["none", "sm", "md", "lg", "xl", "2xl", "3xl"].map((v) => [v, v])],
|
|
59
|
+
grayscale: [["100", "full"], ["0", "off"]],
|
|
60
|
+
mixBlend: en("mix-blend-", ["normal", "multiply", "screen", "overlay", "darken", "lighten", "difference"]),
|
|
61
|
+
transition: [["default", "default"], ...["none", "all", "colors", "opacity", "shadow", "transform"].map((v) => [v, v])],
|
|
62
|
+
ease: en("ease-", ["linear", "in", "out", "in-out"]),
|
|
63
|
+
cursor: en("cursor-", ["pointer", "default", "text", "move", "not-allowed", "wait", "grab", "grabbing", "crosshair", "help"]),
|
|
64
|
+
pointerEvents: en("pointer-events-", ["none", "auto"]),
|
|
65
|
+
userSelect: en("select-", ["none", "text", "all", "auto"]),
|
|
66
|
+
textTransform: [["uppercase", "UPPER"], ["lowercase", "lower"], ["capitalize", "Title"], ["normal-case", "Normal"]],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const CSS_PROPS = ["background", "background-image", "background-color", "border", "border-radius", "box-shadow", "color", "cursor", "display", "filter", "font-family", "font-size", "font-weight", "gap", "grid-template-columns", "height", "justify-content", "letter-spacing", "line-height", "margin", "max-width", "min-height", "mix-blend-mode", "object-fit", "opacity", "outline", "overflow", "padding", "position", "text-align", "text-shadow", "transform", "transition", "width", "z-index", "backdrop-filter", "clip-path", "aspect-ratio", "animation", "-webkit-text-stroke", "scroll-snap-type"];
|
|
70
|
+
|
|
71
|
+
const DATALISTS = Object.entries(PRESETS).map(([k, list]) => `<datalist id="dl-${k}">${list.map((v) => `<option value="${esc(v)}"></option>`).join("")}</datalist>`).join("") +
|
|
72
|
+
`<datalist id="dl-colors">${[...SPECIAL_COLORS, ...HUES.flatMap((h) => SHADES.map((s) => `${h}-${s}`))].map((v) => `<option value="${v}"></option>`).join("")}</datalist>` +
|
|
73
|
+
`<datalist id="dl-fonts">${["sans", "serif", "mono", "system-ui", "Inter", "Arial", "Helvetica", "Georgia", "Times New Roman", "Courier New", "monospace", "sans-serif", "serif"].map((v) => `<option value="${v}"></option>`).join("")}</datalist>` + `<datalist id="dl-cssprops">${CSS_PROPS.map((v) => `<option value="${v}"></option>`).join("")}</datalist>`;
|
|
74
|
+
|
|
75
|
+
export function mountDatalists() {
|
|
76
|
+
const d = document.createElement("div");
|
|
77
|
+
d.hidden = true;
|
|
78
|
+
d.innerHTML = DATALISTS;
|
|
79
|
+
document.body.appendChild(d);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Selectors: what the selection looks like at the panel's breakpoint and state ──────────────────────────────────────────
|
|
83
|
+
// `ui` is the panel's state; left out, it is the store's current one (the editor), components pass the one they were drawn from.
|
|
84
|
+
/** The prefix new Tailwind classes get: "md:hover:", or "" for none. */
|
|
85
|
+
export const variant = (ui = panelStore.get()) => `${ui.bp ? ui.bp + ":" : ""}${ui.st ? ui.st + ":" : ""}`;
|
|
86
|
+
/** The value of a class group on the selection at that breakpoint and state, or "". */
|
|
87
|
+
export const val = (sel, k, ui = panelStore.get()) => { const r = getGroup(sel.classes, k, variant(ui)); return r ? r.value : ""; };
|
|
88
|
+
export const splitOpacity = (v) => { const m = String(v).match(/^(.*?)(?:\/(\d+))?$/); return [m[1], m[2] || ""]; };
|
|
89
|
+
|
|
90
|
+
const tokensOf = (s) => String(s.classes || "").split(/\s+/).filter(Boolean);
|
|
91
|
+
const isUtility = (tok) => { const base = parseToken(tok).base; return Object.keys(GROUPS).some((k) => GROUPS[k].re.test(base)); };
|
|
92
|
+
|
|
93
|
+
/** Whether a property nothing sets yet is written as a Tailwind class or as CSS: Tailwind, except on elements styled only by their design's own CSS. */
|
|
94
|
+
export function modeFor(s, ui = panelStore.get()) {
|
|
95
|
+
if (s.added) return "tailwind";
|
|
96
|
+
const toks = tokensOf(s);
|
|
97
|
+
if (toks.length) return toks.some(isUtility) ? "tailwind" : "css";
|
|
98
|
+
return ui.docTailwind ? "tailwind" : "css";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const srcOf = (s, p) => ((s && s.sources) || {})[p] || { source: "default" };
|
|
102
|
+
export const ownsValue = (src) => src.source === "tailwind" || src.source === "inline";
|
|
103
|
+
/** Whether the element's own styling gives the value (a class, its style attribute, the design's CSS): drawn solid; else faint. */
|
|
104
|
+
export const styled = (src) => ["tailwind", "inline", "design"].includes(src.source) || (src.source === "tie" && Boolean(src.token));
|
|
105
|
+
/** What a row shows: the class's own value on a Tailwind row (64, 1/2, red-500), the computed value otherwise. */
|
|
106
|
+
export const shownOf = (s, p, src) => (src.source === "tailwind" && twShown(p, src.token)) || (s.computed || {})[p] || "";
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
// the tags the element can validly become (the frame works them out from where it sits and what it holds), grouped by kind
|
|
110
|
+
export const TAG_KINDS = [["Layout", /^(div|section|article|aside|header|footer|nav|main|form|figure|blockquote)$/], ["Text", /^(p|h[1-6]|span|label|strong|em|small|b|i|u|s|code|mark|pre)$/], ["Action", /^(a|button)$/], ["List", /^(ul|ol|li|menu)$/], ["Table", /^(td|th|thead|tbody|tfoot)$/], ["Definition", /^(dt|dd)$/]];
|
|
111
|
+
export const EXTRA_KEYS = ["bgImage", "gradFrom", "gradVia", "gradTo", "ring", "ringColor", "spaceX", "spaceY", "gridRows", "colSpan", "rowSpan", "order", "self", "flexNum", "grow", "lineClamp", "rotate", "scale", "translateX", "translateY", "transition", "duration", "delay", "ease", "blur", "backdropBlur", "brightness", "grayscale", "userSelect"];
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What the panel shows, for panel.js: one of
|
|
115
|
+
* - page: the page's info (pageInfo() in editor.js), when no element is selected;
|
|
116
|
+
* - design: { key, s }, the Design tab;
|
|
117
|
+
* - code: { key, path, source, indent, s }, the Code tab: the element's own code, then its classes, inline CSS and attributes;
|
|
118
|
+
* - classes: { key, s }, the same without the element's own code (the page body has none);
|
|
119
|
+
* - ai: { key, path, outer }, the AI tab.
|
|
120
|
+
* `key` is the element's path: the panel gives each element fresh fields. `elementCode` ({ source, indent }) is the selected
|
|
121
|
+
* element's code, worked out by the editor when the Code tab is open.
|
|
122
|
+
*/
|
|
123
|
+
export function inspectorView(s, page, elementCode) {
|
|
124
|
+
if (!s) return { page };
|
|
125
|
+
const key = s.path.join(".");
|
|
126
|
+
const tab = panelStore.get().tab;
|
|
127
|
+
if (tab === "design") return { design: { key, s } };
|
|
128
|
+
if (tab === "ai") return { ai: { key, path: s.path, outer: s.outer } };
|
|
129
|
+
return elementCode ? { code: { key, path: s.path, source: elementCode.source, indent: elementCode.indent, s } } : { classes: { key, s } };
|
|
130
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// What the Properties panel remembers: its tab, the breakpoint and state new classes are written for, which sections are
|
|
2
|
+
// open, which colour palette is open, and whether the open design uses Tailwind. One store; the editor changes it through
|
|
3
|
+
// the functions below and redraws the panel when it changes (editor.js), and the components read it through useUi().
|
|
4
|
+
import { createContext, useContext } from "../vendor/preact-htm.js";
|
|
5
|
+
import { createStore } from "./store.js";
|
|
6
|
+
|
|
7
|
+
/** Sections that start open (the rest start closed). */
|
|
8
|
+
const OPEN_AT_START = ["geom", "css-layout", "css-size", "css-spacing", "css-type", "css-fill", "css-border", "layout", "size", "spacing", "typography", "fill", "border"];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* tab: "design" | "classes" (the Code tab) | "ai". bp, st: the breakpoint and state ("" is none) new classes are written for.
|
|
12
|
+
* open, closed: section ids as the panel remembers them (a section can also open by itself). palette: the colour group whose
|
|
13
|
+
* palette is open. docTailwind: whether the open design uses Tailwind (what a value nothing sets yet is written as).
|
|
14
|
+
*/
|
|
15
|
+
export const panelStore = createStore({
|
|
16
|
+
tab: "design",
|
|
17
|
+
bp: "",
|
|
18
|
+
all: false, // the All chip was clicked: edits are plain classes (bp is "" for that and for nothing picked)
|
|
19
|
+
st: "",
|
|
20
|
+
open: new Set(OPEN_AT_START),
|
|
21
|
+
closed: /** @type {Set<string>} */ (new Set()),
|
|
22
|
+
palette: /** @type {string | null} */ (null),
|
|
23
|
+
docTailwind: true,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** The state the panel is drawn from: the store's, plus what is worked out at the time of drawing (the frame's width). */
|
|
27
|
+
export const UiContext = createContext({ ...panelStore.get(), frameW: 0 });
|
|
28
|
+
export const useUi = () => useContext(UiContext);
|
|
29
|
+
|
|
30
|
+
/** Opens or closes a section: `wasOpen` is what was shown (a section can open by itself), so a click always flips what you see. */
|
|
31
|
+
export function toggleSection(id, wasOpen) {
|
|
32
|
+
panelStore.set(({ open, closed }) => {
|
|
33
|
+
const nextOpen = new Set(open), nextClosed = new Set(closed);
|
|
34
|
+
if (wasOpen) { nextOpen.delete(id); nextClosed.add(id); } else { nextOpen.add(id); nextClosed.delete(id); }
|
|
35
|
+
return { open: nextOpen, closed: nextClosed };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Opens a colour group's palette, or closes it when it is the one open. */
|
|
40
|
+
export const togglePalette = (key) => panelStore.set(({ palette }) => ({ palette: palette === key ? null : key }));
|