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,161 @@
1
+ import { host } from "../host.js";
2
+ import { $, S, collapsed, data, esc, send } from "./base.js";
3
+ import { fitTo, pageGroupOf, view } from "./plane.js";
4
+ import { activate, deletePage, docDirty, docs, renameFrame, selectFrame } from "./designs.js";
5
+ import { PAGE_ICON, parsePath, renderLayers } from "./layers.js";
6
+ import { cmd } from "./frame-messages.js";
7
+
8
+ // ── Pages section (left panel) ───────────────────────────────────────────────
9
+ /** The project and the current page, always visible above the Pages list. */
10
+ function renderDetailsBlock() {
11
+ const pr = $("leftProject");
12
+ const ARROW = '<svg width="11" height="11" 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>';
13
+ if (data.project) {
14
+ pr.href = (host.projectUrl && host.projectUrl(data.project.id)) || "#";
15
+ pr.innerHTML = `<span class="truncate">${esc(data.project.name)}</span><span class="sub2">${ARROW}</span>`;
16
+ } else {
17
+ pr.href = "#";
18
+ pr.innerHTML = '<span class="mut truncate">No project</span>';
19
+ }
20
+ }
21
+
22
+ export const PAGE_EDIT = '<button type="button" data-pedit class="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded text-neutral-500 hover:bg-white/70" title="Page details: name, category, tags, visibility"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg></button>';
23
+ /** The trash at the end of a page's row (faint until the row is pointed at); a project keeps at least one page. */
24
+ export const pageDelete = (d, active, count) => (count > 1 ? `<button type="button" data-pdel="${d.key}" class="layer-act ${active ? "" : "ml-auto"}" title="Delete this page"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/></svg></button>` : "");
25
+ /** A heading in a list of pages where the group changes ("Public site", then "Customer area"). `state.prev` is the group of the page before. */
26
+ export const headingState = { prev: "" };
27
+ export function groupHeading(d) {
28
+ const g = pageGroupOf(d.key);
29
+ const html = g !== headingState.prev && (g || headingState.prev) ? `<div class="group-head">${esc(g || "No group")}</div>` : "";
30
+ headingState.prev = g;
31
+ return html;
32
+ }
33
+ export function renderPagesList() {
34
+ const el = $("pagesList");
35
+ if (!S.leftOpen) return;
36
+ renderDetailsBlock();
37
+ if (el.querySelector(".frename")) return; // not while a name is being typed
38
+ const list = [...docs.values()];
39
+ const tops = list.filter((d) => d.kind !== "section" || !list.some((p) => p.key === d.parent));
40
+ headingState.prev = "";
41
+ const row = (d, depth) => {
42
+ const active = d.key === S.curKey;
43
+ const title = (active ? data.title : d.meta.title) || "Untitled page";
44
+ return `<div class="layer-row cursor-pointer gap-1.5 px-1 ${active ? "is-selected" : ""}" style="padding-left:${depth * 12 + 8}px" data-page="${d.key}" title="Double-click to rename">${d.kind === "section" ? '<span class="shrink-0 font-mono text-[11px] text-neutral-500">section</span>' : PAGE_ICON}<span class="truncate">${esc(title)}</span>${docDirty(d) ? '<i class="dirty-dot" title="Unsaved changes"></i>' : ""}${active ? PAGE_EDIT : ""}${pageDelete(d, active, list.length)}</div>`;
45
+ };
46
+ el.innerHTML = tops.map((d) => groupHeading(d) + row(d, 0) + list.filter((c) => c.kind === "section" && c.parent === d.key).map((c) => row(c, 1)).join("")).join("");
47
+ }
48
+
49
+ let dragPath = null;
50
+ const clearDrop = () => document.querySelectorAll(".drop-before,.drop-after,.drop-inside").forEach((r) => r.classList.remove("drop-before", "drop-after", "drop-inside"));
51
+ const dropZone = (e, row) => { const r = row.getBoundingClientRect(); const y = (e.clientY - r.top) / r.height; return y < 0.28 ? "before" : y > 0.72 ? "after" : "inside"; };
52
+ // an element can't be dropped on itself or inside what it contains
53
+ const badDrop = (target) => dragPath === target || (dragPath !== "" && target.startsWith(`${dragPath}.`));
54
+
55
+ /** Registers this part's listeners and does its first draw (called once, in order, by editor.js). */
56
+ export function init() {
57
+ $("pagesList").addEventListener("click", (e) => {
58
+ if (!e.target.closest("[data-pedit]")) return;
59
+ e.stopImmediatePropagation();
60
+ if (S.mode === "edit") $("detailsBtn").click(); // not in Interact
61
+ }, true);
62
+ $("leftProject").addEventListener("click", (e) => { if (!data.project) { e.preventDefault(); $("detailsBtn").click(); } });
63
+ $("pagesList").addEventListener("click", (e) => {
64
+ const del = e.target.closest("[data-pdel]");
65
+ if (!del) return;
66
+ e.stopImmediatePropagation();
67
+ deletePage(del.dataset.pdel);
68
+ }, true);
69
+ $("pagesList").addEventListener("click", (e) => {
70
+ const row = e.target.closest("[data-page]");
71
+ if (!row || e.target.closest(".frename")) return;
72
+ S.viewTouched = true;
73
+ activate(row.dataset.page);
74
+ fitTo([row.dataset.page], Math.max(view.z, 0.5));
75
+ });
76
+ $("pagesList").addEventListener("dblclick", (e) => {
77
+ const row = e.target.closest("[data-page]");
78
+ if (S.mode !== "edit" || !row || e.target.closest(".frename, [data-pedit]")) return; // not in Interact
79
+ const key = row.dataset.page;
80
+ const d = docs.get(key);
81
+ const current = (key === S.curKey ? data.title : d.meta.title) || "";
82
+ const label = row.querySelector("span.truncate");
83
+ const input = document.createElement("input");
84
+ input.className = "frename";
85
+ input.style.cssText = "min-width:0;width:100%;height:20px;font-size:12px";
86
+ input.value = current;
87
+ input.maxLength = 80;
88
+ label.replaceWith(input);
89
+ input.focus();
90
+ input.select();
91
+ let done = false;
92
+ const finish = (save) => {
93
+ if (done) return;
94
+ done = true;
95
+ const name = input.value.trim();
96
+ input.remove();
97
+ renderPagesList();
98
+ if (save && name && name !== current) renameFrame(key, name, current);
99
+ };
100
+ input.addEventListener("keydown", (ev) => { ev.stopPropagation(); if (ev.key === "Enter") { ev.preventDefault(); finish(true); } if (ev.key === "Escape") { ev.preventDefault(); finish(false); } });
101
+ input.addEventListener("blur", () => finish(true));
102
+ });
103
+ $("pagesAdd").addEventListener("click", () => $("newDesignBtn").click());
104
+
105
+ $("layers").addEventListener("click", (e) => {
106
+ const fr = e.target.closest("[data-frame]");
107
+ if (fr) {
108
+ if (fr.dataset.frame !== S.curKey) activate(fr.dataset.frame);
109
+ return selectFrame();
110
+ }
111
+ const caret = e.target.closest("[data-caret]");
112
+ if (caret) {
113
+ const k = caret.dataset.caret;
114
+ collapsed.has(k) ? collapsed.delete(k) : collapsed.add(k);
115
+ return renderLayers();
116
+ }
117
+ const eye = e.target.closest("[data-eye]"), lock = e.target.closest("[data-lock]");
118
+ if (eye) return cmd({ cmd: "toggleHidden", path: parsePath(eye.dataset.eye) });
119
+ if (lock) return cmd({ cmd: "toggleLock", path: parsePath(lock.dataset.lock) });
120
+ const row = e.target.closest("[data-path]");
121
+ if (row) { send({ cmd: "select", path: parsePath(row.dataset.path) }); send({ cmd: "scrollTo", path: parsePath(row.dataset.path) }); }
122
+ });
123
+ $("layers").addEventListener("mouseover", (e) => { const row = e.target.closest("[data-path]"); if (row) send({ cmd: "hover", path: parsePath(row.dataset.path) }); });
124
+ $("layers").addEventListener("mouseleave", () => send({ cmd: "hover", path: null }));
125
+ $("layers").addEventListener("dragstart", (e) => {
126
+ const row = e.target.closest("[data-path]");
127
+ if (!row) return;
128
+ dragPath = row.dataset.path;
129
+ e.dataTransfer.effectAllowed = "move";
130
+ e.dataTransfer.setData("text/plain", dragPath);
131
+ // a small label follows the pointer (not a smeared copy of the row), and the row itself dims so it is clear what is moving
132
+ const chip = document.createElement("div");
133
+ chip.textContent = row.textContent.replace(/\s+/g, " ").trim();
134
+ chip.style.cssText = "position:fixed;top:-100px;left:-100px;padding:3px 10px;border-radius:6px;background:rgba(17,17,17,.7);color:#fff;font:12px system-ui,sans-serif;white-space:nowrap";
135
+ document.body.appendChild(chip);
136
+ e.dataTransfer.setDragImage(chip, -14, -10); // offset to the lower right, so the pointer and the drop line stay visible
137
+ setTimeout(() => { chip.remove(); row.classList.add("is-dragging"); }, 0);
138
+ });
139
+ $("layers").addEventListener("dragover", (e) => {
140
+ const row = e.target.closest("[data-path]");
141
+ if (!row || dragPath === null) return;
142
+ clearDrop();
143
+ if (badDrop(row.dataset.path)) return;
144
+ e.preventDefault();
145
+ const zone = dropZone(e, row);
146
+ if (row.dataset.path === "" && zone !== "inside") return;
147
+ row.style.setProperty("--ind", row.style.paddingLeft || "0px"); // the insertion line starts at the row's own indent, so the new depth is readable
148
+ row.classList.add(`drop-${zone}`);
149
+ });
150
+ $("layers").addEventListener("dragleave", (e) => { if (!e.relatedTarget || !$("layers").contains(e.relatedTarget)) clearDrop(); });
151
+ $("layers").addEventListener("drop", (e) => {
152
+ const row = e.target.closest("[data-path]");
153
+ if (!row || dragPath === null || badDrop(row.dataset.path)) return;
154
+ e.preventDefault();
155
+ const zone = row.dataset.path === "" ? "inside" : dropZone(e, row);
156
+ clearDrop();
157
+ cmd({ cmd: "moveTo", path: parsePath(dragPath), targetPath: parsePath(row.dataset.path), position: zone });
158
+ dragPath = null;
159
+ });
160
+ $("layers").addEventListener("dragend", () => { clearDrop(); dragPath = null; document.querySelectorAll(".is-dragging").forEach((r) => r.classList.remove("is-dragging")); });
161
+ }
@@ -0,0 +1,47 @@
1
+ import { $ } from "./base.js";
2
+ import { layout } from "./plane.js";
3
+
4
+ // ── Side panels: drag the edge to widen them (never narrower than they start); the width is remembered, a double-click resets it ─────────────
5
+ const MIN = 240;
6
+ const maxWidth = () => Math.max(MIN, Math.min(640, Math.round(innerWidth * 0.45)));
7
+
8
+ function resizable(panel, side, key) {
9
+ panel.classList.add("relative");
10
+ const handle = document.createElement("div");
11
+ handle.className = `rz ${side}`;
12
+ handle.title = "Drag to resize, double-click to reset";
13
+ panel.append(handle);
14
+ const set = (w) => { panel.style.width = `${Math.round(Math.min(maxWidth(), Math.max(MIN, w)))}px`; };
15
+ try { const w = Number(localStorage.getItem(key)); if (w) set(w); } catch { /* storage is optional */ }
16
+ let drag = null;
17
+ handle.addEventListener("pointerdown", (e) => {
18
+ e.preventDefault();
19
+ drag = { x: e.clientX, w: panel.offsetWidth };
20
+ handle.setPointerCapture(e.pointerId); // keeps the drag over the design's iframe too
21
+ handle.classList.add("on");
22
+ document.body.style.cursor = "col-resize";
23
+ });
24
+ handle.addEventListener("pointermove", (e) => {
25
+ if (!drag) return;
26
+ set(drag.w + (side === "l" ? e.clientX - drag.x : drag.x - e.clientX)); // the left panel grows to the right, the right one to the left
27
+ layout();
28
+ });
29
+ const end = (e) => {
30
+ if (!drag) return;
31
+ drag = null;
32
+ handle.classList.remove("on");
33
+ document.body.style.cursor = "";
34
+ try { handle.releasePointerCapture(e.pointerId); } catch { /* already released */ }
35
+ try { localStorage.setItem(key, String(panel.offsetWidth)); } catch { /* storage is optional */ }
36
+ };
37
+ handle.addEventListener("pointerup", end);
38
+ handle.addEventListener("pointercancel", end);
39
+ handle.addEventListener("lostpointercapture", end);
40
+ handle.addEventListener("dblclick", () => { set(MIN); try { localStorage.removeItem(key); } catch { /* storage is optional */ } layout(); });
41
+ }
42
+
43
+ /** Registers this part's listeners (called once, in order, by editor.js). */
44
+ export function init() {
45
+ resizable($("leftPanel"), "l", "wc:leftW");
46
+ resizable($("panel"), "r", "wc:panelW");
47
+ }
@@ -0,0 +1,132 @@
1
+ import { computeLayout } from "@windcraft/core/board-layout.js";
2
+ import { $, DEFAULT_W, S, data, frame, send } from "./base.js";
3
+ import { docs, wraps } from "./designs.js";
4
+ import { placeFloat, renderFloat, renderFrameSel } from "./frame-handles.js";
5
+ import { renderInspector } from "./inspector.js";
6
+
7
+ // ── The plane: every design in the project on one infinite surface ───────────
8
+ const MIN_Z = 0.02, MAX_Z = 256; // 2% to 25600%
9
+ const GRID_FROM = 4; // the pixel grid appears at 400%
10
+ export const view = { x: 0, y: 0, z: 1 };
11
+ export const canvas = $("canvas");
12
+
13
+ export const MIN_H = 720;
14
+ // a frame has its own width, and a height that follows its content unless it was set by hand
15
+ export const widthOf = (d) => (d.board && d.board.w) || d.frameW || DEFAULT_W;
16
+ export const curWidth = () => widthOf(docs.get(S.curKey));
17
+ /** A device's screen height for a frame width (desktop, tablet, phone): the live page (Interact) is shown on a screen that size. */
18
+ export const deviceHeight = (w) => (w >= 1024 ? 800 : w >= 768 ? 1024 : 844);
19
+ export const heightOfDoc = (d) => (d.board && d.board.h) || (d.key === S.curKey
20
+ ? (S.mode === "preview" ? deviceHeight(widthOf(d)) : Math.max(MIN_H, S.frameHeight))
21
+ : Math.max(MIN_H, d.height || 0));
22
+ export const canvasSize = () => [canvas.clientWidth, canvas.clientHeight];
23
+
24
+ /** The group a design belongs to in its project ("Public site"), or "". */
25
+ export const pageGroupOf = (key) => ((key === S.curKey ? data.group : (docs.get(key) || {}).meta?.group) || "");
26
+
27
+ export function positions() {
28
+ return computeLayout([...docs.values()].map((d) => ({ id: d.key, kind: d.kind, parent: d.parent, group: pageGroupOf(d.key), board: d.board })), { frameW: DEFAULT_W, widthOf: (it) => widthOf(docs.get(it.id)), heightOf: (it) => heightOfDoc(docs.get(it.id)) });
29
+ }
30
+
31
+ export function applyView() {
32
+ $("plane").style.transform = `translate(${view.x}px, ${view.y}px) scale(${view.z})`;
33
+ $("plane").style.setProperty("--iz", String(1 / view.z));
34
+ // dot grid: always about 16-32 screen pixels apart (the spacing doubles or halves as you zoom), and whole pixels so the dots stay crisp
35
+ let dot = 24 * view.z;
36
+ while (dot < 16) dot *= 2;
37
+ while (dot >= 32) dot /= 2;
38
+ dot = Math.round(dot);
39
+ canvas.style.backgroundSize = `${dot}px ${dot}px`;
40
+ canvas.style.backgroundPosition = `${Math.round(view.x)}px ${Math.round(view.y)}px`;
41
+ updatePixelGrid();
42
+ if (S.sentZoom !== view.z) { S.sentZoom = view.z; send({ cmd: "view", zoom: view.z }); }
43
+ renderFrameSel(); // the selection is in screen pixels, so it is redrawn on every pan and zoom
44
+ syncZoomSel();
45
+ placeFloat();
46
+ }
47
+
48
+ /** From 400% every design pixel gets a hairline, over the designs and under the selection, like Figma's pixel grid. */
49
+ function updatePixelGrid() {
50
+ const g = $("pgrid");
51
+ if (view.z < GRID_FROM) return g.classList.add("hidden");
52
+ g.classList.remove("hidden");
53
+ // cover just what is on screen, in the plane's own units, snapped to whole pixels
54
+ const x0 = Math.floor(-view.x / view.z), y0 = Math.floor(-view.y / view.z);
55
+ Object.assign(g.style, { left: `${x0}px`, top: `${y0}px`, width: `${Math.ceil(canvas.clientWidth / view.z) + 2}px`, height: `${Math.ceil(canvas.clientHeight / view.z) + 2}px` });
56
+ }
57
+
58
+ function syncZoomSel() {
59
+ const sel = $("zoomSel");
60
+ const hit = [...sel.options].find((o) => Number(o.value) && Math.abs(Number(o.value) - view.z) < 0.005);
61
+ if (hit) { sel.value = hit.value; return; }
62
+ let custom = [...sel.options].find((o) => o.value === "custom");
63
+ if (!custom) { custom = new Option("", "custom"); sel.add(custom); }
64
+ custom.textContent = `${Math.round(view.z * 100)}%`;
65
+ sel.value = "custom";
66
+ placePills(); // the zoom label changes the right group's width
67
+ }
68
+
69
+ export function zoomAt(factor, cx, cy) {
70
+ const z = Math.min(MAX_Z, Math.max(MIN_Z, view.z * factor));
71
+ const k = z / view.z;
72
+ view.x = cx - (cx - view.x) * k;
73
+ view.y = cy - (cy - view.y) * k;
74
+ view.z = z;
75
+ applyView();
76
+ }
77
+
78
+ export function fitTo(keys, maxZ = 1) {
79
+ const pos = positions();
80
+ const list = keys.filter((k) => pos[k]);
81
+ if (!list.length) return;
82
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
83
+ list.forEach((k) => {
84
+ const p = pos[k];
85
+ x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y - 30);
86
+ x1 = Math.max(x1, p.x + widthOf(docs.get(k))); y1 = Math.max(y1, p.y + heightOfDoc(docs.get(k)));
87
+ });
88
+ const [W, H] = canvasSize();
89
+ const z = Math.min(maxZ, Math.max(MIN_Z, Math.min((W - 120) / (x1 - x0), (H - 120) / (y1 - y0))));
90
+ view.z = z;
91
+ view.x = (W - (x1 - x0) * z) / 2 - x0 * z;
92
+ view.y = (H - (y1 - y0) * z) / 2 - y0 * z;
93
+ applyView();
94
+ }
95
+
96
+ /** Sizes and places every frame. The live frame and the previews all follow the same positions. */
97
+ export function layout() {
98
+ if (!S.selected) setTimeout(renderInspector, 0); // the page panel (shown while no element is selected) follows its size and position
99
+ const pos = positions();
100
+ docs.forEach((d, key) => {
101
+ const en = wraps.get(key);
102
+ const p = pos[key];
103
+ if (!en || !p) return;
104
+ const h = heightOfDoc(d);
105
+ Object.assign(en.wrap.style, { left: `${p.x}px`, top: `${p.y}px`, width: `${widthOf(d)}px`, height: `${h}px` });
106
+ // the live iframe never changes parent (that would reload it); it just sits over the active frame (over the page being
107
+ // left, while a switch is under way)
108
+ if (!S.switching) S.frameAt = S.curKey;
109
+ if (key === S.frameAt) Object.assign(frame.style, { left: `${p.x}px`, top: `${p.y}px`, width: `${widthOf(d)}px`, height: `${h}px` });
110
+ });
111
+ placePills();
112
+ applyView();
113
+ renderFloat();
114
+ }
115
+
116
+ /** The two tool groups share the top row; when the canvas is too narrow for both side by side, the right one drops under the left. */
117
+ export function placePills() {
118
+ const left = $("pillL"), right = $("pillR");
119
+ right.style.top = "";
120
+ left.style.maxWidth = "none";
121
+ left.classList.remove("tight");
122
+ const natural = left.offsetWidth; // the left group at its full width
123
+ left.style.maxWidth = "calc(100% - 24px)"; // beyond that the design name shrinks...
124
+ if (canvas.clientWidth < natural - 100) left.classList.add("tight"); // ...and finally disappears
125
+ if (canvas.clientWidth < natural + right.offsetWidth + 12 * 3) right.style.top = "60px";
126
+ }
127
+
128
+ export function handleWheel({ dx, dy, zoomKey, cx, cy }) {
129
+ S.viewTouched = true;
130
+ if (zoomKey) zoomAt(Math.exp(-dy * 0.01), cx, cy);
131
+ else { view.x -= dx; view.y -= dy; applyView(); }
132
+ }
@@ -0,0 +1,52 @@
1
+ import { host } from "../host.js";
2
+ import { createPresenter } from "../presenter.js";
3
+ import { $, S, data, model } from "./base.js";
4
+ import { anyDirty, docs } from "./designs.js";
5
+ import { heightOfDoc, widthOf } from "./plane.js";
6
+
7
+ // ── Preview (the play button) ───────────────────────────────────────────────────────────────────────────────────
8
+ // With an app that has a prototype page (host.previewUrl), the preview is that page, in its own tab: the same page a shared link
9
+ // opens, as Figma's prototype is its own view. Without one (the demo, an embedded editor, a design not saved yet) the same viewer
10
+ // opens over the editor, showing what is on the canvas now.
11
+ // (The Interact tool is different: it runs the one page you are editing, on the canvas.)
12
+
13
+ let viewer = null;
14
+
15
+ /** The pages to show: whole pages in the order of the Pages list (parts of a page are left out); the components when there are no pages. */
16
+ function pagesToShow() {
17
+ const all = [...docs.values()];
18
+ const tops = all.filter((d) => d.kind !== "section" || !all.some((p) => p.key === d.parent));
19
+ const pages = tops.filter((d) => d.kind === "page");
20
+ return pages.length ? pages : tops;
21
+ }
22
+
23
+ const sourceOf = (d) => {
24
+ if (d.key === S.curKey) return { ...S.parsed, markup: model.markup }; // what is on the canvas now, saved or not
25
+ return d.loaded ? { ...d.parsed, markup: d.markup } : d.source;
26
+ };
27
+
28
+ export function openPreview() {
29
+ if (viewer) return;
30
+ const cur = docs.get(S.curKey);
31
+ if (host.previewUrl && data.project && cur && !S.isNew && cur.meta && cur.meta.id) {
32
+ if (anyDirty() && !confirm("The preview shows the saved version of your pages. Open it without your unsaved changes?")) return;
33
+ window.open(host.previewUrl({ projectId: data.project.id, designId: cur.meta.id }), "_blank", "noopener");
34
+ return;
35
+ }
36
+ const list = pagesToShow();
37
+ if (!list.length) return;
38
+ viewer = createPresenter({
39
+ pages: list.map((d) => ({ key: d.key, title: (d.key === S.curKey ? data.title : d.meta.title) || "Untitled page", width: widthOf(d), height: heightOfDoc(d), source: () => sourceOf(d) })),
40
+ start: Math.max(0, list.findIndex((d) => d.key === S.curKey)), // starts on the page you are on
41
+ onClose: closePreview,
42
+ });
43
+ $("playBtn").classList.add("on");
44
+ }
45
+
46
+ export function closePreview() {
47
+ if (!viewer) return;
48
+ viewer.destroy();
49
+ viewer = null;
50
+ $("playBtn").classList.remove("on");
51
+ }
52
+ export const previewOpen = () => Boolean(viewer);