windcraft-editor 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +49 -2
  2. package/core/board-layout.js +71 -0
  3. package/core/class-groups.js +210 -0
  4. package/core/design-spec.js +303 -0
  5. package/core/exporters.js +156 -0
  6. package/core/pages.js +39 -0
  7. package/core/prop-tailwind.js +158 -0
  8. package/core/split-sections.js +99 -0
  9. package/core/theme.js +87 -0
  10. package/design/agent.js +2118 -0
  11. package/design/classes-section.js +103 -0
  12. package/design/code-editor.js +293 -0
  13. package/design/code-view.js +158 -0
  14. package/design/color-picker.js +107 -0
  15. package/design/config.js +5 -0
  16. package/design/controls.js +132 -0
  17. package/design/css-props.js +111 -0
  18. package/design/design-tab.js +120 -0
  19. package/design/device-frame.js +40 -0
  20. package/design/editor/add-menu.js +151 -0
  21. package/design/editor/api.js +47 -0
  22. package/design/editor/base.js +55 -0
  23. package/design/editor/code-panel.js +206 -0
  24. package/design/editor/color-ui.js +135 -0
  25. package/design/editor/designs.js +606 -0
  26. package/design/editor/docs.js +121 -0
  27. package/design/editor/frame-handles.js +223 -0
  28. package/design/editor/frame-messages.js +195 -0
  29. package/design/editor/inspector.js +461 -0
  30. package/design/editor/layers.js +61 -0
  31. package/design/editor/pages-panel.js +161 -0
  32. package/design/editor/panel-resize.js +47 -0
  33. package/design/editor/plane.js +132 -0
  34. package/design/editor/present.js +52 -0
  35. package/design/editor/toolbar.js +267 -0
  36. package/design/editor.js +47 -0
  37. package/design/figma-sections.js +440 -0
  38. package/design/float-toolbar.js +163 -0
  39. package/design/group-controls.js +52 -0
  40. package/design/host.js +22 -0
  41. package/design/panel-model.js +130 -0
  42. package/design/panel-state.js +40 -0
  43. package/design/panel.js +212 -0
  44. package/design/presenter.js +312 -0
  45. package/design/prop-sections.js +18 -0
  46. package/design/selection-overlay.js +93 -0
  47. package/design/store.js +29 -0
  48. package/design/style-sections.js +315 -0
  49. package/design/tailwind-classes.js +65 -0
  50. package/embed-frame.js +43 -0
  51. package/embed.html +15 -0
  52. package/embed.js +60 -0
  53. package/index.js +62 -0
  54. package/package.json +50 -5
  55. package/playground/component-source.js +124 -0
  56. package/styles.css +402 -0
  57. package/template.js +247 -0
  58. package/vendor/codemirror.js +24 -0
  59. package/vendor/preact-htm.js +1 -0
  60. package/vendor/prettier.js +171 -0
@@ -0,0 +1,121 @@
1
+ import { config } from "../config.js";
2
+ import { agentMain } from "../agent.js";
3
+ import { buildPreviewDocument, parseComponentSource, themeMarkup } from "../../playground/component-source.js";
4
+ import { panelStore } from "../panel-state.js";
5
+ import { $, S, data, esc, frame, isTailwindScript, model, send } from "./base.js";
6
+ import { renderFloat, renderSel } from "./frame-handles.js";
7
+ import { renderInspector } from "./inspector.js";
8
+ import { curWidth } from "./plane.js";
9
+ import { anyDirty, docs, refreshLabels, renderDesigns } from "./designs.js";
10
+ import { codeFocused, syncCode } from "./code-panel.js";
11
+
12
+ // ── Documents ────────────────────────────────────────────────────────────────
13
+ const scriptTag = (s) =>
14
+ `<script${s.src ? ` src="${esc(s.src)}"` : ""}${s.type ? ` type="${esc(s.type)}"` : ""}${s.defer ? " defer" : ""}>${s.src ? "" : s.text.replace(/<\/script/gi, "<\\/script")}<\/script>`;
15
+ const styleTag = (s) => `<style${s.type ? ` type="${esc(s.type)}"` : ""}>${s.text.replace(/<\/style/gi, "<\\/style")}</style>`;
16
+
17
+ function editDocument() {
18
+ const head = ['<meta charset="utf-8">', '<meta name="viewport" content="width=device-width, initial-scale=1">'];
19
+ if (!S.parsed.hasTailwindScript) head.push('<script src="https://cdn.tailwindcss.com"><\/script>', themeMarkup());
20
+ else head.push(...S.parsed.scripts.filter(isTailwindScript).map(scriptTag));
21
+ // the design's own stylesheets are marked, so the panel can tell their rules from the ones Tailwind generates (agent.js sourcesOf)
22
+ const mark = (tag) => tag.replace(/^<(style|link)\b/i, "<$1 data-wc-design");
23
+ head.push(...S.parsed.links.map(mark), ...S.parsed.styles.map(styleTag).map(mark), "<style data-wc-editor>html,body{margin:0}</style>");
24
+ const attrs = Object.entries(S.parsed.bodyAttrs).map(([k, v]) => ` ${k}="${esc(v)}"`).join("");
25
+ return `<!DOCTYPE html><html><head>${head.join("\n")}</head><body${attrs}>${model.markup}<script data-wc-agent>window.__wcLoad=${S.loadSeq};(${agentMain.toString()})()<\/script></body></html>`;
26
+ }
27
+
28
+ /** Rebuilds the component source: original head assets and scripts around the edited markup. */
29
+ export function assembleDoc(p, markup) {
30
+ const css = [...p.links, ...p.styles.map(styleTag)].join("\n");
31
+ const js = p.scripts.map(scriptTag).join("\n");
32
+ const bodyAttrs = Object.entries(p.bodyAttrs).map(([k, v]) => ` ${k}="${esc(v)}"`).join("");
33
+ if (bodyAttrs) return `<!DOCTYPE html>\n<html>\n<head>\n${css}\n</head>\n<body${bodyAttrs}>\n${markup}\n${js}\n</body>\n</html>\n`;
34
+ return [css, markup, js].filter(Boolean).join("\n") + "\n";
35
+ }
36
+ export const assemble = () => assembleDoc(S.parsed, model.markup);
37
+
38
+ export function loadFrame() {
39
+ S.loadSeq++;
40
+ S.selected = null;
41
+ S.editingInfo = null;
42
+ S.gestureOn = false;
43
+ S.ftMenu = false;
44
+ S.ftPop = null;
45
+ S.ftSides = {};
46
+ S.selData = null;
47
+ renderSel();
48
+ renderInspector();
49
+ renderFloat();
50
+ frame.srcdoc = S.mode === "edit" ? editDocument() : previewDocument();
51
+ }
52
+
53
+ // The live page (Interact) has none of the editor's own script, so Ctrl/Cmd + scroll or a pinch over it zoomed the whole browser window.
54
+ // This small script keeps those two for the canvas: it stops the browser's zoom and hands the gesture to the editor, which zooms the
55
+ // design plane as it does everywhere else. Plain scrolling stays the page's own.
56
+ // Interact's pointer: the same arrow-with-a-click as the Interact button in the toolbar; the page's own cursors (links, fields) still win.
57
+ const INTERACT_CURSOR = '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%20fill%3D%22none%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cg%20stroke%3D%22%23fff%22%20stroke-width%3D%223.6%22%3E%3Cpath%20d%3D%22M9%209l11%204.5-4.6%201.4L14%2019.5z%22%2F%3E%3Cpath%20d%3D%22M6.5%203.5%207%206M3.5%206.5%206%207M4.5%2010.5l2-1.5M10.5%204.5%209%206.5%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22%23111%22%20stroke-width%3D%221.6%22%3E%3Cpath%20d%3D%22M9%209l11%204.5-4.6%201.4L14%2019.5z%22%2F%3E%3Cpath%20d%3D%22M6.5%203.5%207%206M3.5%206.5%206%207M4.5%2010.5l2-1.5M10.5%204.5%209%206.5%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E") 9 9, default';
58
+ const previewCursorStyle = `<style data-wc-cursor>html{cursor:${INTERACT_CURSOR}}</style>`;
59
+ const previewWheelScript = (load) => `<script data-wc-wheel>(function(){var L=${load};addEventListener("wheel",function(e){if(!(e.ctrlKey||e.metaKey))return;e.preventDefault();parent.postMessage({type:"wheel",x:e.clientX,y:e.clientY,dx:e.deltaX,dy:e.deltaY,zoom:true,load:L},"*")},{passive:false});["gesturestart","gesturechange","gestureend"].forEach(function(t){addEventListener(t,function(e){e.preventDefault()})})})()<\/script>`;
60
+ function previewDocument() {
61
+ const html = buildPreviewDocument(parseComponentSource(assemble()), { width: curWidth() });
62
+ const withCursor = /<\/head>/i.test(html) ? html.replace(/<\/head>/i, previewCursorStyle + "</head>") : previewCursorStyle + html;
63
+ const at = withCursor.lastIndexOf("</body>");
64
+ return at < 0 ? withCursor + previewWheelScript(S.loadSeq) : withCursor.slice(0, at) + previewWheelScript(S.loadSeq) + withCursor.slice(at);
65
+ }
66
+
67
+ // ── History, dirty state, layout ─────────────────────────────────────────────
68
+ /** Does this design use Tailwind utility classes? Decides which style panel opens by default. */
69
+ function refreshDocFlag() {
70
+ const tokens = [];
71
+ String(model.markup).replace(/\bclass\s*=\s*"([^"]*)"|\bclass\s*=\s*'([^']*)'/g, (_, a, b) => { tokens.push(...(a ?? b).split(/\s+/).filter(Boolean)); return ""; });
72
+ const hit = tokens.some((t) => /^(?:flex|grid|block|hidden|inline-flex|rounded)$/.test(t) || /^-?(?:p|m|px|py|pt|pr|pb|pl|mx|my|mt|mr|mb|ml|w|h|gap)-(?:\d|px|auto|full)/.test(t) || /^(?:text|bg|border)-(?:xs|sm|base|lg|xl|\d?xl|[a-z]+-\d{2,3}|black|white)$/.test(t));
73
+ panelStore.set({ docTailwind: Boolean(S.parsed.hasTailwindScript || hit) });
74
+ }
75
+
76
+ export function updateChrome() {
77
+ refreshDocFlag();
78
+ $("undoBtn").disabled = S.hIndex <= 0;
79
+ $("redoBtn").disabled = S.hIndex >= S.history.length - 1;
80
+ const dirty = anyDirty();
81
+ $("saveBtn").disabled = !dirty;
82
+ $("saveState").textContent = dirty ? (S.isNew && docs.size === 1 ? "Not saved yet" : "Unsaved changes") : S.isNew ? "" : "Saved";
83
+ $("versionBadge").textContent = S.isNew ? "new" : `v${data.version}`;
84
+ $("titleText").textContent = data.title || "Untitled component";
85
+ document.title = `${data.title || "New component"} | Windcraft design`;
86
+ const pl = $("projLink");
87
+ pl.classList.toggle("hidden", !data.project);
88
+ pl.classList.toggle("inline-flex", Boolean(data.project));
89
+ $("projSep").classList.toggle("hidden", !data.project);
90
+ if (data.project) { pl.innerHTML = `<span class="truncate">Project: ${esc(data.project.name)}</span><svg class="shrink-0 text-neutral-500" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 2.5h5v5M9.5 2.5l-6 6"/></svg>`; pl.href = `/projects/${data.project.id}`; }
91
+ if (!codeFocused()) syncCode();
92
+ renderDesigns();
93
+ refreshLabels();
94
+ if (config.hooks.chrome) config.hooks.chrome();
95
+ }
96
+
97
+ export function commit(html, key) {
98
+ model.markup = html;
99
+ const now = Date.now();
100
+ if (html === S.history[S.hIndex]) { S.lastKey = key || ""; S.lastTime = now; return updateChrome(); } // nothing changed: no undo step
101
+ // the same key merges into the last step: within 800 ms, or for a "live:" key (a picker being dragged) for as long as it lasts
102
+ if (key && key === S.lastKey && (key.startsWith("live:") || now - S.lastTime < 800) && S.hIndex === S.history.length - 1) {
103
+ S.history[S.hIndex] = html;
104
+ } else {
105
+ S.history = S.history.slice(0, S.hIndex + 1);
106
+ S.history.push(html);
107
+ S.hIndex = S.history.length - 1;
108
+ }
109
+ S.lastKey = key || "";
110
+ S.lastTime = now;
111
+ updateChrome();
112
+ }
113
+
114
+ export function step(delta) {
115
+ const next = S.hIndex + delta;
116
+ if (next < 0 || next >= S.history.length) return;
117
+ S.hIndex = next;
118
+ model.markup = S.history[S.hIndex];
119
+ send({ cmd: "load", html: model.markup, selectPath: S.selected ? S.selected.path : null });
120
+ updateChrome();
121
+ }
@@ -0,0 +1,223 @@
1
+ import { TEXT_TAGS, toolbarHtml } from "../float-toolbar.js";
2
+ import { selectionHtml } from "../selection-overlay.js";
3
+ import { $, S, send } from "./base.js";
4
+ import { docs, setFrameSize, wraps } from "./designs.js";
5
+ import { canvas, heightOfDoc, positions, view, widthOf } from "./plane.js";
6
+ import { setDevice, setProp, setProps } from "./inspector.js";
7
+ import { cmd } from "./frame-messages.js";
8
+ import { setPanel, setPanelTab } from "./color-ui.js";
9
+ import { closeColorPicker, placeBeside } from "../color-picker.js";
10
+
11
+ // ── The floating toolbar, frame handles and the side panel ───────────────────
12
+ // which of the bar's panels is open: border, radius or spacing
13
+ // per group: true when its fields are shown one per side (your choice; unset means automatic)
14
+ export const ftb = $("ftb");
15
+
16
+ function toolbarState() {
17
+ if (S.mode !== "edit" || S.gestureOn || S.tool !== "move") return null;
18
+ if (S.frameSel === S.curKey) {
19
+ const d = docs.get(S.curKey);
20
+ return { kind: "frame", frame: { w: widthOf(d), h: heightOfDoc(d), auto: !(d.board && d.board.h), custom: S.lastCustom } };
21
+ }
22
+ if (!S.selected || S.selected.tag === "body" || !S.selected.rect) return null;
23
+ const editing = Boolean(S.editingInfo && S.editingInfo.on);
24
+ const text = editing || TEXT_TAGS.has(S.selected.tag) || (S.selected.leaf && String(S.selected.text || "").trim() !== "");
25
+ return { kind: text ? "text" : "shape", sel: S.selected, editing, fmt: editing ? S.editingInfo.fmt : null, menu: S.ftMenu, pop: S.ftPop, sides: S.ftSides };
26
+ }
27
+
28
+ /** Where the thing the toolbar belongs to sits inside the canvas element. */
29
+ function anchorRect(st) {
30
+ const d = docs.get(S.curKey);
31
+ const p = positions()[S.curKey];
32
+ if (!p) return null;
33
+ if (st.kind === "frame") return { x: view.x + p.x * view.z, y: view.y + p.y * view.z, w: widthOf(d) * view.z, h: heightOfDoc(d) * view.z, gap: 38 };
34
+ const r = st.editing && S.editingInfo.rect ? S.editingInfo.rect : st.sel.rect;
35
+ return { x: view.x + (p.x + r.x) * view.z, y: view.y + (p.y + r.y) * view.z, w: r.w * view.z, h: r.h * view.z, gap: 10 };
36
+ }
37
+
38
+ export function placeFloat() {
39
+ if (ftb.classList.contains("hidden")) return;
40
+ const st = toolbarState();
41
+ const a = st && anchorRect(st);
42
+ if (!a) return;
43
+ const cw = canvas.clientWidth, ch = canvas.clientHeight;
44
+ const w = ftb.offsetWidth, h = ftb.offsetHeight;
45
+ const left = Math.min(Math.max(8, a.x), Math.max(8, cw - w - 8));
46
+ let top = a.y - h - a.gap;
47
+ if (top < 56) top = a.y + a.h + 10;
48
+ top = Math.min(Math.max(56, top), Math.max(56, ch - h - 8));
49
+ ftb.classList.toggle("popdown", top < 270); // a panel opens upward, unless the bar is too close to the top of the canvas
50
+ ftb.style.left = `${Math.round(left)}px`;
51
+ ftb.style.top = `${Math.round(top)}px`;
52
+ // a panel of the bar (padding, margin, radius, border) opens beside the element, as the colour picker does, not over the design
53
+ const pop = ftb.querySelector(".ftpop");
54
+ if (pop && st.kind !== "frame") {
55
+ const cr = canvas.getBoundingClientRect(), fr = ftb.getBoundingClientRect();
56
+ const near = { left: cr.left + a.x, top: cr.top + a.y, right: cr.left + a.x + a.w, bottom: cr.top + a.y + a.h };
57
+ const [x, y] = placeBeside(near, cr, null, pop.offsetWidth, pop.offsetHeight);
58
+ pop.style.left = `${Math.round(x - fr.left)}px`;
59
+ pop.style.top = `${Math.round(y - fr.top)}px`;
60
+ pop.style.bottom = "auto";
61
+ }
62
+ }
63
+
64
+ // the frame's handles, in screen pixels like the element selection (a handle must be 9px at any zoom)
65
+ const FRAME_HANDLES = [
66
+ ["n", "left:0;top:0;width:100%;height:10px;transform:translateY(-50%);cursor:ns-resize;"], ["s", "left:0;top:100%;width:100%;height:10px;transform:translateY(-50%);cursor:ns-resize;"],
67
+ ["w", "left:0;top:0;height:100%;width:10px;transform:translateX(-50%);cursor:ew-resize;"], ["e", "left:100%;top:0;height:100%;width:10px;transform:translateX(-50%);cursor:ew-resize;"],
68
+ ];
69
+ const FRAME_CORNERS = [["nw", "left:0;top:0;cursor:nwse-resize;"], ["ne", "left:100%;top:0;cursor:nesw-resize;"], ["se", "left:100%;top:100%;cursor:nwse-resize;"], ["sw", "left:0;top:100%;cursor:nesw-resize;"]];
70
+
71
+ export function renderFrameSel() {
72
+ renderSel();
73
+ // a frame's name is blue only while the frame, or something inside it, is selected
74
+ wraps.forEach((en, key) => en.wrap.classList.toggle("sel", key === S.curKey && (S.frameSel === S.curKey || Boolean(S.selected && S.selected.tag !== "body"))));
75
+ const el = $("fsel");
76
+ const d = docs.get(S.curKey);
77
+ const p = positions()[S.curKey];
78
+ if (S.frameSel !== S.curKey || S.mode !== "edit" || !p) { el.innerHTML = ""; linesFrame = ""; paintLines(); return; }
79
+ const z = view.z;
80
+ const box = `left:${Math.round((view.x + p.x * z) * 100) / 100}px;top:${Math.round((view.y + p.y * z) * 100) / 100}px;width:${Math.round(widthOf(d) * z * 100) / 100}px;height:${Math.round(heightOfDoc(d) * z * 100) / 100}px;`;
81
+ const fx = view.x + p.x * z, fy = view.y + p.y * z;
82
+ const outline = `<svg class="sout"><rect x="${fx - 0.75}" y="${fy - 0.75}" width="${widthOf(d) * z + 1.5}" height="${heightOfDoc(d) * z + 1.5}" fill="none" stroke="#fff" stroke-width="1.5"/></svg>`;
83
+ linesFrame = outline;
84
+ paintLines();
85
+ el.innerHTML = `<div class="fbox" style="${box}">` + FRAME_HANDLES.map(([n, css]) => `<div class="fh" data-fh="${n}" style="${css}"></div>`).join("") + FRAME_CORNERS.map(([n, css]) => `<div class="fh fc" data-fh="${n}" style="${css}transform:translate(-50%,-50%);"></div>`).join("") + "</div>";
86
+ }
87
+
88
+ /** The selection box and its handles, above the frame (see selection-overlay.js). */
89
+
90
+ // the outlines of the selection and of the selected frame share one layer that inverts what is under it; handles and labels sit above it
91
+ let linesSel = "", linesFrame = "";
92
+ const paintLines = () => { $("lines").innerHTML = linesSel + linesFrame; };
93
+ export function renderSel() {
94
+ const el = $("sel");
95
+ const p = positions()[S.curKey];
96
+ if (S.mode !== "edit" || S.frameSel === S.curKey || !S.selData || !p) { el.innerHTML = ""; $("slbl").innerHTML = ""; linesSel = ""; paintLines(); return; }
97
+ const parts = selectionHtml(S.selData, { zoom: view.z, ox: view.x + p.x * view.z, oy: view.y + p.y * view.z });
98
+ linesSel = parts.lines;
99
+ paintLines();
100
+ el.innerHTML = parts.html;
101
+ $("slbl").innerHTML = parts.label; // the size label keeps its own colours, so it sits outside the inverting layers
102
+ }
103
+
104
+ export function renderFloat(force) {
105
+ renderFrameSel();
106
+ const st = toolbarState();
107
+ if (!st) { ftb.classList.add("hidden"); ftb.classList.remove("flex"); return; }
108
+ // don't rebuild the bar under a number field someone is typing in, unless a button asked for it
109
+ const typing = !force && document.activeElement && ftb.contains(document.activeElement) && document.activeElement.matches("input");
110
+ if (!typing) ftb.innerHTML = toolbarHtml(st);
111
+ ftb.classList.remove("hidden");
112
+ ftb.classList.add("flex");
113
+ placeFloat();
114
+ }
115
+
116
+ export const SIDES4 = ["top", "right", "bottom", "left"];
117
+ const CORNER = { tl: "top-left", tr: "top-right", bl: "bottom-left", br: "bottom-right" };
118
+ const SIDE_KEY = { top: "T", right: "R", bottom: "B", left: "L" };
119
+ const CORNER_KEY = { tl: "radiusTL", tr: "radiusTR", bl: "radiusBL", br: "radiusBR" };
120
+ const allSides = (f) => SIDES4.map(f);
121
+ /** A value typed into one of the bar's panels (a border width, a corner, a padding or margin side), written where that value lives (setProps). */
122
+ export function applyPanel(key, raw) {
123
+ const colors = allSides((d) => `border-${d}-color`);
124
+ if (key === "border-color-hex") { const h = raw.trim().replace(/^#?/, "#"); return /^#[0-9a-f]{6}$/i.test(h) ? setProps(colors, h, "borderColor") : renderFloat(true); }
125
+ if (key === "border-color") return setProps(colors, raw, "borderColor");
126
+ const n = Number(raw);
127
+ if (raw.trim() === "" || !Number.isFinite(n)) return renderFloat(true);
128
+ const v = Math.round(n * 10) / 10; // margins may be negative
129
+ const nn = Math.max(0, v); // everything else may not
130
+ const c = S.selected.computed || {};
131
+ const borderless = SIDES4.every((d) => !parseFloat(c[`border-${d}-width`]));
132
+ // a border width on a box with no border style shows nothing: give it a solid one first (its own undo step)
133
+ const one = `border-${Date.now()}`; // the style and the width below share it: one undo step
134
+ const solid = () => { if (borderless && n > 0) setProps(allSides((d) => `border-${d}-style`), "solid", "borderStyle", [], undefined, one); };
135
+ if (key === "bw-all") { solid(); return setProps(allSides((d) => `border-${d}-width`), nn, "borderWidth", ["borderT", "borderR", "borderB", "borderL"], undefined, one); }
136
+ if (key.startsWith("bw-")) { solid(); const d = key.slice(3); return setProps([`border-${d}-width`], nn, `border${SIDE_KEY[d]}`, [], undefined, one); }
137
+ if (key === "r-all") return FLOAT_ACTIONS.radius(nn);
138
+ if (key.startsWith("r-")) return setProps([`border-${CORNER[key.slice(2)]}-radius`], nn, CORNER_KEY[key.slice(2)]);
139
+ const [kind, side] = key.split("-"); // padding-h, margin-top ...
140
+ const k = kind === "padding" ? "p" : "m";
141
+ const val = kind === "padding" ? nn : v;
142
+ if (side === "h") return setProps([`${kind}-left`, `${kind}-right`], val, `${k}x`, [`${k}l`, `${k}r`]);
143
+ if (side === "v") return setProps([`${kind}-top`, `${kind}-bottom`], val, `${k}y`, [`${k}t`, `${k}b`]);
144
+ return setProps([`${kind}-${side}`], val, `${k}${side[0]}`);
145
+ }
146
+ export const FLOAT_ACTIONS = {
147
+ bold() {
148
+ if (S.editingInfo && S.editingInfo.on) return send({ cmd: "exec", name: "bold" });
149
+ const on = parseInt(S.selected.computed["font-weight"], 10) >= 600;
150
+ setProp("font-weight", on ? "400" : "700");
151
+ },
152
+ italic() {
153
+ if (S.editingInfo && S.editingInfo.on) return send({ cmd: "exec", name: "italic" });
154
+ const on = S.selected.computed["font-style"] === "italic";
155
+ setProp("font-style", on ? "normal" : "italic");
156
+ },
157
+ underline() {
158
+ if (S.editingInfo && S.editingInfo.on) return send({ cmd: "exec", name: "underline" });
159
+ const on = /underline/.test(S.selected.computed["text-decoration-line"] || "");
160
+ setProp("text-decoration-line", on ? "none" : "underline");
161
+ },
162
+ link() {
163
+ const url = prompt("Link address", "https://");
164
+ if (!url) return;
165
+ if (S.editingInfo && S.editingInfo.on) send({ cmd: "exec", name: "createLink", arg: url });
166
+ else if (S.selected.tag === "a") cmd({ cmd: "setAttr", path: S.selected.path, name: "href", value: url });
167
+ else alert("Select some text while editing it (double-click), then add the link.");
168
+ },
169
+ align() {
170
+ const cur = S.selected.computed["text-align"];
171
+ const next = { left: "center", start: "center", center: "right", right: "left", justify: "left" }[cur] || "center";
172
+ setProp("text-align", next);
173
+ },
174
+ radius(n) {
175
+ // every corner: one rounded-* class, or inline radius where a corner comes from CSS
176
+ setProps(["border-top-left-radius", "border-top-right-radius", "border-bottom-right-radius", "border-bottom-left-radius"], n, "radius", ["radiusTL", "radiusTR", "radiusBR", "radiusBL"]);
177
+ },
178
+ more() { closeColorPicker(); S.ftMenu = !S.ftMenu; S.ftPop = null; renderFloat(true); },
179
+ pop(key) { closeColorPicker(); S.ftPop = S.ftPop === key ? null : key; S.ftMenu = false; renderFloat(true); },
180
+ side(key, btn) { S.ftSides[key] = !btn.classList.contains("on"); renderFloat(true); },
181
+ bstyle(kind) {
182
+ const c = S.selected.computed || {};
183
+ const none = ["top", "right", "bottom", "left"].every((d) => !parseFloat(c[`border-${d}-width`]));
184
+ const one = `border-${Date.now()}`; // style and width: one undo step
185
+ setProps(SIDES4.map((d) => `border-${d}-style`), kind, "borderStyle", [], undefined, one);
186
+ if (kind !== "none" && none) setProps(SIDES4.map((d) => `border-${d}-width`), 1, "borderWidth", ["borderT", "borderR", "borderB", "borderL"], undefined, one); // a line style on a borderless box: make it visible
187
+ },
188
+ dup() { S.ftMenu = false; cmd({ cmd: "duplicate", path: S.selected.path }); },
189
+ del() { S.ftMenu = false; cmd({ cmd: "remove", path: S.selected.path }); },
190
+ up() { S.ftMenu = false; cmd({ cmd: "move", path: S.selected.path, dir: -1 }); },
191
+ down() { S.ftMenu = false; cmd({ cmd: "move", path: S.selected.path, dir: 1 }); },
192
+ free() { S.ftMenu = false; cmd({ cmd: "freePosition", path: S.selected.path, on: !S.selected.geom.abs }); },
193
+ group() { S.ftMenu = false; send({ cmd: "group" }); },
194
+ ungroup() { S.ftMenu = false; send({ cmd: "ungroup" }); },
195
+ front() { S.ftMenu = false; send({ cmd: "order", mode: "front" }); },
196
+ back() { S.ftMenu = false; send({ cmd: "order", mode: "back" }); },
197
+ props() { S.ftMenu = false; setPanel(true); setPanelTab("props"); renderFloat(true); },
198
+ };
199
+
200
+ /** Registers this part's listeners and does its first draw (called once, in order, by editor.js). */
201
+ export function init() {
202
+
203
+ // The bar's buttons act on press, not on click: a value that was just typed makes the bar rebuild itself,
204
+ // which would swallow the click. Pressing also must not take focus from text being edited.
205
+ ftb.addEventListener("mousedown", (e) => {
206
+ if (e.target.closest("select, input, label")) return;
207
+ e.preventDefault();
208
+ // a number field in the bar commits first
209
+ const a = document.activeElement;
210
+ if (a && ftb.contains(a) && a.matches("input")) a.blur();
211
+ const dev = e.target.closest("[data-ftdev]");
212
+ if (dev && dev.dataset.ftdev === "custom") return S.lastCustom && setFrameSize({ w: S.lastCustom.w, h: S.lastCustom.h });
213
+ if (dev) return setDevice(Number(dev.dataset.ftdev));
214
+ const b = e.target.closest("[data-ft]");
215
+ if (b && FLOAT_ACTIONS[b.dataset.ft] && S.selected) FLOAT_ACTIONS[b.dataset.ft](b.dataset.arg, b);
216
+ });
217
+
218
+ // pressing anywhere else in the editor (the canvas, a layer) also lets a focused bar field commit first
219
+ document.addEventListener("pointerdown", (e) => {
220
+ const a = document.activeElement;
221
+ if (a && ftb.contains(a) && a.matches("input, select") && !e.target.closest("#ftb")) a.blur();
222
+ }, true);
223
+ }
@@ -0,0 +1,195 @@
1
+ import { closeColorPicker } from "../color-picker.js";
2
+ import { CSS_PROP_LIST } from "../css-props.js";
3
+ import { panelStore } from "../panel-state.js";
4
+ import { S, data, frame, model, send } from "./base.js";
5
+ import { docs, scheduleLayout, selectFrame, wraps } from "./designs.js";
6
+ import { pathKey, renderLayers } from "./layers.js";
7
+ import { FLOAT_ACTIONS, ftb, renderFloat, renderSel } from "./frame-handles.js";
8
+ import { handleKey, keyZoom, setSpace, setTool } from "./toolbar.js";
9
+ import { applyView, fitTo, handleWheel, layout, positions, view } from "./plane.js";
10
+ import { commit, updateChrome } from "./docs.js";
11
+ import { convertGesture, renderInspector, setRotation } from "./inspector.js";
12
+ import { updateCodeHighlight } from "./code-panel.js";
13
+
14
+ let pendingKey = "";
15
+
16
+ // A drag that starts in the page and leaves the frame: the frame gets no more pointer events outside itself (it is a separate
17
+ // document), so a shape drawn up to the edge never finished. While a gesture runs, this window passes the pointer on, in the page's own units.
18
+ let following = null;
19
+ function followOutside() {
20
+ if (following) return;
21
+ const forward = (ev) => (e) => {
22
+ const r = frame.getBoundingClientRect(), k = r.width / (frame.offsetWidth || r.width);
23
+ send({ cmd: "extPointer", ev, x: (e.clientX - r.left) / k, y: (e.clientY - r.top) / k, shift: e.shiftKey, alt: e.altKey, meta: e.metaKey, ctrl: e.ctrlKey });
24
+ };
25
+ const move = forward("move"), up = (e) => { forward("up")(e); stopFollowing(); };
26
+ window.addEventListener("pointermove", move, true);
27
+ window.addEventListener("pointerup", up, true);
28
+ following = { move, up };
29
+ }
30
+ function stopFollowing() {
31
+ if (!following) return;
32
+ window.removeEventListener("pointermove", following.move, true);
33
+ window.removeEventListener("pointerup", following.up, true);
34
+ following = null;
35
+ }
36
+ export const cmd = (msg, key = "") => { pendingKey = key; send(key ? { key, ...msg } : msg); };
37
+
38
+ // A design takes a moment to load into the canvas (its Tailwind styling, its pictures): until it does, the canvas shows a loading
39
+ // message instead of an empty frame in a corner. It goes at the first "ready" from the frame, or after 15 seconds whatever happens.
40
+ let announced = false;
41
+ function hideBoot() {
42
+ const b = document.getElementById("boot");
43
+ if (b) { b.classList.add("hidden"); b.classList.remove("flex"); }
44
+ // the first design is painted: whoever opened the editor (a page, the embedding dialog) can take its own loading message away
45
+ if (!announced) { announced = true; document.dispatchEvent(new CustomEvent("wc-editor-ready")); }
46
+ }
47
+
48
+ /** Registers this part's listeners and does its first draw (called once, in order, by editor.js). */
49
+ /**
50
+ * A frame that follows its content's height can't follow content sized by the screen (min-h-screen, h-screen, 100vh) plus
51
+ * anything more (a border, padding, a second section): each time the frame grows to fit, the screen grows with it and the content
52
+ * outgrows it again, without end. Growth that repeats by the same amount, quickly, is that loop: the frame stops following it
53
+ * and keeps the height it has. A height that is smaller starts over.
54
+ */
55
+ const growth = [];
56
+ function viewportLoop(h) {
57
+ const now = performance.now(), prev = S.frameHeight || 0;
58
+ if (h <= prev) { growth.length = 0; return false; }
59
+ growth.push({ t: now, d: h - prev });
60
+ while (growth.length && now - growth[0].t > 2000) growth.shift();
61
+ const last = growth.slice(-4);
62
+ return last.length >= 4 && last.every((g) => Math.abs(g.d - last[0].d) <= 1);
63
+ }
64
+
65
+ export function init() {
66
+ const boot = document.getElementById("boot");
67
+ if (boot && (data.htmlCode || data.id)) { boot.classList.remove("hidden"); boot.classList.add("flex"); setTimeout(hideBoot, 15000); }
68
+
69
+ // ── Messages from the frame ──────────────────────────────────────────────────
70
+ window.addEventListener("message", (e) => {
71
+ const m = e.data || {};
72
+ if (m.type === "preview-height" && e.source !== frame.contentWindow) {
73
+ for (const [key, en] of wraps) {
74
+ if (en.ghost && en.ghost.contentWindow === e.source) { docs.get(key).height = Math.max(120, Math.min(6000, m.height)); scheduleLayout(); return; }
75
+ }
76
+ return;
77
+ }
78
+ if (e.source !== frame.contentWindow) return;
79
+ // a document that was replaced (switching designs reloads the frame) can still have messages in flight
80
+ if (m.load !== undefined && m.load !== S.loadSeq) return;
81
+ if (S.handingOver) return; // the page being left, still in the frame for a moment (activate)
82
+ // Pressing inside the page doesn't take focus from a field in the bar. Let that field commit (to the element it was showing)
83
+ // before the selection moves on, so the bar then rebuilds for the new element instead of keeping a stale field.
84
+ // An edit reports the same element back: that must not blur a field still in use (a colour picker being dragged).
85
+ const otherElement = m.type === "selected" && !(S.selected && m.info && pathKey(S.selected.path) === pathKey(m.info.path));
86
+ if (otherElement || (m.type === "gesture" && m.on)) {
87
+ closeColorPicker(); // its colours belong to the element it was opened for
88
+ const a = document.activeElement;
89
+ if (a && ftb.contains(a) && a.matches("input, select")) a.blur();
90
+ }
91
+ if (m.type === "zoomKey") return keyZoom(m.key);
92
+ if (m.type === "wheel") {
93
+ const pos = positions()[S.curKey] || { x: 0, y: 0 };
94
+ return handleWheel({ dx: m.dx, dy: m.dy, zoomKey: m.zoom, cx: view.x + (pos.x + m.x) * view.z, cy: view.y + (pos.y + m.y) * view.z });
95
+ }
96
+ if (m.type === "ready") {
97
+ if (S.switching) {
98
+ S.switching = false;
99
+ // the frame moves over now (layout below) but shows a moment later, once Tailwind has styled the new design; the
100
+ // snapshot is right in the meantime
101
+ const key = S.curKey, token = S.switchToken;
102
+ setTimeout(() => {
103
+ if (token !== S.switchToken || key !== S.curKey) return;
104
+ const en = wraps.get(key);
105
+ if (en && en.ghost) { en.ghost.remove(); en.ghost = null; }
106
+ frame.style.visibility = "";
107
+ }, 120);
108
+ }
109
+ if (!S.history.length) { S.history = [m.html]; S.hIndex = 0; model.markup = m.html; if (!S.isNew) S.savedMarkup = m.html; }
110
+ S.tree = m.tree;
111
+ renderLayers();
112
+ updateChrome();
113
+ layout();
114
+ if (!S.viewTouched) fitTo([S.curKey]);
115
+ S.sentZoom = 0;
116
+ applyView();
117
+ setTimeout(hideBoot, 150); // the first paint of the fitted design, then the loading message goes
118
+ send({ cmd: "tool", tool: S.tool });
119
+ send({ cmd: "cssProps", props: CSS_PROP_LIST });
120
+ send({ cmd: "previewState", state: panelStore.get().st }); // a reloaded frame shows the picked state again
121
+ renderFloat();
122
+ } else if (m.type === "height" || m.type === "preview-height") {
123
+ if (viewportLoop(m.height)) return;
124
+ S.frameHeight = m.height;
125
+ layout();
126
+ if (!S.viewTouched) fitTo([S.curKey]);
127
+ } else if (m.type === "changed") {
128
+ S.tree = m.tree;
129
+ if (m.silent) model.markup = m.html;
130
+ else commit(m.html, m.key || pendingKey);
131
+ pendingKey = "";
132
+ if (m.info) S.selected = m.info;
133
+ if (m.body) S.pageBody = m.body;
134
+ renderLayers();
135
+ renderInspector();
136
+ renderFloat();
137
+ } else if (m.type === "rotate") {
138
+ // the rotate handle was let go: the angle is written like the panel's rotation field does
139
+ if (S.selected && pathKey(S.selected.path) === pathKey(m.path)) setRotation(m.value);
140
+ } else if (m.type === "radius") {
141
+ // a corner dot was dragged: apply the radius the same way the toolbar does (class in Tailwind mode, style otherwise)
142
+ if (S.selected && pathKey(S.selected.path) === pathKey(m.path)) FLOAT_ACTIONS.radius(m.value);
143
+ } else if (m.type === "info") {
144
+ // the same element with fresh numbers (styles finished loading): update quietly
145
+ if (S.selected && m.info && pathKey(S.selected.path) === pathKey(m.info.path)) {
146
+ S.selected = m.info;
147
+ renderInspector();
148
+ renderFloat();
149
+ }
150
+ } else if (m.type === "typing") {
151
+ model.markup = m.html;
152
+ updateChrome();
153
+ } else if (m.type === "overlay") {
154
+ S.selData = m;
155
+ renderSel();
156
+ } else if (m.type === "editing") {
157
+ S.editingInfo = m;
158
+ renderFloat();
159
+ } else if (m.type === "gesture") {
160
+ S.gestureOn = m.on;
161
+ m.on ? followOutside() : stopFollowing();
162
+ renderFloat();
163
+ } else if (m.type === "tool") {
164
+ setTool(m.tool, true);
165
+ } else if (m.type === "space") {
166
+ setSpace(m.down);
167
+ } else if (m.type === "selected") {
168
+ // the same element reporting again (after an edit) keeps its open menu; a different one starts fresh
169
+ const newSelection = !(S.selected && m.info && pathKey(S.selected.path) === pathKey(m.info.path));
170
+ if (newSelection) {
171
+ S.ftMenu = false; S.ftPop = null; S.ftSides = {};
172
+ // a state (Hover, Focus...) belongs to the element it was picked for: the next one starts at its normal look. The screen size stays (it is the frame's).
173
+ if (panelStore.get().st) { panelStore.set({ st: "" }); send({ cmd: "previewState", state: "" }); }
174
+ }
175
+ S.selected = m.info;
176
+ S.frameSel = null;
177
+ updateCodeHighlight(newSelection); // find it in the code, and scroll there when it is a different element
178
+ renderLayers();
179
+ renderInspector();
180
+ renderFloat();
181
+ } else if (m.type === "body") {
182
+ S.pageBody = m.info; // the page's styling (its body), shown under the page panel
183
+ if (!S.selected) renderInspector();
184
+ } else if (m.type === "frameClick") {
185
+ selectFrame(); // the empty part of the design was clicked: the frame, with its handles and the page panel
186
+ } else if (m.type === "hoverFrame") {
187
+ const en = wraps.get(S.curKey);
188
+ if (en) en.wrap.classList.toggle("bodyhover", Boolean(m.on));
189
+ } else if (m.type === "convert") {
190
+ convertGesture(m.items || []);
191
+ } else if (m.type === "key") {
192
+ handleKey(m);
193
+ }
194
+ });
195
+ }