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,461 @@
|
|
|
1
|
+
import { panelStore, togglePalette, toggleSection } from "../panel-state.js";
|
|
2
|
+
import { BREAKPOINT_WIDTH, setGroup, getGroup, GROUPS, parseToken } from "@windcraft/core/class-groups.js";
|
|
3
|
+
import { renderPanel } from "../panel.js";
|
|
4
|
+
import { inspectorView, variant, val, srcOf, modeFor } from "../panel-model.js";
|
|
5
|
+
import { checkTags } from "../code-view.js";
|
|
6
|
+
import { groupOf, variantOf, twShown, hasScale, twValue, fromPx, tokenFor } from "@windcraft/core/prop-tailwind.js";
|
|
7
|
+
import { normalizeValue } from "../css-props.js";
|
|
8
|
+
import { host } from "../host.js";
|
|
9
|
+
import { $, S, data, model, send } from "./base.js";
|
|
10
|
+
import { clampSize, docs, queueBoardSave, rememberCustom, renameFrame, setFrameSize, setPageGroup } from "./designs.js";
|
|
11
|
+
import { curWidth, fitTo, heightOfDoc, layout, positions, widthOf } from "./plane.js";
|
|
12
|
+
import { draftPage } from "./add-menu.js";
|
|
13
|
+
import { cmd } from "./frame-messages.js";
|
|
14
|
+
import { setPanel } from "./color-ui.js";
|
|
15
|
+
import { elementRange } from "./code-panel.js";
|
|
16
|
+
import { loadFrame } from "./docs.js";
|
|
17
|
+
|
|
18
|
+
// ── Inspector (the panel is components: see panel.js; what it shows, panel-model.js; its state, panel-state.js) ──
|
|
19
|
+
export const insp = $("inspector");
|
|
20
|
+
|
|
21
|
+
/** The selected page's own properties, for the panel. */
|
|
22
|
+
function pageInfo() {
|
|
23
|
+
const d = docs.get(S.curKey), p = positions()[S.curKey] || { x: 0, y: 0 };
|
|
24
|
+
return { name: data.title || "", group: data.group || "", w: widthOf(d), h: heightOfDoc(d), auto: !(d.board && d.board.h), empty: model.markup.trim() === "", x: Math.round(p.x), y: Math.round(p.y), version: data.version, isNew: S.isNew, project: data.project && data.project.name };
|
|
25
|
+
}
|
|
26
|
+
/** A page panel field (name, size, position) typed by the user: applied if it makes sense, otherwise the panel shows the old value again. */
|
|
27
|
+
function setPageField(key, text) {
|
|
28
|
+
const v = text.trim();
|
|
29
|
+
if (key === "group") return setPageGroup(S.curKey, v);
|
|
30
|
+
if (key === "name") {
|
|
31
|
+
const prev = data.title;
|
|
32
|
+
if (v && v !== prev) renameFrame(S.curKey, v, prev); else renderInspector();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (key === "h" && v === "") {
|
|
36
|
+
const d = docs.get(S.curKey), b = { ...(d.board || {}) };
|
|
37
|
+
delete b.h;
|
|
38
|
+
d.board = { x: positions()[S.curKey].x, y: positions()[S.curKey].y, ...b };
|
|
39
|
+
queueBoardSave(S.curKey);
|
|
40
|
+
layout();
|
|
41
|
+
return renderInspector();
|
|
42
|
+
}
|
|
43
|
+
const n = Number(v);
|
|
44
|
+
if (v === "" || !Number.isFinite(n)) return renderInspector();
|
|
45
|
+
setPageBox({ [key]: key === "w" ? clampSize(n, 8000) : key === "h" ? clampSize(n, 20000) : Math.round(n) });
|
|
46
|
+
renderInspector();
|
|
47
|
+
}
|
|
48
|
+
function setPageBox(patch) {
|
|
49
|
+
const d = docs.get(S.curKey), p = positions()[S.curKey];
|
|
50
|
+
d.board = { x: p.x, y: p.y, ...(d.board || {}), ...patch };
|
|
51
|
+
rememberCustom(d);
|
|
52
|
+
queueBoardSave(S.curKey);
|
|
53
|
+
layout();
|
|
54
|
+
}
|
|
55
|
+
/** What the panel's components ask of the editor (panel.js). */
|
|
56
|
+
const panelActions = {
|
|
57
|
+
applyElement: applyElementCode,
|
|
58
|
+
get canAI() { return typeof host.generate === "function"; }, // an app with no AI behind it gets no AI box and no AI tab
|
|
59
|
+
askAI: (path, outer, prompt) => askAI(path, outer, prompt),
|
|
60
|
+
draftPage: (prompt) => draftPage(prompt),
|
|
61
|
+
setPageField,
|
|
62
|
+
setDevice: (w) => setDevice(w),
|
|
63
|
+
fit: () => { S.viewTouched = true; fitTo([S.curKey]); },
|
|
64
|
+
details: () => $("detailsBtn").click(),
|
|
65
|
+
// the top of the Design tab (design-tab.js)
|
|
66
|
+
select: (path) => send({ cmd: "select", path }),
|
|
67
|
+
move: (dir) => { if (S.selected) cmd({ cmd: "move", path: S.selected.path, dir }); },
|
|
68
|
+
duplicate: () => { if (S.selected) cmd({ cmd: "duplicate", path: S.selected.path }); },
|
|
69
|
+
remove: () => { if (S.selected) cmd({ cmd: "remove", path: S.selected.path }); },
|
|
70
|
+
setTag: (tag) => { if (S.selected) cmd({ cmd: "setTag", path: S.selected.path, tag }); },
|
|
71
|
+
setTab: (tab) => panelStore.set({ tab }),
|
|
72
|
+
closePanel: () => setPanel(false),
|
|
73
|
+
// a breakpoint picked: the frame goes to its width, so what is edited is what is seen; All sizes leaves the frame as it is
|
|
74
|
+
setBreakpoint: (bp) => { panelStore.set({ bp, all: bp === "" }); if (bp) setDevice(BREAKPOINT_WIDTH[bp]); },
|
|
75
|
+
// the state picked: classes are written for it, and the selected element shows it on the canvas (its hover look...)
|
|
76
|
+
setState: (st) => { panelStore.set({ st }); send({ cmd: "previewState", state: st }); },
|
|
77
|
+
setText: (text) => { if (S.selected) cmd({ cmd: "setText", path: S.selected.path, text }, "text"); },
|
|
78
|
+
// position and layout (figma-sections.js)
|
|
79
|
+
setRotation: (deg) => setRotation(deg),
|
|
80
|
+
setGeom: (key, text) => {
|
|
81
|
+
if (!S.selected) return;
|
|
82
|
+
const n = Number(text);
|
|
83
|
+
if (text.trim() === "" || !Number.isFinite(n)) return renderInspector(); // not a number: show the old value again
|
|
84
|
+
const prop = { w: "width", h: "height", x: "left", y: "top" }[key];
|
|
85
|
+
if (prop) setProp(prop, `${Math.round(prop === "width" || prop === "height" ? Math.max(1, n) : n)}px`);
|
|
86
|
+
else cmd({ cmd: "setGeom", path: S.selected.path, [key]: n });
|
|
87
|
+
},
|
|
88
|
+
setProps: (props, value, group, clear) => { if (S.selected) setProps(props, value, group, clear); },
|
|
89
|
+
freePosition: (on) => { if (S.selected) cmd({ cmd: "freePosition", path: S.selected.path, on }); },
|
|
90
|
+
align: (mode) => send({ cmd: "align", mode }),
|
|
91
|
+
group: () => { if (S.selected) send({ cmd: "group" }); },
|
|
92
|
+
ungroup: () => { if (S.selected) send({ cmd: "ungroup" }); },
|
|
93
|
+
autoLayout: () => { if (S.selected) send({ cmd: "autoLayout" }); },
|
|
94
|
+
sizeItMyself: () => { if (S.selected) sizeItMyself(); },
|
|
95
|
+
// the class-group controls (group-controls.js): the Tailwind extras
|
|
96
|
+
applyValue: (key, value) => { if (S.selected) applyValue(key, value); },
|
|
97
|
+
applyColor: (key, name, op, undoKey) => { if (S.selected) applyColor(key, name, op, undoKey); },
|
|
98
|
+
togglePalette,
|
|
99
|
+
customColor: (key, hex, done, input) => {
|
|
100
|
+
if (!S.selected) return;
|
|
101
|
+
applyColor(key, hex, undefined, liveKey(`c-${key}`));
|
|
102
|
+
if (done) pickerClosed(input);
|
|
103
|
+
},
|
|
104
|
+
redraw: () => renderInspector(),
|
|
105
|
+
// the property sections (prop-sections.js)
|
|
106
|
+
setProp: (prop, value, css, key) => { if (S.selected) setProp(prop, value, css, key); }, // css: what to write when it goes to CSS (fit -> fit-content)
|
|
107
|
+
setPropLive: (prop, hex) => { if (S.selected) setProp(prop, hex, undefined, liveKey(`p-${prop}`)); },
|
|
108
|
+
setPropDone: (prop, hex, input) => { if (S.selected) setProp(prop, hex, undefined, liveKey(`p-${prop}`)); pickerClosed(input); },
|
|
109
|
+
// one colour on several properties at once (a stroke's four sides), dragged in the picker as one undo step
|
|
110
|
+
setPropsLive: (props, hex, group) => { if (S.selected) setProps(props, hex, group, [], undefined, liveKey(`ps-${group}`)); },
|
|
111
|
+
setPropsDone: (props, hex, group, input) => { if (S.selected) setProps(props, hex, group, [], undefined, liveKey(`ps-${group}`)); pickerClosed(input); },
|
|
112
|
+
resetProp: (prop) => { if (S.selected) setProp(prop, ""); },
|
|
113
|
+
arrange: (kind) => { if (S.selected) arrange(kind); },
|
|
114
|
+
toggleSection,
|
|
115
|
+
setAttr: (name, value) => { if (S.selected) cmd({ cmd: "setAttr", path: S.selected.path, name, value: value === "" ? null : value }, `attr-${name}`); },
|
|
116
|
+
// the classes, inline CSS and attributes below the element's code (classes-section.js)
|
|
117
|
+
addClasses: (text) => {
|
|
118
|
+
if (!S.selected) return;
|
|
119
|
+
const add = text.split(/\s+/).filter(Boolean);
|
|
120
|
+
if (!add.length) return;
|
|
121
|
+
const cur = S.selected.classes.split(/\s+/).filter(Boolean);
|
|
122
|
+
setClassString([...cur, ...add.filter((c) => !cur.includes(c))].join(" "));
|
|
123
|
+
},
|
|
124
|
+
setClassesText: (text) => { if (S.selected) { S.selected.classes = text.trim(); cmd({ cmd: "setClasses", path: S.selected.path, value: S.selected.classes }, "classes"); } },
|
|
125
|
+
setStyle: (css) => { if (S.selected) cmd({ cmd: "setAttr", path: S.selected.path, name: "style", value: css }); },
|
|
126
|
+
setAttribute: (name, value) => { if (S.selected) cmd({ cmd: "setAttr", path: S.selected.path, name, value }); },
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* The page panel's styling sections edit the page's body. They use the same actions as an element's sections, run with the body
|
|
130
|
+
* standing in as the selection for that one call, so every property works the same way on the page (classes or CSS, at its source).
|
|
131
|
+
*/
|
|
132
|
+
const bodyActions = Object.fromEntries(Object.entries(panelActions).map(([k, f]) => [k, typeof f !== "function" ? f : (...a) => {
|
|
133
|
+
if (S.selected || !S.pageBody) return undefined;
|
|
134
|
+
S.selected = S.pageBody;
|
|
135
|
+
try { return f(...a); } finally { S.selected = null; }
|
|
136
|
+
}]));
|
|
137
|
+
/** Draws the panel. Every part is a component that keeps its own typed text, so it can be redrawn at any time, whatever has focus. */
|
|
138
|
+
export function renderInspector() {
|
|
139
|
+
const ui = { ...panelStore.get(), frameW: curWidth() }; // the panel's state, and the frame's width (for the Screen chips)
|
|
140
|
+
const sel = S.selected && S.selected === S.pageBody ? null : S.selected; // drawn while a body action runs: still the page
|
|
141
|
+
const elementCode = sel && ui.tab === "classes" ? sourceOf(sel.path) : null;
|
|
142
|
+
const top = insp.scrollTop;
|
|
143
|
+
const view = inspectorView(sel, sel ? null : pageInfo(), elementCode);
|
|
144
|
+
if (view.page && S.pageBody) view.pageBody = { s: S.pageBody, actions: bodyActions };
|
|
145
|
+
renderPanel(insp, view, panelActions, ui);
|
|
146
|
+
insp.scrollTop = top;
|
|
147
|
+
}
|
|
148
|
+
// a change to the panel's own state (its tab, the breakpoint, an open section...) redraws it, once however many changed
|
|
149
|
+
let panelDrawQueued = false;
|
|
150
|
+
|
|
151
|
+
// ── The Code tab's element code (panel.js): the element's source, editable in place ──
|
|
152
|
+
/** The element at `path` as written in the design: its code with its lines' common indentation taken off, and that indentation. */
|
|
153
|
+
function sourceOf(path) {
|
|
154
|
+
if (!path || !path.length) return null;
|
|
155
|
+
const r = elementRange(model.markup, path);
|
|
156
|
+
if (!r) return null;
|
|
157
|
+
const lines = model.markup.slice(r.start, r.end).split("\n");
|
|
158
|
+
const indents = lines.slice(1).filter((l) => l.trim()).map((l) => l.match(/^[ \t]*/)[0]);
|
|
159
|
+
const indent = indents.length ? indents.reduce((a, b) => { let i = 0; while (i < a.length && a[i] === b[i]) i++; return a.slice(0, i); }) : "";
|
|
160
|
+
return { source: [lines[0], ...lines.slice(1).map((l) => (l.startsWith(indent) ? l.slice(indent.length) : l))].join("\n"), indent };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Puts the code typed for the element at `path` in its place (one undo step), if it is exactly one element with matching
|
|
164
|
+
* tags, and that element is still the one the code came from (`original`). Returns { ok, message }.
|
|
165
|
+
*/
|
|
166
|
+
function applyElementCode(path, code, indent, original) {
|
|
167
|
+
const v = code.trim();
|
|
168
|
+
if (v === original.trim()) return { ok: true };
|
|
169
|
+
const now = sourceOf(path);
|
|
170
|
+
if (!now || now.source !== original) return { ok: false, message: "That element changed since, so what you typed was not applied." };
|
|
171
|
+
const bad = checkTags(v);
|
|
172
|
+
if (bad) return { ok: false, message: `Not applied. Line ${bad.line}: ${bad.message}` };
|
|
173
|
+
const tpl = document.createElement("template");
|
|
174
|
+
tpl.innerHTML = v;
|
|
175
|
+
const nodes = [...tpl.content.childNodes].filter((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));
|
|
176
|
+
if (nodes.length !== 1 || nodes[0].nodeType !== 1) return { ok: false, message: "Not applied: the code must be exactly one element (wrap several in one)." };
|
|
177
|
+
cmd({ cmd: "replaceOuter", path, html: v.split("\n").map((l, i) => (i && l ? indent + l : l)).join("\n") });
|
|
178
|
+
return { ok: true };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function applyValue(key, value, undoKey = "") {
|
|
182
|
+
if (!S.selected) return;
|
|
183
|
+
const next = setGroup(S.selected.classes, key, value, variant());
|
|
184
|
+
if (next === null) return renderInspector();
|
|
185
|
+
if (next === S.selected.classes) return; // nothing to change (a class it doesn't have, taken off): no empty undo step
|
|
186
|
+
S.selected.classes = next;
|
|
187
|
+
if (onAll([key], next, variant(), undoKey)) return;
|
|
188
|
+
cmd({ cmd: "setClasses", path: S.selected.path, value: next }, undoKey);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function applyColor(key, name, op, undoKey = "") {
|
|
192
|
+
const o = op === undefined ? (val(S.selected, key).match(/\/(\d+)$/) || [])[1] || "" : op;
|
|
193
|
+
const base = /^(#|rgb|hsl)/.test(name) ? `[${name.replace(/\s+/g, "_")}]` : name;
|
|
194
|
+
applyValue(key, base ? base + (o && o !== "100" ? `/${o}` : "") : "", undoKey);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Colour pickers apply as they are dragged ─────────────────────────────────
|
|
198
|
+
// Every pick, from opening a picker to closing it, is one undo step: a "live:" key (merged by commit without the time limit)
|
|
199
|
+
// that moves on once the picker closes. The picker field is blurred then, so the bar and panel show the final value.
|
|
200
|
+
let liveSession = 0;
|
|
201
|
+
export const liveKey = (field) => `live:${field}:${liveSession}`;
|
|
202
|
+
export const pickerClosed = (t) => { liveSession++; t.blur(); };
|
|
203
|
+
|
|
204
|
+
// ── Which breakpoint and state a class gets ──────────────────────────────────
|
|
205
|
+
// Only the ones picked in the panel (the Screen and State chips): with none picked, an edit changes the class that gives the
|
|
206
|
+
// value where it is (md:p-6 stays md:), and anything new is a plain class for every size, as a frontend developer writes it.
|
|
207
|
+
/** The variant picked in the panel ("md:hover:"), or "" for none. */
|
|
208
|
+
const picked = () => (panelStore.get().bp || panelStore.get().st ? variant() : "");
|
|
209
|
+
function newVariant() { return picked(); }
|
|
210
|
+
/**
|
|
211
|
+
* A multiple selection: the same class change goes to every element selected, in one undo step. `groups` are the class groups the
|
|
212
|
+
* edit touched and `next` the primary element's classes after it (where the new class is read from). True when it handled the edit.
|
|
213
|
+
*/
|
|
214
|
+
function onAll(groups, next, vp, key) {
|
|
215
|
+
const s = S.selected;
|
|
216
|
+
if (!s || !s.others || !s.others.length || next === null) return false;
|
|
217
|
+
const ops = groups.filter((g) => GROUPS[g]).map((g) => { const r = getGroup(next, g, vp); return { re: GROUPS[g].re.source, token: r ? parseToken(r.token).base : "" }; });
|
|
218
|
+
cmd({ cmd: "groupOn", paths: [s.path, ...s.others], ops, vp, key }, key);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
/** The same edit written with an arbitrary value (rounded-[8px]), for when the design's theme gives the scale class another size. */
|
|
222
|
+
function altFor(classes, group, css, vp, clear = []) {
|
|
223
|
+
const m = /^(-?\d*\.?\d+)px$/.exec(css || "");
|
|
224
|
+
if (!m || !GROUPS[group]) return null;
|
|
225
|
+
const tv = `[${Number(m[1])}px]`, built = GROUPS[group].build(tv);
|
|
226
|
+
if (!GROUPS[group].re.test(built)) return null;
|
|
227
|
+
let c = classes;
|
|
228
|
+
for (const k of clear) { const n = setGroup(c, k, "", vp); if (n !== null) c = n; }
|
|
229
|
+
const next = setGroup(c, group, tv, vp);
|
|
230
|
+
return next === null ? null : { classes: everySize(next, group), token: vp + built };
|
|
231
|
+
}
|
|
232
|
+
/** "All" clicked on purpose: edits are plain classes for every size, not the md:/xl: class that gives the value at this width. */
|
|
233
|
+
const allPlain = () => Boolean(panelStore.get().all) && !picked();
|
|
234
|
+
/** One value for every size: the breakpoint classes of the group (sm:opacity-50, md:p-6) come off, so the plain one is what shows at every width. States (hover:) stay. */
|
|
235
|
+
const everySize = (classes, group) => (!allPlain() || classes === null ? classes : classes.split(/\s+/).filter((t) => {
|
|
236
|
+
const v = parseToken(t).variant.split(":").filter(Boolean);
|
|
237
|
+
return !(v.length && v.every((x) => BREAKPOINT_WIDTH[x] !== undefined) && GROUPS[group].re.test(parseToken(t).base));
|
|
238
|
+
}).join(" "));
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* A property edited in the panel is written where its value comes from: a Tailwind class is replaced (at the same md: / hover:
|
|
242
|
+
* variant), an inline style or the design's own CSS gets an inline style. A property nothing sets yet becomes a Tailwind class,
|
|
243
|
+
* unless the element is styled only by its design's CSS. The frame checks the result and falls back to CSS when a class can't win.
|
|
244
|
+
*/
|
|
245
|
+
/**
|
|
246
|
+
* The outline shorthand ("2px solid blue-500", "3px dashed #f00", "none") as the classes that make it on a Tailwind element:
|
|
247
|
+
* outline (style), outline-2 (width), outline-blue-500 (colour). Returns false when the value is not one it can read.
|
|
248
|
+
*/
|
|
249
|
+
function setOutline(v, key) {
|
|
250
|
+
const s = S.selected, vp = picked();
|
|
251
|
+
let classes = s.classes;
|
|
252
|
+
const put = (g, tv) => { const n = setGroup(classes, g, tv, vp); if (n !== null) classes = n; };
|
|
253
|
+
if (v === "" || v === "none") { ["outlineStyle", "outlineW", "outlineColor"].forEach((g) => put(g, "")); }
|
|
254
|
+
else {
|
|
255
|
+
let width = null, style = "solid", color = null;
|
|
256
|
+
for (const t of v.replace(/\s*,\s*/g, ",").match(/(?:rgba?|hsla?)\([^)]*\)|\S+/g) || []) {
|
|
257
|
+
if (/^\d*\.?\d+(px)?$/.test(t)) width = parseFloat(t);
|
|
258
|
+
else if (/^(solid|dashed|dotted|double)$/.test(t)) style = t;
|
|
259
|
+
else color = t;
|
|
260
|
+
}
|
|
261
|
+
if (width === null && !color && style === "solid" && !/solid/.test(v)) return false;
|
|
262
|
+
const wv = width === null ? "2" : [0, 1, 2, 4, 8].includes(width) ? String(width) : `[${width}px]`;
|
|
263
|
+
put("outlineStyle", style === "solid" ? "outline" : `outline-${style}`);
|
|
264
|
+
put("outlineW", wv);
|
|
265
|
+
if (color) put("outlineColor", /^(#|rgb|hsl)/.test(color) ? color : color);
|
|
266
|
+
}
|
|
267
|
+
if (classes === s.classes) return true;
|
|
268
|
+
s.classes = classes;
|
|
269
|
+
cmd({ cmd: "setClasses", path: s.path, value: classes }, key);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function setProp(prop, raw, cssOverride, key) {
|
|
274
|
+
if (!S.selected) return;
|
|
275
|
+
if (prop === "outline" && modeFor(S.selected) === "tailwind" && setOutline(String(raw ?? "").trim(), key)) return;
|
|
276
|
+
const s = S.selected, src = srcOf(s, prop), v = String(raw ?? "").trim();
|
|
277
|
+
const group = groupOf(prop);
|
|
278
|
+
const onTw = src.source === "tailwind";
|
|
279
|
+
const winning = onTw ? variantOf(src.token) : "";
|
|
280
|
+
// a breakpoint or state picked in the panel is where the edit goes; else the md:/xl: class that gives the value, where it is
|
|
281
|
+
const vp = picked() || (allPlain() ? "" : winning);
|
|
282
|
+
const send = (m) => cmd({ cmd: "setProp", path: s.path, prop, key, ...m }, key);
|
|
283
|
+
if (v === "") {
|
|
284
|
+
// the exact class that gives the value comes off (px-4 for padding-left), at the picked breakpoint / state if one is picked
|
|
285
|
+
let tok = picked() || allPlain() ? tokenFor(s.classes, prop, vp) : onTw ? { token: src.token } : null;
|
|
286
|
+
if (!tok || !tok.token) tok = tokenFor(s.classes, prop, vp); // a property whose source isn't found by its name (rotate) still has its class
|
|
287
|
+
if (tok && tok.token) { const next = s.classes.split(/\s+/).filter((t) => t && t !== tok.token).join(" "); s.classes = next; if (group && onAll([group], next, vp, key)) return; return send({ route: "tw", classes: next }); }
|
|
288
|
+
if (src.source === "inline") return send({ route: "css", css: null });
|
|
289
|
+
return renderInspector();
|
|
290
|
+
}
|
|
291
|
+
// "500" means 500px, except on a row showing a Tailwind value (64 from w-64), where a bare number is Tailwind's scale
|
|
292
|
+
const scale = onTw && twShown(prop, src.token) !== null && hasScale(group);
|
|
293
|
+
const candidate = scale && /^-?\d*\.?\d+$/.test(v) ? v : normalizeValue(prop, v);
|
|
294
|
+
const css = cssOverride !== undefined ? cssOverride : CSS.supports(prop, candidate) ? candidate : null;
|
|
295
|
+
const tv = group ? twValue(prop, v, scale) : null;
|
|
296
|
+
let token = null;
|
|
297
|
+
if (tv !== null) { const built = GROUPS[group].build(tv); if (GROUPS[group].re.test(built)) token = built; }
|
|
298
|
+
// a md: or hover: value can only be a class (a style attribute has no breakpoints or states)
|
|
299
|
+
const toTw = Boolean(picked()) || onTw || src.source === "tie" || (src.source !== "inline" && src.source !== "design" && modeFor(s) === "tailwind");
|
|
300
|
+
if (toTw && token) {
|
|
301
|
+
const next = everySize(setGroup(s.classes, group, tv, vp), group);
|
|
302
|
+
const alt = /\[/.test(token) ? null : altFor(s.classes, group, css, vp);
|
|
303
|
+
// kept here at once, so an edit sent right after this one (a line style, then a width) builds on it
|
|
304
|
+
if (next !== null) { s.classes = next; if (onAll([group], next, vp, key)) return; return send({ route: "tw", classes: next, token: vp + token, css, alt }); }
|
|
305
|
+
}
|
|
306
|
+
if (css === null && !token) return renderInspector(); // neither valid CSS nor a Tailwind value: put the old value back
|
|
307
|
+
send({ route: "css", css, token }); // a Tailwind value on a CSS row (red-500): the frame measures what it means
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* One value for several properties at once (all four paddings, the two sides of padding-h, every border width or corner), written
|
|
312
|
+
* like setProp: as the `group` class when they are Tailwind's or unset on a Tailwind element (clearing the per-side classes in
|
|
313
|
+
* `clear`, which would otherwise win), and as inline CSS on every one of them when any comes from CSS.
|
|
314
|
+
*/
|
|
315
|
+
export function setProps(props, raw, group, clear = [], cssOverride, key) {
|
|
316
|
+
if (!S.selected) return;
|
|
317
|
+
const s = S.selected, srcs = props.map((p) => srcOf(s, p));
|
|
318
|
+
const v = String(raw ?? "").trim();
|
|
319
|
+
const px = /^-?\d*\.?\d+(px)?$/.test(v);
|
|
320
|
+
const n = parseFloat(v);
|
|
321
|
+
const css = cssOverride !== undefined ? cssOverride : px ? `${n}px` : CSS.supports(props[0], v) ? v : null;
|
|
322
|
+
const own = srcs.find((x) => x.source === "tailwind");
|
|
323
|
+
const vp = picked() || (allPlain() ? "" : (own && variantOf(own.token)) || "");
|
|
324
|
+
const fromCss = srcs.some((x) => x.source === "inline" || x.source === "design");
|
|
325
|
+
const tv = group ? (px ? fromPx(group, n) : twValue(props[0], v, false) ?? v) : null;
|
|
326
|
+
let token = null;
|
|
327
|
+
if (tv !== null) { const built = GROUPS[group].build(tv); if (GROUPS[group].re.test(built)) token = built; }
|
|
328
|
+
if ((!fromCss || picked()) && token && (own || picked() || modeFor(s) === "tailwind")) {
|
|
329
|
+
let classes = s.classes;
|
|
330
|
+
for (const k of clear) { const next = setGroup(classes, k, "", vp); if (next !== null) classes = next; }
|
|
331
|
+
const next = everySize(setGroup(classes, group, tv, vp), group);
|
|
332
|
+
const alt = /\[/.test(token) ? null : altFor(s.classes, group, css, vp, clear);
|
|
333
|
+
if (next !== null) { s.classes = next; if (onAll([...clear, group], next, vp, key)) return; return cmd({ cmd: "setProp", path: s.path, props, route: "tw", classes: next, token: vp + token, css, alt, key }, key); }
|
|
334
|
+
}
|
|
335
|
+
cmd({ cmd: "setProp", path: s.path, props, route: "css", css, token, key }, key);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* A drag, resize or new shape has ended and its pixels are inline. Each value becomes a Tailwind class (at its own md: / hover:
|
|
340
|
+
* variant if a class set it before) when the element is Tailwind's: made here, or the value came from a class, or nothing set it
|
|
341
|
+
* on an element that uses Tailwind. A value that came from CSS stays CSS, and so does everything on an element styled only by its
|
|
342
|
+
* design's CSS. The frame puts the classes on, checks them and saves the result as one undo step (agent.js applyGesture).
|
|
343
|
+
*/
|
|
344
|
+
export function convertGesture(items) {
|
|
345
|
+
const blocked = items.find((it) => it.blocked);
|
|
346
|
+
if (blocked) flashHint(`This element's ${blocked.blocked} is set by its flex layout. Use "Size it myself" in the panel to size it by hand.`);
|
|
347
|
+
send({ cmd: "applyGesture", items: items.map((it) => {
|
|
348
|
+
const tw = it.made || modeFor({ classes: it.classes }) === "tailwind";
|
|
349
|
+
let classes = it.classes;
|
|
350
|
+
const tokens = {};
|
|
351
|
+
Object.entries(it.values).forEach(([prop, value]) => {
|
|
352
|
+
const pre = it.pre[prop] || { source: "default" };
|
|
353
|
+
if (!it.made && (pre.source === "inline" || pre.source === "design")) return; // it was CSS: it stays CSS
|
|
354
|
+
if (!tw && pre.source !== "tailwind") return;
|
|
355
|
+
const group = groupOf(prop);
|
|
356
|
+
if (!group) return;
|
|
357
|
+
const tv = /^-?[\d.]+px$/.test(value) ? fromPx(group, Math.round(parseFloat(value))) : twValue(prop, value, false); // a size, or a word (flex, column, absolute)
|
|
358
|
+
if (tv === null) return;
|
|
359
|
+
const built = GROUPS[group].build(tv);
|
|
360
|
+
if (!GROUPS[group].re.test(built)) return;
|
|
361
|
+
// judged on the classes from before this gesture: a left-[..] written a moment ago must not make top an xl: class
|
|
362
|
+
const vp = (pre.source === "tailwind" && variantOf(pre.token)) || newVariant();
|
|
363
|
+
const next = setGroup(classes, group, tv, vp);
|
|
364
|
+
if (next === null) return;
|
|
365
|
+
classes = next;
|
|
366
|
+
tokens[prop] = vp + built;
|
|
367
|
+
});
|
|
368
|
+
// class groups the element's old place gave it (margins, alignment) come off
|
|
369
|
+
let cleared = false;
|
|
370
|
+
if (tw || it.made) (it.clear || []).forEach((group) => { const next = setGroup(classes, group, "", ""); if (next !== null && next !== classes) { classes = next; cleared = true; } });
|
|
371
|
+
return { path: it.path, classes, tokens, cleared };
|
|
372
|
+
}) });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** A flex child that grows to fill its row: stop flex from sizing it (flex-none, or flex: none in CSS), so a width or height set on it applies. */
|
|
376
|
+
function sizeItMyself() {
|
|
377
|
+
if (!S.selected) return;
|
|
378
|
+
if (modeFor(S.selected) !== "tailwind") return cmd({ cmd: "setStyleProps", path: S.selected.path, props: { flex: "none" } });
|
|
379
|
+
let classes = setGroup(S.selected.classes, "grow", "", variant()) ?? S.selected.classes;
|
|
380
|
+
classes = setGroup(classes, "flexNum", "flex-none", variant()) ?? classes;
|
|
381
|
+
setClassString(classes);
|
|
382
|
+
}
|
|
383
|
+
/** A short note in the hint under the canvas, back to the usual text after a few seconds. */
|
|
384
|
+
let hintTimer = 0;
|
|
385
|
+
function flashHint(text) {
|
|
386
|
+
const h = $("canvasHint");
|
|
387
|
+
clearTimeout(hintTimer);
|
|
388
|
+
h.textContent = text;
|
|
389
|
+
hintTimer = setTimeout(() => { h.textContent = S.mode === "edit" ? "Click to select. Double-click text to edit it. Alt-drag to place freely. Scroll to pan, Ctrl/Cmd + scroll to zoom." : h.textContent; }, 4000);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Row / Column / Grid for a box whose children are stacked in the flow, in one undo step: Tailwind classes (flex, flex-col,
|
|
394
|
+
* grid grid-cols-N, gap-*) unless its display comes from CSS or it is a CSS-only element, then the same as inline CSS. The gap
|
|
395
|
+
* keeps the children's current spacing (agent.js arrangeOf).
|
|
396
|
+
*/
|
|
397
|
+
function arrange(kind) {
|
|
398
|
+
const a = S.selected && S.selected.arrange;
|
|
399
|
+
if (!a) return;
|
|
400
|
+
const gap = kind === "row" ? a.rowGap : a.colGap;
|
|
401
|
+
const cols = Math.min(a.count, 3);
|
|
402
|
+
const src = srcOf(S.selected, "display").source;
|
|
403
|
+
if (src === "inline" || src === "design" || modeFor(S.selected) !== "tailwind") {
|
|
404
|
+
return cmd({ cmd: "setStyleProps", path: S.selected.path, props: {
|
|
405
|
+
display: kind === "grid" ? "grid" : "flex",
|
|
406
|
+
"flex-direction": kind === "col" ? "column" : null,
|
|
407
|
+
"grid-template-columns": kind === "grid" ? `repeat(${cols}, minmax(0, 1fr))` : null,
|
|
408
|
+
gap: kind === "grid" ? `${a.colGap}px ${a.rowGap}px` : `${gap}px`, // row-gap column-gap
|
|
409
|
+
} });
|
|
410
|
+
}
|
|
411
|
+
let c = S.selected.classes;
|
|
412
|
+
const set = (k, v) => { const next = setGroup(c, k, v, ""); if (next !== null) c = next; };
|
|
413
|
+
set("display", kind === "grid" ? "grid" : "flex");
|
|
414
|
+
set("flexDir", kind === "col" ? "flex-col" : "");
|
|
415
|
+
set("gridCols", kind === "grid" ? String(cols) : "");
|
|
416
|
+
// a grid's columns get the spacing as gap-x; down the columns the children's own margins still space them (gap-y)
|
|
417
|
+
if (kind === "grid") { set("gap", ""); set("gapX", a.rowGap ? fromPx("gapX", a.rowGap) : ""); set("gapY", a.colGap ? fromPx("gapY", a.colGap) : ""); } else set("gap", gap ? fromPx("gap", gap) : "");
|
|
418
|
+
setClassString(c);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The turn of the selected element, in degrees: a class (rotate-45, rotate-[15deg]) where it is Tailwind's, a rotate style otherwise; 0 takes it off. */
|
|
422
|
+
export function setRotation(deg) {
|
|
423
|
+
if (!S.selected) return;
|
|
424
|
+
const n = Math.round(Number(deg) * 10) / 10;
|
|
425
|
+
if (!Number.isFinite(n)) return renderInspector();
|
|
426
|
+
setProp("rotate", n % 360 === 0 ? "" : `${n}deg`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function setDevice(w) {
|
|
430
|
+
// a preset is a whole size: its width, with the height following the content (a hand-set height belongs to Custom)
|
|
431
|
+
setFrameSize({ w, h: null });
|
|
432
|
+
if (S.mode === "preview") loadFrame();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const setClassString = (next) => { S.selected.classes = next; cmd({ cmd: "setClasses", path: S.selected.path, value: next }); };
|
|
436
|
+
|
|
437
|
+
// ── Ask AI about the selected element (the AI tab) ───────────────────────────
|
|
438
|
+
/** Changes the element at `path` (whose code is `outer`) as asked. Returns { ok, message? } for the AI tab to show. */
|
|
439
|
+
async function askAI(path, outer, prompt) {
|
|
440
|
+
try {
|
|
441
|
+
const html = await host.generate({
|
|
442
|
+
prompt: `${prompt}\n\nReturn ONLY the full modified HTML of this single element, keeping the same root tag. No explanation and no code fences.`,
|
|
443
|
+
currentHtml: outer,
|
|
444
|
+
useLibrary: false,
|
|
445
|
+
});
|
|
446
|
+
if (!html.startsWith("<")) throw new Error("The AI didn't return HTML. Try rephrasing.");
|
|
447
|
+
cmd({ cmd: "replaceOuter", path, html });
|
|
448
|
+
return { ok: true };
|
|
449
|
+
} catch (err) {
|
|
450
|
+
return { ok: false, message: err.message };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Registers this part's listeners and does its first draw (called once, in order, by editor.js). */
|
|
455
|
+
export function init() {
|
|
456
|
+
panelStore.subscribe(() => {
|
|
457
|
+
if (panelDrawQueued) return;
|
|
458
|
+
panelDrawQueued = true;
|
|
459
|
+
queueMicrotask(() => { panelDrawQueued = false; renderInspector(); });
|
|
460
|
+
});
|
|
461
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { $, S, collapsed, esc } from "./base.js";
|
|
2
|
+
|
|
3
|
+
// ── Layers ───────────────────────────────────────────────────────────────────
|
|
4
|
+
export const pathKey = (p) => (p || []).join(".");
|
|
5
|
+
// A small page icon: the Pages section and the page rows use it instead of repeating the word "page"
|
|
6
|
+
export const PAGE_ICON = '<svg class="shrink-0 text-neutral-500" width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><rect x="3" y="2" width="10" height="12" rx="1.5"/></svg>';
|
|
7
|
+
// Figma-style chevron: points right when closed, down when open
|
|
8
|
+
const chevron = (open) => `<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" style="${open ? "transform:rotate(90deg);" : ""}"><path d="M4.5 2.5 8 6 4.5 9.5"/></svg>`;
|
|
9
|
+
|
|
10
|
+
// the eye and the lock of a row: faint until the row is pointed at, solid while on (hidden or locked)
|
|
11
|
+
const EYE = '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/></svg>';
|
|
12
|
+
const EYE_OFF = '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 2l12 12M6.6 3.8A6 6 0 018 3.5c4 0 6.5 4.5 6.5 4.5a10 10 0 01-2 2.4M4.2 5.2A10 10 0 001.5 8S4 12.5 8 12.5a6 6 0 002.6-.6"/></svg>';
|
|
13
|
+
const LOCK = '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="7" width="9" height="6.5" rx="1.2"/><path d="M5.5 7V5a2.5 2.5 0 015 0v2"/></svg>';
|
|
14
|
+
const UNLOCK = '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="7" width="9" height="6.5" rx="1.2"/><path d="M5.5 7V5a2.5 2.5 0 014.8-1"/></svg>';
|
|
15
|
+
|
|
16
|
+
function layerNode(n, depth) {
|
|
17
|
+
const key = pathKey(n.path);
|
|
18
|
+
const has = n.children.length > 0;
|
|
19
|
+
const isCollapsed = collapsed.has(key);
|
|
20
|
+
const isSel = S.selected && pathKey(S.selected.path) === key;
|
|
21
|
+
const acts = n.tag === "body" ? "" : `<span class="layer-acts"><span data-eye="${key}" class="layer-act ${n.hidden ? "on" : ""}" title="${n.hidden ? "Show" : "Hide"} (Ctrl/Cmd+Shift+H)">${n.hidden ? EYE_OFF : EYE}</span><span data-lock="${key}" class="layer-act ${n.locked ? "on" : ""}" title="${n.locked ? "Unlock" : "Lock"} (Ctrl/Cmd+Shift+L)">${n.locked ? LOCK : UNLOCK}</span></span>`;
|
|
22
|
+
const row = `<div class="layer-row cursor-pointer gap-1 px-1 ${isSel ? "is-selected" : ""} ${n.hidden ? "layer-hidden" : ""}" style="padding-left:${depth * 12 + 4}px" data-path="${key}" ${n.tag === "body" ? "" : 'draggable="true"'}>
|
|
23
|
+
<span data-caret="${key}" class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded text-neutral-500 hover:bg-neutral-200">${has ? chevron(!isCollapsed) : ""}</span>
|
|
24
|
+
<span class="shrink-0 font-mono text-[11px] text-neutral-500">${n.tag}</span>
|
|
25
|
+
<span class="truncate text-neutral-800">${esc(n.label)}</span>${acts}</div>`;
|
|
26
|
+
return row + (has && !isCollapsed ? n.children.map((c) => layerNode(c, depth + 1)).join("") : "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let revealedKey = null;
|
|
30
|
+
/** Scrolls the Layers panel to the selected row (opening its collapsed parents first). Only when the selection changed, so scrolling by hand isn't undone. */
|
|
31
|
+
export function revealLayer(force) {
|
|
32
|
+
const key = S.selected ? pathKey(S.selected.path) : null;
|
|
33
|
+
if (!key && key !== "") { revealedKey = null; return; }
|
|
34
|
+
if (!force && key === revealedKey) return;
|
|
35
|
+
if (!S.leftOpen) return;
|
|
36
|
+
const pane = $("leftBody");
|
|
37
|
+
const row = $("layers").querySelector(".layer-row.is-selected");
|
|
38
|
+
if (!row) return;
|
|
39
|
+
revealedKey = key;
|
|
40
|
+
const pr = pane.getBoundingClientRect(), rr = row.getBoundingClientRect();
|
|
41
|
+
if (rr.top < pr.top + 8 || rr.bottom > pr.bottom - 8) pane.scrollTop += (rr.top + rr.height / 2) - (pr.top + pr.height / 2);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let expandedFor = null;
|
|
45
|
+
export function renderLayers() {
|
|
46
|
+
// a new selection must be visible in the tree: open every parent of the selected element (once per selection, so you can still collapse one afterwards)
|
|
47
|
+
const selKey = S.selected ? pathKey(S.selected.path) : null;
|
|
48
|
+
if (selKey !== expandedFor) {
|
|
49
|
+
expandedFor = selKey;
|
|
50
|
+
if (S.selected && S.selected.path && S.selected.path.length) {
|
|
51
|
+
let opened = false;
|
|
52
|
+
for (let i = 0; i < S.selected.path.length; i++) { if (collapsed.delete(pathKey(S.selected.path.slice(0, i)))) opened = true; }
|
|
53
|
+
if (opened) revealedKey = null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Pages are their own section above, so Layers is just the current page's element tree.
|
|
57
|
+
$("layers").innerHTML = S.tree ? layerNode(S.tree, 0) : '<div class="px-2 py-2 text-[11px] text-neutral-500">Loading…</div>';
|
|
58
|
+
revealLayer();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const parsePath = (s) => (s === "" ? [] : s.split(".").map(Number));
|