windcraft-editor 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -2
- package/core/board-layout.js +71 -0
- package/core/class-groups.js +210 -0
- package/core/design-spec.js +303 -0
- package/core/exporters.js +156 -0
- package/core/pages.js +39 -0
- package/core/prop-tailwind.js +158 -0
- package/core/split-sections.js +99 -0
- package/core/theme.js +87 -0
- package/design/agent.js +2118 -0
- package/design/classes-section.js +103 -0
- package/design/code-editor.js +293 -0
- package/design/code-view.js +158 -0
- package/design/color-picker.js +107 -0
- package/design/config.js +5 -0
- package/design/controls.js +132 -0
- package/design/css-props.js +111 -0
- package/design/design-tab.js +120 -0
- package/design/device-frame.js +40 -0
- package/design/editor/add-menu.js +151 -0
- package/design/editor/api.js +47 -0
- package/design/editor/base.js +55 -0
- package/design/editor/code-panel.js +206 -0
- package/design/editor/color-ui.js +135 -0
- package/design/editor/designs.js +606 -0
- package/design/editor/docs.js +121 -0
- package/design/editor/frame-handles.js +223 -0
- package/design/editor/frame-messages.js +195 -0
- package/design/editor/inspector.js +461 -0
- package/design/editor/layers.js +61 -0
- package/design/editor/pages-panel.js +161 -0
- package/design/editor/panel-resize.js +47 -0
- package/design/editor/plane.js +132 -0
- package/design/editor/present.js +52 -0
- package/design/editor/toolbar.js +267 -0
- package/design/editor.js +47 -0
- package/design/figma-sections.js +440 -0
- package/design/float-toolbar.js +163 -0
- package/design/group-controls.js +52 -0
- package/design/host.js +22 -0
- package/design/panel-model.js +130 -0
- package/design/panel-state.js +40 -0
- package/design/panel.js +212 -0
- package/design/presenter.js +312 -0
- package/design/prop-sections.js +18 -0
- package/design/selection-overlay.js +93 -0
- package/design/store.js +29 -0
- package/design/style-sections.js +315 -0
- package/design/tailwind-classes.js +65 -0
- package/embed-frame.js +43 -0
- package/embed.html +15 -0
- package/embed.js +60 -0
- package/index.js +62 -0
- package/package.json +50 -5
- package/playground/component-source.js +124 -0
- package/styles.css +402 -0
- package/template.js +247 -0
- package/vendor/codemirror.js +24 -0
- package/vendor/preact-htm.js +1 -0
- package/vendor/prettier.js +171 -0
package/design/agent.js
ADDED
|
@@ -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
|
+
}
|