windcraft-editor 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -2
- package/core/board-layout.js +71 -0
- package/core/class-groups.js +210 -0
- package/core/design-spec.js +303 -0
- package/core/exporters.js +156 -0
- package/core/pages.js +39 -0
- package/core/prop-tailwind.js +158 -0
- package/core/split-sections.js +99 -0
- package/core/theme.js +87 -0
- package/design/agent.js +2118 -0
- package/design/classes-section.js +103 -0
- package/design/code-editor.js +293 -0
- package/design/code-view.js +158 -0
- package/design/color-picker.js +107 -0
- package/design/config.js +5 -0
- package/design/controls.js +132 -0
- package/design/css-props.js +111 -0
- package/design/design-tab.js +120 -0
- package/design/device-frame.js +40 -0
- package/design/editor/add-menu.js +151 -0
- package/design/editor/api.js +47 -0
- package/design/editor/base.js +55 -0
- package/design/editor/code-panel.js +206 -0
- package/design/editor/color-ui.js +135 -0
- package/design/editor/designs.js +606 -0
- package/design/editor/docs.js +121 -0
- package/design/editor/frame-handles.js +223 -0
- package/design/editor/frame-messages.js +195 -0
- package/design/editor/inspector.js +461 -0
- package/design/editor/layers.js +61 -0
- package/design/editor/pages-panel.js +161 -0
- package/design/editor/panel-resize.js +47 -0
- package/design/editor/plane.js +132 -0
- package/design/editor/present.js +52 -0
- package/design/editor/toolbar.js +267 -0
- package/design/editor.js +47 -0
- package/design/figma-sections.js +440 -0
- package/design/float-toolbar.js +163 -0
- package/design/group-controls.js +52 -0
- package/design/host.js +22 -0
- package/design/panel-model.js +130 -0
- package/design/panel-state.js +40 -0
- package/design/panel.js +212 -0
- package/design/presenter.js +312 -0
- package/design/prop-sections.js +18 -0
- package/design/selection-overlay.js +93 -0
- package/design/store.js +29 -0
- package/design/style-sections.js +315 -0
- package/design/tailwind-classes.js +65 -0
- package/embed-frame.js +43 -0
- package/embed.html +15 -0
- package/embed.js +60 -0
- package/index.js +62 -0
- package/package.json +50 -5
- package/playground/component-source.js +124 -0
- package/styles.css +402 -0
- package/template.js +247 -0
- package/vendor/codemirror.js +24 -0
- package/vendor/preact-htm.js +1 -0
- package/vendor/prettier.js +171 -0
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import { parseComponentSource, buildPreviewDocument } from "../../playground/component-source.js";
|
|
2
|
+
import { host } from "../host.js";
|
|
3
|
+
import { normalizeGroup } from "@windcraft/core/pages.js";
|
|
4
|
+
import { GAP_X } from "@windcraft/core/board-layout.js";
|
|
5
|
+
import { DEVICES } from "../float-toolbar.js";
|
|
6
|
+
import { $, S, collapsed, data, esc, frame, model, send } from "./base.js";
|
|
7
|
+
import { MIN_H, applyView, canvas, fitTo, heightOfDoc, layout, pageGroupOf, placePills, positions, view, widthOf } from "./plane.js";
|
|
8
|
+
import { PAGE_EDIT, groupHeading, headingState, renderPagesList } from "./pages-panel.js";
|
|
9
|
+
import { loadFrame, updateChrome } from "./docs.js";
|
|
10
|
+
import { renderLayers } from "./layers.js";
|
|
11
|
+
import { renderFloat } from "./frame-handles.js";
|
|
12
|
+
import { renderInspector } from "./inspector.js";
|
|
13
|
+
import { updateCodeHighlight } from "./code-panel.js";
|
|
14
|
+
import { DEFAULT_CATEGORY, setTool } from "./toolbar.js";
|
|
15
|
+
|
|
16
|
+
// ── Many designs on one plane ────────────────────────────────────────────────
|
|
17
|
+
// One design is live (the iframe you edit). The others are previews in the same project; click one to switch to it.
|
|
18
|
+
// Each design keeps its own history, so switching never loses edits. Save writes every changed design.
|
|
19
|
+
const META_KEYS = ["id", "title", "version", "category", "description", "tags", "isPublished", "useAsReference", "project", "group"];
|
|
20
|
+
const takeMeta = () => Object.fromEntries(META_KEYS.map((k) => [k, data[k]]));
|
|
21
|
+
const applyMeta = (m) => META_KEYS.forEach((k) => { data[k] = m[k]; });
|
|
22
|
+
|
|
23
|
+
export const docs = new Map();
|
|
24
|
+
export const wraps = new Map();
|
|
25
|
+
let draftCount = 0;
|
|
26
|
+
let planeProject = data.project || null;
|
|
27
|
+
|
|
28
|
+
function stashActive() {
|
|
29
|
+
const d = docs.get(S.curKey);
|
|
30
|
+
Object.assign(d, { meta: takeMeta(), parsed: S.parsed, markup: model.markup, history: S.history, hIndex: S.hIndex, isNew: S.isNew, savedMarkup: S.savedMarkup, height: S.frameHeight, loaded: true });
|
|
31
|
+
}
|
|
32
|
+
export function loadActiveFromStash() {
|
|
33
|
+
const d = docs.get(S.curKey);
|
|
34
|
+
applyMeta(d.meta);
|
|
35
|
+
S.isNew = d.isNew;
|
|
36
|
+
S.savedMarkup = d.savedMarkup;
|
|
37
|
+
}
|
|
38
|
+
function ensureLoaded(d) {
|
|
39
|
+
if (d.loaded) return;
|
|
40
|
+
d.parsed = parseComponentSource(d.source);
|
|
41
|
+
d.markup = d.parsed.markup;
|
|
42
|
+
d.history = [];
|
|
43
|
+
d.hIndex = -1;
|
|
44
|
+
d.savedMarkup = d.markup;
|
|
45
|
+
d.loaded = true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const docDirty = (d) => d.loaded && (d.isNew ? d.markup.trim() !== "" : d.markup !== d.savedMarkup);
|
|
49
|
+
export function anyDirty() {
|
|
50
|
+
stashActive();
|
|
51
|
+
return [...docs.values()].some(docDirty);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function remapKey(oldKey, newKey) {
|
|
55
|
+
if (oldKey === newKey) return;
|
|
56
|
+
const d = docs.get(oldKey);
|
|
57
|
+
if (!d) return;
|
|
58
|
+
d.key = newKey;
|
|
59
|
+
docs.delete(oldKey);
|
|
60
|
+
docs.set(newKey, d);
|
|
61
|
+
const en = wraps.get(oldKey);
|
|
62
|
+
if (en) { wraps.delete(oldKey); wraps.set(newKey, en); en.wrap.dataset.key = newKey; }
|
|
63
|
+
if (S.curKey === oldKey) S.curKey = newKey;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// frames
|
|
67
|
+
const io = new IntersectionObserver((entries) => entries.forEach((e) => {
|
|
68
|
+
if (e.isIntersecting) mountGhost(e.target.dataset.key);
|
|
69
|
+
}), { root: canvas, rootMargin: "600px" });
|
|
70
|
+
|
|
71
|
+
function makeWrap(key) {
|
|
72
|
+
const wrap = document.createElement("div");
|
|
73
|
+
wrap.className = "fwrap";
|
|
74
|
+
wrap.dataset.key = key;
|
|
75
|
+
wrap.innerHTML = '<div class="flabel drag" data-label></div><div class="frame"></div>';
|
|
76
|
+
$("frames").appendChild(wrap);
|
|
77
|
+
const en = { wrap, box: wrap.querySelector(".frame"), label: wrap.querySelector(".flabel"), ghost: null, shield: null };
|
|
78
|
+
wraps.set(key, en);
|
|
79
|
+
io.observe(wrap);
|
|
80
|
+
return en;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function mountGhost(key, force) {
|
|
84
|
+
const en = wraps.get(key);
|
|
85
|
+
const d = docs.get(key);
|
|
86
|
+
if (!en || !d || key === S.curKey || (en.ghost && !force)) return;
|
|
87
|
+
const p = d.loaded ? { ...d.parsed, markup: d.markup } : parseComponentSource(d.source);
|
|
88
|
+
if (!en.ghost) {
|
|
89
|
+
en.ghost = document.createElement("iframe");
|
|
90
|
+
en.ghost.setAttribute("sandbox", "allow-scripts");
|
|
91
|
+
en.ghost.setAttribute("tabindex", "-1");
|
|
92
|
+
en.ghost.style.cssText = "position:absolute;left:0;top:0;width:100%;height:100%;border:0;pointer-events:none;background:#fff;";
|
|
93
|
+
en.box.prepend(en.ghost);
|
|
94
|
+
}
|
|
95
|
+
en.ghost.title = d.meta.title || "Design";
|
|
96
|
+
en.ghost.srcdoc = buildPreviewDocument(p, { width: widthOf(d) });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function setRole(key) {
|
|
100
|
+
const en = wraps.get(key);
|
|
101
|
+
if (!en) return;
|
|
102
|
+
const active = key === S.curKey;
|
|
103
|
+
en.wrap.classList.toggle("active", active);
|
|
104
|
+
en.wrap.classList.toggle("ghost", !active);
|
|
105
|
+
if (active) {
|
|
106
|
+
if (en.ghost && !S.switching) { en.ghost.remove(); en.ghost = null; } // while switching it stays until the live frame is ready
|
|
107
|
+
if (en.shield) { en.shield.remove(); en.shield = null; }
|
|
108
|
+
} else if (!en.shield) {
|
|
109
|
+
en.shield = document.createElement("div");
|
|
110
|
+
en.shield.className = "gshield";
|
|
111
|
+
en.shield.dataset.shield = "";
|
|
112
|
+
en.shield.style.cursor = "pointer";
|
|
113
|
+
en.box.appendChild(en.shield);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function refreshLabels() {
|
|
118
|
+
docs.forEach((d, key) => {
|
|
119
|
+
const en = wraps.get(key);
|
|
120
|
+
if (!en || en.renaming) return;
|
|
121
|
+
const active = key === S.curKey;
|
|
122
|
+
const title = active ? data.title : d.meta.title;
|
|
123
|
+
const ver = active ? data.version : d.meta.version;
|
|
124
|
+
const state = (active ? S.isNew : d.isNew) ? "new" : d.kind === "section" ? "section" : `v${ver}`;
|
|
125
|
+
const group = pageGroupOf(key);
|
|
126
|
+
en.label.innerHTML = `${group ? `<i class="fgroup">${esc(group)} / </i>` : ""}${esc(title || "Untitled page")}<span>${state}</span>${docDirty(d) ? '<i class="dirty-dot" title="Unsaved changes"></i>' : ""}`;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let layoutQueued = false;
|
|
131
|
+
export function scheduleLayout() {
|
|
132
|
+
if (layoutQueued) return;
|
|
133
|
+
layoutQueued = true;
|
|
134
|
+
requestAnimationFrame(() => { layoutQueued = false; layout(); });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function buildWraps() {
|
|
138
|
+
docs.forEach((_, key) => makeWrap(key));
|
|
139
|
+
docs.forEach((_, key) => setRole(key));
|
|
140
|
+
refreshLabels();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// the designs list
|
|
144
|
+
export function renderDesigns() {
|
|
145
|
+
headingState.prev = "";
|
|
146
|
+
const list = [...docs.values()];
|
|
147
|
+
const pages = list.filter((d) => d.kind !== "section" || !list.some((p) => p.key === d.parent));
|
|
148
|
+
const row = (d, depth) => {
|
|
149
|
+
const active = d.key === S.curKey;
|
|
150
|
+
const title = (active ? data.title : d.meta.title) || "Untitled page";
|
|
151
|
+
return `<div class="layer-row cursor-pointer gap-1 px-1 ${active ? "is-selected" : ""}" style="padding-left:${depth * 14 + 6}px" data-design="${d.key}">
|
|
152
|
+
<span class="shrink-0 font-mono text-[10px] ${active ? "" : "text-neutral-500"}">${d.kind === "section" ? "sec" : "page"}</span>
|
|
153
|
+
<span class="truncate">${esc(title)}</span>${docDirty(d) ? '<i class="dirty-dot"></i>' : ""}${active ? PAGE_EDIT : ""}</div>`;
|
|
154
|
+
};
|
|
155
|
+
renderPagesList();
|
|
156
|
+
$("designsCount").textContent = String(list.length);
|
|
157
|
+
$("designsName").textContent = (data.project && data.project.name) || data.title || "Untitled page";
|
|
158
|
+
placePills();
|
|
159
|
+
$("designs").innerHTML = pages.map((p) => groupHeading(p) + row(p, 0) + list.filter((s) => s.kind === "section" && s.parent === p.key).map((s) => row(s, 1)).join("")).join("");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Makes another design the live one. The current one stays on the plane as a preview with its edits. */
|
|
163
|
+
export function activate(key) {
|
|
164
|
+
if (key === S.curKey || !docs.has(key)) return;
|
|
165
|
+
stashActive();
|
|
166
|
+
const prev = docs.get(S.curKey);
|
|
167
|
+
const next = docs.get(key);
|
|
168
|
+
ensureLoaded(next);
|
|
169
|
+
applyMeta(next.meta);
|
|
170
|
+
S.parsed = next.parsed;
|
|
171
|
+
model.markup = next.markup;
|
|
172
|
+
S.history = next.history;
|
|
173
|
+
S.hIndex = next.hIndex;
|
|
174
|
+
S.isNew = next.isNew;
|
|
175
|
+
S.savedMarkup = next.savedMarkup;
|
|
176
|
+
S.frameHeight = Math.max(MIN_H, next.height || 0);
|
|
177
|
+
S.lastKey = "";
|
|
178
|
+
S.selected = null;
|
|
179
|
+
S.tree = null;
|
|
180
|
+
collapsed.clear();
|
|
181
|
+
const oldKey = S.curKey;
|
|
182
|
+
mountGhost(key); // snapshots are made as pages scroll into view: one opened from the Pages list may not have one yet
|
|
183
|
+
S.curKey = key;
|
|
184
|
+
S.frameSel = null;
|
|
185
|
+
// Switching pages never shows one page's design in another's place: the live frame stays over the page being left until its
|
|
186
|
+
// snapshot has painted, then loads the new design out of sight while the new page shows its own snapshot; once the new
|
|
187
|
+
// design reports ready, the frame moves over and the snapshot goes (see the "ready" message).
|
|
188
|
+
const token = ++S.switchToken;
|
|
189
|
+
S.switching = true;
|
|
190
|
+
S.handingOver = true;
|
|
191
|
+
setRole(key);
|
|
192
|
+
setRole(oldKey);
|
|
193
|
+
mountGhost(oldKey, true);
|
|
194
|
+
host.showDesign(next.isNew ? null : next.meta.id);
|
|
195
|
+
document.dispatchEvent(new CustomEvent("design-switched"));
|
|
196
|
+
let started = false;
|
|
197
|
+
const start = () => {
|
|
198
|
+
if (started || token !== S.switchToken) return;
|
|
199
|
+
started = true;
|
|
200
|
+
S.handingOver = false;
|
|
201
|
+
frame.style.visibility = "hidden";
|
|
202
|
+
loadFrame();
|
|
203
|
+
};
|
|
204
|
+
const left = wraps.get(oldKey);
|
|
205
|
+
if (left && left.ghost) left.ghost.addEventListener("load", () => setTimeout(start, 60), { once: true }); // a moment to paint
|
|
206
|
+
setTimeout(start, 700); // a snapshot that is slow to load doesn't hold the switch up
|
|
207
|
+
renderLayers();
|
|
208
|
+
updateChrome();
|
|
209
|
+
layout();
|
|
210
|
+
} // frameAt: the page the live frame sits over
|
|
211
|
+
// the old design is still in the frame, waiting for its snapshot: what it reports isn't the new page's
|
|
212
|
+
|
|
213
|
+
// moving frames: drag a label, or drag a preview. A plain click on a preview opens it.
|
|
214
|
+
const SNAP = 8;
|
|
215
|
+
const pendingSave = new Map();
|
|
216
|
+
let saveTimer = null;
|
|
217
|
+
export async function saveBoard(positions) {
|
|
218
|
+
if (!planeProject) return;
|
|
219
|
+
try {
|
|
220
|
+
await host.saveLayout(planeProject.id, positions);
|
|
221
|
+
} catch { /* the position stays on screen; the next drag saves it again */ }
|
|
222
|
+
}
|
|
223
|
+
export function queueBoardSave(key) {
|
|
224
|
+
const d = docs.get(key);
|
|
225
|
+
if (!d || d.isNew) return;
|
|
226
|
+
pendingSave.set(key, d.board);
|
|
227
|
+
clearTimeout(saveTimer);
|
|
228
|
+
saveTimer = setTimeout(flushBoard, 500);
|
|
229
|
+
}
|
|
230
|
+
export const boardPayload = ([id, p]) => ({ id, x: p ? p.x : null, y: p ? p.y : null, w: p ? p.w ?? null : undefined, h: p ? p.h ?? null : undefined });
|
|
231
|
+
function flushBoard() {
|
|
232
|
+
if (!pendingSave.size) return;
|
|
233
|
+
const positions = [...pendingSave].map(boardPayload);
|
|
234
|
+
pendingSave.clear();
|
|
235
|
+
saveBoard(positions);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let gesture = null;
|
|
239
|
+
/** A pointer position in the live frame's own coordinates (what the in-frame agent works in). */
|
|
240
|
+
function frameEvent(e) {
|
|
241
|
+
const r = canvas.getBoundingClientRect();
|
|
242
|
+
const p = positions()[S.curKey] || { x: 0, y: 0 };
|
|
243
|
+
return { clientX: (e.clientX - r.left - view.x) / view.z - p.x, clientY: (e.clientY - r.top - view.y) / view.z - p.y, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey };
|
|
244
|
+
}
|
|
245
|
+
let lastHoverClear = 0;
|
|
246
|
+
|
|
247
|
+
/** Empty canvas with the arrow: drag a box. Frames it touches get selected. */
|
|
248
|
+
function drawMarquee(g, e) {
|
|
249
|
+
const r = canvas.getBoundingClientRect();
|
|
250
|
+
const x0 = Math.min(g.sx, e.clientX) - r.left, y0 = Math.min(g.sy, e.clientY) - r.top;
|
|
251
|
+
const m = $("marquee");
|
|
252
|
+
Object.assign(m.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` });
|
|
253
|
+
}
|
|
254
|
+
function finishMarquee(g, e) {
|
|
255
|
+
$("marquee").style.display = "none";
|
|
256
|
+
if (!g.moved) return clearSelection();
|
|
257
|
+
const r = canvas.getBoundingClientRect();
|
|
258
|
+
const toWorld = (cx, cy) => ({ x: (cx - r.left - view.x) / view.z, y: (cy - r.top - view.y) / view.z });
|
|
259
|
+
const a = toWorld(Math.min(g.sx, e.clientX), Math.min(g.sy, e.clientY)), b = toWorld(Math.max(g.sx, e.clientX), Math.max(g.sy, e.clientY));
|
|
260
|
+
const pos = positions();
|
|
261
|
+
const hit = [...docs.keys()].filter((key) => {
|
|
262
|
+
const p = pos[key], d = docs.get(key);
|
|
263
|
+
return p && p.x < b.x && p.x + widthOf(d) > a.x && p.y < b.y && p.y + heightOfDoc(d) > a.y;
|
|
264
|
+
});
|
|
265
|
+
if (!hit.length) return clearSelection();
|
|
266
|
+
const key = hit.includes(S.curKey) ? S.curKey : hit[0];
|
|
267
|
+
if (key !== S.curKey) activate(key);
|
|
268
|
+
selectFrame();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Double-click a frame's name to rename it. Enter or clicking away saves, Esc cancels. */
|
|
272
|
+
function startRename(key) {
|
|
273
|
+
const en = wraps.get(key);
|
|
274
|
+
const d = docs.get(key);
|
|
275
|
+
if (S.mode !== "edit" || !en || !d || en.renaming) return; // Interact uses the page as it is: nothing is renamed there
|
|
276
|
+
en.renaming = true;
|
|
277
|
+
const current = (key === S.curKey ? data.title : d.meta.title) || "";
|
|
278
|
+
en.label.innerHTML = `<input class="frename" value="${esc(current)}" maxlength="80" aria-label="Frame name">`;
|
|
279
|
+
const input = en.label.querySelector("input");
|
|
280
|
+
input.focus();
|
|
281
|
+
input.select();
|
|
282
|
+
let done = false;
|
|
283
|
+
const finish = (save) => {
|
|
284
|
+
if (done) return;
|
|
285
|
+
done = true;
|
|
286
|
+
en.renaming = false;
|
|
287
|
+
const name = input.value.trim();
|
|
288
|
+
refreshLabels();
|
|
289
|
+
if (save && name && name !== current) renameFrame(key, name, current);
|
|
290
|
+
};
|
|
291
|
+
input.addEventListener("keydown", (e) => {
|
|
292
|
+
e.stopPropagation();
|
|
293
|
+
if (e.key === "Enter") { e.preventDefault(); finish(true); }
|
|
294
|
+
if (e.key === "Escape") { e.preventDefault(); finish(false); }
|
|
295
|
+
});
|
|
296
|
+
input.addEventListener("blur", () => finish(true));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Puts a design in a group of its project ("Public site"), or out of every group when empty. Saved designs keep it. */
|
|
300
|
+
export async function setPageGroup(key, text) {
|
|
301
|
+
const d = docs.get(key);
|
|
302
|
+
const next = normalizeGroup(text), prev = pageGroupOf(key);
|
|
303
|
+
const apply = (g) => { d.meta.group = g; if (key === S.curKey) data.group = g; refreshLabels(); renderDesigns(); layout(); renderInspector(); };
|
|
304
|
+
if (next === prev) return renderInspector();
|
|
305
|
+
apply(next);
|
|
306
|
+
const isNewDoc = key === S.curKey ? S.isNew : d.isNew;
|
|
307
|
+
if (isNewDoc || !d.meta.id) return; // a design that isn't saved yet takes its group when it is saved
|
|
308
|
+
try {
|
|
309
|
+
await host.updateComponent(d.meta.id, { group: next });
|
|
310
|
+
} catch (err) {
|
|
311
|
+
apply(prev);
|
|
312
|
+
alert(err.message || "Could not change the group");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
export async function renameFrame(key, name, previous) {
|
|
316
|
+
const d = docs.get(key);
|
|
317
|
+
const isNewDoc = key === S.curKey ? S.isNew : d.isNew;
|
|
318
|
+
const apply = (t) => { d.meta.title = t; if (key === S.curKey) { data.title = t; renderInspector(); } updateChrome(); }; // the page panel shows the name
|
|
319
|
+
apply(name);
|
|
320
|
+
if (isNewDoc || !d.meta.id) return; // a design that isn't saved yet takes its name when it is
|
|
321
|
+
try {
|
|
322
|
+
await host.updateComponent(d.meta.id, { title: name });
|
|
323
|
+
} catch (err) {
|
|
324
|
+
apply(previous);
|
|
325
|
+
alert(err.message || "Could not rename it");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The page and the sections hanging under it. */
|
|
330
|
+
const withSections = (key) => [key, ...[...docs.values()].filter((d) => d.kind === "section" && d.parent === key).map((d) => d.key)];
|
|
331
|
+
/**
|
|
332
|
+
* Deletes a page (and its sections) after asking. Saved ones go from the library first: if that fails nothing changes here. The page being
|
|
333
|
+
* edited hands over to a neighbour before it goes. A project keeps at least one page.
|
|
334
|
+
*/
|
|
335
|
+
export async function deletePage(key) {
|
|
336
|
+
const d = docs.get(key);
|
|
337
|
+
if (!d || docs.size < 2 || S.mode !== "edit") return;
|
|
338
|
+
const keys = withSections(key);
|
|
339
|
+
if (keys.length >= docs.size) return alert("A project keeps at least one page.");
|
|
340
|
+
const title = (key === S.curKey ? data.title : d.meta.title) || "Untitled page";
|
|
341
|
+
const extra = keys.length > 1 ? ` and its ${keys.length - 1} section${keys.length > 2 ? "s" : ""}` : "";
|
|
342
|
+
if (!confirm(`Delete "${title}"${extra}? This can't be undone.`)) return;
|
|
343
|
+
const saved = keys.map((k) => docs.get(k)).filter((x) => x && !(x.key === S.curKey ? S.isNew : x.isNew) && x.meta.id);
|
|
344
|
+
if (saved.length) {
|
|
345
|
+
if (!host.deleteComponent) return alert("This editor can't delete pages.");
|
|
346
|
+
try {
|
|
347
|
+
for (const x of saved) await host.deleteComponent(x.meta.id); // the page last: its sections go with it on the server
|
|
348
|
+
} catch (err) {
|
|
349
|
+
return alert(err.message || "Could not delete the page");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (keys.includes(S.curKey)) { // hand over to a page that stays: the one after it, else the one before
|
|
353
|
+
const order = [...docs.keys()], at = order.indexOf(key);
|
|
354
|
+
const next = [...order.slice(at + 1), ...order.slice(0, at).reverse()].find((k) => !keys.includes(k));
|
|
355
|
+
activate(next);
|
|
356
|
+
}
|
|
357
|
+
keys.forEach((k) => {
|
|
358
|
+
const en = wraps.get(k);
|
|
359
|
+
if (en) { io.unobserve(en.wrap); en.wrap.remove(); wraps.delete(k); }
|
|
360
|
+
docs.delete(k);
|
|
361
|
+
pendingSave.delete(k);
|
|
362
|
+
});
|
|
363
|
+
refreshLabels();
|
|
364
|
+
renderDesigns();
|
|
365
|
+
renderLayers();
|
|
366
|
+
updateChrome();
|
|
367
|
+
layout();
|
|
368
|
+
S.viewTouched = true;
|
|
369
|
+
fitTo([S.curKey], Math.max(view.z, 0.5));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function clearSelection() {
|
|
373
|
+
S.frameSel = null;
|
|
374
|
+
S.selected = null;
|
|
375
|
+
updateCodeHighlight(false);
|
|
376
|
+
send({ cmd: "deselect" });
|
|
377
|
+
renderInspector();
|
|
378
|
+
renderLayers();
|
|
379
|
+
renderFloat();
|
|
380
|
+
}
|
|
381
|
+
export function selectFrame() {
|
|
382
|
+
S.selected = null;
|
|
383
|
+
updateCodeHighlight(false);
|
|
384
|
+
send({ cmd: "deselect" });
|
|
385
|
+
S.frameSel = S.curKey;
|
|
386
|
+
renderInspector();
|
|
387
|
+
renderLayers();
|
|
388
|
+
renderFloat();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export const clampSize = (n, max) => Math.min(max, Math.max(120, Math.round(n)));
|
|
392
|
+
|
|
393
|
+
/** Drag a frame handle: the frame changes size and the design inside reflows to fit. */
|
|
394
|
+
function resizeFrame(g, dx, dy) {
|
|
395
|
+
let x = g.ox, y = g.oy, w = g.ow, h = g.oh;
|
|
396
|
+
if (g.h.includes("e")) w = g.ow + dx;
|
|
397
|
+
if (g.h.includes("s")) h = g.oh + dy;
|
|
398
|
+
if (g.h.includes("w")) w = g.ow - dx;
|
|
399
|
+
if (g.h.includes("n")) h = g.oh - dy;
|
|
400
|
+
w = clampSize(w, 8000);
|
|
401
|
+
h = clampSize(h, 20000);
|
|
402
|
+
if (g.h.includes("w")) x = g.ox + g.ow - w;
|
|
403
|
+
if (g.h.includes("n")) y = g.oy + g.oh - h;
|
|
404
|
+
const d = docs.get(g.key);
|
|
405
|
+
const vertical = g.h.includes("n") || g.h.includes("s");
|
|
406
|
+
d.board = { x: Math.round(x), y: Math.round(y), w, ...(vertical || (d.board && d.board.h) ? { h } : {}) };
|
|
407
|
+
rememberCustom(d);
|
|
408
|
+
layout();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The last size set by hand (anything that isn't Desktop, Tablet or Mobile) stays available as the Custom button. */
|
|
412
|
+
|
|
413
|
+
export function rememberCustom(d) {
|
|
414
|
+
const w = d.board && d.board.w;
|
|
415
|
+
if (!w || DEVICES.some(([p]) => p === w)) return;
|
|
416
|
+
S.lastCustom = { w, h: (d.board && d.board.h) || null };
|
|
417
|
+
try { localStorage.setItem("wc:customSize", JSON.stringify(S.lastCustom)); } catch { /* storage is optional */ }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Sets the live frame's width and/or height (null height = follow the content). Used by the frame toolbar. */
|
|
421
|
+
export function setFrameSize(sz) {
|
|
422
|
+
const d = docs.get(S.curKey);
|
|
423
|
+
const p = positions()[S.curKey];
|
|
424
|
+
const b = { x: p.x, y: p.y, ...(d.board || {}) };
|
|
425
|
+
if ("w" in sz) b.w = clampSize(sz.w, 8000);
|
|
426
|
+
if ("h" in sz) { if (sz.h === null) delete b.h; else b.h = clampSize(sz.h, 20000); }
|
|
427
|
+
d.board = b;
|
|
428
|
+
rememberCustom(d);
|
|
429
|
+
queueBoardSave(S.curKey);
|
|
430
|
+
layout();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Records where a page sits now as its board position, if it has none. A page without one is placed automatically, in the slot its
|
|
435
|
+
* order gives it, so once another page holds a saved position the two can land on the same spot after a reload. Done for every page
|
|
436
|
+
* when a page is added, and for a page when it is first saved.
|
|
437
|
+
*/
|
|
438
|
+
export function pinBoard(key, queue = true) {
|
|
439
|
+
const d = docs.get(key), p = positions()[key];
|
|
440
|
+
if (!d || d.board || !p) return;
|
|
441
|
+
d.board = { x: Math.round(p.x), y: Math.round(p.y) };
|
|
442
|
+
if (queue) queueBoardSave(key); // (a design not saved yet is written by its first save)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// a new design on the same plane
|
|
446
|
+
async function ensureProject() {
|
|
447
|
+
if (planeProject) return planeProject;
|
|
448
|
+
const name = prompt("Designs that share a canvas live in a project. Name this project:", data.title || "My project");
|
|
449
|
+
if (!name || !name.trim()) return null;
|
|
450
|
+
let project;
|
|
451
|
+
try { project = await host.createProject(name); } catch (err) { alert(err.message || "Could not create the project"); return null; }
|
|
452
|
+
if (!S.isNew) {
|
|
453
|
+
try { await host.updateComponent(data.id, { project: project.id }); } catch { alert("Could not add this design to the project"); return null; }
|
|
454
|
+
}
|
|
455
|
+
planeProject = project;
|
|
456
|
+
(data.projects ||= []).push(project);
|
|
457
|
+
data.project = project;
|
|
458
|
+
const d = docs.get(S.curKey);
|
|
459
|
+
d.kind = "page";
|
|
460
|
+
return project;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Registers this part's listeners and does its first draw (called once, in order, by editor.js). */
|
|
464
|
+
export function init() {
|
|
465
|
+
|
|
466
|
+
docs.set(S.curKey, { key: S.curKey, meta: takeMeta(), kind: data.kind || (data.project ? "page" : "component"), parent: data.parent || null, board: data.board || null, frameW: data.frameW || null, parsed: S.parsed, markup: model.markup, history: S.history, hIndex: S.hIndex, isNew: S.isNew, savedMarkup: S.savedMarkup, height: 0, loaded: true });
|
|
467
|
+
(data.plane || []).forEach((it) => {
|
|
468
|
+
if (it.id === S.curKey) return;
|
|
469
|
+
docs.set(it.id, {
|
|
470
|
+
key: it.id, kind: it.kind, parent: it.parent, board: it.board, frameW: it.frameW || null, source: it.htmlCode, loaded: false, isNew: false, height: 0,
|
|
471
|
+
meta: { id: it.id, title: it.title, group: it.group || "", version: it.version, category: it.category, description: it.description, tags: it.tags, isPublished: it.isPublished, useAsReference: it.useAsReference !== false, project: data.project },
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
$("designs").addEventListener("click", (e) => {
|
|
475
|
+
if (e.target.closest("[data-pedit]")) { $("detailsBtn").click(); return; }
|
|
476
|
+
const r = e.target.closest("[data-design]");
|
|
477
|
+
if (!r) return;
|
|
478
|
+
S.viewTouched = true;
|
|
479
|
+
$("designsMenu").classList.add("hidden");
|
|
480
|
+
activate(r.dataset.design);
|
|
481
|
+
fitTo([r.dataset.design], Math.max(view.z, 0.5));
|
|
482
|
+
});
|
|
483
|
+
window.addEventListener("beforeunload", () => {
|
|
484
|
+
if (pendingSave.size && planeProject) host.saveLayoutOnExit(planeProject.id, [...pendingSave].map(boardPayload));
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
canvas.addEventListener("pointerdown", (e) => {
|
|
488
|
+
if (e.target === $("panShield") || e.target.closest(".frename")) return;
|
|
489
|
+
// the middle button always pans (the arrow tool uses empty space to draw a selection box instead)
|
|
490
|
+
if (e.button === 1 && !e.target.closest("#ftb")) {
|
|
491
|
+
e.preventDefault();
|
|
492
|
+
gesture = { type: "pan", mid: true, sx: e.clientX, sy: e.clientY, vx: view.x, vy: view.y, moved: false };
|
|
493
|
+
try { canvas.setPointerCapture(e.pointerId); } catch { /* capture is optional */ }
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (e.button !== 0) return;
|
|
497
|
+
const sh = e.target.closest("[data-sh]");
|
|
498
|
+
if (sh) {
|
|
499
|
+
// a selection handle: the agent runs the resize / rotate / radius, fed with this pointer
|
|
500
|
+
gesture = { type: "sel", hd: sh.dataset.sh };
|
|
501
|
+
canvas.style.cursor = getComputedStyle(sh).cursor;
|
|
502
|
+
send({ cmd: "hstart", hd: sh.dataset.sh, e: frameEvent(e) });
|
|
503
|
+
try { canvas.setPointerCapture(e.pointerId); } catch { /* capture is optional */ }
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const fh = e.target.closest("[data-fh]");
|
|
507
|
+
const label = e.target.closest("[data-label]");
|
|
508
|
+
const shield = e.target.closest("[data-shield]");
|
|
509
|
+
const wrap = (label || shield) && e.target.closest(".fwrap");
|
|
510
|
+
if (fh && S.frameSel === S.curKey) {
|
|
511
|
+
const d = docs.get(S.curKey);
|
|
512
|
+
const pos = positions()[S.curKey];
|
|
513
|
+
gesture = { type: "fresize", key: S.curKey, h: fh.dataset.fh, sx: e.clientX, sy: e.clientY, ox: pos.x, oy: pos.y, ow: widthOf(d), oh: heightOfDoc(d), moved: false };
|
|
514
|
+
} else if (wrap) {
|
|
515
|
+
const key = wrap.dataset.key;
|
|
516
|
+
const pos = positions()[key];
|
|
517
|
+
gesture = { type: "frame", key, wrap, sx: e.clientX, sy: e.clientY, ox: pos.x, oy: pos.y, moved: false, label: Boolean(label) };
|
|
518
|
+
} else if (e.target === canvas || e.target === $("plane") || e.target === $("frames")) {
|
|
519
|
+
gesture = S.tool === "move"
|
|
520
|
+
? { type: "marquee", sx: e.clientX, sy: e.clientY, moved: false }
|
|
521
|
+
: { type: "pan", sx: e.clientX, sy: e.clientY, vx: view.x, vy: view.y, moved: false };
|
|
522
|
+
} else return;
|
|
523
|
+
try { canvas.setPointerCapture(e.pointerId); } catch { /* capture is optional */ }
|
|
524
|
+
});
|
|
525
|
+
canvas.addEventListener("pointermove", (e) => {
|
|
526
|
+
if (!gesture) {
|
|
527
|
+
// the pointer is over the canvas, not the live frame: the frame never hears it leave, so tell it to drop its hover highlight
|
|
528
|
+
const now = Date.now();
|
|
529
|
+
if (now - lastHoverClear > 100) { lastHoverClear = now; send({ cmd: "hover", path: null }); }
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (gesture.type === "sel") return send({ cmd: "hmove", e: frameEvent(e) });
|
|
533
|
+
const dx = e.clientX - gesture.sx, dy = e.clientY - gesture.sy;
|
|
534
|
+
if (!gesture.moved && Math.hypot(dx, dy) < 4) return;
|
|
535
|
+
gesture.moved = true;
|
|
536
|
+
S.viewTouched = true;
|
|
537
|
+
if (gesture.type === "pan") { view.x = gesture.vx + dx; view.y = gesture.vy + dy; return applyView(); }
|
|
538
|
+
if (gesture.type === "marquee") return drawMarquee(gesture, e);
|
|
539
|
+
if (gesture.type === "fresize") return resizeFrame(gesture, dx / view.z, dy / view.z);
|
|
540
|
+
gesture.wrap.classList.add("dragging");
|
|
541
|
+
const d = docs.get(gesture.key);
|
|
542
|
+
const p = { x: Math.round((gesture.ox + dx / view.z) / SNAP) * SNAP, y: Math.round((gesture.oy + dy / view.z) / SNAP) * SNAP };
|
|
543
|
+
d.board = { ...(d.board || {}), ...p };
|
|
544
|
+
gesture.wrap.style.left = `${p.x}px`;
|
|
545
|
+
gesture.wrap.style.top = `${p.y}px`;
|
|
546
|
+
if (gesture.key === S.curKey) { frame.style.left = `${p.x}px`; frame.style.top = `${p.y}px`; }
|
|
547
|
+
renderFloat();
|
|
548
|
+
});
|
|
549
|
+
canvas.addEventListener("pointerup", (e) => {
|
|
550
|
+
const g = gesture;
|
|
551
|
+
gesture = null;
|
|
552
|
+
if (!g) return;
|
|
553
|
+
if (g.type === "sel") { canvas.style.cursor = ""; return send({ cmd: "hend", e: frameEvent(e) }); }
|
|
554
|
+
if (g.type === "pan") { if (!g.moved && !g.mid) clearSelection(); return; }
|
|
555
|
+
if (g.type === "marquee") return finishMarquee(g, e);
|
|
556
|
+
if (g.type === "fresize") { if (g.moved) queueBoardSave(g.key); return; }
|
|
557
|
+
g.wrap.classList.remove("dragging");
|
|
558
|
+
if (g.moved) { queueBoardSave(g.key); layout(); return; }
|
|
559
|
+
const switched = g.key !== S.curKey;
|
|
560
|
+
if (switched) activate(g.key);
|
|
561
|
+
if (g.label || switched) selectFrame();
|
|
562
|
+
});
|
|
563
|
+
canvas.addEventListener("dblclick", (e) => {
|
|
564
|
+
// the canvas holds the pointer during a click, so look at what is really under the cursor
|
|
565
|
+
const under = document.elementFromPoint(e.clientX, e.clientY);
|
|
566
|
+
const label = under && under.closest("[data-label]");
|
|
567
|
+
if (label && !label.querySelector("input")) startRename(label.closest(".fwrap").dataset.key);
|
|
568
|
+
});
|
|
569
|
+
try { S.lastCustom = JSON.parse(localStorage.getItem("wc:customSize") || "null"); } catch { /* storage is optional */ }
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Where a new page goes: in the row of the page being edited, just right of the furthest frame in that row, so it lands on nothing.
|
|
573
|
+
* The spot is kept (a board position), so moving other pages later never moves it.
|
|
574
|
+
*/
|
|
575
|
+
function freeSpot() {
|
|
576
|
+
const pos = positions(), cur = pos[S.curKey] || { x: 0, y: 0 };
|
|
577
|
+
let right = null;
|
|
578
|
+
docs.forEach((d, k) => {
|
|
579
|
+
const p = pos[k];
|
|
580
|
+
if (!p) return;
|
|
581
|
+
const h = heightOfDoc(d);
|
|
582
|
+
if (p.y < cur.y + MIN_H && cur.y < p.y + h) right = Math.max(right ?? -Infinity, p.x + widthOf(d)); // a frame beside that row
|
|
583
|
+
});
|
|
584
|
+
return { x: Math.round(right === null ? 0 : right + GAP_X), y: Math.round(cur.y) };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
$("newDesignBtn").addEventListener("click", async () => {
|
|
588
|
+
$("designsMenu").classList.add("hidden");
|
|
589
|
+
const project = await ensureProject();
|
|
590
|
+
if (!project) return;
|
|
591
|
+
const key = `draft-${++draftCount}`;
|
|
592
|
+
[...docs.keys()].forEach(pinBoard); // the pages already here keep their places
|
|
593
|
+
const spot = freeSpot();
|
|
594
|
+
docs.set(key, {
|
|
595
|
+
key, kind: "page", parent: null, board: spot, source: "", loaded: true, isNew: true, height: 0,
|
|
596
|
+
parsed: parseComponentSource(""), markup: "", history: [], hIndex: -1, savedMarkup: "",
|
|
597
|
+
meta: { id: null, title: "Untitled page", version: 0, category: data.category || DEFAULT_CATEGORY, description: "A page on the canvas.", tags: [], isPublished: false, useAsReference: true, project },
|
|
598
|
+
});
|
|
599
|
+
makeWrap(key);
|
|
600
|
+
S.viewTouched = true;
|
|
601
|
+
activate(key);
|
|
602
|
+
layout();
|
|
603
|
+
fitTo([key], 0.8);
|
|
604
|
+
setTool("move");
|
|
605
|
+
});
|
|
606
|
+
}
|