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,2118 @@
1
+ // Runs INSIDE the sandboxed preview iframe (stringified via Function.toString).
2
+ // Everything it needs must be defined within agentMain. The sandbox has no same-origin access,
3
+ // so all communication with the editor is postMessage.
4
+ export function agentMain() {
5
+ const root = document.documentElement;
6
+ const post = (m) => parent.postMessage({ ...m, load: window.__wcLoad }, "*");
7
+ const BLUE = "#111";
8
+ const RED = "#f24822";
9
+
10
+ // ── Overlay (selection boxes, handles, guides). Lives outside <body>, never saved. ──────────
11
+ const mk = (css) => { const d = document.createElement("div"); d.style.cssText = css; return d; };
12
+ const overlay = mk("position:absolute;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none;");
13
+ const hoverBox = mk(`position:absolute;border:1px solid ${BLUE};box-shadow:0 0 0 1px rgba(255,255,255,.7);background:rgba(0,0,0,.04);pointer-events:none;display:none;box-sizing:border-box;`);
14
+ const guideLayer = mk("position:absolute;left:0;top:0;");
15
+ const draftBox = mk(`position:absolute;border:1px solid ${BLUE};box-shadow:0 0 0 1px rgba(255,255,255,.7);background:rgba(0,0,0,.05);display:none;box-sizing:border-box;`);
16
+ const marqueeBox = mk(`position:absolute;border:1px solid ${BLUE};box-shadow:0 0 0 1px rgba(255,255,255,.7);background:rgba(0,0,0,.06);display:none;box-sizing:border-box;`);
17
+ const insertLine = mk(`position:absolute;background:${BLUE};box-shadow:0 0 0 1px rgba(255,255,255,.8);display:none;pointer-events:none;`);
18
+ // while an element is dragged through the page flow: the box that follows the pointer, the container it would land in, and a short note
19
+ const RIM = "box-shadow:0 0 0 1px rgba(255,255,255,.75);";
20
+ const ghostBox = mk(`position:absolute;border:1px dashed ${BLUE};${RIM}background:rgba(0,0,0,.05);display:none;pointer-events:none;box-sizing:border-box;`);
21
+ const dropBox = mk(`position:absolute;border:1.5px solid ${BLUE};${RIM}background:rgba(0,0,0,.035);display:none;pointer-events:none;box-sizing:border-box;`);
22
+ const dropHint = mk(`position:absolute;display:none;pointer-events:none;background:${BLUE};color:#fff;font:500 11px/1 system-ui,sans-serif;white-space:nowrap;border-radius:4px;`);
23
+ // the dashed boxes of what the selection holds are drawn in the inverse of whatever is under them (white, difference), so they show on dark
24
+ // pictures and light pages alike. A blend only reaches the page from outside the overlay, which is a layer of its own: this one sits beside it.
25
+ const kidLayer = mk("position:absolute;left:0;top:0;width:0;height:0;z-index:2147483646;pointer-events:none;");
26
+ overlay.append(hoverBox, guideLayer, draftBox, marqueeBox, ghostBox, dropBox, insertLine, dropHint);
27
+ const isOverlay = (n) => overlay.contains(n) || kidLayer.contains(n);
28
+ root.appendChild(kidLayer);
29
+ root.appendChild(overlay);
30
+
31
+ // In the editor the cursor belongs to the active tool, never to the design: a button's own cursor:pointer would show a
32
+ // pointing hand over it. Everything inherits the cursor set on <html> (arrow, grab, crosshair...); text being typed keeps its I-beam.
33
+ const cursorStyle = document.createElement("style");
34
+ cursorStyle.setAttribute("data-wc-agent-style", "");
35
+ cursorStyle.textContent = "html, body { overscroll-behavior: none; } html * { cursor: inherit !important; } [contenteditable=true], [contenteditable=true] * { cursor: text !important; }";
36
+ document.head.appendChild(cursorStyle);
37
+ root.style.cursor = "default"; // the Move tool is active from the start
38
+
39
+ // ── State ───────────────────────────────────────────────────────────────────────────────
40
+ let tool = "move";
41
+ let zoom = 1;
42
+ let sels = [];
43
+ let inside = null; // the group gone into with a double-click (see pickable)
44
+ let shownState = ""; // the state picked in the panel, shown on the selection (see showState)
45
+ let hov = null;
46
+ let editing = null;
47
+ let gesture = null;
48
+ let cssProps = [];
49
+ // elements made in this editor session (drawn, inserted from the + menu, dropped, duplicated from one of those): they are always styled with Tailwind
50
+ const made = new WeakSet();
51
+ const markMade = (el) => { made.add(el); el.querySelectorAll("*").forEach((n) => made.add(n)); };
52
+ const primary = () => sels[sels.length - 1] || null;
53
+ const px = (n) => n / zoom;
54
+
55
+ const DRAW = new Set(["frame", "rect", "ellipse", "line", "arrow", "text", "image", "button"]);
56
+ // the arrow tool's pointer: a bold dark arrow with a white edge (so it shows on any page), tip at the hot spot
57
+ const ARROW = 'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cpath%20d%3D%22M21%2011%202%202l9%2019%202-8z%22%20fill%3D%22%23111%22%20stroke%3D%22%23fff%22%20stroke-width%3D%221.6%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E") 2 2, default';
58
+ const CURSOR = { move: ARROW, hand: "grab", frame: "crosshair", rect: "crosshair", ellipse: "crosshair", line: "crosshair", arrow: "crosshair", text: "text", image: "crosshair", button: "crosshair" };
59
+ const CONTAINERS = new Set(["div", "section", "main", "header", "footer", "aside", "nav", "article", "form", "ul", "ol", "li", "figure", "body"]);
60
+ const PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160"><rect width="240" height="160" fill="#e5e5e5"/><circle cx="92" cy="58" r="12" fill="#bdbdbd"/><path d="M60 128l36-44 26 30 20-22 38 36z" fill="#bdbdbd"/></svg>');
61
+ const TEMPLATES = {
62
+ frame: { tag: "div", cls: "absolute bg-white border border-neutral-300", w: 320, h: 240 },
63
+ rect: { tag: "div", cls: "absolute bg-neutral-300 rounded-md", w: 160, h: 100 },
64
+ ellipse: { tag: "div", cls: "absolute bg-neutral-300 rounded-full", w: 120, h: 120 },
65
+ line: { tag: "div", cls: "absolute bg-neutral-900 text-neutral-900", w: 160, h: 2 },
66
+ arrow: { tag: "div", cls: "absolute bg-neutral-900 text-neutral-900", w: 160, h: 2, html: '<i class="pointer-events-none absolute right-0 top-1/2 -translate-y-1/2 border-y-[5px] border-y-transparent border-l-[9px] border-l-current"></i>' },
67
+ text: { tag: "p", cls: "absolute text-base text-neutral-900 whitespace-nowrap", text: "Text", auto: true },
68
+ image: { tag: "img", cls: "absolute object-cover rounded-md bg-neutral-200", w: 240, h: 160, attrs: { src: PLACEHOLDER, alt: "Image" } },
69
+ button: { tag: "button", cls: "absolute rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white whitespace-nowrap", text: "Button", auto: true },
70
+ };
71
+
72
+ // ── Geometry helpers ────────────────────────────────────────────────────────────────────
73
+ const isAbs = (el) => { const p = getComputedStyle(el).position; return p === "absolute" || p === "fixed"; };
74
+ const pageRect = (el) => {
75
+ const r = el.getBoundingClientRect();
76
+ // a group in a grid has no box of its own (display: contents, so the grid's cells stay as they were): the box around its children
77
+ if (!r.width && !r.height && el.children && el.children.length && getComputedStyle(el).display === "contents") {
78
+ const rs = Array.prototype.map.call(el.children, pageRect).filter((q) => q.w || q.h);
79
+ if (rs.length) { const x = Math.min(...rs.map((q) => q.x)), y = Math.min(...rs.map((q) => q.y)); return { x, y, w: Math.max(...rs.map((q) => q.x + q.w)) - x, h: Math.max(...rs.map((q) => q.y + q.h)) - y }; }
80
+ }
81
+ return { x: r.left + window.scrollX, y: r.top + window.scrollY, w: r.width, h: r.height };
82
+ };
83
+ const rotationOf = (el) => {
84
+ const cs = getComputedStyle(el), t = cs.transform;
85
+ const m = t && t.match(/^matrix\(([^)]+)\)/);
86
+ if (!m) { const r = /^(-?[\d.]+)deg$/.exec(cs.rotate || ""); return r ? Math.round(Number(r[1]) * 10) / 10 : 0; } // Tailwind 4 turns with the rotate property
87
+ const [a, b] = m[1].split(",").map(Number);
88
+ return Math.round(Math.atan2(b, a) * 180 / Math.PI * 10) / 10;
89
+ };
90
+ const geomOf = (el) => {
91
+ const cs = getComputedStyle(el);
92
+ const abs = isAbs(el);
93
+ return {
94
+ abs,
95
+ x: Math.round(abs ? parseFloat(cs.left) || 0 : el.offsetLeft),
96
+ y: Math.round(abs ? parseFloat(cs.top) || 0 : el.offsetTop),
97
+ w: Math.round(parseFloat(cs.width) || el.offsetWidth),
98
+ h: Math.round(parseFloat(cs.height) || el.offsetHeight),
99
+ rot: rotationOf(el),
100
+ };
101
+ };
102
+ const setPx = (el, prop, v) => { el.style[prop] = `${Math.round(v)}px`; };
103
+
104
+ /** Origin of an element's coordinate space in page coordinates (static body uses the page itself). */
105
+ const originOf = (container) => {
106
+ if (container === document.body && getComputedStyle(container).position === "static") return { x: 0, y: 0 };
107
+ const r = pageRect(container);
108
+ const cs = getComputedStyle(container);
109
+ return { x: r.x + (parseFloat(cs.borderLeftWidth) || 0) - container.scrollLeft, y: r.y + (parseFloat(cs.borderTopWidth) || 0) - container.scrollTop };
110
+ };
111
+ const ensurePositioned = (c) => { if (c !== document.body && getComputedStyle(c).position === "static") c.classList.add("relative"); };
112
+
113
+ const pathOf = (el) => {
114
+ const p = [];
115
+ while (el && el !== document.body) {
116
+ const par = el.parentElement;
117
+ if (!par) return null;
118
+ p.unshift(Array.prototype.indexOf.call(par.children, el));
119
+ el = par;
120
+ }
121
+ return p;
122
+ };
123
+ const elAt = (path) => {
124
+ let el = document.body;
125
+ for (const i of path || []) { el = el && el.children[i]; if (!el) return null; }
126
+ return el;
127
+ };
128
+ const alive = (el) => el && document.body.contains(el);
129
+
130
+ // ── Overlay drawing ─────────────────────────────────────────────────────────────────────
131
+ function place(box, el) {
132
+ if (!el || !alive(el)) { box.style.display = "none"; return; }
133
+ const r = pageRect(el);
134
+ Object.assign(box.style, { display: "block", left: `${r.x}px`, top: `${r.y}px`, width: `${r.w}px`, height: `${r.h}px` });
135
+ }
136
+
137
+ // The selection box, handles and size label are drawn by the editor, above the frame, so nothing clips them at the
138
+ // frame's edge and the outline of an element hanging outside the frame stays visible. The agent only describes it.
139
+ let lastOverlay = "";
140
+ const boxOf = (el) => {
141
+ const br = pageRect(el);
142
+ const w = el.offsetWidth || br.w, h = el.offsetHeight || br.h;
143
+ const cs = getComputedStyle(el);
144
+ // each corner's horizontal and vertical radius in pixels (a percentage is of the width / height)
145
+ const corners = ["TopLeft", "TopRight", "BottomRight", "BottomLeft"].map((c) => {
146
+ const t = String(cs[`border${c}Radius`] || "0").split(/\s+/);
147
+ const px = (v, ref) => (String(v).endsWith("%") ? (parseFloat(v) * ref) / 100 : parseFloat(v) || 0);
148
+ return [px(t[0], w), px(t[1] === undefined ? t[0] : t[1], h)];
149
+ });
150
+ return {
151
+ x: br.x + br.w / 2 - w / 2, y: br.y + br.h / 2 - h / 2, w, h, rot: rotationOf(el),
152
+ rh: corners.map((c) => c[0]), rv: corners.map((c) => c[1]), r0: corners[0][0],
153
+ };
154
+ };
155
+ function redraw() {
156
+ sels = sels.filter(alive);
157
+ const el = primary();
158
+ const items = sels.filter((e) => e !== document.body).map(boxOf);
159
+ const handles = Boolean(el && sels.length === 1 && el !== document.body && tool === "move" && !editing && !isLocked(el));
160
+ let label = "", at = null;
161
+ if (el && el !== document.body && gesture && gesture.moved) {
162
+ const g = geomOf(el);
163
+ if (gesture.type === "radius") { label = `Radius ${gesture.r}`; at = gesture.at; }
164
+ else if (gesture.type === "rotate") { label = `${gesture.deg === undefined ? "" : gesture.deg + "°"}`; at = gesture.at; }
165
+ else label = sels.length > 1 ? `${sels.length} selected` : `${g.w} × ${g.h}`;
166
+ }
167
+ const bbox = el && el !== document.body && alive(el) ? pageRect(el) : null;
168
+ const data = JSON.stringify({ items, handles, label, bbox, at });
169
+ if (data !== lastOverlay) { lastOverlay = data; post({ type: "overlay", items, handles, label, bbox, at }); }
170
+ place(hoverBox, tool === "move" && hov && hov !== document.body && !sels.includes(hov) ? hov : null); // not the page itself
171
+ drawKids();
172
+ }
173
+
174
+ // the box selected shows what it holds: its children, dashed (the one under the pointer is outlined solid by the hover box)
175
+ let kidsKey = "";
176
+ function drawKids() {
177
+ const el = sels.length === 1 ? sels[0] : null;
178
+ const list = !el || el === document.body || tool !== "move" || editing || gesture ? [] : realKids(el).slice(0, 60).filter((k) => k !== hov).map(pageRect).filter((r) => r.w >= 2 && r.h >= 2);
179
+ const key = list.map((r) => `${r.x},${r.y},${r.w},${r.h}`).join("|"); // redrawn on every pointer move: only rebuilt when something changed
180
+ if (key === kidsKey) return;
181
+ kidsKey = key;
182
+ kidLayer.textContent = "";
183
+ list.forEach((r) => kidLayer.appendChild(mk(`position:absolute;left:${r.x}px;top:${r.y}px;width:${r.w}px;height:${r.h}px;border:1px dashed #fff;mix-blend-mode:difference;box-sizing:border-box;pointer-events:none;`)));
184
+ }
185
+
186
+ const clearGuides = () => { guideLayer.innerHTML = ""; insertLine.style.display = "none"; ghostBox.style.display = "none"; dropBox.style.display = "none"; dropHint.style.display = "none"; };
187
+
188
+ // ── Describing and serializing ──────────────────────────────────────────────────────────
189
+ const computedOf = (el) => {
190
+ const cs = getComputedStyle(el);
191
+ const o = {};
192
+ cssProps.forEach((p) => { o[p] = cs.getPropertyValue(p); });
193
+ return o;
194
+ };
195
+ // ── Where each property's value comes from ──────────────────────────────────────────────
196
+ // A Tailwind class on this element, its inline style, the design's own CSS (<style>/<link> marked data-wc-design by the editor),
197
+ // inherited, or nothing. The winner is found by switching each kind of stylesheet off for an instant and seeing whether the
198
+ // value changes (synchronous: nothing repaints); which rule it is comes from walking the stylesheets.
199
+ const LOGICAL = {
200
+ "padding-left": ["padding-inline-start"], "padding-right": ["padding-inline-end"], "padding-top": ["padding-block-start"], "padding-bottom": ["padding-block-end"],
201
+ "margin-left": ["margin-inline-start"], "margin-right": ["margin-inline-end"], "margin-top": ["margin-block-start"], "margin-bottom": ["margin-block-end"],
202
+ width: ["inline-size"], height: ["block-size"], "min-width": ["min-inline-size"], "max-width": ["max-inline-size"], "min-height": ["min-block-size"], "max-height": ["max-block-size"],
203
+ left: ["inset-inline-start"], right: ["inset-inline-end"], top: ["inset-block-start"], bottom: ["inset-block-end"],
204
+ };
205
+ // Tailwind v4 writes shorthands with var() (padding-inline: calc(var(--spacing) * 4)), which leave the longhands empty in the rule
206
+ const shorthandsOf = (p) => {
207
+ const m = p.match(/^(padding|margin)-(left|right|top|bottom)$/);
208
+ if (m) return [`${m[1]}-${/left|right/.test(m[2]) ? "inline" : "block"}`, m[1]];
209
+ if (/^(left|right|top|bottom)$/.test(p)) return [/left|right/.test(p) ? "inset-inline" : "inset-block", "inset"];
210
+ if (/^border-(top|bottom)-(left|right)-radius$/.test(p)) return ["border-radius"];
211
+ const b = p.match(/^border-(top|right|bottom|left)-(width|style|color)$/);
212
+ if (b) return [`border-${b[2]}`, `border-${b[1]}`, `border-${/left|right/.test(b[1]) ? "inline" : "block"}-${b[2]}`, "border"];
213
+ return { "background-color": ["background"], "row-gap": ["gap"], "column-gap": ["gap"], "font-size": ["font"], "font-weight": ["font"], "line-height": ["font"], "flex-direction": ["flex-flow"], "flex-wrap": ["flex-flow"], "text-decoration-line": ["text-decoration"] }[p] || [];
214
+ };
215
+ const unesc = (s) => s.replace(/\\(.)/g, "$1");
216
+ const classNamesIn = (sel) => [...sel.matchAll(/\.((?:\\.|[\w-])+)/g)].map((m) => unesc(m[1]));
217
+ function sheetsByKind() {
218
+ const owners = [...document.querySelectorAll("style, link[rel=stylesheet]")].filter((n) => !n.hasAttribute("data-wc-agent-style") && !n.hasAttribute("data-wc-editor"));
219
+ const design = owners.filter((n) => n.hasAttribute("data-wc-design"));
220
+ const tw = owners.filter((n) => !n.hasAttribute("data-wc-design") && n.tagName === "STYLE" && n.type !== "text/tailwindcss");
221
+ // classes the design defines itself in <style type="text/tailwindcss"> (.btn { @apply ... }): compiled into Tailwind's sheet, but the design's
222
+ const custom = new Set(design.filter((n) => n.type === "text/tailwindcss").flatMap((n) => [...n.textContent.matchAll(/\.(-?[A-Za-z_][\w-]*)/g)].map((m) => m[1])));
223
+ return { design: design.map((n) => n.sheet).filter(Boolean), tw: tw.map((n) => n.sheet).filter(Boolean), custom };
224
+ }
225
+ function sourcesOf(el, props) {
226
+ if (!el || el === document.body || !props.length) return {};
227
+ const { design, tw, custom } = sheetsByKind();
228
+ const rulesOf = (sheet) => {
229
+ const out = [];
230
+ const walk = (list) => {
231
+ for (const r of list) {
232
+ if (r.media && r.cssRules && !(r instanceof CSSStyleRule)) { if (matchMedia(r.media.mediaText).matches) walk(r.cssRules); continue; }
233
+ if (r instanceof CSSStyleRule) {
234
+ let hit = false;
235
+ try { hit = el.matches(r.selectorText); } catch (err) { /* a selector this browser can't match */ }
236
+ if (hit) out.push(r);
237
+ if (r.cssRules && r.cssRules.length) walk(r.cssRules);
238
+ continue;
239
+ }
240
+ if (r.cssRules) walk(r.cssRules); // @layer, @supports
241
+ }
242
+ };
243
+ try { walk(sheet.cssRules); } catch (err) { out.cross = true; } // a linked sheet from another site can't be read
244
+ return out;
245
+ };
246
+ const twRules = tw.flatMap(rulesOf);
247
+ const designLists = design.map(rulesOf);
248
+ const designRules = designLists.flat();
249
+ const designUnreadable = designLists.some((l) => l.cross);
250
+ const toks = [...el.classList].filter((t) => !custom.has(t)).map((t) => ({ t, sel: "." + CSS.escape(t) }));
251
+ const tokenOf = (r) => { const s = r.selectorText; const hit = toks.find(({ sel }) => s === sel || (s.startsWith(sel) && /^[:[]/.test(s.slice(sel.length)))); return hit ? hit.t : null; };
252
+ const declares = (r, p) => [p, ...(LOGICAL[p] || []), ...shorthandsOf(p)].some((q) => r.style.getPropertyValue(q) !== "");
253
+ // of the rules that set a property, the likely winner: one naming a class of this element beats a reset like `*` or `h1`; then the later one
254
+ const best = (rules) => { const own = rules.filter((r) => classNamesIn(r.selectorText).some((n) => el.classList.contains(n))); const l = own.length ? own : rules; return l[l.length - 1]; };
255
+
256
+ const read = () => { const cs = getComputedStyle(el); const o = {}; props.forEach((p) => { o[p] = cs.getPropertyValue(p); }); return o; };
257
+ const base = read();
258
+ const without = (sheets) => { sheets.forEach((s) => { s.disabled = true; }); const v = read(); sheets.forEach((s) => { s.disabled = false; }); return v; };
259
+ const noTw = tw.length ? without(tw) : base, noDesign = design.length ? without(design) : base;
260
+
261
+ const res = {};
262
+ props.forEach((p) => {
263
+ const inline = el.style.getPropertyValue(p);
264
+ const ownTw = twRules.filter((r) => declares(r, p));
265
+ const twTok = ownTw.map(tokenOf).filter(Boolean);
266
+ const ownDesign = designRules.filter((r) => declares(r, p));
267
+ const twWins = base[p] !== noTw[p], designWins = base[p] !== noDesign[p];
268
+ let s;
269
+ if (/^(initial|inherit|unset|revert)$/.test(inline)) s = { source: "off" }; // switched off over a rule of the design's CSS (the panel's −): reads as not set
270
+ else if (twWins && twTok.length) s = { source: "tailwind", token: twTok[twTok.length - 1] };
271
+ else if (twWins && ownTw.length) {
272
+ const sel = best(ownTw).selectorText;
273
+ const names = classNamesIn(sel);
274
+ const mine = names.find((n) => custom.has(n) && el.classList.contains(n));
275
+ const parent = names.find((n) => !el.classList.contains(n) && el.parentElement && el.parentElement.closest("." + CSS.escape(n)));
276
+ s = mine ? { source: "design", selector: sel } : parent ? { source: "parent", token: parent } : { source: "default" }; // the last: Tailwind's reset
277
+ } else if (designWins && ownDesign.length) s = { source: "design", selector: best(ownDesign).selectorText };
278
+ else if (designWins && designUnreadable) s = { source: "design", selector: "a linked stylesheet" };
279
+ else if (inline) s = { source: "inline" };
280
+ else if (twTok.length || ownDesign.length) s = { source: "tie", token: twTok[twTok.length - 1] || null }; // two sources set the same value
281
+ else if (twWins || designWins) s = { source: "inherited" };
282
+ else s = { source: "default" };
283
+ res[p] = s;
284
+ });
285
+ return res;
286
+ }
287
+
288
+ // A brand-new class takes Tailwind a moment to build. Wait until its rule is in Tailwind's stylesheet (checked every few ms),
289
+ // at most 150 ms, so the time a released drag spends converting stays short.
290
+ const hasRule = (token) => {
291
+ const sel = "." + CSS.escape(token);
292
+ const walk = (list) => { for (const r of list) { if (r.selectorText && (r.selectorText === sel || r.selectorText.startsWith(sel + ":") || r.selectorText.startsWith(sel + " ") || r.selectorText.startsWith(sel + ","))) return true; if (r.cssRules && walk(r.cssRules)) return true; } return false; };
293
+ return sheetsByKind().tw.some((sh) => { try { return walk(sh.cssRules); } catch (err) { return false; } });
294
+ };
295
+ async function settle(tokens) {
296
+ const t0 = performance.now();
297
+ const list = (tokens || []).filter(Boolean);
298
+ do {
299
+ await new Promise((r) => setTimeout(r, 8));
300
+ if (list.length && list.every(hasRule)) return;
301
+ } while (performance.now() - t0 < 150);
302
+ }
303
+ /** The CSS value a Tailwind class gives a property, measured on a hidden twin next to the element (used when the class itself can't win). */
304
+ async function valueOfClass(el, token, prop) {
305
+ if (!token || !el.parentElement) return null;
306
+ const twin = document.createElement(el.tagName);
307
+ twin.className = token;
308
+ twin.style.cssText = "visibility:hidden;pointer-events:none;position:absolute;";
309
+ el.after(twin);
310
+ await settle([token]);
311
+ const v = getComputedStyle(twin).getPropertyValue(prop);
312
+ twin.remove();
313
+ return v || null;
314
+ }
315
+ const tidyStyle = (el) => { if (!el.getAttribute("style")) el.removeAttribute("style"); };
316
+ /**
317
+ * A property edit from the panel, written where it belongs and checked: "tw" sets the new class string and keeps it only if that
318
+ * class is now what gives the property its value (something stronger, like the design's CSS or a md: class, makes it fall back);
319
+ * "css" writes the inline style, made !important when the design's own CSS would still win.
320
+ */
321
+ // ── After a drag, resize or draw: pixels become Tailwind classes (the editor builds them, this frame checks them) ──
322
+ const GESTURE_PROPS = ["width", "height", "left", "top", "margin-left", "margin-top", "margin-right", "margin-bottom", "align-self"];
323
+ const inlineOf = (el, props) => { const o = {}; props.forEach((p) => { o[p] = el.style.getPropertyValue(p); }); return o; };
324
+ /** What the editor needs to turn an element's new inline pixels into classes: the values, its classes, and where each value came from before. */
325
+ function gestureItem(el, pre, props, clear) {
326
+ const values = {};
327
+ props.forEach((p) => { const v = el.style.getPropertyValue(p); if (v && !el.style.getPropertyPriority(p)) values[p] = v; });
328
+ // `clear`: class groups (mt, mx, self...) to take off the element, for the margins and alignment its old place gave it
329
+ return { path: pathOf(el), classes: el.getAttribute("class") || "", made: made.has(el), pre: pre || {}, values, ...(clear && clear.length ? { clear } : {}) };
330
+ }
331
+ /**
332
+ * The editor's answer: per element, the class string and the class meant to give each property its value. The classes go on
333
+ * first, and only once Tailwind has built them is the inline pixel removed, all in one go, so nothing flickers. If any of an
334
+ * element's properties then takes its value from something else, that element gets its classes and pixels back.
335
+ */
336
+ // From release until the editor's classes are in, new presses wait: a second drag in that moment would work on pixels that
337
+ // are about to become classes. If the editor never answers, the pixels are saved as they are.
338
+ let converting = false, convertTimer = 0;
339
+ function askConvert(items) {
340
+ converting = true;
341
+ clearTimeout(convertTimer);
342
+ convertTimer = setTimeout(() => { converting = false; changed(); }, 2000);
343
+ post({ type: "convert", items });
344
+ }
345
+ async function applyGesture(m) {
346
+ const work = (m.items || []).map((it) => ({ it, el: elAt(it.path), props: Object.keys(it.tokens || {}) })).filter((w) => w.el && (w.props.length || w.it.cleared));
347
+ work.forEach((w) => { w.prevClass = w.el.getAttribute("class"); w.prevStyle = w.el.getAttribute("style"); w.el.setAttribute("class", w.it.classes); });
348
+ if (!converting) return; // too late: the pixels were already saved
349
+ clearTimeout(convertTimer);
350
+ if (work.length) await settle(work.flatMap((w) => Object.values(w.it.tokens)));
351
+ work.forEach((w) => {
352
+ const { el, it, props } = w;
353
+ props.forEach((p) => el.style.removeProperty(p));
354
+ tidyStyle(el);
355
+ const now = sourcesOf(el, props);
356
+ const lost = props.some((p) => { const s = now[p] || {}; return s.source === "design" || s.source === "inline" || (s.source === "tailwind" && s.token !== it.tokens[p]); });
357
+ if (!lost) return;
358
+ if (w.prevClass === null) el.removeAttribute("class"); else el.setAttribute("class", w.prevClass);
359
+ if (w.prevStyle === null) el.removeAttribute("style"); else el.setAttribute("style", w.prevStyle);
360
+ });
361
+ converting = false;
362
+ redraw();
363
+ changed();
364
+ }
365
+
366
+ let propSeq = 0;
367
+ async function setProp(el, m) {
368
+ const seq = ++propSeq; // a colour picker being dragged sends edit after edit: only the latest one gets checked (and maybe undone)
369
+ const props = m.props || [m.prop]; // several at once for an all-sides edit (padding, border width, radius)
370
+ let css = m.css;
371
+ let perProp = null; // the fallback CSS measured for each property
372
+ if (m.route === "tw") {
373
+ const prevClass = el.getAttribute("class"), prevStyle = el.getAttribute("style");
374
+ if (m.classes) el.setAttribute("class", m.classes); else el.removeAttribute("class");
375
+ props.forEach((p) => el.style.removeProperty(p));
376
+ tidyStyle(el);
377
+ if (!m.token) return; // a class was removed: nothing to check
378
+ await settle([m.token]);
379
+ if (seq !== propSeq) return;
380
+ const now = sourcesOf(el, props);
381
+ const lost = props.some((p) => { const s = now[p] || {}; return s.source === "design" || s.source === "inline" || (s.source === "tailwind" && s.token !== m.token); });
382
+ if (!lost) {
383
+ // a size typed in px that the class does not give (the design's theme has another scale: rounded-lg is 24px here): the same value as an arbitrary one
384
+ if (m.alt && /^-?[\d.]+px$/.test(m.css || "") && /^(?:border-.*-radius|padding-|margin-|font-size|line-height|letter-spacing|(?:row-|column-)?gap|border-.*-width)/.test(props[0])) {
385
+ const want = parseFloat(m.css), got = parseFloat(getComputedStyle(el).getPropertyValue(props[0]));
386
+ if (Number.isFinite(got) && Math.abs(got - want) > 0.6) { el.setAttribute("class", m.alt.classes); await settle([m.alt.token]); }
387
+ }
388
+ return;
389
+ }
390
+ if (prevClass === null) el.removeAttribute("class"); else el.setAttribute("class", prevClass);
391
+ if (prevStyle === null) el.removeAttribute("style"); else el.setAttribute("style", prevStyle);
392
+ if (!css) {
393
+ const base = m.token.replace(/^(?:[^[\]:]*(?:\[[^\]]*\])?[^[\]:]*:)+/, ""); // without its md: / hover: prefix
394
+ perProp = {};
395
+ for (const p of props) perProp[p] = await valueOfClass(el, base, p);
396
+ }
397
+ } else if (!css && m.token) { // a Tailwind value typed on a CSS row (red-500): measure what it means
398
+ perProp = {};
399
+ for (const p of props) perProp[p] = await valueOfClass(el, m.token, p);
400
+ }
401
+ props.forEach((p) => {
402
+ const v = perProp ? perProp[p] : css;
403
+ if (v === null || v === undefined || v === "") { el.style.removeProperty(p); return; }
404
+ el.style.setProperty(p, v);
405
+ });
406
+ // the design's own CSS still wins (an !important rule): only an important inline value beats it
407
+ const after = sourcesOf(el, props);
408
+ props.forEach((p) => { const v = el.style.getPropertyValue(p); if (v && (after[p] || {}).source === "design") el.style.setProperty(p, v, "important"); });
409
+ tidyStyle(el);
410
+ }
411
+
412
+ /** For a child of a flex box: its direction and whether flex sizes it along that axis (flex-1, grow, a basis), which a width or height can't override. */
413
+ const flexItemOf = (el) => {
414
+ const p = el.parentElement;
415
+ if (!p || el === document.body || !/flex/.test(getComputedStyle(p).display)) return null;
416
+ const cs = getComputedStyle(el), dir = getComputedStyle(p).flexDirection.startsWith("row") ? "row" : "column";
417
+ return { dir, sized: (parseFloat(cs.flexGrow) || 0) > 0 || cs.flexBasis !== "auto" };
418
+ };
419
+ /**
420
+ * A box with two or more children stacked in the normal flow can be made a row, column or grid. The gap that keeps today's
421
+ * spacing: in the flow, touching margins collapse into the larger one; in flex they add up, so the margins come off the gap.
422
+ */
423
+ const arrangeOf = (el) => {
424
+ if (!el || el === document.body || !/^(block|flow-root|list-item)$/.test(getComputedStyle(el).display)) return null;
425
+ const kids = Array.prototype.filter.call(el.children, (k) => !["SCRIPT", "STYLE"].includes(k.tagName) && !isAbs(k));
426
+ if (kids.length < 2) return null;
427
+ const med = (a) => { const b = [...a].sort((x, y) => x - y); return b[Math.floor(b.length / 2)] || 0; };
428
+ const rs = kids.map((k) => ({ r: k.getBoundingClientRect(), cs: getComputedStyle(k) }));
429
+ const space = med(rs.slice(1).map((b, i) => b.r.top - rs[i].r.bottom));
430
+ const margins = med(rs.slice(1).map((b, i) => (parseFloat(rs[i].cs.marginBottom) || 0) + (parseFloat(b.cs.marginTop) || 0)));
431
+ return { count: kids.length, colGap: Math.max(0, Math.round(space - margins)), rowGap: Math.max(0, Math.round(space)) };
432
+ };
433
+ // ── Which tags an element can become ───────────────────────────────────────────────────
434
+ // Only swaps that keep the HTML valid for where the element sits and what it holds: text tags (p, h1, span...) only around
435
+ // text and inline content, no link or button inside another, list items only in a list, one <main>, no form in a form.
436
+ // Images, inputs, media and table rows keep their own tag (another one would drop what they are).
437
+ const FLOW_TAGS = ["div", "section", "article", "aside", "header", "footer", "nav", "main", "form", "figure", "blockquote"];
438
+ const TEXT_TAGS = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "span", "label", "strong", "em", "small"];
439
+ const ACTION_TAGS = ["a", "button"];
440
+ const PHRASING = new Set(["a", "abbr", "b", "br", "button", "code", "em", "i", "img", "input", "kbd", "label", "mark", "q", "s", "select", "small", "span", "strong", "sub", "sup", "svg", "textarea", "time", "u", "wbr", "picture", "video", "audio", "canvas", "iframe", "object", "data", "var", "samp", "cite", "dfn", "bdi", "bdo", "ruby", "del", "ins", "map", "meter", "output", "progress", "slot", "template", "noscript", "script", "style"]);
441
+ const TEXT_ONLY = new Set(["p", "h1", "h2", "h3", "h4", "h5", "h6", "span", "label", "strong", "em", "small", "a", "button", "b", "i", "u", "s", "code", "q", "cite", "mark", "sub", "sup", "abbr", "time", "legend", "dt", "caption", "summary", "pre"]);
442
+ const INTERACTIVE = "a, button, input, select, textarea, label, details, iframe, [tabindex]";
443
+ const PAIRS = { td: ["td", "th"], th: ["td", "th"], dt: ["dt", "dd"], dd: ["dt", "dd"], thead: ["thead", "tbody", "tfoot"], tbody: ["thead", "tbody", "tfoot"], tfoot: ["thead", "tbody", "tfoot"] };
444
+ const SWAPPABLE = new Set([...FLOW_TAGS, ...TEXT_TAGS, ...ACTION_TAGS, "ul", "ol", "li", "menu", "b", "i", "u", "s", "code", "mark", "pre"]);
445
+ function tagOptions(el) {
446
+ const tag = el.tagName.toLowerCase();
447
+ if (el === document.body || isOverlay(el)) return [];
448
+ if (PAIRS[tag]) return PAIRS[tag];
449
+ if (!SWAPPABLE.has(tag)) return [];
450
+ const parent = el.parentElement, ptag = parent ? parent.tagName.toLowerCase() : "body";
451
+ if (["ul", "ol", "menu"].includes(ptag)) return ["li"]; // a list holds only list items
452
+ const kids = Array.prototype.filter.call(el.children, (k) => !["SCRIPT", "STYLE", "TEMPLATE"].includes(k.tagName));
453
+ const kidTags = kids.map((k) => k.tagName.toLowerCase());
454
+ if (kids.length && kidTags.every((t) => t === "li")) return ["ul", "ol"]; // its items need a list around them
455
+ const up = (sel) => Boolean(parent && parent.closest(sel));
456
+ const down = (sel) => Boolean(el.querySelector(sel));
457
+ const inlineOnly = kidTags.every((t) => PHRASING.has(t)); // holds only text and inline elements
458
+ const inText = TEXT_ONLY.has(ptag) || up("p, h1, h2, h3, h4, h5, h6, span, a, button, label");
459
+ const inAction = up("a, button");
460
+ let out = [];
461
+ if (!inText) {
462
+ out.push(...FLOW_TAGS.filter((t) => {
463
+ if (t === "main") return (tag === "main" || !document.querySelector("main")) && !up("article, aside, footer, header, nav"); // one per page
464
+ if (t === "header" || t === "footer") return !up("header, footer") && !down("header, footer");
465
+ if (t === "form") return !up("form") && !down("form");
466
+ return true;
467
+ }));
468
+ if (!kids.length && !el.textContent.trim()) out.push("ul", "ol"); // an empty one can start a list (text can't sit in one)
469
+ }
470
+ if (inlineOnly) {
471
+ out.push(...(inText ? TEXT_TAGS.filter((t) => !/^(p|h\d)$/.test(t)) : TEXT_TAGS).filter((t) => t !== "label" || (!inAction && !up("label") && !down("label"))));
472
+ if (!inAction && !down(INTERACTIVE)) out.push(...ACTION_TAGS);
473
+ }
474
+ if (!out.includes(tag)) out.unshift(tag);
475
+ return [...new Set(out)];
476
+ }
477
+
478
+ const describe = (el) => {
479
+ if (!el) return null;
480
+ // the editor's own cursor rule (everything follows the tool) is not the design's: off while the element is read, so its
481
+ // cursor-pointer shows as pointer in the panel
482
+ cursorStyle.disabled = true;
483
+ try { return describeNow(el); } finally { cursorStyle.disabled = false; }
484
+ };
485
+ const describeNow = (el) => {
486
+ const attrs = {};
487
+ Array.prototype.forEach.call(el.attributes, (a) => { if (a.name !== "class" && a.name !== "style" && a.name !== "contenteditable") attrs[a.name] = a.value; });
488
+ const crumbs = [];
489
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) crumbs.unshift({ tag: n.tagName.toLowerCase(), path: n === document.body ? [] : pathOf(n) });
490
+ return {
491
+ path: el === document.body ? [] : pathOf(el),
492
+ tag: el.tagName.toLowerCase(),
493
+ classes: el.getAttribute("class") || "",
494
+ style: el.getAttribute("style") || "",
495
+ leaf: el.children.length === 0,
496
+ group: el.hasAttribute("data-wc-group"),
497
+ ownText: Array.prototype.some.call(el.childNodes, (n) => n.nodeType === 3 && n.textContent.trim() !== ""), // text of its own: Typography shows
498
+ text: el.children.length === 0 ? el.textContent : null,
499
+ attrs, crumbs,
500
+ geom: geomOf(el),
501
+ rect: el === document.body ? null : pageRect(el),
502
+ computed: computedOf(el),
503
+ sources: sourcesOf(el, cssProps),
504
+ flexItem: flexItemOf(el),
505
+ parentLayout: el !== document.body && el.parentElement ? layoutOf(el.parentElement) : null,
506
+ arrange: arrangeOf(el),
507
+ tagOptions: tagOptions(el),
508
+ added: made.has(el),
509
+ count: sels.length,
510
+ others: sels.length > 1 && el === primary() ? sels.filter((o) => o !== el).map(pathOf) : [], // the rest of a multiple selection: a class change goes to all of them
511
+ outer: el.outerHTML.slice(0, 20000),
512
+ };
513
+ };
514
+
515
+ let count = 0;
516
+ const walk = (el, depth) => {
517
+ if (count > 600 || depth > 14) return null;
518
+ const tag = el.tagName.toLowerCase();
519
+ if (tag === "script" || tag === "style" || tag === "link") return null;
520
+ count++;
521
+ const own = Array.prototype.filter.call(el.childNodes, (n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(" ").trim();
522
+ const label = el.id ? "#" + el.id : own ? own.slice(0, 26) : (el.getAttribute("class") || "").split(/\s+/)[0] || "";
523
+ const children = Array.prototype.map.call(el.children, (c) => walk(c, depth + 1)).filter(Boolean);
524
+ return { tag, label, path: el === document.body ? [] : pathOf(el), children, locked: el.hasAttribute("data-wc-locked"), hidden: el.classList.contains("hidden") };
525
+ };
526
+ const tree = () => { count = 0; return walk(document.body, 0); };
527
+ const serialize = () => {
528
+ const c = document.body.cloneNode(true);
529
+ c.querySelectorAll("[contenteditable]").forEach((e) => e.removeAttribute("contenteditable"));
530
+ c.querySelectorAll("[data-wc-state]").forEach((e) => e.removeAttribute("data-wc-state")); // a state shown while editing, not part of the design
531
+ c.querySelectorAll("script[data-wc-agent]").forEach((e) => e.remove());
532
+ return c.innerHTML.trim();
533
+ };
534
+
535
+ function changed(silent, key) {
536
+ redraw();
537
+ const info = primary() && alive(primary()) ? describe(primary()) : null;
538
+ // with nothing selected the editor shows the page, and the page's styling is the body's: it gets the body's fresh values
539
+ post({ type: "changed", silent: Boolean(silent), key: key || "", html: serialize(), tree: tree(), info, body: info ? null : describe(document.body) });
540
+ }
541
+ function setSelection(list, quiet) {
542
+ sels = list.filter(Boolean);
543
+ showState(); // the picked state shows on whatever is selected now
544
+ if (inside && !(sels.length && sels.every((el) => el !== inside && inside.contains(el)))) inside = null; // picked something outside: out of the group
545
+ redraw();
546
+ if (!quiet) post({ type: "selected", info: describe(primary()) });
547
+ }
548
+ const select = (el, quiet) => setSelection(el ? [el] : [], quiet);
549
+ function setTool(t, quiet) {
550
+ tool = t;
551
+ root.style.cursor = CURSOR[t] || "default";
552
+ redraw();
553
+ if (!quiet) post({ type: "tool", tool: t });
554
+ }
555
+
556
+ // A locked element (and what is inside it) cannot be picked, moved or resized on the page: a press on it goes to what holds it. The
557
+ // lock is an attribute on the element, so it is saved with the design. It can still be chosen in the Layers panel, to unlock it.
558
+ const isLocked = (el) => Boolean(el && el.closest && el.closest("[data-wc-locked]"));
559
+ const targetOf = (e) => {
560
+ let t = e.target;
561
+ if (t && t.nodeType !== 1) t = t.parentElement;
562
+ if (!t || isOverlay(t) || t === root) return document.body;
563
+ while (t && t !== document.body && isLocked(t)) t = t.parentElement;
564
+ if (e.metaKey || e.ctrlKey) return t || document.body; // Cmd/Ctrl: the deepest, whatever the level
565
+ return pickable(t || document.body);
566
+ };
567
+ // ── Groups ── A group (Ctrl+G) is a box made around a few elements, marked data-wc-group so it is saved with the design. On the page
568
+ // it acts as one thing, as in Figma: a click picks the whole group, a double-click goes inside it (then what is in it can be picked
569
+ // and moved), and picking something outside it or Esc comes back out. The Layers panel can still pick anything.
570
+ /** The group `el` is in, if any (not `el` itself). What is in a group stays in it until it is ungrouped. */
571
+ const groupAround = (el) => (el && el.parentElement ? el.parentElement.closest("[data-wc-group]") : null);
572
+ /** May `el` be put inside `parent`? Only within the group it is in, if it is in one. */
573
+ const staysInGroup = (el, parent) => { const gp = groupAround(el); return !gp || (!!parent && gp.contains(parent)); };
574
+ /** The elements of the design inside `el` that can be picked (not the editor's own overlay, scripts or styles). */
575
+ const realKids = (el) => [...el.children].filter((c) => c !== overlay && !/^(script|style|link|meta)$/i.test(c.tagName));
576
+ /** What a click on `t` picks, as in Figma: the top level of the design first, a double-click goes one level in, and from there a click
577
+ * picks at that level (the box selected, or the one gone into, and what sits beside it). Cmd/Ctrl-click picks the deepest. */
578
+ function pickable(t) {
579
+ if (!t || t === document.body) return t;
580
+ const sv = t.closest && t.closest("svg"); // an icon or chart is one thing: its paths are never picked on their own
581
+ if (sv && sv !== t) t = sv;
582
+ const top = document.body; // the top level is what sits directly in the page, wrapper or not: the outermost box can always be picked
583
+ // from the selection outwards, the first box that holds `t` is the level: what is picked is that box's child under the pointer
584
+ // (so the cards beside a selected card, or the sections around it, can each be picked with one click)
585
+ const cur = sels.length === 1 && alive(sels[0]) ? sels[0] : null;
586
+ let from = top;
587
+ if (inside && inside !== t && inside.contains(t) && top.contains(inside)) from = inside;
588
+ else if (cur && cur !== document.body && top.contains(cur)) for (let a = cur.parentElement; a && top.contains(a); a = a.parentElement) if (a.contains(t) && a !== t) { from = a; break; }
589
+ while (t.parentElement && t.parentElement !== from) t = t.parentElement;
590
+ return t;
591
+ }
592
+ const containerAt = (x, y) => {
593
+ let t = document.elementFromPoint(x, y);
594
+ if (!t || isOverlay(t) || t === root) return document.body;
595
+ while (t && t !== document.body && !CONTAINERS.has(t.tagName.toLowerCase())) t = t.parentElement;
596
+ return t || document.body;
597
+ };
598
+
599
+ // ── Creating elements ───────────────────────────────────────────────────────────────────
600
+ function createAt(kind, container, lx, ly, w, h) {
601
+ const t = TEMPLATES[kind];
602
+ const el = document.createElement(t.tag);
603
+ el.className = t.cls;
604
+ if (t.attrs) Object.keys(t.attrs).forEach((k) => el.setAttribute(k, t.attrs[k]));
605
+ if (t.text) el.textContent = t.text;
606
+ if (t.html) el.innerHTML = t.html;
607
+ ensurePositioned(container);
608
+ container.appendChild(el);
609
+ markMade(el);
610
+ el.style.position = "absolute";
611
+ setPx(el, "left", lx);
612
+ setPx(el, "top", ly);
613
+ if (!t.auto || (w && h)) { setPx(el, "width", w || t.w); setPx(el, "height", h || t.h); }
614
+ if (t.auto && !(w && h)) { /* keeps its natural size */ }
615
+ return el;
616
+ }
617
+
618
+ function finishDraw(g, e) {
619
+ draftBox.style.display = "none";
620
+ const dragged = Math.abs(e.clientX - g.sx) > 4 || Math.abs(e.clientY - g.sy) > 4;
621
+ const o = originOf(g.container);
622
+ const x0 = Math.min(g.sx, e.clientX) + window.scrollX - o.x;
623
+ const y0 = Math.min(g.sy, e.clientY) + window.scrollY - o.y;
624
+ const w = Math.abs(e.clientX - g.sx), h = Math.abs(e.clientY - g.sy);
625
+ const t = TEMPLATES[g.kind];
626
+ if (g.kind === "line" || g.kind === "arrow") {
627
+ // a line runs from where the drag began to where it ended: a box of that length, centred on the midpoint and turned to the angle
628
+ const dx = dragged ? e.clientX - g.sx : t.w, dy = dragged ? e.clientY - g.sy : 0;
629
+ const len = Math.max(8, Math.hypot(dx, dy));
630
+ const mx = g.sx + dx / 2 + window.scrollX - o.x, my = g.sy + dy / 2 + window.scrollY - o.y;
631
+ const line = createAt(g.kind, g.container, mx - len / 2, my - 1, len, 2);
632
+ const deg = Math.round(Math.atan2(dy, dx) * 1800 / Math.PI) / 10;
633
+ if (deg) { // the turn is a class like any other (rotate-45, rotate-[33.7deg]), not an inline style
634
+ const step = [0, 1, 2, 3, 6, 12, 45, 90, 180].includes(Math.abs(deg));
635
+ line.classList.add(step ? `${deg < 0 ? "-" : ""}rotate-${Math.abs(deg)}` : `rotate-[${deg}deg]`);
636
+ }
637
+ setSelection([line]);
638
+ setTool("move");
639
+ return askConvert([gestureItem(line, {}, ["position", "left", "top", "width", "height"])]);
640
+ }
641
+ const el = dragged && !(g.kind === "text" || g.kind === "button")
642
+ ? createAt(g.kind, g.container, x0, y0, w, h)
643
+ : createAt(g.kind, g.container, x0, y0);
644
+ setSelection([el]);
645
+ setTool("move");
646
+ askConvert([gestureItem(el, {}, ["position", "left", "top", "width", "height"])]);
647
+ if (t.text) startEdit(el, true);
648
+ }
649
+
650
+ // ── Inline editing ──────────────────────────────────────────────────────────────────────
651
+ function startEdit(el, selectAll) {
652
+ if (!el || el === document.body) return;
653
+ editing = el;
654
+ el.setAttribute("contenteditable", "true");
655
+ el.focus();
656
+ if (selectAll) { const r = document.createRange(); r.selectNodeContents(el); const s = getSelection(); s.removeAllRanges(); s.addRange(r); }
657
+ redraw();
658
+ postEditing();
659
+ }
660
+ const fmtState = () => ({ bold: document.queryCommandState("bold"), italic: document.queryCommandState("italic"), underline: document.queryCommandState("underline") });
661
+ function postEditing() { post({ type: "editing", on: Boolean(editing), rect: editing ? pageRect(editing) : null, fmt: editing ? fmtState() : null }); }
662
+ function finishEdit() {
663
+ if (!editing) return;
664
+ const el = editing;
665
+ editing = null;
666
+ el.removeAttribute("contenteditable");
667
+ postEditing();
668
+ changed();
669
+ }
670
+
671
+ // ── Snapping ────────────────────────────────────────────────────────────────────────────
672
+ // ── Smart guides ────────────────────────────────────────────────────────────────────────
673
+ // Everything is in page coordinates. A moving box snaps to the edges and centres of EVERY box around it (not just its siblings),
674
+ // the lines that lined up are drawn only as far as the boxes involved, and the gaps to the neighbours are measured.
675
+ const GUIDE_SKIP = new Set(["SCRIPT", "STYLE", "BR", "TEMPLATE", "HEAD", "META", "LINK"]);
676
+ function targetsFor(exclude) {
677
+ const out = [];
678
+ document.body.querySelectorAll("*").forEach((n) => {
679
+ if (isOverlay(n) || GUIDE_SKIP.has(n.tagName) || exclude.some((x) => x === n || x.contains(n))) return;
680
+ const r = pageRect(n);
681
+ if (r.w >= 1 && r.h >= 1) out.push(r);
682
+ });
683
+ out.push({ x: 0, y: 0, w: Math.max(root.scrollWidth, innerWidth), h: Math.max(root.scrollHeight, innerHeight) });
684
+ return out;
685
+ }
686
+ const three = (a, len) => [a, a + len / 2, a + len];
687
+ /** The smallest move that puts one of `vals` on one of the targets' lines, or null when nothing is close enough. */
688
+ function nearestLine(vals, lineSets, t) {
689
+ let best = null;
690
+ vals.forEach((v) => lineSets.forEach((set) => set.forEach((c) => { const d = c - v; if (Math.abs(d) <= t && (best === null || Math.abs(d) < Math.abs(best))) best = d; })));
691
+ return best;
692
+ }
693
+ const cross = (x, y) => guideLayer.appendChild(mk(`position:absolute;left:${x}px;top:${y}px;width:${px(9)}px;height:${px(1)}px;background:${RED};transform:translate(-50%,-50%) rotate(45deg);`)) && guideLayer.appendChild(mk(`position:absolute;left:${x}px;top:${y}px;width:${px(9)}px;height:${px(1)}px;background:${RED};transform:translate(-50%,-50%) rotate(-45deg);`));
694
+ function chip(x, y, text) {
695
+ const c = mk(`position:absolute;left:${x}px;top:${y}px;transform:translate(-50%,-50%);background:${RED};color:#fff;font:600 ${px(11)}px/1 system-ui,sans-serif;padding:${px(3)}px ${px(6)}px;border-radius:${px(4)}px;white-space:nowrap;`);
696
+ c.textContent = text;
697
+ guideLayer.appendChild(c);
698
+ }
699
+ /** A measured gap: a line between two edges with end ticks and the distance in a red chip. axis "x" runs left to right at height `at`. */
700
+ function drawGap(axis, from, to, at) {
701
+ const len = to - from;
702
+ if (len < 1) return;
703
+ const line = axis === "x" ? `left:${from}px;top:${at}px;width:${len}px;height:${px(1)}px;` : `left:${at}px;top:${from}px;width:${px(1)}px;height:${len}px;`;
704
+ guideLayer.appendChild(mk(`position:absolute;${line}background:${RED};`));
705
+ [from, to].forEach((v) => guideLayer.appendChild(mk(axis === "x"
706
+ ? `position:absolute;left:${v}px;top:${at - px(4)}px;width:${px(1)}px;height:${px(9)}px;background:${RED};`
707
+ : `position:absolute;left:${at - px(4)}px;top:${v}px;width:${px(9)}px;height:${px(1)}px;background:${RED};`)));
708
+ axis === "x" ? chip(from + len / 2, at - px(11), String(Math.round(len))) : chip(at + px(14), from + len / 2, String(Math.round(len)));
709
+ }
710
+ /** The nearest box on each side that overlaps `box` in the other direction. */
711
+ function neighbours(box, targets) {
712
+ const n = { left: null, right: null, up: null, down: null };
713
+ const ov = (a0, a1, b0, b1) => Math.min(a1, b1) - Math.max(a0, b0);
714
+ targets.forEach((r) => {
715
+ if (ov(r.y, r.y + r.h, box.y, box.y + box.h) > 0) {
716
+ if (r.x + r.w <= box.x + 0.5 && (!n.left || r.x + r.w > n.left.x + n.left.w)) n.left = r;
717
+ if (r.x >= box.x + box.w - 0.5 && (!n.right || r.x < n.right.x)) n.right = r;
718
+ }
719
+ if (ov(r.x, r.x + r.w, box.x, box.x + box.w) > 0) {
720
+ if (r.y + r.h <= box.y + 0.5 && (!n.up || r.y + r.h > n.up.y + n.up.h)) n.up = r;
721
+ if (r.y >= box.y + box.h - 0.5 && (!n.down || r.y < n.down.y)) n.down = r;
722
+ }
723
+ });
724
+ return n;
725
+ }
726
+ const mid = (a0, a1, b0, b1) => (Math.max(a0, b0) + Math.min(a1, b1)) / 2;
727
+ const gapBetween = (side, a, b) => (side === "left" ? a.x - (b.x + b.w) : side === "right" ? b.x - (a.x + a.w) : side === "up" ? a.y - (b.y + b.h) : b.y - (a.y + a.h));
728
+ /** Beyond the first neighbour: the next boxes in the same direction that sit the same distance apart, so a whole evenly spaced row shows its gaps. */
729
+ function chainGaps(side, first, d, targets) {
730
+ const out = [];
731
+ let cur = first;
732
+ for (let i = 0; i < 12; i++) {
733
+ const nb = neighbours(cur, targets)[side];
734
+ if (!nb || Math.abs(gapBetween(side, cur, nb) - d) > 0.6) break;
735
+ const horizontal = side === "left" || side === "right";
736
+ const [a, b] = side === "left" || side === "up" ? [nb, cur] : [cur, nb];
737
+ out.push(horizontal
738
+ ? { axis: "x", from: a.x + a.w, to: b.x, at: mid(nb.y, nb.y + nb.h, cur.y, cur.y + cur.h) }
739
+ : { axis: "y", from: a.y + a.h, to: b.y, at: mid(nb.x, nb.x + nb.w, cur.x, cur.x + cur.w) });
740
+ cur = nb;
741
+ }
742
+ return out;
743
+ }
744
+ /** Draw what the box lined up with (only the lines in `keysX` / `keysY`, 0 = start, 1 = centre, 2 = end) and the gaps worth showing. */
745
+ function showGuides(box, targets, keysX, keysY, measure, want = []) {
746
+ guideLayer.innerHTML = "";
747
+ const near = (a, b) => Math.abs(a - b) < 0.6;
748
+ [["x", keysX], ["y", keysY]].forEach(([axis, keys]) => {
749
+ const seen = [];
750
+ keys.forEach((k) => {
751
+ const v = axis === "x" ? three(box.x, box.w)[k] : three(box.y, box.h)[k];
752
+ if (seen.some((p) => near(p, v))) return;
753
+ const parts = targets.map((r) => { const ls = axis === "x" ? three(r.x, r.w) : three(r.y, r.h); const j = ls.findIndex((l) => near(l, v)); return j < 0 ? null : { r, j }; }).filter(Boolean);
754
+ if (!parts.length) return;
755
+ seen.push(v);
756
+ const rs = [box, ...parts.map((p) => p.r)];
757
+ const lo = Math.min(...rs.map((r) => (axis === "x" ? r.y : r.x))), hi = Math.max(...rs.map((r) => (axis === "x" ? r.y + r.h : r.x + r.w)));
758
+ guideLayer.appendChild(mk(axis === "x"
759
+ ? `position:absolute;left:${v}px;top:${lo}px;width:${px(1)}px;height:${hi - lo}px;background:${RED};`
760
+ : `position:absolute;top:${v}px;left:${lo}px;height:${px(1)}px;width:${hi - lo}px;background:${RED};`));
761
+ // an x on the box and on the two boxes at the far ends of the line (not on every box in between), at their corners or middles
762
+ const far = [parts.reduce((m, p) => ((axis === "x" ? p.r.y : p.r.x) < (axis === "x" ? m.r.y : m.r.x) ? p : m), parts[0]), parts.reduce((m, p) => ((axis === "x" ? p.r.y + p.r.h : p.r.x + p.r.w) > (axis === "x" ? m.r.y + m.r.h : m.r.x + m.r.w) ? p : m), parts[0])];
763
+ [{ r: box, j: k }, ...far.filter((p, i) => far.indexOf(p) === i)].forEach(({ r, j }) => {
764
+ const a0 = axis === "x" ? r.y : r.x, a1 = axis === "x" ? r.y + r.h : r.x + r.w;
765
+ (j === 1 ? [(a0 + a1) / 2] : [a0, a1]).forEach((a) => (axis === "x" ? cross(v, a) : cross(a, v)));
766
+ });
767
+ });
768
+ });
769
+ // gaps: equal spacing between two neighbours is always shown; with Alt, the distance to the nearest box on every side
770
+ const n = neighbours(box, targets);
771
+ const gx = n.left && n.right ? [box.x - (n.left.x + n.left.w), n.right.x - (box.x + box.w)] : null;
772
+ const gy = n.up && n.down ? [box.y - (n.up.y + n.up.h), n.down.y - (box.y + box.h)] : null;
773
+ const eqX = gx && near(gx[0], gx[1]), eqY = gy && near(gy[0], gy[1]);
774
+ // Distances show: with Alt, whenever the box has lined up with something (so you see how far it is from its neighbours), and for equal spacing
775
+ const lined = guideLayer.children.length > 0;
776
+ // `want`: the sides along the direction being moved or resized always get their distance to the neighbouring box
777
+ const sides = measure || lined ? ["left", "right", "up", "down"] : [...new Set([...want, ...(eqX ? ["left", "right"] : []), ...(eqY ? ["up", "down"] : [])])];
778
+ sides.forEach((side) => {
779
+ const r = n[side];
780
+ if (!r) return;
781
+ chainGaps(side, r, gapBetween(side, box, r), targets).forEach((c) => drawGap(c.axis, c.from, c.to, c.at));
782
+ if (side === "left") drawGap("x", r.x + r.w, box.x, mid(r.y, r.y + r.h, box.y, box.y + box.h));
783
+ if (side === "right") drawGap("x", box.x + box.w, r.x, mid(r.y, r.y + r.h, box.y, box.y + box.h));
784
+ if (side === "up") drawGap("y", r.y + r.h, box.y, mid(r.x, r.x + r.w, box.x, box.x + box.w));
785
+ if (side === "down") drawGap("y", box.y + box.h, r.y, mid(r.x, r.x + r.w, box.x, box.x + box.w));
786
+ });
787
+ }
788
+ /** How far to nudge `box` along each axis so it lines up with the boxes around it, or sits an equal distance between two of them. */
789
+ function snapDelta(box, targets, keysX, keysY) {
790
+ const t = px(6);
791
+ const aX = nearestLine(keysX.map((k) => three(box.x, box.w)[k]), targets.map((r) => three(r.x, r.w)), t);
792
+ const aY = nearestLine(keysY.map((k) => three(box.y, box.h)[k]), targets.map((r) => three(r.y, r.h)), t);
793
+ const n = neighbours(box, targets);
794
+ // spacing candidates: an equal distance from the two neighbours either side, or the same distance from a neighbour as that neighbour
795
+ // sits from the box beyond it (an evenly spaced row stays even)
796
+ const likeBeyond = (first, side, boxStart, boxEnd) => {
797
+ if (!first) return null;
798
+ const beyond = neighbours(first, targets)[side];
799
+ if (!beyond) return null;
800
+ const g0 = gapBetween(side, first, beyond);
801
+ if (g0 < 1) return null;
802
+ return side === "left" ? first.x + first.w + g0 - boxStart : side === "right" ? first.x - g0 - boxEnd : side === "up" ? first.y + first.h + g0 - boxStart : first.y - g0 - boxEnd;
803
+ };
804
+ const between = (a, b, boxStart, boxEnd, aEnd, bStart) => (a && b ? ((bStart - boxEnd) - (boxStart - aEnd)) / 2 : null);
805
+ const near = (list) => list.filter((d) => d !== null && Math.abs(d) <= t).sort((p, q) => Math.abs(p) - Math.abs(q))[0];
806
+ const gX = near([between(n.left, n.right, box.x, box.x + box.w, n.left && n.left.x + n.left.w, n.right && n.right.x), likeBeyond(n.left, "left", box.x, box.x + box.w), likeBeyond(n.right, "right", box.x, box.x + box.w)]);
807
+ const gY = near([between(n.up, n.down, box.y, box.y + box.h, n.up && n.up.y + n.up.h, n.down && n.down.y), likeBeyond(n.up, "up", box.y, box.y + box.h), likeBeyond(n.down, "down", box.y, box.y + box.h)]);
808
+ // an exact gap wins over an edge that is about as close: the spacing is the thing that is hard to eyeball
809
+ const choose = (align, gap) => (gap !== undefined && (align === null || Math.abs(gap) <= Math.abs(align) + px(4)) ? gap : align);
810
+ return { dx: choose(aX, gX), dy: choose(aY, gY) }; // null on an axis where nothing was close enough
811
+ }
812
+
813
+ // ── Gestures ────────────────────────────────────────────────────────────────────────────
814
+ function startMove(e, all) {
815
+ const items = all.filter((el) => !isLocked(el)); // a locked element stays where it is
816
+ if (!items.length) return;
817
+ const abs = items.filter(isAbs);
818
+ // Alt-drag on an element in the page flow places it freely: it turns absolute (where it is) on the first movement, so an
819
+ // Alt-click alone changes nothing
820
+ if (!abs.length && e.altKey && items[0] && items[0] !== document.body && items[0].parentElement) {
821
+ gesture = { type: "move", freeOnMove: items[0], items: [], sx: e.clientX, sy: e.clientY, moved: false, container: items[0].parentElement };
822
+ return;
823
+ }
824
+ if (abs.length) {
825
+ gesture = { type: "move", items: abs.map((el) => ({ el, g: geomOf(el), pre: sourcesOf(el, ["left", "top"]) })), sx: e.clientX, sy: e.clientY, moved: false, container: abs[0].parentElement || document.body };
826
+ } else {
827
+ gesture = { type: "reorder", el: items[0], rect: pageRect(items[0]), sx: e.clientX, sy: e.clientY, moved: false, before: null };
828
+ }
829
+ }
830
+
831
+ function moveGesture(g, e) {
832
+ if (g.freeOnMove) {
833
+ const el = g.freeOnMove;
834
+ g.freeOnMove = null;
835
+ const pre = sourcesOf(el, FREE_PROPS);
836
+ freePosition(el, true);
837
+ g.items = [{ el, g: geomOf(el), pre }];
838
+ g.container = el.parentElement;
839
+ g.freed = true;
840
+ setSelection([el], true);
841
+ }
842
+ let dx = e.clientX - g.sx, dy = e.clientY - g.sy;
843
+ if (e.shiftKey) { if (Math.abs(dx) > Math.abs(dy)) dy = 0; else dx = 0; }
844
+ const o = originOf(g.container);
845
+ if (!g.targets) g.targets = targetsFor(g.items.map((i) => i.el));
846
+ const x0 = Math.min(...g.items.map((i) => i.g.x)) + dx, y0 = Math.min(...g.items.map((i) => i.g.y)) + dy;
847
+ const x1 = Math.max(...g.items.map((i) => i.g.x + i.g.w)) + dx, y1 = Math.max(...g.items.map((i) => i.g.y + i.g.h)) + dy;
848
+ const box = { x: x0 + o.x, y: y0 + o.y, w: x1 - x0, h: y1 - y0 };
849
+ const measure = g.freed ? e.metaKey : e.altKey || e.metaKey; // Alt: no snapping, just the distances (an Alt-drag that freed the element: Cmd)
850
+ const s = measure ? { dx: null, dy: null } : snapDelta(box, g.targets, [0, 1, 2], [0, 1, 2]);
851
+ // Snaps are sticky: once locked on a line (a centre, an edge, an equal gap) the box stays there until the pointer has moved a
852
+ // clear distance past it, then it lets go. So it moves in steps from line to line, not smoothly through them.
853
+ g.hold = g.hold || { x: null, y: null };
854
+ const release = px(4);
855
+ if (measure) g.hold = { x: null, y: null };
856
+ [["x", "dx"], ["y", "dy"]].forEach(([a, d]) => {
857
+ const h = g.hold[a], fresh = s[d];
858
+ // keep the line it is on only while it is close, and never over another line that is nearer to the pointer
859
+ if (h !== null && Math.abs(box[a] - h) <= release && (fresh === null || Math.abs(fresh) >= Math.abs(box[a] - h))) s[d] = h - box[a];
860
+ else g.hold[a] = fresh === null ? null : box[a] + fresh;
861
+ });
862
+ s.dx = s.dx || 0; s.dy = s.dy || 0;
863
+ box.x += s.dx; box.y += s.dy;
864
+ const ax = Math.abs(dx), ay = Math.abs(dy);
865
+ const want = ax > 2 * ay ? ["left", "right"] : ay > 2 * ax ? ["up", "down"] : ["left", "right", "up", "down"]; // the axis it is moving along
866
+ showGuides(box, g.targets, [0, 1, 2], [0, 1, 2], measure, want);
867
+ g.items.forEach((i) => { setPx(i.el, "left", i.g.x + dx + s.dx); setPx(i.el, "top", i.g.y + dy + s.dy); });
868
+ }
869
+
870
+ // Where would the dragged element land if let go here? Decided from the real layout under the pointer: the container it is over
871
+ // (flex row or column, grid, plain block flow), the child it is next to, and which side of that child.
872
+ const layoutOf = (c) => {
873
+ const cs = getComputedStyle(c);
874
+ if (cs.display.includes("grid")) return "grid";
875
+ if (cs.display.includes("flex")) return cs.flexDirection.startsWith("row") ? "flex row" : "flex column";
876
+ return "page flow";
877
+ };
878
+ function dropTarget(el, x, y) {
879
+ const hit = document.elementsFromPoint(x, y).find((n) => !isOverlay(n) && n !== root && !el.contains(n)) || document.body;
880
+ // a box that only holds text (a card label, a button-like div) is an item to drop beside, not a container to drop into
881
+ const holdsText = (k) => !k.children.length && k.textContent.trim() !== "";
882
+ let c = hit;
883
+ while (c && c !== document.body && (!CONTAINERS.has(c.tagName.toLowerCase()) || holdsText(c))) c = c.parentElement;
884
+ c = c || document.body;
885
+ const px0 = x + window.scrollX, py0 = y + window.scrollY;
886
+ // near the edge of a container, "before / after it" in its own parent wins over "inside it". For a neighbour of the dragged
887
+ // element the edges are wide (the outer 30%), so passing over it reorders and only aiming at its middle drops inside it.
888
+ if (c !== document.body && c.parentElement) {
889
+ const r = pageRect(c), parentLayout = layoutOf(c.parentElement);
890
+ const horizontalParent = parentLayout === "flex row" || parentLayout === "grid";
891
+ const along = horizontalParent ? r.w : r.h;
892
+ const band = c.parentElement === el.parentElement ? Math.max(8, along * 0.3) : Math.min(14, Math.min(r.w, r.h) / 4);
893
+ const edge = horizontalParent ? Math.min(px0 - r.x, r.x + r.w - px0) : Math.min(py0 - r.y, r.y + r.h - py0);
894
+ if (edge >= 0 && edge < band) return { container: c.parentElement, ref: c, before: horizontalParent ? px0 < r.x + r.w / 2 : py0 < r.y + r.h / 2, horizontal: horizontalParent };
895
+ }
896
+ const kids = Array.prototype.filter.call(c.children, (k) => k !== el && !["SCRIPT", "STYLE"].includes(k.tagName) && !(k.hasAttribute && k.hasAttribute("data-wc-agent-style")));
897
+ if (!kids.length) return { container: c, ref: null };
898
+ // the child the pointer is over, or the nearest one
899
+ let ref = null, best = Infinity;
900
+ kids.forEach((k) => {
901
+ const r = pageRect(k);
902
+ const dx = px0 < r.x ? r.x - px0 : px0 > r.x + r.w ? px0 - r.x - r.w : 0;
903
+ const dy = py0 < r.y ? r.y - py0 : py0 > r.y + r.h ? py0 - r.y - r.h : 0;
904
+ const d = Math.hypot(dx, dy);
905
+ if (d < best) { best = d; ref = k; }
906
+ });
907
+ const r = pageRect(ref), l = layoutOf(c);
908
+ const inline = /^inline/.test(getComputedStyle(ref).display);
909
+ const horizontal = l === "flex row" || (l === "grid" && py0 >= r.y && py0 <= r.y + r.h) || (l === "page flow" && inline);
910
+ return { container: c, ref, before: horizontal ? px0 < r.x + r.w / 2 : py0 < r.y + r.h / 2, horizontal };
911
+ }
912
+
913
+
914
+ // ── Nudging: a drag moves the element with a pair of margins, and nothing around it moves ──────────────────
915
+ // The margin behind the element grows by the distance dragged and the margin ahead of it shrinks by the same amount (below zero
916
+ // if it has to), so the space it takes in the layout never changes and its neighbours and parent stay exactly where they are.
917
+ // A drag that is clearly meant to put it somewhere else (see wantsBlockDrag) becomes a block drag instead.
918
+ const NUDGE_PROPS = ["margin-left", "margin-right", "margin-top", "margin-bottom", "align-self", "width", "height"];
919
+ const ORANGE = "#f59e0b";
920
+ const kidsOf = (c, skip) => Array.prototype.filter.call(c.children, (k) => k !== skip && !["SCRIPT", "STYLE"].includes(k.tagName) && !(k.hasAttribute && k.hasAttribute("data-wc-agent-style")));
921
+ const canNudge = (el) => !!el.parentElement && el !== document.body && !isAbs(el) && getComputedStyle(el).display !== "inline";
922
+ /** Would dropping at `t` leave the element where it is among its siblings? */
923
+ function sameSlot(el, t) {
924
+ if (t.container !== el.parentElement) return false;
925
+ if (!t.ref) return true;
926
+ const kids = kidsOf(el.parentElement, null), others = kids.filter((k) => k !== el);
927
+ const i = others.indexOf(t.ref);
928
+ return i >= 0 && (t.before ? i : i + 1) === kids.indexOf(el);
929
+ }
930
+ function nudgeStart(g) {
931
+ const el = g.el, par = el.parentElement, cs = getComputedStyle(el), pcs = getComputedStyle(par), pr = pageRect(par);
932
+ const f = (v) => parseFloat(v) || 0;
933
+ const bx = f(pcs.borderLeftWidth) + f(pcs.paddingLeft), bt = f(pcs.borderTopWidth) + f(pcs.paddingTop);
934
+ const box = { x: pr.x + bx, y: pr.y + bt, w: pr.w - bx - f(pcs.borderRightWidth) - f(pcs.paddingRight), h: pr.h - bt - f(pcs.borderBottomWidth) - f(pcs.paddingBottom) };
935
+ const idx = Array.prototype.indexOf.call(par.children, el);
936
+ const others = kidsOf(par, el).filter((k) => !isAbs(k) && getComputedStyle(k).display !== "none").map((k) => ({ el: k, r: pageRect(k), before: Array.prototype.indexOf.call(par.children, k) < idx }));
937
+ const r0 = pageRect(el);
938
+ // which sides are auto (ml-auto, mx-auto, mt-auto ...): that side is left alone and the other one moves
939
+ const tokens = (el.getAttribute("class") || "").split(/\s+/).map((t) => t.replace(/^(?:[\w-]+:)*/, ""));
940
+ const isAuto = (prop, side, ax) => { const v = el.style.getPropertyValue(prop); return v ? v === "auto" : tokens.some((t) => t === "m-auto" || t === `m${ax}-auto` || t === `m${side}-auto`); };
941
+ const auto = { l: isAuto("margin-left", "l", "x"), r: isAuto("margin-right", "r", "x"), t: isAuto("margin-top", "t", "y"), b: isAuto("margin-bottom", "b", "y") };
942
+ // the margins of the neighbours just above and below in the flow, which its own top and bottom margins collapse with
943
+ const flowKids = kidsOf(par, null).filter((k) => !isAbs(k) && getComputedStyle(k).display !== "none"), fi = flowKids.indexOf(el);
944
+ const side = { above: flowKids[fi - 1] ? f(getComputedStyle(flowKids[fi - 1]).marginBottom) : 0, below: flowKids[fi + 1] ? f(getComputedStyle(flowKids[fi + 1]).marginTop) : 0 };
945
+ g.nudge = { w0: cs.width, h0: cs.height, active: false, inl0: inlineOf(el, NUDGE_PROPS), pre: sourcesOf(el, NUDGE_PROPS), m: { x: f(cs.marginLeft), y: f(cs.marginTop) }, f: { x: f(cs.marginRight), y: f(cs.marginBottom) }, auto, side, r0, box, outer: pr, lay: layoutOf(par), others };
946
+ }
947
+ /** The gap two adjoining vertical margins make (they collapse), and the margin that makes `gap` next to `other`. */
948
+ const collapse = (p, q) => (p >= 0 && q >= 0 ? Math.max(p, q) : p <= 0 && q <= 0 ? Math.min(p, q) : p + q);
949
+ const solveCollapse = (gap, other) => (other >= 0 ? (gap >= other ? gap : gap - other) : gap >= 0 ? gap - other : Math.min(gap, other) === gap ? gap : gap - other);
950
+ const sameRect = (p, q) => Math.abs(p.x - q.x) < 1 && Math.abs(p.y - q.y) < 1 && Math.abs(p.w - q.w) < 1 && Math.abs(p.h - q.h) < 1;
951
+ /** Did anything other than the dragged element move? Its neighbours and its parent must stay exactly where they were. */
952
+ const othersStay = (n, el) => n.others.every((o) => sameRect(pageRect(o.el), o.r)) && sameRect(pageRect(el.parentElement), n.outer);
953
+ /** How far the element's margin pushed what comes after it along `a` (a neighbour after it, or the parent growing). */
954
+ function pushed(n, el, a) {
955
+ const Z = a === "x" ? "w" : "h";
956
+ for (const o of n.others) { if (o.r[a] <= n.r0[a]) continue; const d = pageRect(o.el)[a] - o.r[a]; if (Math.abs(d) > 0.5) return d; }
957
+ return pageRect(el.parentElement)[Z] - n.outer[Z];
958
+ }
959
+ const restoreProp = (el, n, p) => { if (n.inl0[p]) el.style.setProperty(p, n.inl0[p]); else el.style.removeProperty(p); };
960
+ function restoreNudge(g) {
961
+ const n = g.nudge;
962
+ if (!n || !n.active) return;
963
+ NUDGE_PROPS.forEach((p) => restoreProp(g.el, n, p));
964
+ tidyStyle(g.el);
965
+ n.active = false;
966
+ guideLayer.innerHTML = "";
967
+ }
968
+ /** Is this axis one that the element's own margin moves its siblings along (block flow down, a flex box along its direction)? */
969
+ const mainAxis = (lay, a) => (lay === "page flow" || lay === "flex column" ? a === "y" : lay === "flex row" ? a === "x" : false);
970
+ const crossAxis = (lay, a) => (lay === "flex row" ? a === "y" : lay === "flex column" ? a === "x" : lay === "grid" ? a === "y" : false);
971
+ /** The line, among the container's edges and centre, its neighbours' edges and the spacing used between them, that `start` .. `start + size` is close to. */
972
+ function containerLine(n, a, start, size) {
973
+ const S = a, Z = a === "x" ? "w" : "h", main = mainAxis(n.lay, a);
974
+ const lines = [{ at: n.box[S], e: "s", k: "start" }, { at: n.box[S] + n.box[Z] / 2, e: "c", k: "centre" }, { at: n.box[S] + n.box[Z], e: "e", k: "end" }];
975
+ const sibs = n.others.filter((o) => !main || o.before);
976
+ sibs.forEach((o) => lines.push({ at: o.r[S], e: "s", k: "sib" }, { at: o.r[S] + o.r[Z] / 2, e: "c", k: "sib" }, { at: o.r[S] + o.r[Z], e: "e", k: "sib" }));
977
+ const gaps = [];
978
+ if (main) {
979
+ const before = n.others.filter((o) => o.before).sort((p, q) => p.r[S] - q.r[S]);
980
+ const all = n.others.slice().sort((p, q) => p.r[S] - q.r[S]);
981
+ for (let i = 1; i < all.length; i++) { const d = all[i].r[S] - (all[i - 1].r[S] + all[i - 1].r[Z]); if (d >= 0) gaps.push(Math.round(d)); }
982
+ const end = before.length ? Math.max(...before.map((o) => o.r[S] + o.r[Z])) : n.box[S];
983
+ const med = gaps.length ? gaps.sort((p, q) => p - q)[Math.floor(gaps.length / 2)] : 0;
984
+ [0, med].forEach((gp) => lines.push({ at: end + gp, e: "s", k: "gap", gap: gp }));
985
+ }
986
+ const T = px(6);
987
+ let best = null, gap = null;
988
+ lines.forEach((c) => {
989
+ const d = c.at - (c.e === "s" ? start : c.e === "c" ? start + size / 2 : start + size);
990
+ if (Math.abs(d) > T) return;
991
+ if (c.k === "gap") { if (!gap || Math.abs(d) < Math.abs(gap.d)) gap = { ...c, d }; } else if (!best || Math.abs(d) < Math.abs(best.d)) best = { ...c, d };
992
+ });
993
+ return gap && (!best || Math.abs(gap.d) <= Math.abs(best.d) + px(4)) ? gap : best; // the spacing wins over an edge that is about as close
994
+ }
995
+ function nudgeGesture(g, e) {
996
+ const n = g.nudge, el = g.el;
997
+ let dx = e.clientX - g.sx, dy = e.clientY - g.sy;
998
+ if (e.shiftKey) { if (Math.abs(dx) > Math.abs(dy)) dy = 0; else dx = 0; }
999
+ NUDGE_PROPS.forEach((p) => restoreProp(el, n, p));
1000
+ n.active = true;
1001
+ const lines = [], said = [];
1002
+ const cur = (p) => parseFloat(el.style.getPropertyValue(p)) || 0, used = (p) => parseFloat(getComputedStyle(el)[p === "margin-right" ? "marginRight" : "marginBottom"]) || 0;
1003
+ const step = (v) => (e.metaKey ? Math.round(v) : Math.round(v / 4) * 4); // on the Tailwind scale; Cmd: to the pixel
1004
+ ["x", "y"].forEach((a) => {
1005
+ const d = a === "x" ? dx : dy;
1006
+ if (Math.abs(d) < 0.5) return; // not dragged along this axis: nothing to write
1007
+ const N = a === "x" ? "margin-left" : "margin-top", F = a === "x" ? "margin-right" : "margin-bottom";
1008
+ const autoN = n.auto[a === "x" ? "l" : "t"], autoF = n.auto[a === "x" ? "r" : "b"];
1009
+ // pushed from the far side (ml-auto, mx-auto, mt-auto): the auto side stays auto and the other side moves the opposite way;
1010
+ // mx-auto becomes ml-auto plus a right margin
1011
+ if (autoN) {
1012
+ const v = step(n.f[a] - d), want = n.r0[a] + (n.f[a] - v);
1013
+ setPx(el, F, v);
1014
+ for (let i = 0; i < 2; i++) { const err = want - pageRect(el)[a]; if (Math.abs(err) <= 0.5) break; setPx(el, F, cur(F) - err); }
1015
+ return;
1016
+ }
1017
+ const near = step(n.m[a] + d), dd = near - n.m[a], want = n.r0[a] + dd;
1018
+ if (Math.abs(dd) < 0.5) return;
1019
+ // sideways in the page flow nothing is pushed (blocks stack), and a right margin does nothing there: the left one alone
1020
+ const pair = !autoF && !(a === "x" && n.lay === "page flow");
1021
+ if (a === "y" && n.lay === "page flow") {
1022
+ // down the page a margin collapses with the neighbour's: the gap is the larger of two positive margins, the sum of a
1023
+ // positive and a negative one. So the margins are solved for the gaps wanted above and below, not just added to.
1024
+ setPx(el, N, solveCollapse(collapse(n.side.above, n.m.y) + dd, n.side.above));
1025
+ if (pair) setPx(el, F, solveCollapse(collapse(n.f.y, n.side.below) - dd, n.side.below));
1026
+ } else {
1027
+ setPx(el, N, near);
1028
+ if (pair) setPx(el, F, n.f[a] - dd);
1029
+ }
1030
+ // collapsing margins (page flow) and boxes that are centred or spread out don't add up simply: corrected on the real layout,
1031
+ // the near margin until the element is where it should be, the far one until nothing after it was pushed
1032
+ for (let i = 0; i < 3; i++) {
1033
+ const err = want - pageRect(el)[a];
1034
+ if (Math.abs(err) > 0.5) setPx(el, N, cur(N) + err);
1035
+ const push = pair ? pushed(n, el, a) : 0;
1036
+ if (Math.abs(push) > 0.5) setPx(el, F, used(F) - push);
1037
+ if (Math.abs(err) <= 0.5 && Math.abs(push) <= 0.5) break;
1038
+ }
1039
+ });
1040
+ // the far half of a pair that changes nothing (a right margin in a column, a bottom one that sets no height) is left off
1041
+ ["margin-right", "margin-bottom"].forEach((p) => {
1042
+ const v = el.style.getPropertyValue(p);
1043
+ if (!v || v === n.inl0[p]) return;
1044
+ const look = () => [el, ...n.others.map((o) => o.el), el.parentElement].map(pageRect), before = look();
1045
+ restoreProp(el, n, p);
1046
+ if (!look().every((q, i) => sameRect(q, before[i]))) el.style.setProperty(p, v);
1047
+ });
1048
+ // Moving must not resize: an auto-width block narrows as its margin grows, a stretched flex item shrinks. Where the size changed
1049
+ // it is pinned to what it was; if something else still resizes it (flex-grow), the far margin gives the difference back.
1050
+ let kept = "";
1051
+ const rs = pageRect(el);
1052
+ if (Math.abs(rs.w - n.r0.w) > 0.5) {
1053
+ el.style.setProperty("width", n.w0);
1054
+ const again = pageRect(el).w;
1055
+ if (Math.abs(again - n.r0.w) > 0.5) {
1056
+ restoreProp(el, n, "width");
1057
+ setPx(el, "margin-right", (parseFloat(getComputedStyle(el).marginRight) || 0) - (n.r0.w - pageRect(el).w));
1058
+ } else kept = `width kept ${Math.round(n.r0.w)}px`;
1059
+ }
1060
+ if (Math.abs(pageRect(el).h - n.r0.h) > 0.5) { el.style.setProperty("height", n.h0); if (Math.abs(pageRect(el).h - n.r0.h) > 0.5) restoreProp(el, n, "height"); else kept = kept || `height kept ${Math.round(n.r0.h)}px`; }
1061
+ if (kept) said.push(kept);
1062
+ // what it shows: each margin as an orange measuring line with its number (as Figma shows distances), and the lines it snapped to
1063
+ guideLayer.innerHTML = "";
1064
+ const r = pageRect(el), cs = getComputedStyle(el), ml = parseFloat(cs.marginLeft) || 0, mt = parseFloat(cs.marginTop) || 0, mr = parseFloat(cs.marginRight) || 0, mb = parseFloat(cs.marginBottom) || 0;
1065
+ const isAutoNow = (p, fromClass) => { const v = el.style.getPropertyValue(p); return v ? v === "auto" : fromClass; };
1066
+ const auto = isAutoNow("margin-left", n.auto.l), autoT = isAutoNow("margin-top", n.auto.t);
1067
+ const tag = (x, y, text) => { const b = guideLayer.appendChild(mk(`position:absolute;left:${x}px;top:${y}px;transform:translate(-50%,-50%);background:${ORANGE};color:#fff;font:600 ${px(10)}px/1 system-ui,sans-serif;padding:${px(2)}px ${px(4)}px;border-radius:${px(3)}px;white-space:nowrap;`)); b.textContent = text; return b; };
1068
+ // a margin as a line across its length with a tick at each end, through the middle of the element's side, and its number on it;
1069
+ // a negative margin runs over the element's own edge and its number has a minus
1070
+ const t = px(1), cap = px(6);
1071
+ const measure = (a, from, len, at, v) => {
1072
+ const css = (x, y, w, h) => guideLayer.appendChild(mk(`position:absolute;left:${x}px;top:${y}px;width:${w}px;height:${h}px;background:${ORANGE};`));
1073
+ const label = `${v < 0 ? "−" : ""}${Math.round(Math.abs(v))}`;
1074
+ if (a === "x") { css(from, at - t / 2, len, t); css(from - t / 2, at - cap / 2, t, cap); css(from + len - t / 2, at - cap / 2, t, cap); tag(from + len / 2, at - px(12), label); }
1075
+ else { css(at - t / 2, from, t, len); css(at - cap / 2, from - t / 2, cap, t); css(at - cap / 2, from + len - t / 2, cap, t); tag(at + px(8), from + len / 2, label).style.transform = "translate(0,-50%)"; }
1076
+ };
1077
+ const real = (p) => { const v = el.style.getPropertyValue(p); return v && v !== "auto"; };
1078
+ const cy = r.y + r.h / 2, cx = r.x + r.w / 2;
1079
+ if (Math.abs(ml) >= 1 && !auto) measure("x", ml > 0 ? r.x - ml : r.x, Math.abs(ml), cy, ml);
1080
+ if (Math.abs(mr) >= 1 && real("margin-right")) measure("x", mr > 0 ? r.x + r.w : r.x + r.w + mr, Math.abs(mr), cy, mr);
1081
+ if (Math.abs(mt) >= 1 && !autoT) measure("y", mt > 0 ? r.y - mt : r.y, Math.abs(mt), cx, mt);
1082
+ if (Math.abs(mb) >= 1 && real("margin-bottom")) measure("y", mb > 0 ? r.y + r.h : r.y + r.h + mb, Math.abs(mb), cx, mb);
1083
+ lines.forEach((l) => guideLayer.appendChild(mk(l.a === "x"
1084
+ ? `position:absolute;left:${l.at}px;top:${n.box.y}px;width:${px(1)}px;height:${n.box.h}px;background:${RED};`
1085
+ : `position:absolute;top:${l.at}px;left:${n.box.x}px;height:${px(1)}px;width:${n.box.w}px;background:${RED};`)));
1086
+ dropHint.style.display = "none"; // the numbers on the lines say it all
1087
+ ghostBox.style.display = "none"; dropBox.style.display = "none"; insertLine.style.display = "none";
1088
+ }
1089
+
1090
+
1091
+ // ── Dropping into another slot or container: the element takes on the spacing there ──────────────────
1092
+ // Margins and alignment that belonged to its old place are cleaned off, it gets the rhythm its new neighbours use (their
1093
+ // margin-top, or margin-bottom), the neighbour it left behind loses the spacing it only had for it, and dropped near the
1094
+ // centre or the end of a column it is centred or pushed there. Everything goes through the same pixels-to-classes step as a move.
1095
+ const inFlow = (k) => !isAbs(k) && getComputedStyle(k).display !== "none";
1096
+ const median = (a) => { const b = a.slice().sort((x, y) => x - y); return b.length ? b[Math.floor(b.length / 2)] : 0; };
1097
+ function dropSmart(g, e, old, wasFirst, wasLast) {
1098
+ const el = g.el, par = el.parentElement;
1099
+ if (!par || el === document.body) return [];
1100
+ const plan = new Map(); // element -> { set: {prop: px | word}, clear: [class groups] }
1101
+ const of = (k) => { if (!plan.has(k)) plan.set(k, { set: {}, clear: [] }); return plan.get(k); };
1102
+ const f = (v) => parseFloat(v) || 0;
1103
+ const has = (k, re) => (k.getAttribute("class") || "").split(/\s+/).some((t) => re.test(t.replace(/^(?:[\w-]+:)*/, "")));
1104
+ const flow = (c) => kidsOf(c, null).filter(inFlow);
1105
+ const lay = layoutOf(par), moved = par !== old;
1106
+ // a margin on one side, as the value it should have: a class that gave it a value is taken off to get 0
1107
+ const want = (k, prop, v) => {
1108
+ const group = prop === "margin-top" ? "mt" : "mb";
1109
+ if (Math.abs(f(getComputedStyle(k)[prop === "margin-top" ? "marginTop" : "marginBottom"]) - v) < 0.5) return;
1110
+ if (v === 0 && has(k, new RegExp(`^${group}-`))) { const c = of(k).clear; if (!c.includes(group)) c.push(group); } else of(k).set[prop] = v;
1111
+ };
1112
+ const noMargins = (k, groups) => groups.forEach((gp) => { if (has(k, new RegExp(`^${gp}-(?!auto$)`)) && !of(k).clear.includes(gp)) of(k).clear.push(gp); });
1113
+ // 1. alignment and auto margins only mean something in the layout they were made for
1114
+ if (moved && lay !== layoutOf(old)) {
1115
+ if (has(el, /^self-/)) of(el).clear.push("self");
1116
+ ["mx", "ml", "mr"].forEach((gp) => { if (has(el, new RegExp(`^${gp}-auto$`))) of(el).clear.push(gp); });
1117
+ }
1118
+ // 2. spacing: a gap already spaces its children, so margins along it would double up; in the flow the neighbours' rhythm is kept
1119
+ const sibs = flow(par).filter((k) => k !== el);
1120
+ if (lay !== "page flow") {
1121
+ const pcs = getComputedStyle(par);
1122
+ if (lay !== "flex row" && f(pcs.rowGap) > 0) noMargins(el, ["mt", "mb", "my"]);
1123
+ if (lay !== "flex column" && f(pcs.columnGap) > 0) noMargins(el, ["ml", "mr", "mx"]);
1124
+ // across the box (a row's top and bottom, a column's sides) a margin only shows as a shift, unless the neighbours use it too
1125
+ if (moved && sibs.length) {
1126
+ const cross = lay === "flex row" || lay === "grid" ? [["marginTop", ["mt", "my"]], ["marginBottom", ["mb", "my"]]] : lay === "flex column" ? [["marginLeft", ["ml", "mx"]], ["marginRight", ["mr", "mx"]]] : [];
1127
+ cross.forEach(([side, groups]) => { if (median(sibs.map((k) => f(getComputedStyle(k)[side]))) === 0) noMargins(el, groups); });
1128
+ }
1129
+ } else if (sibs.length >= 2) {
1130
+ const kids = flow(par), i = kids.indexOf(el), cs = sibs.map((k) => getComputedStyle(k));
1131
+ const mt = median(cs.slice(1).map((c) => f(c.marginTop))), mb = median(cs.slice(0, -1).map((c) => f(c.marginBottom)));
1132
+ if (mt > 0) {
1133
+ want(el, "margin-top", i === 0 ? 0 : mt);
1134
+ if (kids[i + 1]) want(kids[i + 1], "margin-top", mt);
1135
+ if (mb === 0 && f(getComputedStyle(el).marginBottom) > 0) noMargins(el, ["mb"]);
1136
+ } else if (mb > 0) {
1137
+ want(el, "margin-bottom", i === kids.length - 1 ? 0 : mb);
1138
+ if (kids[i - 1]) want(kids[i - 1], "margin-bottom", mb);
1139
+ if (f(getComputedStyle(el).marginTop) > 0) noMargins(el, ["mt"]);
1140
+ }
1141
+ }
1142
+ // 3. the place it left: the neighbour that became first (or last) loses the spacing it only had for the element
1143
+ if (moved && layoutOf(old) === "page flow") {
1144
+ const kids = flow(old);
1145
+ if (kids.length >= 2) {
1146
+ const cs = kids.map((k) => getComputedStyle(k));
1147
+ if (wasFirst && median(cs.slice(1).map((c) => f(c.marginTop))) > 0) want(kids[0], "margin-top", 0);
1148
+ if (wasLast && median(cs.slice(0, -1).map((c) => f(c.marginBottom))) > 0) want(kids[kids.length - 1], "margin-bottom", 0);
1149
+ }
1150
+ }
1151
+ // 4. dropped near the middle or the end of a column: centred, or pushed there
1152
+ if (!e.metaKey && (lay === "page flow" || lay === "flex column") && isFinite(g.sx)) {
1153
+ const r = pageRect(el), tmp = { el };
1154
+ nudgeStart(tmp);
1155
+ const n = tmp.nudge;
1156
+ if (r.w < n.box.w - 1) {
1157
+ const at = g.rect.x + (e.clientX - g.sx), hit = containerLine(n, "x", at, r.w);
1158
+ if (hit && (hit.k === "centre" || hit.k === "end")) {
1159
+ const props = lay === "flex column" ? { "align-self": hit.k === "centre" ? "center" : "flex-end" } : hit.k === "centre" ? { "margin-left": "auto", "margin-right": "auto" } : { "margin-left": "auto" };
1160
+ const was = inlineOf(el, Object.keys(props));
1161
+ Object.entries(props).forEach(([k, v]) => el.style.setProperty(k, v));
1162
+ const ok = Math.abs(pageRect(el).x - (at + hit.d)) <= 2;
1163
+ Object.keys(props).forEach((k) => { if (was[k]) el.style.setProperty(k, was[k]); else el.style.removeProperty(k); });
1164
+ tidyStyle(el);
1165
+ if (ok) {
1166
+ Object.assign(of(el).set, props);
1167
+ of(el).clear = of(el).clear.filter((gp) => !["mx", "ml", "mr", "self"].includes(gp));
1168
+ }
1169
+ }
1170
+ }
1171
+ }
1172
+ const work = [];
1173
+ plan.forEach((p, k) => { const props = Object.keys(p.set); if (props.length || p.clear.length) work.push({ k, p, props, pre: sourcesOf(k, props) }); });
1174
+ work.forEach(({ k, p }) => Object.entries(p.set).forEach(([prop, v]) => k.style.setProperty(prop, typeof v === "number" ? `${Math.round(v)}px` : v)));
1175
+ return work.map(({ k, p, props, pre }) => gestureItem(k, pre, props, p.clear));
1176
+ }
1177
+
1178
+ /**
1179
+ * When does a margin drag become a block drag (reorder, or move into another container)? From what the drag looks like:
1180
+ * - area: how much of its original place the dragged box has left (60% or more);
1181
+ * - speed: a fast drag past that point means "put it somewhere else";
1182
+ * - holding still: resting 300ms over another place (beside another child, or in another container) once 60% is left;
1183
+ * - position: the pointer out of the parent, or a quick throw, at any time.
1184
+ * Once a block drag it stays one until let go; let go in its own place, nothing changes.
1185
+ */
1186
+ const BLOCK = { area: 0.6, fast: 1, hold: 300, rest: 80, still: 4, flick: 2, flickMin: 60, out: 8 }; // share, screen px/ms, ms (to switch, and before the outline shows), px, px/ms, px, px
1187
+ function wantsBlockDrag(g, e) {
1188
+ if (g.block) return true;
1189
+ if (!g.nudge) nudgeStart(g);
1190
+ const n = g.nudge, now = performance.now(), tr = g.trail || (g.trail = []);
1191
+ tr.push({ t: now, x: e.clientX, y: e.clientY });
1192
+ while (tr.length > 1 && now - tr[0].t > 100) tr.shift();
1193
+ const speed = Math.hypot(e.clientX - tr[0].x, e.clientY - tr[0].y) * zoom / Math.max(1, now - tr[0].t);
1194
+ const dx = e.clientX - g.sx, dy = e.clientY - g.sy;
1195
+ // how much of its place is left: the box where the pointer would put it against where it was
1196
+ const r = n.r0, ox = Math.max(0, Math.min(r.x + r.w, r.x + dx + r.w) - Math.max(r.x, r.x + dx)), oy = Math.max(0, Math.min(r.y + r.h, r.y + dy + r.h) - Math.max(r.y, r.y + dy));
1197
+ const left = r.w * r.h > 0 ? 1 - (ox * oy) / (r.w * r.h) : 1;
1198
+ const o = n.outer, px0 = e.clientX + window.scrollX, py0 = e.clientY + window.scrollY, m = px(BLOCK.out);
1199
+ const outside = px0 < o.x - m || px0 > o.x + o.w + m || py0 < o.y - m || py0 > o.y + o.h + m;
1200
+ const thrown = speed > BLOCK.flick && Math.hypot(dx, dy) > BLOCK.flickMin;
1201
+ // holding still: from where the pointer came to rest; a timer checks again when the time is up, since a still pointer sends nothing
1202
+ if (!g.still || Math.hypot(e.clientX - g.still.x, e.clientY - g.still.y) > BLOCK.still) {
1203
+ g.still = { x: e.clientX, y: e.clientY, t: now };
1204
+ clearTimeout(g.holdTimer);
1205
+ g.holdTimer = setTimeout(() => { if (gesture === g && !g.block && g.lastE) reorderGesture(g, g.lastE); }, BLOCK.rest + 10);
1206
+ }
1207
+ g.lastE = e;
1208
+ let held = false;
1209
+ g.holding = null;
1210
+ if (left >= BLOCK.area) {
1211
+ const t = dropTarget(g.el, e.clientX, e.clientY); // the margins moved only this element, so what is under the pointer is real
1212
+ if (!sameSlot(g.el, t) && staysInGroup(g.el, t.container)) {
1213
+ const time = now - g.still.t;
1214
+ held = time >= BLOCK.hold;
1215
+ if (time >= BLOCK.rest) g.holding = { t, share: Math.min(1, time / BLOCK.hold) };
1216
+ }
1217
+ }
1218
+ g.block = outside || thrown || (left >= BLOCK.area && (speed > BLOCK.fast || held));
1219
+ clearTimeout(g.tick);
1220
+ if (g.block) { clearTimeout(g.holdTimer); g.holding = null; }
1221
+ else if (g.holding) g.tick = setTimeout(() => { if (gesture === g && !g.block) reorderGesture(g, g.lastE); }, 60); // the outline fills in
1222
+ return g.block;
1223
+ }
1224
+ /** Resting over another place: its outline fills in over the hold time, so the switch is seen coming (and avoided by moving on). */
1225
+ function showHolding(g) {
1226
+ const h = g.holding;
1227
+ if (!h) return;
1228
+ const cr = pageRect(h.t.container), tag = h.t.container === document.body ? "page" : h.t.container.tagName.toLowerCase();
1229
+ Object.assign(dropBox.style, { display: "block", left: `${cr.x}px`, top: `${cr.y}px`, width: `${cr.w}px`, height: `${cr.h}px`, borderWidth: `${px(1.5)}px`, opacity: String(0.25 + 0.75 * h.share) });
1230
+ dropHint.textContent = `Hold to move into ${tag} · ${Math.round(h.share * 100)}%`;
1231
+ Object.assign(dropHint.style, { display: "block", left: `${cr.x}px`, top: `${Math.max(0, cr.y - px(22))}px`, fontSize: `${px(11)}px`, padding: `${px(4)}px ${px(7)}px` });
1232
+ }
1233
+ function reorderGesture(g, e) {
1234
+ if (canNudge(g.el) && !wantsBlockDrag(g, e)) { nudgeGesture(g, e); showHolding(g); redraw(); return; }
1235
+ dropBox.style.opacity = "";
1236
+ restoreNudge(g); // the drop place is worked out on the layout as it really is, not the one the nudge had shifted
1237
+ const t = dropTarget(g.el, e.clientX, e.clientY);
1238
+ // an element in a group stays in it: a drop outside the group changes nothing (ungroup to take it out)
1239
+ const held = !staysInGroup(g.el, t.container);
1240
+ g.same = held || sameSlot(g.el, t);
1241
+ g.target = t.ref || t.container;
1242
+ g.pos = t.ref ? (t.before ? "before" : "after") : "inside";
1243
+ // the box that follows the pointer
1244
+ const gx = g.rect.x + (e.clientX - g.sx), gy = g.rect.y + (e.clientY - g.sy);
1245
+ Object.assign(ghostBox.style, { display: "block", left: `${gx}px`, top: `${gy}px`, width: `${g.rect.w}px`, height: `${g.rect.h}px` });
1246
+ // the container it would land in
1247
+ const cr = pageRect(held ? groupAround(g.el) : t.container); // held in a group: the group it stays in is outlined
1248
+ Object.assign(dropBox.style, { display: "block", left: `${cr.x}px`, top: `${cr.y}px`, width: `${cr.w}px`, height: `${cr.h}px`, borderWidth: `${px(1.5)}px` });
1249
+ const tag = t.container === document.body ? "page" : t.container.tagName.toLowerCase();
1250
+ dropHint.textContent = held ? "In a group · ungroup to take it out" : g.same ? "Back where it was" : `${t.ref ? "Insert in" : "Drop into"} ${tag} · ${layoutOf(t.container)}`;
1251
+ Object.assign(dropHint.style, { display: "block", left: `${cr.x}px`, top: `${Math.max(0, cr.y - px(22))}px`, fontSize: `${px(11)}px`, padding: `${px(4)}px ${px(7)}px` });
1252
+ if (!t.ref || g.same) { insertLine.style.display = "none"; return; } // no line where letting go changes nothing
1253
+ // the line sits in the gap between the two children it goes between, and never outside the container
1254
+ const r = pageRect(t.ref), th = px(2.5);
1255
+ const kids = kidsOf(t.container, g.el).filter(inFlow), i = kids.indexOf(t.ref), nb = kids[t.before ? i - 1 : i + 1];
1256
+ const nr = nb ? pageRect(nb) : null;
1257
+ let at = t.horizontal ? (t.before ? r.x : r.x + r.w) : (t.before ? r.y : r.y + r.h);
1258
+ if (nr) at = (at + (t.horizontal ? (t.before ? nr.x + nr.w : nr.x) : (t.before ? nr.y + nr.h : nr.y))) / 2;
1259
+ const line = t.horizontal
1260
+ ? { l: Math.min(Math.max(at, cr.x), cr.x + cr.w) - th / 2, t: r.y, w: th, h: r.h }
1261
+ : { l: Math.max(r.x, cr.x), t: Math.min(Math.max(at, cr.y), cr.y + cr.h) - th / 2, w: Math.min(r.w, cr.w), h: th };
1262
+ Object.assign(insertLine.style, { display: "block", left: `${line.l}px`, top: `${line.t}px`, width: `${line.w}px`, height: `${line.h}px` });
1263
+ }
1264
+
1265
+ function resizeGesture(g, e) {
1266
+ // work in the element's own axes, so a rotated element resizes along its own edges
1267
+ const th = g.rot * Math.PI / 180, cos = Math.cos(th), sin = Math.sin(th);
1268
+ const sdx = e.clientX - g.sx, sdy = e.clientY - g.sy;
1269
+ const dx = sdx * cos + sdy * sin, dy = -sdx * sin + sdy * cos;
1270
+ const hd = g.h;
1271
+ let w = g.g.w, h = g.g.h;
1272
+ if (hd.includes("e")) w = g.g.w + dx;
1273
+ if (hd.includes("s")) h = g.g.h + dy;
1274
+ if (hd.includes("w")) w = g.g.w - dx;
1275
+ if (hd.includes("n")) h = g.g.h - dy;
1276
+ if ((e.shiftKey || g.ratio) && g.g.w && g.g.h) { // Shift, or a locked aspect ratio, keeps the shape
1277
+ const r = g.g.w / g.g.h;
1278
+ if (hd.length === 2 || hd === "e" || hd === "w") h = w / r; else w = h * r;
1279
+ }
1280
+ w = Math.max(1, w); h = Math.max(1, h);
1281
+ // a flex child that flex sizes along its axis (flex-1, grow) ignores a size there: leave that axis alone, or the margin that
1282
+ // keeps the opposite edge in place would push it sideways instead
1283
+ const fx = !g.abs && g.fx && g.fx.sized ? g.fx.dir : null;
1284
+ if (fx === "row") w = g.g.w;
1285
+ if (fx === "column") h = g.g.h;
1286
+ // the edges being dragged snap to the edges and centres of the boxes around, like a move does
1287
+ let keysX = [], keysY = [];
1288
+ if (g.rot === 0 && !e.shiftKey && !e.altKey) {
1289
+ if (!g.pr) { g.pr = pageRect(g.el); g.targets = targetsFor([g.el]); }
1290
+ const t = px(6), pr = g.pr;
1291
+ const edgeX = hd.includes("e") ? pr.x + w : hd.includes("w") ? pr.x + pr.w - w : null;
1292
+ const edgeY = hd.includes("s") ? pr.y + h : hd.includes("n") ? pr.y + pr.h - h : null;
1293
+ let sx = edgeX === null ? null : nearestLine([edgeX], g.targets.map((r) => three(r.x, r.w)), t);
1294
+ let sy = edgeY === null ? null : nearestLine([edgeY], g.targets.map((r) => three(r.y, r.h)), t);
1295
+ // sticky, like a move: stay on a line until the edge has been dragged clearly past it
1296
+ g.hold = g.hold || { x: null, y: null };
1297
+ const release = px(4);
1298
+ const hold = (edge, fresh, h) => (h !== null && Math.abs(edge - h) <= release && (fresh === null || Math.abs(fresh) >= Math.abs(edge - h)) ? { s: h - edge, h } : { s: fresh, h: fresh === null ? null : edge + fresh });
1299
+ if (edgeX !== null) { const r = hold(edgeX, sx, g.hold.x); sx = r.s; g.hold.x = r.h; }
1300
+ if (edgeY !== null) { const r = hold(edgeY, sy, g.hold.y); sy = r.s; g.hold.y = r.h; }
1301
+ if (sx !== null) w = Math.max(1, hd.includes("e") ? w + sx : w - sx);
1302
+ if (sy !== null) h = Math.max(1, hd.includes("s") ? h + sy : h - sy);
1303
+ keysX = edgeX === null ? [] : [hd.includes("e") ? 2 : 0];
1304
+ keysY = edgeY === null ? [] : [hd.includes("s") ? 2 : 0];
1305
+ g.guide = { want: [hd.includes("e") ? "right" : hd.includes("w") ? "left" : null, hd.includes("s") ? "down" : hd.includes("n") ? "up" : null].filter(Boolean), box: { x: hd.includes("w") ? pr.x + pr.w - w : pr.x, y: hd.includes("n") ? pr.y + pr.h - h : pr.y, w, h }, keysX, keysY };
1306
+ } else g.guide = null;
1307
+ if (fx !== "row") setPx(g.el, "width", w);
1308
+ if (fx !== "column") setPx(g.el, "height", h);
1309
+ if (!g.abs) {
1310
+ if (g.rot === 0) {
1311
+ if (hd.includes("w") && fx !== "row") setPx(g.el, "marginLeft", g.ml - (w - g.g.w));
1312
+ if (hd.includes("n") && fx !== "column") setPx(g.el, "marginTop", g.mt - (h - g.g.h));
1313
+ }
1314
+ return;
1315
+ }
1316
+ // keep the opposite edge or corner where it was (or the center, with Alt)
1317
+ const ax = hd.includes("w") ? 1 : hd.includes("e") ? -1 : 0;
1318
+ const ay = hd.includes("n") ? 1 : hd.includes("s") ? -1 : 0;
1319
+ const rot = (x, y) => ({ x: x * cos - y * sin, y: x * sin + y * cos });
1320
+ const a0 = rot(ax * g.g.w / 2, ay * g.g.h / 2), a1 = rot(ax * w / 2, ay * h / 2);
1321
+ const cx = e.altKey ? g.c.x : g.c.x + a0.x - a1.x;
1322
+ const cy = e.altKey ? g.c.y : g.c.y + a0.y - a1.y;
1323
+ setPx(g.el, "left", cx - w / 2 - g.o.x);
1324
+ setPx(g.el, "top", cy - h / 2 - g.o.y);
1325
+ }
1326
+
1327
+ // Drag a corner dot toward the middle: every corner gets the same radius, previewed inline until the editor applies it as a class or style.
1328
+ function radiusGesture(g, e) {
1329
+ const th = -g.rot * Math.PI / 180;
1330
+ const px0 = e.clientX + window.scrollX - g.c.x, py0 = e.clientY + window.scrollY - g.c.y;
1331
+ const lx = px0 * Math.cos(th) - py0 * Math.sin(th) + g.w / 2;
1332
+ const ly = px0 * Math.sin(th) + py0 * Math.cos(th) + g.h / 2;
1333
+ const east = g.corner.includes("e"), south = g.corner.includes("s");
1334
+ const inset = ((east ? g.w - lx : lx) + (south ? g.h - ly : ly)) / 2;
1335
+ g.r = Math.max(0, Math.min(Math.round(inset - px(11)), Math.floor(Math.min(g.w, g.h) / 2)));
1336
+ g.el.style.borderRadius = `${g.r}px`;
1337
+ }
1338
+
1339
+ function rotateGesture(g, e) {
1340
+ const a = Math.atan2(e.clientY + window.scrollY - g.cy, e.clientX + window.scrollX - g.cx) * 180 / Math.PI;
1341
+ let rot = g.start + (a - g.a0);
1342
+ if (e.shiftKey) rot = Math.round(rot / 15) * 15;
1343
+ g.deg = Math.round((((rot + 180) % 360 + 360) % 360 - 180) * 10) / 10; // the angle shown while turning, -180 to 180
1344
+ g.at = { x: e.clientX, y: e.clientY };
1345
+ g.el.style.transform = rot % 360 === 0 ? "" : `rotate(${Math.round(rot * 10) / 10}deg)`;
1346
+ if (!g.el.style.transform) g.el.style.removeProperty("transform");
1347
+ }
1348
+
1349
+ function marqueeSelect(g, e) {
1350
+ const x0 = Math.min(g.sx, e.clientX) + window.scrollX, y0 = Math.min(g.sy, e.clientY) + window.scrollY;
1351
+ const x1 = Math.max(g.sx, e.clientX) + window.scrollX, y1 = Math.max(g.sy, e.clientY) + window.scrollY;
1352
+ Object.assign(marqueeBox.style, { display: "block", left: `${x0}px`, top: `${y0}px`, width: `${x1 - x0}px`, height: `${y1 - y0}px` });
1353
+ const touches = (el) => { const r = pageRect(el); return r.x < x1 && r.x + r.w > x0 && r.y < y1 && r.y + r.h > y0; };
1354
+ // as in Figma: the boxes at the level being worked in (the one gone into, else the top of the page) that the rectangle touches;
1355
+ // a lone wrapper holding everything doesn't count as a level, so the boxes inside it are the ones picked
1356
+ let level = inside && alive(inside) ? inside : document.body;
1357
+ const shown = (el) => realKids(el).filter((c) => getComputedStyle(c).display !== "none" && !isLocked(c));
1358
+ for (let n = shown(level); n.length === 1 && !isTextual(n[0]) && shown(n[0]).length; n = shown(level)) level = n[0];
1359
+ let hits = shown(level).filter(touches);
1360
+ if (!hits.length) { // nothing there: free-positioned boxes anywhere, as before
1361
+ hits = Array.prototype.filter.call(document.body.querySelectorAll("*"), (el) => !isOverlay(el) && isAbs(el) && touches(el)).filter((el, _, all) => !all.some((o) => o !== el && o.contains(el)));
1362
+ }
1363
+ g.hits = hits;
1364
+ }
1365
+
1366
+ /** A handle drawn by the editor was pressed. The editor then forwards the pointer (hmove / hend) in this frame's coordinates. */
1367
+ function beginHandle(hd, e) {
1368
+ const el = primary();
1369
+ if (!el || el === document.body) return;
1370
+ const g = geomOf(el);
1371
+ const br = pageRect(el);
1372
+ if (hd.startsWith("rad")) {
1373
+ gesture = { type: "radius", el, corner: hd.slice(4), c: { x: br.x + br.w / 2, y: br.y + br.h / 2 }, rot: g.rot, w: el.offsetWidth || br.w, h: el.offsetHeight || br.h, prev: el.style.borderRadius, r: parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0, sx: e.clientX, sy: e.clientY, moved: false };
1374
+ } else if (hd.startsWith("rot")) {
1375
+ const cx = br.x + br.w / 2, cy = br.y + br.h / 2;
1376
+ gesture = { type: "rotate", el, prevTransform: el.style.transform, cx, cy, a0: Math.atan2(e.clientY + window.scrollY - cy, e.clientX + window.scrollX - cx) * 180 / Math.PI, start: g.rot, moved: false };
1377
+ } else {
1378
+ const cs0 = getComputedStyle(el);
1379
+ const ar = cs0.aspectRatio;
1380
+ gesture = { type: "resize", el, ratio: ar && ar !== "auto", fx: flexItemOf(el), pre: sourcesOf(el, GESTURE_PROPS), inl0: inlineOf(el, GESTURE_PROPS), h: hd, g, abs: g.abs, rot: g.rot, ml: parseFloat(cs0.marginLeft) || 0, mt: parseFloat(cs0.marginTop) || 0, c: { x: br.x + br.w / 2, y: br.y + br.h / 2 }, o: originOf(el.parentElement || document.body), sx: e.clientX, sy: e.clientY, moved: false };
1381
+ }
1382
+ }
1383
+
1384
+ document.addEventListener("pointerdown", (e) => {
1385
+ if (e.button !== 0) return;
1386
+ if (converting) { e.preventDefault(); return; } // the last drag is still becoming classes (askConvert)
1387
+ if (editing && editing.contains(e.target)) return;
1388
+ if (editing) finishEdit();
1389
+ try { root.setPointerCapture(e.pointerId); } catch (err) { /* capture is optional */ }
1390
+ e.preventDefault();
1391
+
1392
+ if (tool === "hand") { gesture = { type: "pan", sx: e.clientX, sy: e.clientY, moved: false }; root.style.cursor = "grabbing"; return; }
1393
+ if (DRAW.has(tool)) {
1394
+ gesture = { type: "draw", kind: tool, sx: e.clientX, sy: e.clientY, container: containerAt(e.clientX, e.clientY), moved: false };
1395
+ return;
1396
+ }
1397
+ const t = targetOf(e);
1398
+ if (t === document.body) {
1399
+ gesture = { type: "marquee", sx: e.clientX, sy: e.clientY, shift: e.shiftKey, hits: [], moved: false };
1400
+ if (!e.shiftKey) setSelection([]);
1401
+ return;
1402
+ }
1403
+ if (e.shiftKey) setSelection(sels.includes(t) ? sels.filter((s) => s !== t) : [...sels, t]);
1404
+ else if (!sels.includes(t)) setSelection([t]);
1405
+ startMove(e, sels.length ? sels : [t]);
1406
+ }, true);
1407
+
1408
+ // the pointer over the empty part of the design: the editor outlines the frame, as it does for a frame that isn't open
1409
+ let frameHovered = false;
1410
+ const hoverFrame = (on) => { if (on !== frameHovered) { frameHovered = on; post({ type: "hoverFrame", on }); } };
1411
+ function onMove(e) {
1412
+ const g = gesture;
1413
+ if (!g) {
1414
+ if (!editing && tool === "move") {
1415
+ hov = targetOf(e);
1416
+ // over the box that is selected: the child under the pointer is outlined (a double-click goes into it)
1417
+ const cur = sels.length === 1 ? sels[0] : null, raw = e.target && e.target.nodeType === 1 && !isOverlay(e.target) ? e.target : null;
1418
+ if (hov && cur && hov === cur && raw && raw !== cur && cur.contains(raw) && !isTextual(cur) && !e.metaKey && !e.ctrlKey) hov = childOf(cur, raw.closest("svg") || raw);
1419
+ redraw();
1420
+ }
1421
+ hoverFrame(!editing && tool === "move" && hov === document.body);
1422
+ return;
1423
+ }
1424
+ if (!g.moved && Math.hypot(e.clientX - g.sx, e.clientY - g.sy) < 3 && g.type !== "rotate") return;
1425
+ g.moved = true;
1426
+ if (!g.notified) { g.notified = true; post({ type: "gesture", on: true }); }
1427
+ if (g.type === "draw") {
1428
+ const x0 = Math.min(g.sx, e.clientX) + window.scrollX, y0 = Math.min(g.sy, e.clientY) + window.scrollY;
1429
+ Object.assign(draftBox.style, { display: "block", left: `${x0}px`, top: `${y0}px`, width: `${Math.abs(e.clientX - g.sx)}px`, height: `${Math.abs(e.clientY - g.sy)}px` });
1430
+ } else if (g.type === "move") { moveGesture(g, e); redraw(); }
1431
+ else if (g.type === "reorder") reorderGesture(g, e);
1432
+ else if (g.type === "resize") { resizeGesture(g, e); if (g.guide) showGuides(g.guide.box, g.targets, g.guide.keysX, g.guide.keysY, false, g.guide.want); else guideLayer.innerHTML = ""; redraw(); }
1433
+ else if (g.type === "rotate") { rotateGesture(g, e); redraw(); }
1434
+ else if (g.type === "radius") { g.at = { x: e.clientX, y: e.clientY }; radiusGesture(g, e); redraw(); }
1435
+ else if (g.type === "marquee") marqueeSelect(g, e);
1436
+ else if (g.type === "pan") { post({ type: "pan", dx: e.clientX - g.sx, dy: e.clientY - g.sy }); g.sx = e.clientX; g.sy = e.clientY; }
1437
+ }
1438
+ document.addEventListener("pointermove", onMove, true);
1439
+
1440
+ function onUp(e) {
1441
+ const g = gesture;
1442
+ gesture = null;
1443
+ clearGuides();
1444
+ marqueeBox.style.display = "none";
1445
+ root.style.cursor = CURSOR[tool] || "default";
1446
+ if (!g) return;
1447
+ if (g.notified) post({ type: "gesture", on: false });
1448
+ if (g.type === "radius") {
1449
+ if (!g.moved) return;
1450
+ if (g.prev) g.el.style.borderRadius = g.prev; else g.el.style.removeProperty("border-radius");
1451
+ if (!g.el.getAttribute("style")) g.el.removeAttribute("style");
1452
+ redraw();
1453
+ return post({ type: "radius", path: pathOf(g.el), value: g.r });
1454
+ }
1455
+ if (g.type === "rotate") { // the turn becomes a class (rotate-45, rotate-[15deg]); the inline transform it used while turning goes
1456
+ if (g.prevTransform) g.el.style.transform = g.prevTransform; else g.el.style.removeProperty("transform");
1457
+ if (!g.el.getAttribute("style")) g.el.removeAttribute("style");
1458
+ redraw();
1459
+ return g.moved && g.deg !== undefined ? post({ type: "rotate", path: pathOf(g.el), value: g.deg }) : undefined;
1460
+ }
1461
+ if (g.type === "draw") return finishDraw(g, e);
1462
+ if (g.type === "marquee") {
1463
+ if (g.moved) setSelection(g.shift ? [...new Set([...sels, ...g.hits])] : g.hits);
1464
+ else if (!g.shift) post({ type: "frameClick" }); // a click on the empty part of the design: the frame itself
1465
+ return;
1466
+ }
1467
+ if (g.type === "reorder") { clearTimeout(g.holdTimer); clearTimeout(g.tick); dropBox.style.opacity = ""; }
1468
+ if (g.type === "reorder" && g.moved && g.nudge && g.nudge.active) {
1469
+ const el = g.el, n = g.nudge;
1470
+ // a value that changes nothing is not kept: each written margin (the far one of a pair across a row that something else sets
1471
+ // the height of, a pinned width that was already that) is taken off and kept off if nothing on the page moved
1472
+ const look = () => [el, ...n.others.map((o) => o.el), el.parentElement].map(pageRect);
1473
+ ["margin-right", "margin-bottom", "width", "height", "margin-left", "margin-top"].forEach((p) => {
1474
+ const v = el.style.getPropertyValue(p);
1475
+ if (v === n.inl0[p]) return;
1476
+ const before = look();
1477
+ restoreProp(el, n, p);
1478
+ if (!look().every((r, i) => sameRect(r, before[i]))) el.style.setProperty(p, v);
1479
+ });
1480
+ const props = NUDGE_PROPS.filter((p) => el.style.getPropertyValue(p) !== n.inl0[p]); // what the nudge wrote
1481
+ tidyStyle(el);
1482
+ if (!props.length) return redraw(); // back where it was
1483
+ return askConvert([gestureItem(el, n.pre, props)]);
1484
+ }
1485
+ if (g.type === "reorder" && g.moved && g.same) { redraw(); return; } // a block drag that ended in its own slot: nothing moves
1486
+ if (g.type === "reorder" && g.moved && g.target) {
1487
+ const was = g.el.parentElement, wasKids = was ? kidsOf(was, null).filter(inFlow) : [];
1488
+ const wasFirst = wasKids[0] === g.el, wasLast = wasKids[wasKids.length - 1] === g.el;
1489
+ if (g.pos === "before") g.target.before(g.el); else if (g.pos === "after") g.target.after(g.el); else g.target.appendChild(g.el);
1490
+ setSelection([g.el]);
1491
+ const items = was ? dropSmart(g, e, was, wasFirst, wasLast) : [];
1492
+ return items.length ? askConvert(items) : changed();
1493
+ }
1494
+ if (g.type === "pan") return;
1495
+ if (g.type === "move" && g.freeOnMove) return post({ type: "selected", info: describe(primary()) }); // Alt-click: nothing freed
1496
+ if (g.moved && g.type === "move") return askConvert(g.items.map((i) => gestureItem(i.el, i.pre, g.freed ? FREE_PROPS : ["left", "top"])));
1497
+ if (g.moved && g.type === "resize") {
1498
+ // a size or position the resize rewrote without changing it (a corner dragged sideways, the top-left of a box resized from
1499
+ // its bottom-right) is dropped, not saved as a class
1500
+ [["width", g.g.w], ["height", g.g.h], ["left", g.g.x], ["top", g.g.y]].forEach(([p, was]) => {
1501
+ if (!g.inl0[p] && Math.round(parseFloat(g.el.style.getPropertyValue(p))) === Math.round(was)) g.el.style.removeProperty(p);
1502
+ });
1503
+ tidyStyle(g.el);
1504
+ // with the ratio locked the height follows the width: a fixed height would break the lock
1505
+ if (g.ratio && g.el.style.getPropertyValue("width") && !g.inl0.height) g.el.style.removeProperty("height");
1506
+ const props = GESTURE_PROPS.filter((p) => g.el.style.getPropertyValue(p) !== g.inl0[p]); // what the resize wrote
1507
+ const item = gestureItem(g.el, g.pre, props);
1508
+ // pulled along the axis flex sizes it on: nothing changed there, and the editor says why
1509
+ if (!g.abs && g.fx && g.fx.sized && (g.fx.dir === "row" ? /[ew]/.test(g.h) : /[ns]/.test(g.h))) item.blocked = g.fx.dir === "row" ? "width" : "height";
1510
+ return askConvert([item]);
1511
+ }
1512
+ if (g.moved) changed();
1513
+ else post({ type: "selected", info: describe(primary()) });
1514
+ }
1515
+ document.addEventListener("pointerup", onUp, true);
1516
+ // leaving the frame must also drop the hover highlight (no pointermove arrives once the pointer is outside)
1517
+ const dropHover = () => { hoverFrame(false); if (hov && !gesture) { hov = null; redraw(); } };
1518
+ root.addEventListener("pointerleave", dropHover);
1519
+ document.addEventListener("mouseleave", dropHover);
1520
+
1521
+ const hitAt = (x, y) => document.elementsFromPoint(x, y).find((n) => !isOverlay(n) && n !== root) || document.body;
1522
+ /** Has words of its own (not only elements): a double-click edits it, rather than going into what is in it. */
1523
+ const isTextual = (el) => [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
1524
+ /** The child of `box` that holds `el`. */
1525
+ const childOf = (box, el) => { let c = el; while (c.parentElement && c.parentElement !== box) c = c.parentElement; return c; };
1526
+ document.addEventListener("dblclick", (e) => {
1527
+ e.preventDefault();
1528
+ const el = hitAt(e.clientX, e.clientY);
1529
+ if (el === document.body || tool !== "move" || isLocked(el)) return;
1530
+ const cur = sels.length === 1 && alive(sels[0]) ? sels[0] : null;
1531
+ if (cur && cur !== el && cur.contains(el) && !(el.closest("svg") === cur) && !isTextual(cur)) {
1532
+ // into the selected box: the one of its children under the pointer is picked (and what sits beside it can be picked next)
1533
+ const c = childOf(cur, el.closest("svg") || el);
1534
+ inside = cur;
1535
+ setSelection([c]);
1536
+ return;
1537
+ }
1538
+ const target = cur && cur.contains(el) && isTextual(cur) ? cur : cur === el ? el : pickable(el);
1539
+ setSelection([target]);
1540
+ if (isTextual(target) || !realKids(target).length) startEdit(target, false);
1541
+ }, true);
1542
+ document.addEventListener("selectionchange", () => { if (editing) postEditing(); });
1543
+ // while typing, keep the editor's copy of the markup (and so the Code panel) current instead of waiting for the edit to finish
1544
+ let typingTimer = null;
1545
+ document.addEventListener("input", () => {
1546
+ if (!editing) return;
1547
+ clearTimeout(typingTimer);
1548
+ typingTimer = setTimeout(() => { if (editing) post({ type: "typing", html: serialize() }); }, 80);
1549
+ }, true);
1550
+ document.addEventListener("focusout", (e) => { if (editing && e.target === editing) finishEdit(); }, true);
1551
+ document.addEventListener("submit", (e) => e.preventDefault(), true);
1552
+ document.addEventListener("click", (e) => { if (!(editing && editing.contains(e.target))) { e.preventDefault(); e.stopPropagation(); } }, true);
1553
+
1554
+ // ── Align, group, duplicate, delete ─────────────────────────────────────────────────────
1555
+ function align(mode) {
1556
+ const items = sels.filter(isAbs);
1557
+ if (!items.length) return;
1558
+ const gs = items.map((el) => ({ el, g: geomOf(el) }));
1559
+ let box;
1560
+ if (items.length === 1) {
1561
+ const par = items[0].parentElement;
1562
+ box = { x: 0, y: 0, w: par === document.body ? Math.max(root.scrollWidth, innerWidth) : par.clientWidth, h: par === document.body ? Math.max(root.scrollHeight, innerHeight) : par.clientHeight };
1563
+ } else {
1564
+ const x0 = Math.min(...gs.map((i) => i.g.x)), y0 = Math.min(...gs.map((i) => i.g.y));
1565
+ box = { x: x0, y: y0, w: Math.max(...gs.map((i) => i.g.x + i.g.w)) - x0, h: Math.max(...gs.map((i) => i.g.y + i.g.h)) - y0 };
1566
+ }
1567
+ if (mode === "distribute-h" || mode === "distribute-v") {
1568
+ if (gs.length < 3) return;
1569
+ const horiz = mode === "distribute-h";
1570
+ gs.sort((a, b) => (horiz ? a.g.x - b.g.x : a.g.y - b.g.y));
1571
+ const total = gs.reduce((s, i) => s + (horiz ? i.g.w : i.g.h), 0);
1572
+ const gap = ((horiz ? box.w : box.h) - total) / (gs.length - 1);
1573
+ let cur = horiz ? box.x : box.y;
1574
+ gs.forEach((i) => { setPx(i.el, horiz ? "left" : "top", cur); cur += (horiz ? i.g.w : i.g.h) + gap; });
1575
+ } else {
1576
+ gs.forEach(({ el, g }) => {
1577
+ if (mode === "left") setPx(el, "left", box.x);
1578
+ if (mode === "hcenter") setPx(el, "left", box.x + (box.w - g.w) / 2);
1579
+ if (mode === "right") setPx(el, "left", box.x + box.w - g.w);
1580
+ if (mode === "top") setPx(el, "top", box.y);
1581
+ if (mode === "vcenter") setPx(el, "top", box.y + (box.h - g.h) / 2);
1582
+ if (mode === "bottom") setPx(el, "top", box.y + box.h - g.h);
1583
+ });
1584
+ }
1585
+ changed();
1586
+ }
1587
+
1588
+ function group() {
1589
+ const items = sels.filter((el) => el !== document.body && el.parentElement && !isLocked(el));
1590
+ if (items.length < 2) return;
1591
+ const par = items[0].parentElement;
1592
+ if (items.some((el) => el.parentElement !== par)) return;
1593
+ if (!items.every(isAbs)) return items.some(isAbs) ? undefined : groupInFlow(items, par); // free and flowing mixed: nothing sensible
1594
+ const gs = items.map((el) => ({ el, g: geomOf(el) }));
1595
+ const x0 = Math.min(...gs.map((i) => i.g.x)), y0 = Math.min(...gs.map((i) => i.g.y));
1596
+ const x1 = Math.max(...gs.map((i) => i.g.x + i.g.w)), y1 = Math.max(...gs.map((i) => i.g.y + i.g.h));
1597
+ const grp = document.createElement("div");
1598
+ grp.className = "absolute";
1599
+ grp.style.position = "absolute";
1600
+ grp.setAttribute("data-wc-group", "");
1601
+ setPx(grp, "left", x0); setPx(grp, "top", y0); setPx(grp, "width", x1 - x0); setPx(grp, "height", y1 - y0);
1602
+ par.insertBefore(grp, items[0]);
1603
+ gs.forEach(({ el, g }) => { grp.appendChild(el); setPx(el, "left", g.x - x0); setPx(el, "top", g.y - y0); });
1604
+ setSelection([grp]);
1605
+ changed();
1606
+ }
1607
+
1608
+ /** Elements in the page flow, a flex box or a grid: a box around them in their place, laid out as their parent lays them out, so
1609
+ * grouping changes nothing on the page (in a grid the box has none of its own: display: contents keeps every cell where it was). */
1610
+ function groupInFlow(items, par) {
1611
+ const ordered = Array.prototype.filter.call(par.children, (c) => items.includes(c));
1612
+ const lay = layoutOf(par), pcs = getComputedStyle(par);
1613
+ const grp = document.createElement("div");
1614
+ grp.setAttribute("data-wc-group", "");
1615
+ par.insertBefore(grp, ordered[0]);
1616
+ ordered.forEach((el) => grp.appendChild(el));
1617
+ made.add(grp);
1618
+ let props = [];
1619
+ if (lay === "grid") grp.className = "contents";
1620
+ else if (lay !== "page flow") {
1621
+ const want = { display: "flex", "flex-direction": pcs.flexDirection, "align-items": pcs.alignItems, "column-gap": pcs.columnGap, "row-gap": pcs.rowGap };
1622
+ const plain = { "flex-direction": ["row"], "align-items": ["normal", "stretch"], "column-gap": ["normal", "0px"], "row-gap": ["normal", "0px"] };
1623
+ props = Object.keys(want).filter((k) => !(plain[k] || []).includes(want[k]));
1624
+ props.forEach((k) => grp.style.setProperty(k, want[k]));
1625
+ }
1626
+ setSelection([grp]);
1627
+ if (props.length) return askConvert([gestureItem(grp, {}, props)]);
1628
+ changed();
1629
+ }
1630
+
1631
+ /**
1632
+ * Shift+A: several free elements in one parent become a flex row or column (whichever way they are spread), in the order
1633
+ * they sit, with the gap they have most often, in a free box where they were. The box is the editor's, so it gets Tailwind
1634
+ * classes (askConvert); the elements lose their own position and keep their size.
1635
+ */
1636
+ function autoLayout() {
1637
+ const items = sels.filter((el) => isAbs(el) && el.parentElement);
1638
+ if (items.length < 2) return;
1639
+ const par = items[0].parentElement;
1640
+ if (items.some((el) => el.parentElement !== par)) return;
1641
+ const rs = items.map((el) => ({ el, r: pageRect(el) }));
1642
+ const x0 = Math.min(...rs.map((i) => i.r.x)), y0 = Math.min(...rs.map((i) => i.r.y));
1643
+ const x1 = Math.max(...rs.map((i) => i.r.x + i.r.w)), y1 = Math.max(...rs.map((i) => i.r.y + i.r.h));
1644
+ const row = (x1 - x0) - Math.max(...rs.map((i) => i.r.w)) >= (y1 - y0) - Math.max(...rs.map((i) => i.r.h));
1645
+ rs.sort((a, b) => (row ? a.r.x - b.r.x : a.r.y - b.r.y));
1646
+ const gaps = rs.slice(1).map((b, i) => (row ? b.r.x - (rs[i].r.x + rs[i].r.w) : b.r.y - (rs[i].r.y + rs[i].r.h))).map((g) => Math.max(0, Math.round(g))).sort((a, b) => a - b);
1647
+ const gap = gaps[Math.floor(gaps.length / 2)] || 0;
1648
+ const o = originOf(par);
1649
+ const box = document.createElement("div");
1650
+ Object.assign(box.style, { position: "absolute", display: "flex", alignItems: "flex-start", gap: `${gap}px` });
1651
+ if (!row) box.style.flexDirection = "column";
1652
+ setPx(box, "left", x0 - o.x); setPx(box, "top", y0 - o.y);
1653
+ par.insertBefore(box, rs[0].el);
1654
+ made.add(box);
1655
+ rs.forEach(({ el }) => {
1656
+ [...el.classList].filter((t) => POSITION_TOKEN.test(t)).forEach((t) => el.classList.remove(t));
1657
+ if (!el.classList.length) el.removeAttribute("class");
1658
+ ["position", "left", "top", "right", "bottom", "transform"].forEach((p) => el.style.removeProperty(p));
1659
+ tidyStyle(el);
1660
+ box.appendChild(el);
1661
+ });
1662
+ setSelection([box], true);
1663
+ askConvert([gestureItem(box, {}, ["position", "left", "top", "display", "flex-direction", "align-items", "gap"])]);
1664
+ }
1665
+
1666
+ /**
1667
+ * Shift+G: free elements that sit in a matrix (every column in line, every row in line, a cell each) become a grid: columns as wide as
1668
+ * their widest cell, the gaps they have most often, in a free box where they were, like Shift+A does for a row or column. Anything that
1669
+ * is not a clean matrix is left alone.
1670
+ */
1671
+ function gridLayout() {
1672
+ const items = sels.filter((el) => isAbs(el) && el.parentElement && !isLocked(el));
1673
+ if (items.length < 4) return;
1674
+ const par = items[0].parentElement;
1675
+ if (items.some((el) => el.parentElement !== par)) return;
1676
+ const rs = items.map((el) => ({ el, r: pageRect(el) }));
1677
+ const T = 3; // pixels of slack
1678
+ const cluster = (values) => {
1679
+ const out = [];
1680
+ [...values].sort((a, b) => a - b).forEach((v) => { const l = out[out.length - 1]; if (l && v - l.max <= T) { l.max = v; l.sum += v; l.n++; } else out.push({ max: v, sum: v, n: 1 }); });
1681
+ return out.map((c) => c.sum / c.n);
1682
+ };
1683
+ const xs = cluster(rs.map((i) => i.r.x)), ys = cluster(rs.map((i) => i.r.y));
1684
+ if (xs.length < 2 || ys.length < 2 || xs.length * ys.length !== rs.length) return;
1685
+ const cell = Array.from({ length: ys.length }, () => Array(xs.length).fill(null));
1686
+ for (const i of rs) {
1687
+ const c = xs.findIndex((x) => Math.abs(x - i.r.x) <= T), r = ys.findIndex((y) => Math.abs(y - i.r.y) <= T);
1688
+ if (c < 0 || r < 0 || cell[r][c]) return;
1689
+ cell[r][c] = i;
1690
+ }
1691
+ const colW = xs.map((_, c) => Math.max(...cell.map((row) => row[c].r.w)));
1692
+ const rowH = ys.map((_, r) => Math.max(...cell[r].map((i) => i.r.h)));
1693
+ const median = (list) => { const s = list.map((g) => Math.max(0, Math.round(g))).sort((a, b) => a - b); return s.length ? s[Math.floor(s.length / 2)] : 0; };
1694
+ const gapX = median(xs.slice(1).map((x, c) => x - (xs[c] + colW[c]))), gapY = median(ys.slice(1).map((y, r) => y - (ys[r] + rowH[r])));
1695
+ const o = originOf(par);
1696
+ const box = document.createElement("div");
1697
+ Object.assign(box.style, { position: "absolute", display: "grid", alignItems: "start", justifyItems: "start", columnGap: `${gapX}px`, rowGap: `${gapY}px`, gridTemplateColumns: colW.map((w) => `${Math.round(w)}px`).join(" ") });
1698
+ setPx(box, "left", xs[0] - o.x); setPx(box, "top", ys[0] - o.y);
1699
+ par.insertBefore(box, rs[0].el);
1700
+ made.add(box);
1701
+ cell.flat().forEach(({ el }) => {
1702
+ [...el.classList].filter((t) => POSITION_TOKEN.test(t)).forEach((t) => el.classList.remove(t));
1703
+ if (!el.classList.length) el.removeAttribute("class");
1704
+ ["position", "left", "top", "right", "bottom", "transform"].forEach((p) => el.style.removeProperty(p));
1705
+ tidyStyle(el);
1706
+ box.appendChild(el);
1707
+ });
1708
+ setSelection([box], true);
1709
+ askConvert([gestureItem(box, {}, ["position", "left", "top", "display", "grid-template-columns", "align-items", "justify-items", "column-gap", "row-gap"])]);
1710
+ }
1711
+
1712
+ function ungroup() {
1713
+ const el = primary();
1714
+ if (!el || el === document.body || !el.children.length || !el.parentElement) return;
1715
+ if (!isAbs(el)) {
1716
+ if (!el.hasAttribute("data-wc-group")) return; // only a box Ctrl+G made is taken apart; any other box is part of the design
1717
+ const kids = Array.prototype.slice.call(el.children);
1718
+ kids.forEach((k) => el.before(k));
1719
+ el.remove();
1720
+ setSelection(kids);
1721
+ return changed();
1722
+ }
1723
+ const g = geomOf(el);
1724
+ const kids = Array.prototype.slice.call(el.children).filter(isAbs);
1725
+ if (!kids.length) return;
1726
+ kids.forEach((k) => { const kg = geomOf(k); el.parentElement.insertBefore(k, el); setPx(k, "left", kg.x + g.x); setPx(k, "top", kg.y + g.y); });
1727
+ if (!el.children.length) el.remove();
1728
+ setSelection(kids);
1729
+ changed();
1730
+ }
1731
+
1732
+ function duplicate() {
1733
+ const copies = sels.filter((el) => el !== document.body).map((el) => {
1734
+ const c = el.cloneNode(true);
1735
+ el.after(c);
1736
+ if (isAbs(c)) { const g = geomOf(c); setPx(c, "left", g.x + 12); setPx(c, "top", g.y + 12); }
1737
+ return c;
1738
+ });
1739
+ if (copies.length) { setSelection(copies); changed(); }
1740
+ }
1741
+
1742
+ // Cut / copy / paste of elements, inside this page (the markup is kept as text; a pasted copy lands after the selection, or at the end of the page)
1743
+ let held = [];
1744
+ function copySelection(cut) {
1745
+ const items = sels.filter((el) => el !== document.body);
1746
+ if (!items.length) return;
1747
+ held = items.map((el) => el.outerHTML);
1748
+ if (cut) removeSelection();
1749
+ }
1750
+ function pasteSelection() {
1751
+ if (!held.length) return;
1752
+ const at = sels.filter((el) => el !== document.body).pop();
1753
+ const made = held.map((h) => { const t = document.createElement("template"); t.innerHTML = h.trim(); return t.content.firstElementChild; }).filter(Boolean);
1754
+ made.forEach((c) => {
1755
+ if (at) at.after(c); else root.appendChild(c);
1756
+ if (isAbs(c)) { const g = geomOf(c); setPx(c, "left", g.x + 12); setPx(c, "top", g.y + 12); }
1757
+ });
1758
+ if (made.length) { setSelection(made); changed(); }
1759
+ }
1760
+
1761
+ function removeSelection() {
1762
+ const items = sels.filter((el) => el !== document.body);
1763
+ if (!items.length) return;
1764
+ const next = items[0].parentElement;
1765
+ items.forEach((el) => el.remove());
1766
+ setSelection(next && next !== root ? [next] : []);
1767
+ changed();
1768
+ }
1769
+
1770
+ function nudge(dx, dy) {
1771
+ const items = sels.filter((el) => isAbs(el) && !isLocked(el));
1772
+ if (!items.length) return;
1773
+ items.forEach((el) => { const g = geomOf(el); setPx(el, "left", g.x + dx); setPx(el, "top", g.y + dy); });
1774
+ changed(false, "nudge");
1775
+ }
1776
+
1777
+ const POSITION_TOKEN = /^(?:[\w-]+:)*(?:absolute|fixed|-?(?:left|top|right|bottom|inset(?:-[xy])?)-\S+)$/;
1778
+ const FREE_PROPS = ["position", "left", "top", "width", "height"];
1779
+ function freePosition(el, on) {
1780
+ if (!el || el === document.body) return;
1781
+ if (on) {
1782
+ if (isAbs(el)) return;
1783
+ const par = el.parentElement || document.body;
1784
+ ensurePositioned(par);
1785
+ const o = originOf(par);
1786
+ const r = pageRect(el);
1787
+ el.style.position = "absolute"; // as pixels for now: askConvert turns them into classes on a Tailwind element
1788
+ // an absolute box's margins still push it, so they come off its left / top: it stays exactly where it was
1789
+ const cs = getComputedStyle(el);
1790
+ setPx(el, "left", r.x - o.x - (parseFloat(cs.marginLeft) || 0)); setPx(el, "top", r.y - o.y - (parseFloat(cs.marginTop) || 0));
1791
+ // keep the size only where being absolute would change it (a full-width block shrinks to its content)
1792
+ [["width", "w"], ["height", "h"]].forEach(([p, k]) => { if (Math.abs(pageRect(el)[k] - r[k]) > 0.5) setPx(el, p, r[k]); });
1793
+ } else {
1794
+ // the position classes too, at any breakpoint (absolute md:left-[..] top-10 ...); a size it was given stays
1795
+ [...el.classList].filter((t) => POSITION_TOKEN.test(t)).forEach((t) => el.classList.remove(t));
1796
+ if (!el.classList.length) el.removeAttribute("class");
1797
+ ["position", "left", "top", "width", "height", "transform"].forEach((p) => el.style.removeProperty(p));
1798
+ if (!el.getAttribute("style")) el.removeAttribute("style");
1799
+ }
1800
+ }
1801
+
1802
+ /**
1803
+ * Layer order is the order among siblings in the HTML (a later sibling is drawn over an earlier one; in a flex row or column it is also
1804
+ * the order across), as in Figma. mode: front, back (all the way), forward, backward (one step). A selection keeps its own order.
1805
+ */
1806
+ function reorder(mode) {
1807
+ const items = sels.filter((el) => el !== document.body && el.parentElement && !isLocked(el));
1808
+ if (!items.length) return;
1809
+ for (const par of new Set(items.map((el) => el.parentElement))) {
1810
+ const mine = Array.prototype.filter.call(par.children, (c) => items.includes(c)); // in page order
1811
+ if (mode === "front") mine.forEach((el) => par.appendChild(el));
1812
+ else if (mode === "back") [...mine].reverse().forEach((el) => par.insertBefore(el, par.firstElementChild));
1813
+ else if (mode === "forward") [...mine].reverse().forEach((el) => { const n = el.nextElementSibling; if (n && !items.includes(n)) n.after(el); });
1814
+ else if (mode === "backward") mine.forEach((el) => { const p = el.previousElementSibling; if (p && !items.includes(p)) p.before(el); });
1815
+ }
1816
+ changed();
1817
+ }
1818
+
1819
+ /** Lock or unlock the selection (all locked when any is not, else all unlocked). */
1820
+ function toggleLockSelection() {
1821
+ const items = sels.filter((el) => el !== document.body);
1822
+ if (!items.length) return;
1823
+ const on = !items.every((el) => el.hasAttribute("data-wc-locked"));
1824
+ items.forEach((el) => (on ? el.setAttribute("data-wc-locked", "") : el.removeAttribute("data-wc-locked")));
1825
+ changed();
1826
+ }
1827
+
1828
+ /** Hide or show the selection: Tailwind's `hidden` class, so the code says so too. */
1829
+ function toggleHiddenSelection() {
1830
+ const items = sels.filter((el) => el !== document.body);
1831
+ if (!items.length) return;
1832
+ const on = !items.every((el) => el.classList.contains("hidden"));
1833
+ items.forEach((el) => { el.classList.toggle("hidden", on); if (!el.getAttribute("class")) el.removeAttribute("class"); });
1834
+ changed();
1835
+ }
1836
+
1837
+ // ── Keyboard ────────────────────────────────────────────────────────────────────────────
1838
+ document.addEventListener("keydown", (e) => {
1839
+ // Ctrl/Cmd + = / - / 0 zoom the plane, never this frame's page
1840
+ if ((e.metaKey || e.ctrlKey) && !e.altKey && ["=", "+", "-", "_", "0"].includes(e.key)) { e.preventDefault(); post({ type: "zoomKey", key: e.key }); return; }
1841
+ if (editing) {
1842
+ if (e.key === "Escape" || (e.key === "Enter" && !e.shiftKey)) { e.preventDefault(); finishEdit(); }
1843
+ return;
1844
+ }
1845
+ const meta = e.metaKey || e.ctrlKey;
1846
+ const k = e.key.toLowerCase();
1847
+ if (e.code === "Space") { e.preventDefault(); post({ type: "space", down: true }); return; }
1848
+ if (e.key === "Enter" && !meta && !e.altKey && tool === "move" && sels.length === 1) { // Figma: Enter goes into the box, Shift+Enter back out to what holds it
1849
+ const cur = sels[0];
1850
+ if (e.shiftKey) { const par = cur.parentElement; if (par && par !== document.body) { e.preventDefault(); inside = par.parentElement && par.parentElement !== document.body ? par.parentElement : null; setSelection([par]); } return; }
1851
+ const kid = !isTextual(cur) && realKids(cur).find((c) => getComputedStyle(c).display !== "none");
1852
+ if (kid) { e.preventDefault(); inside = cur; setSelection([kid]); return; }
1853
+ }
1854
+ if (meta && k === "g") { e.preventDefault(); e.shiftKey ? ungroup() : group(); return; }
1855
+ if (meta && (e.code === "BracketRight" || e.code === "BracketLeft")) { e.preventDefault(); reorder(e.code === "BracketRight" ? (e.shiftKey ? "front" : "forward") : (e.shiftKey ? "back" : "backward")); return; }
1856
+ if (meta && e.shiftKey && k === "l") { e.preventDefault(); toggleLockSelection(); return; }
1857
+ if (meta && e.shiftKey && k === "h") { e.preventDefault(); toggleHiddenSelection(); return; }
1858
+ if (e.shiftKey && !meta && k === "a") { e.preventDefault(); autoLayout(); return; }
1859
+ if (e.shiftKey && !meta && k === "g") { e.preventDefault(); gridLayout(); return; }
1860
+ if (meta && k === "d") { e.preventDefault(); duplicate(); return; }
1861
+ if (meta && !e.shiftKey && (k === "c" || k === "x") && sels.length) { e.preventDefault(); copySelection(k === "x"); return; }
1862
+ if (meta && !e.shiftKey && k === "v") { e.preventDefault(); pasteSelection(); return; }
1863
+ if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); removeSelection(); return; }
1864
+ if (e.key.startsWith("Arrow") && sels.length) {
1865
+ e.preventDefault();
1866
+ const s = e.shiftKey ? 10 : 1;
1867
+ nudge(e.key === "ArrowLeft" ? -s : e.key === "ArrowRight" ? s : 0, e.key === "ArrowUp" ? -s : e.key === "ArrowDown" ? s : 0);
1868
+ return;
1869
+ }
1870
+ if (e.key === "Escape") { e.preventDefault(); if (tool !== "move") setTool("move"); else if (inside && sels.length && sels.every((el) => inside.contains(el))) setSelection([inside]); else setSelection([]); return; }
1871
+ if (e.shiftKey && (e.key === "!" || e.key === "@")) { e.preventDefault(); post({ type: "key", key: e.key, meta: false, shift: true }); return; }
1872
+ const forward = (meta && ["z", "y", "s"].includes(k)) || (!meta && !e.altKey && ["v", "f", "r", "o", "t", "i", "b", "h", "p"].includes(k));
1873
+ if (forward) { e.preventDefault(); post({ type: "key", key: k, meta, shift: e.shiftKey }); }
1874
+ }, true);
1875
+
1876
+ // the editor owns the plane: scrolling pans it and Ctrl/Cmd + scroll (or a pinch) zooms it
1877
+ window.addEventListener("wheel", (e) => {
1878
+ e.preventDefault();
1879
+ post({ type: "wheel", x: e.clientX, y: e.clientY, dx: e.deltaX, dy: e.deltaY, zoom: e.ctrlKey || e.metaKey });
1880
+ }, { passive: false });
1881
+
1882
+ document.addEventListener("keyup", (e) => { if (e.code === "Space" && !editing) post({ type: "space", down: false }); }, true);
1883
+
1884
+ // ── While editing, the page holds still: hover, focus and active styles are off ──────────────────────
1885
+ // A button that changes colour under the pointer, or a field's focus ring, would get in the way of selecting and editing (and
1886
+ // the panel would show a look that isn't the element's own). Every rule for those states (hover:, focus:, active:, group-hover:,
1887
+ // in Tailwind's output or the design's CSS) is rewritten to match an attribute instead (data-wc-state="hover"), which no element
1888
+ // has, so it shows nowhere; the Interact tool shows the page with them, live. The panel's State picks one to show on the selected
1889
+ // element (previewState), so its hover look can be seen and edited. Tailwind rewrites its styles as classes change, so new rules
1890
+ // are caught as they appear.
1891
+ // a real pseudo-class, not an escaped colon inside a class name (.md\:hover\:bg-x is a name; its :hover at the end is the state)
1892
+ const STATE = /(?<!\\):(?:hover|focus-visible|focus-within|focus|active)\b/;
1893
+ const STATES = /(?<!\\):(?:hover|focus-visible|focus-within|focus|active)\b/g;
1894
+ function freezeStates() {
1895
+ const walk = (rules) => {
1896
+ for (const r of rules) {
1897
+ if (r.selectorText && STATE.test(r.selectorText)) { try { r.selectorText = r.selectorText.replace(STATES, (m) => `[data-wc-state~="${m.slice(1)}"]`); } catch (_) { /* a selector the browser won't take back stays */ } }
1898
+ if (r.cssRules) walk(r.cssRules); // @media, @supports, @layer, and nested rules (&:hover)
1899
+ }
1900
+ };
1901
+ for (const sheet of document.styleSheets) { try { walk(sheet.cssRules); } catch (_) { /* a stylesheet from another site can't be read */ } }
1902
+ }
1903
+ freezeStates();
1904
+ /**
1905
+ * The state picked in the panel, shown on the selected element: hover / focus / active on the element itself, group-hover on
1906
+ * its .group parent, peer-hover on the .peer before it. Kept as the selection changes; never saved (serialize strips it).
1907
+ */
1908
+ function showState() {
1909
+ document.querySelectorAll("[data-wc-state]").forEach((n) => n.removeAttribute("data-wc-state"));
1910
+ const el = primary();
1911
+ if (!shownState || !el || el === document.body) return;
1912
+ const m = shownState.match(/^(group|peer)-(hover|focus|focus-visible|focus-within|active)$/);
1913
+ let target = el, st = shownState;
1914
+ if (m) {
1915
+ st = m[2];
1916
+ if (m[1] === "group") target = el.parentElement && el.parentElement.closest(".group");
1917
+ else { target = el.previousElementSibling; while (target && !target.classList.contains("peer")) target = target.previousElementSibling; }
1918
+ }
1919
+ if (target && /^(hover|focus|focus-visible|focus-within|active)$/.test(st)) target.setAttribute("data-wc-state", st === "focus-visible" ? "focus focus-visible" : st);
1920
+ }
1921
+ document.addEventListener("load", (e) => { if (e.target && e.target.tagName === "LINK") freezeStates(); }, true); // a stylesheet that arrives later
1922
+
1923
+ // Tailwind writes its styles a moment after an element appears, so the numbers the editor shows can be stale
1924
+ let infoTimer = null;
1925
+ new MutationObserver(() => {
1926
+ freezeStates();
1927
+ redraw();
1928
+ clearTimeout(infoTimer);
1929
+ infoTimer = setTimeout(() => {
1930
+ if (gesture) return;
1931
+ if (primary() && alive(primary())) post({ type: "info", info: describe(primary()) });
1932
+ else post({ type: "body", info: describe(document.body) }); // nothing selected: the page panel's styling of the body
1933
+ }, 150);
1934
+ }).observe(document.head, { childList: true, subtree: true, characterData: true });
1935
+ window.addEventListener("scroll", redraw, { passive: true });
1936
+ window.addEventListener("resize", redraw);
1937
+ new ResizeObserver(() => { redraw(); post({ type: "height", height: Math.ceil(root.getBoundingClientRect().height) }); }).observe(root);
1938
+
1939
+ // images dropped from the desktop become <img> elements
1940
+ document.addEventListener("dragover", (e) => { e.preventDefault(); }, true);
1941
+ document.addEventListener("drop", (e) => {
1942
+ e.preventDefault();
1943
+ const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
1944
+ if (!file || !/^image\//.test(file.type) || file.size > 1500000) return;
1945
+ const reader = new FileReader();
1946
+ reader.onload = () => {
1947
+ const container = containerAt(e.clientX, e.clientY);
1948
+ const o = originOf(container);
1949
+ const el = createAt("image", container, e.clientX + window.scrollX - o.x, e.clientY + window.scrollY - o.y);
1950
+ el.setAttribute("src", reader.result);
1951
+ el.setAttribute("alt", file.name.replace(/\.[^.]+$/, ""));
1952
+ setSelection([el]);
1953
+ changed();
1954
+ };
1955
+ reader.readAsDataURL(file);
1956
+ }, true);
1957
+
1958
+ // ── Commands from the editor ────────────────────────────────────────────────────────────
1959
+ const mutate = {
1960
+ setClasses: (el, m) => { m.value ? el.setAttribute("class", m.value) : el.removeAttribute("class"); },
1961
+ /** Another tag for the element (div -> section, p -> h2...), if it's one it can validly become: attributes and children move over unchanged. */
1962
+ setTag: (el, m) => {
1963
+ const tag = String(m.tag || "").toLowerCase();
1964
+ if (tag === el.tagName.toLowerCase() || !tagOptions(el).includes(tag)) return;
1965
+ const next = document.createElement(tag);
1966
+ Array.prototype.forEach.call(el.attributes, (at) => next.setAttribute(at.name, at.value));
1967
+ while (el.firstChild) next.appendChild(el.firstChild);
1968
+ el.replaceWith(next);
1969
+ if (made.has(el)) made.add(next);
1970
+ sels = sels.map((x) => (x === el ? next : x));
1971
+ },
1972
+ setText: (el, m) => { if (el.children.length === 0) el.textContent = m.text; },
1973
+ setAttr: (el, m) => { m.value === null ? el.removeAttribute(m.name) : el.setAttribute(m.name, m.value); },
1974
+ remove: (el) => { const p = el.parentElement; el.remove(); sels = p && p !== root ? [p] : []; },
1975
+ duplicate: (el) => { const c = el.cloneNode(true); el.after(c); if (made.has(el)) markMade(c); sels = [c]; },
1976
+ move: (el, m) => {
1977
+ if (m.dir < 0 && el.previousElementSibling) el.previousElementSibling.before(el);
1978
+ if (m.dir > 0 && el.nextElementSibling) el.nextElementSibling.after(el);
1979
+ },
1980
+ front: (el) => { if (el.parentElement) el.parentElement.appendChild(el); },
1981
+ back: (el) => { const p = el.parentElement; if (p) p.insertBefore(el, p.firstElementChild); },
1982
+ toggleLock: (el) => { if (el.hasAttribute("data-wc-locked")) el.removeAttribute("data-wc-locked"); else el.setAttribute("data-wc-locked", ""); },
1983
+ toggleHidden: (el) => { el.classList.toggle("hidden"); if (!el.getAttribute("class")) el.removeAttribute("class"); },
1984
+ moveTo: (el, m) => {
1985
+ const t = elAt(m.targetPath);
1986
+ if (!t || el.contains(t) || el === t) return;
1987
+ if (!staysInGroup(el, m.position === "inside" ? t : t.parentElement)) return; // in a group: it stays there until ungrouped
1988
+ if (m.position === "before") t.before(el);
1989
+ else if (m.position === "after") t.after(el);
1990
+ else t.appendChild(el);
1991
+ },
1992
+ replaceOuter: (el, m) => {
1993
+ const tpl = document.createElement("template");
1994
+ tpl.innerHTML = m.html;
1995
+ const n = tpl.content.firstElementChild;
1996
+ if (n) { el.replaceWith(n); sels = sels.includes(el) ? sels.map((x) => (x === el ? n : x)) : sels; } // the selection stays where it is
1997
+ },
1998
+ insert: (el, m) => {
1999
+ const tpl = document.createElement("template");
2000
+ tpl.innerHTML = m.html;
2001
+ const n = tpl.content.firstElementChild;
2002
+ if (!n) return;
2003
+ if (m.position === "inside") el.appendChild(n); else el.after(n);
2004
+ markMade(n);
2005
+ sels = [n];
2006
+ },
2007
+ setGeom: (el, m) => {
2008
+ const abs = isAbs(el);
2009
+ if (abs && m.x !== undefined && m.x !== null) setPx(el, "left", m.x);
2010
+ if (abs && m.y !== undefined && m.y !== null) setPx(el, "top", m.y);
2011
+ if (m.w !== undefined && m.w !== null) setPx(el, "width", Math.max(1, m.w));
2012
+ if (m.h !== undefined && m.h !== null) setPx(el, "height", Math.max(1, m.h));
2013
+ if (m.rot !== undefined && m.rot !== null) { el.style.transform = Number(m.rot) % 360 === 0 ? "" : `rotate(${Number(m.rot)}deg)`; if (!el.style.transform) el.style.removeProperty("transform"); }
2014
+ },
2015
+
2016
+ setStyleProps: (el, m) => {
2017
+ Object.keys(m.props).forEach((prop) => { const v = m.props[prop]; if (v === null || v === "") el.style.removeProperty(prop); else el.style.setProperty(prop, v); });
2018
+ if (!el.getAttribute("style")) el.removeAttribute("style");
2019
+ },
2020
+ setStyleProp: (el, m) => {
2021
+ if (m.value === null || m.value === "") el.style.removeProperty(m.prop);
2022
+ else el.style.setProperty(m.prop, m.value);
2023
+ if (!el.getAttribute("style")) el.removeAttribute("style");
2024
+ },
2025
+ };
2026
+
2027
+ window.addEventListener("message", (e) => {
2028
+ if (e.source !== parent) return;
2029
+ const m = e.data || {};
2030
+ if (m.cmd === "load") {
2031
+ editing = null;
2032
+ document.body.innerHTML = m.html;
2033
+ sels = m.selectPath ? [elAt(m.selectPath)].filter(Boolean) : [];
2034
+ changed(true);
2035
+ if (primary()) post({ type: "selected", info: describe(primary()) });
2036
+ return;
2037
+ }
2038
+ if (m.cmd === "extPointer") { // a drag that left the frame: the page around it sends the pointer on (the frame gets nothing outside itself)
2039
+ const ev = { clientX: m.x, clientY: m.y, shiftKey: Boolean(m.shift), altKey: Boolean(m.alt), metaKey: Boolean(m.meta), ctrlKey: Boolean(m.ctrl), button: 0, target: document.body, preventDefault() {}, stopPropagation() {} };
2040
+ if (m.ev === "up") onUp(ev); else if (gesture) onMove(ev);
2041
+ return;
2042
+ }
2043
+ if (m.cmd === "select") { if (Array.isArray(m.paths)) setSelection(m.paths.map(elAt).filter(Boolean)); else select(elAt(m.path)); return; } // paths: several at once
2044
+ if (m.cmd === "previewState") { shownState = m.state || ""; showState(); redraw(); if (primary() && alive(primary())) post({ type: "info", info: describe(primary()) }); return; }
2045
+ if (m.cmd === "deselect") { editing = null; setSelection([], true); post({ type: "body", info: describe(document.body) }); return; } // the page's styling, for the page panel
2046
+ if (m.cmd === "hover") { hov = m.path ? elAt(m.path) : null; redraw(); return; }
2047
+ if (m.cmd === "tool") { setTool(m.tool, true); return; }
2048
+ if (m.cmd === "cssProps") { cssProps = m.props || []; return; }
2049
+ if (m.cmd === "hstart") { beginHandle(m.hd, m.e); return; }
2050
+ if (m.cmd === "hmove") { onMove(m.e); return; }
2051
+ if (m.cmd === "hend") { onUp(m.e); return; }
2052
+ if (m.cmd === "exec") {
2053
+ if (editing && m.name) { editing.focus(); document.execCommand(m.name, false, m.arg || null); postEditing(); }
2054
+ return;
2055
+ }
2056
+ if (m.cmd === "view") { zoom = m.zoom || 1; redraw(); return; }
2057
+ if (m.cmd === "scrollTo") { const el = elAt(m.path); if (el) el.scrollIntoView({ block: "center", behavior: "smooth" }); return; }
2058
+ if (m.cmd === "align") { align(m.mode); return; }
2059
+ if (m.cmd === "order") { reorder(m.mode); return; }
2060
+ if (m.cmd === "lockSel") { toggleLockSelection(); return; }
2061
+ if (m.cmd === "hideSel") { toggleHiddenSelection(); return; }
2062
+ if (m.cmd === "group") { group(); return; }
2063
+ if (m.cmd === "ungroup") { ungroup(); return; }
2064
+ if (m.cmd === "deleteSel") { removeSelection(); return; }
2065
+ if (m.cmd === "duplicateSel") { duplicate(); return; }
2066
+ if (m.cmd === "copySel") { copySelection(false); return; }
2067
+ if (m.cmd === "cutSel") { copySelection(true); return; }
2068
+ if (m.cmd === "pasteSel") { pasteSelection(); return; }
2069
+ if (m.cmd === "nudgeSel") { nudge(m.dx || 0, m.dy || 0); return; }
2070
+ if (m.cmd === "applyGesture") { applyGesture(m); return; }
2071
+ if (m.cmd === "freePosition") {
2072
+ const el = elAt(m.path);
2073
+ if (!el) return;
2074
+ if (!m.on) { freePosition(el, false); changed(); return; }
2075
+ if (isAbs(el)) return;
2076
+ const pre = sourcesOf(el, FREE_PROPS);
2077
+ freePosition(el, true);
2078
+ askConvert([gestureItem(el, pre, FREE_PROPS)]);
2079
+ return;
2080
+ }
2081
+ if (m.cmd === "groupOn") { // one class change (a group's class at a breakpoint / state) on every element of a multiple selection
2082
+ (m.paths || []).forEach((pa) => {
2083
+ const el = elAt(pa);
2084
+ if (!el) return;
2085
+ let kept = (el.getAttribute("class") || "").split(/\s+/).filter(Boolean);
2086
+ (m.ops || []).forEach((op) => {
2087
+ const re = new RegExp(op.re);
2088
+ kept = kept.filter((t) => { const i = t.lastIndexOf(":"); return !(t.slice(0, i + 1) === m.vp && re.test(t.slice(i + 1))); });
2089
+ if (op.token) kept.push(m.vp + op.token);
2090
+ });
2091
+ if (kept.length) el.setAttribute("class", kept.join(" ")); else el.removeAttribute("class");
2092
+ });
2093
+ changed(false, m.key);
2094
+ if (primary() && alive(primary())) post({ type: "selected", info: describe(primary()) });
2095
+ return;
2096
+ }
2097
+ if (m.cmd === "autoLayout") { autoLayout(); return; }
2098
+ if (m.cmd === "gridLayout") { gridLayout(); return; }
2099
+ if (m.cmd === "setProp") {
2100
+ const el = elAt(m.path);
2101
+ if (el) setProp(el, m).then(() => { changed(false, m.key); if (primary() && alive(primary())) post({ type: "selected", info: describe(primary()) }); });
2102
+ return;
2103
+ }
2104
+ if (mutate[m.cmd]) {
2105
+ const el = elAt(m.path);
2106
+ if (!el) return;
2107
+ mutate[m.cmd](el, m);
2108
+ changed(false, m.key); // the editor's undo key comes back with the result
2109
+ if (primary() && alive(primary())) post({ type: "selected", info: describe(primary()) });
2110
+ }
2111
+ });
2112
+
2113
+ post({ type: "ready", html: serialize(), tree: tree() });
2114
+ // the page panel shows the page's styling from the start: sent again once the styles have loaded, when the values are real
2115
+ const postBody = () => { if (!primary()) post({ type: "body", info: describe(document.body) }); };
2116
+ postBody();
2117
+ if (document.readyState === "complete") setTimeout(postBody, 300); else window.addEventListener("load", () => setTimeout(postBody, 100));
2118
+ }