@divlet/web 0.0.0-stage → 0.0.2
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 +12 -2
- package/dist/LICENSE +21 -0
- package/dist/board.css +950 -0
- package/dist/board.js +427 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.html +73 -0
- package/dist/index.js +3 -0
- package/dist/lib/actions.js +472 -0
- package/dist/lib/activity.js +311 -0
- package/dist/lib/artboards.js +56 -0
- package/dist/lib/ask.js +350 -0
- package/dist/lib/attrs.js +144 -0
- package/dist/lib/board-host.d.ts +14 -0
- package/dist/lib/board-host.js +2 -0
- package/dist/lib/board-session.d.ts +53 -0
- package/dist/lib/board-session.js +24 -0
- package/dist/lib/canvas.js +1594 -0
- package/dist/lib/chat.js +156 -0
- package/dist/lib/clip.js +231 -0
- package/dist/lib/crumbbar.js +56 -0
- package/dist/lib/crumbs.js +63 -0
- package/dist/lib/css.js +238 -0
- package/dist/lib/expandbar.js +64 -0
- package/dist/lib/file-picker.js +103 -0
- package/dist/lib/help.js +273 -0
- package/dist/lib/host.js +16 -0
- package/dist/lib/http-services.js +24 -0
- package/dist/lib/http-session.js +45 -0
- package/dist/lib/http.js +17 -0
- package/dist/lib/inspector.js +857 -0
- package/dist/lib/layers.js +298 -0
- package/dist/lib/menu.js +119 -0
- package/dist/lib/palette.js +292 -0
- package/dist/lib/panels.js +27 -0
- package/dist/lib/project.js +188 -0
- package/dist/lib/resize.js +114 -0
- package/dist/lib/shortcuts.js +91 -0
- package/dist/lib/sidebar.js +85 -0
- package/dist/lib/store.js +249 -0
- package/dist/lib/suggest.js +130 -0
- package/dist/lib/toolbar.js +54 -0
- package/dist/lib/typography.js +120 -0
- package/dist/lib/util.js +177 -0
- package/package.json +37 -4
|
@@ -0,0 +1,1594 @@
|
|
|
1
|
+
import { $, $$, el, icon, clamp, round } from './util.js';
|
|
2
|
+
import { publishObservations } from './host.js';
|
|
3
|
+
import { store, emit, on, documents, descriptor, request, post, apiUrl, commit, edit, idle, undo, newGroup, selectArtboards, selectNodes, toast, viewState, saveViewState } from './store.js';
|
|
4
|
+
|
|
5
|
+
const PREFETCH_TARGET = 8;
|
|
6
|
+
const OVERSCAN = 550;
|
|
7
|
+
// A frame drawn narrower than this on screen shows its cached picture instead of the live page (when the server can make one).
|
|
8
|
+
const LOD_WIDTH = 260;
|
|
9
|
+
const MIN_K = 0.04;
|
|
10
|
+
const MAX_K = 4;
|
|
11
|
+
const MIN_SIZE = 80;
|
|
12
|
+
const WIDTH_PRESETS = [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920];
|
|
13
|
+
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
14
|
+
|
|
15
|
+
const viewport = $('#viewport');
|
|
16
|
+
const stage = $('#stage');
|
|
17
|
+
const guides = $('#guides');
|
|
18
|
+
const marquee = $('#marquee');
|
|
19
|
+
const badge = $('#hover-badge');
|
|
20
|
+
|
|
21
|
+
export const camera = { x: 0, y: 0, k: 1 };
|
|
22
|
+
export const entries = new Map();
|
|
23
|
+
const overrides = new Map();
|
|
24
|
+
const pendingOverrides = new Map();
|
|
25
|
+
let frameScheduled = false;
|
|
26
|
+
let spaceDown = false;
|
|
27
|
+
let snapDisabled = false;
|
|
28
|
+
let gesture = null;
|
|
29
|
+
let initialFit = false;
|
|
30
|
+
let animation = 0;
|
|
31
|
+
const pointers = new Map();
|
|
32
|
+
const tap = { time: 0, x: 0, y: 0, key: null };
|
|
33
|
+
|
|
34
|
+
/** Our own double-click detection: pointer capture retargets native dblclick events, and it also covers double-tap. */
|
|
35
|
+
function doubleTap(event, key) {
|
|
36
|
+
const double = key === tap.key && event.timeStamp - tap.time < 420 && Math.hypot(event.clientX - tap.x, event.clientY - tap.y) < 8;
|
|
37
|
+
Object.assign(tap, double ? { time: 0, key: null } : { time: event.timeStamp, x: event.clientX, y: event.clientY, key });
|
|
38
|
+
return double;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* ---- geometry ------------------------------------------------------------- */
|
|
42
|
+
export const rectOf = doc => (overrides.has(doc.id) ? overrides.get(doc.id) : doc.artboard);
|
|
43
|
+
const screenRect = rect => ({ x: camera.x + rect.x * camera.k, y: camera.y + rect.y * camera.k, width: rect.width * camera.k, height: rect.height * camera.k });
|
|
44
|
+
const worldPoint = (clientX, clientY) => { const b = viewport.getBoundingClientRect(); return { x: (clientX - b.left - camera.x) / camera.k, y: (clientY - b.top - camera.y) / camera.k }; };
|
|
45
|
+
|
|
46
|
+
function place(entry) {
|
|
47
|
+
if (store.expanded?.entry === entry) return;
|
|
48
|
+
const rect = rectOf(entry.doc);
|
|
49
|
+
const s = screenRect(rect);
|
|
50
|
+
entry.el.style.transform = `translate(${s.x}px, ${s.y}px)`;
|
|
51
|
+
entry.el.style.width = `${s.width}px`;
|
|
52
|
+
entry.el.style.height = `${s.height}px`;
|
|
53
|
+
entry.body.style.width = `${rect.width}px`;
|
|
54
|
+
entry.body.style.height = `${rect.height}px`;
|
|
55
|
+
entry.body.style.transform = `scale(${camera.k})`;
|
|
56
|
+
syncLod(entry, s.width);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/*
|
|
60
|
+
* Far zoom: each frame photographs itself in the browser; an optional host prepares only its accepted font closure.
|
|
61
|
+
* and the board keeps the picture; a frame drawn narrower than LOD_WIDTH shows it instead of the live page, which stays loaded
|
|
62
|
+
* but is not painted. Zoom in, select or expand and the same page is simply shown again, so nothing reloads or flashes.
|
|
63
|
+
* Pictures are taken one at a time while the browser is idle, as soon as a page has rendered.
|
|
64
|
+
*/
|
|
65
|
+
const captureQueue = [];
|
|
66
|
+
let capturing = null;
|
|
67
|
+
function queueCapture(entry) {
|
|
68
|
+
if (!entry.port || entry.shot?.render >= entry.doc.render || captureQueue.includes(entry)) return;
|
|
69
|
+
captureQueue.push(entry);
|
|
70
|
+
pumpCapture();
|
|
71
|
+
}
|
|
72
|
+
function pumpCapture() {
|
|
73
|
+
if (capturing) return;
|
|
74
|
+
const entry = captureQueue.shift();
|
|
75
|
+
if (!entry) return;
|
|
76
|
+
capturing = entry;
|
|
77
|
+
const captureId = entry.captureId = crypto.randomUUID();
|
|
78
|
+
(window.requestIdleCallback ?? (run => setTimeout(run, 60)))(async () => {
|
|
79
|
+
if (capturing !== entry || entry.captureId !== captureId) return;
|
|
80
|
+
const port = entry.port, render = entry.renderedVersion, generation = entry.deliveryGeneration;
|
|
81
|
+
if (!port || !entry.loaded || render !== entry.doc.render || store.expanded?.entry === entry) { finishCapture(entry, captureId); return; }
|
|
82
|
+
const abort = entry.captureAbort = new AbortController();
|
|
83
|
+
entry.captureTimer = setTimeout(() => finishCapture(entry, captureId), 8000);
|
|
84
|
+
try {
|
|
85
|
+
let fontCSS;
|
|
86
|
+
const capability = store.state?.project?.capabilities?.captureFonts;
|
|
87
|
+
if (capability?.maxBytes > 0) {
|
|
88
|
+
const result = await request(`api/capture-fonts/${encodeURIComponent(entry.doc.id)}?render=${render}`, { signal: abort.signal });
|
|
89
|
+
if (result.documentId !== entry.doc.id || result.render !== render || typeof result.css !== 'string' || result.css.length > capability.maxBytes) throw new Error('Invalid snapshot font response');
|
|
90
|
+
fontCSS = result.css;
|
|
91
|
+
}
|
|
92
|
+
if (capturing !== entry || entry.captureId !== captureId || entry.port !== port || entry.deliveryGeneration !== generation || entry.doc.render !== render || store.expanded?.entry === entry) { finishCapture(entry, captureId); return; }
|
|
93
|
+
port.postMessage({ type: 'divlet:capture', captureId, fontCSS });
|
|
94
|
+
} catch { finishCapture(entry, captureId); }
|
|
95
|
+
}, { timeout: 2000 });
|
|
96
|
+
}
|
|
97
|
+
function finishCapture(entry, captureId = entry.captureId) {
|
|
98
|
+
if (capturing !== entry || entry.captureId !== captureId) return;
|
|
99
|
+
clearTimeout(entry.captureTimer);
|
|
100
|
+
entry.captureAbort?.abort(); entry.captureAbort = null; entry.captureId = null;
|
|
101
|
+
capturing = null;
|
|
102
|
+
pumpCapture();
|
|
103
|
+
}
|
|
104
|
+
function receiveShot(entry, data) {
|
|
105
|
+
if (capturing !== entry || data.captureId !== entry.captureId) return;
|
|
106
|
+
if (!store.expanded && data.blob instanceof Blob && data.version === entry.doc.render && data.version === entry.renderedVersion) {
|
|
107
|
+
if (entry.shot) URL.revokeObjectURL(entry.shot.url);
|
|
108
|
+
entry.shot = { render: data.version, url: URL.createObjectURL(data.blob) };
|
|
109
|
+
syncLod(entry);
|
|
110
|
+
}
|
|
111
|
+
finishCapture(entry, data.captureId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function syncLod(entry, screenWidth = entry.el.getBoundingClientRect().width) {
|
|
115
|
+
const show = !store.expanded && !store.selected.has(entry.doc.id) && !entry.name.querySelector('input') && screenWidth < LOD_WIDTH && !!entry.iframe && entry.shot?.render === entry.doc.render;
|
|
116
|
+
if (show) {
|
|
117
|
+
const image = entry.shotImage ??= Object.assign(new Image(), { className: 'frame-snapshot', alt: '', draggable: false });
|
|
118
|
+
if (image.dataset.url !== entry.shot.url) { image.dataset.url = entry.shot.url; image.src = entry.shot.url; }
|
|
119
|
+
if (!image.isConnected) entry.body.insertBefore(image, entry.cover);
|
|
120
|
+
} else if (entry.shotImage?.isConnected) entry.shotImage.remove();
|
|
121
|
+
entry.el.classList.toggle('lod', show);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function paintGrid() {
|
|
125
|
+
let step = 24 * camera.k;
|
|
126
|
+
while (step < 14) step *= 2;
|
|
127
|
+
stage.style.setProperty('--canvas-scale', String(camera.k));
|
|
128
|
+
viewport.style.setProperty('--grid', `${step}px`);
|
|
129
|
+
viewport.style.setProperty('--gx', `${camera.x % step}px`);
|
|
130
|
+
viewport.style.setProperty('--gy', `${camera.y % step}px`);
|
|
131
|
+
$('#zoom-value').textContent = `${Math.round(camera.k * 100)}%`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const persist = (() => { let timer; return () => { clearTimeout(timer); timer = setTimeout(() => { if (store.state) saveViewState(store.state.projectId, { ...camera }); }, 250); }; })();
|
|
135
|
+
|
|
136
|
+
export function applyCamera() {
|
|
137
|
+
hideBadge();
|
|
138
|
+
for (const entry of entries.values()) place(entry);
|
|
139
|
+
paintGrid();
|
|
140
|
+
scheduleFrames();
|
|
141
|
+
persist();
|
|
142
|
+
emit('camera', camera);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function setCamera(next) {
|
|
146
|
+
camera.k = clamp(next.k ?? camera.k, MIN_K, MAX_K);
|
|
147
|
+
camera.x = next.x ?? camera.x;
|
|
148
|
+
camera.y = next.y ?? camera.y;
|
|
149
|
+
applyCamera();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function animateTo(target) {
|
|
153
|
+
cancelAnimationFrame(animation);
|
|
154
|
+
if (reduceMotion.matches) return setCamera(target);
|
|
155
|
+
const from = { ...camera };
|
|
156
|
+
const start = performance.now();
|
|
157
|
+
const duration = 360;
|
|
158
|
+
const step = now => {
|
|
159
|
+
const t = clamp((now - start) / duration, 0, 1);
|
|
160
|
+
const e = 1 - (1 - t) ** 3;
|
|
161
|
+
// interpolate in log-space so zoom feels even
|
|
162
|
+
const k = Math.exp(Math.log(from.k) + (Math.log(target.k) - Math.log(from.k)) * e);
|
|
163
|
+
setCamera({ k, x: from.x + (target.x - from.x) * e, y: from.y + (target.y - from.y) * e });
|
|
164
|
+
if (t < 1) animation = requestAnimationFrame(step);
|
|
165
|
+
};
|
|
166
|
+
animation = requestAnimationFrame(step);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function zoomAt(factor, clientX, clientY, { animate = false } = {}) {
|
|
170
|
+
const bounds = viewport.getBoundingClientRect();
|
|
171
|
+
const px = (clientX ?? bounds.left + bounds.width / 2) - bounds.left;
|
|
172
|
+
const py = (clientY ?? bounds.top + bounds.height / 2) - bounds.top;
|
|
173
|
+
const k = clamp(camera.k * factor, MIN_K, MAX_K);
|
|
174
|
+
const ratio = k / camera.k;
|
|
175
|
+
const target = { k, x: px - (px - camera.x) * ratio, y: py - (py - camera.y) * ratio };
|
|
176
|
+
if (animate) animateTo(target);
|
|
177
|
+
else { cancelAnimationFrame(animation); setCamera(target); }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function zoomTo(k, { animate = true } = {}) {
|
|
181
|
+
const bounds = viewport.getBoundingClientRect();
|
|
182
|
+
zoomAt(k / camera.k, bounds.left + bounds.width / 2, bounds.top + bounds.height / 2, { animate });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function boundsOf(docs) {
|
|
186
|
+
if (!docs.length) return null;
|
|
187
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
188
|
+
for (const doc of docs) {
|
|
189
|
+
const r = rectOf(doc);
|
|
190
|
+
x0 = Math.min(x0, r.x); y0 = Math.min(y0, r.y); x1 = Math.max(x1, r.x + r.width); y1 = Math.max(y1, r.y + r.height);
|
|
191
|
+
}
|
|
192
|
+
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The part of the canvas not covered by floating panels (in viewport coordinates). */
|
|
196
|
+
function safeArea() {
|
|
197
|
+
const b = viewport.getBoundingClientRect();
|
|
198
|
+
const area = { left: 0, top: 0, right: b.width, bottom: b.height };
|
|
199
|
+
if (b.width < 720) return area;
|
|
200
|
+
for (const id of ['sidebar', 'inspector']) {
|
|
201
|
+
const node = document.getElementById(id);
|
|
202
|
+
if (!node || node.hidden) continue;
|
|
203
|
+
const r = node.getBoundingClientRect();
|
|
204
|
+
if (r.left - b.left < b.width / 2) area.left = Math.max(area.left, r.right - b.left);
|
|
205
|
+
else area.right = Math.min(area.right, r.left - b.left);
|
|
206
|
+
}
|
|
207
|
+
if (area.right - area.left < 320) return { left: 0, top: 0, right: b.width, bottom: b.height };
|
|
208
|
+
return area;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Frame a world rectangle in the free part of the canvas; `label` reserves room for artboard names. */
|
|
212
|
+
function fitRect(b, { animate = true, maxZoom = 1, label = 28 } = {}) {
|
|
213
|
+
const area = safeArea();
|
|
214
|
+
const width = area.right - area.left;
|
|
215
|
+
const height = area.bottom - area.top;
|
|
216
|
+
const narrow = viewport.clientWidth < 720;
|
|
217
|
+
const padX = narrow ? 24 : 72;
|
|
218
|
+
const padTop = 64;
|
|
219
|
+
const padBottom = narrow ? 150 : 96;
|
|
220
|
+
const k = clamp(Math.min((width - padX * 2) / b.width, (height - padTop - padBottom) / (b.height + label)), MIN_K, Math.max(maxZoom, MIN_K));
|
|
221
|
+
const target = { k, x: area.left + (width - b.width * k) / 2 - b.x * k, y: area.top + padTop + (height - padTop - padBottom - (b.height + label) * k) / 2 - b.y * k + label * k };
|
|
222
|
+
if (animate) animateTo(target);
|
|
223
|
+
else setCamera(target);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* After a panel opens: if it covers the selected artboards, bring them into the free part of the canvas. They are panned
|
|
228
|
+
* when they fit there and framed when they do not; nothing moves when the selection is already in the clear.
|
|
229
|
+
*/
|
|
230
|
+
export function revealSelection() {
|
|
231
|
+
const docs = [...store.selected].map(descriptor).filter(Boolean);
|
|
232
|
+
const b = boundsOf(docs);
|
|
233
|
+
if (!b) return;
|
|
234
|
+
const area = safeArea();
|
|
235
|
+
const pad = 24;
|
|
236
|
+
const left = b.x * camera.k + camera.x, right = (b.x + b.width) * camera.k + camera.x;
|
|
237
|
+
if (left >= area.left && right <= area.right) return;
|
|
238
|
+
if ((right - left) + 2 * pad > area.right - area.left) { fitRect(b); return; }
|
|
239
|
+
const dx = left < area.left ? area.left + pad - left : area.right - pad - right;
|
|
240
|
+
animateTo({ k: camera.k, x: camera.x + dx, y: camera.y });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function fit(ids, options) {
|
|
244
|
+
const docs = (ids?.length ? ids.map(descriptor) : documents()).filter(Boolean);
|
|
245
|
+
const b = boundsOf(docs);
|
|
246
|
+
if (b) fitRect(b, options);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Zoom to one element of a page using the geometry measured in its frame. */
|
|
250
|
+
export function fitNode(documentId, nodeId) {
|
|
251
|
+
const doc = descriptor(documentId);
|
|
252
|
+
const seen = store.observations.get(documentId);
|
|
253
|
+
const node = seen?.nodes.find(item => item.id === nodeId);
|
|
254
|
+
if (!doc) return;
|
|
255
|
+
if (!node || node.rect.width < 1 || node.rect.height < 1) return fit([documentId]);
|
|
256
|
+
const a = rectOf(doc);
|
|
257
|
+
const x = clamp(a.x + node.rect.x, a.x, a.x + a.width - 1);
|
|
258
|
+
const y = clamp(a.y + node.rect.y, a.y, a.y + a.height - 1);
|
|
259
|
+
const width = Math.max(1, Math.min(node.rect.width, a.x + a.width - x));
|
|
260
|
+
const height = Math.max(1, Math.min(node.rect.height, a.y + a.height - y));
|
|
261
|
+
const pad = 24;
|
|
262
|
+
fitRect({ x: x - pad, y: y - pad, width: width + pad * 2, height: height + pad * 2 }, { maxZoom: 2, label: 0 });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** True when at least part of an artboard is on screen. */
|
|
266
|
+
function anyVisible() {
|
|
267
|
+
const b = viewport.getBoundingClientRect();
|
|
268
|
+
return documents().some(doc => { const s = screenRect(rectOf(doc)); return s.x + s.width > 0 && s.x < b.width && s.y + s.height > 0 && s.y < b.height; });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const focusArtboard = (id, options) => fit([id], options);
|
|
272
|
+
|
|
273
|
+
/** Bring an artboard into view: pan if it fits at the current zoom, zoom out only if it must. */
|
|
274
|
+
export function reveal(id) {
|
|
275
|
+
const doc = descriptor(id);
|
|
276
|
+
if (!doc || store.expanded) return;
|
|
277
|
+
const r = rectOf(doc);
|
|
278
|
+
const s = screenRect(r);
|
|
279
|
+
const area = safeArea();
|
|
280
|
+
const top = area.top + 52;
|
|
281
|
+
const bottom = area.bottom - 76;
|
|
282
|
+
if (s.x >= area.left + 12 && s.x + s.width <= area.right - 12 && s.y - 28 >= top && s.y + s.height <= bottom) return;
|
|
283
|
+
if (s.width > area.right - area.left - 48 || s.height + 28 > bottom - top) return fit([id], { maxZoom: camera.k });
|
|
284
|
+
const cx = (area.left + area.right) / 2;
|
|
285
|
+
const cy = (top + bottom) / 2;
|
|
286
|
+
animateTo({ k: camera.k, x: cx - (r.x + r.width / 2) * camera.k, y: cy - (r.y + r.height / 2 - 14) * camera.k });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* ---- artboards ------------------------------------------------------------ */
|
|
290
|
+
function createArtboard(doc) {
|
|
291
|
+
const wrapper = el('section', 'artboard');
|
|
292
|
+
wrapper.dataset.artboardId = doc.id;
|
|
293
|
+
const label = el('div', 'ab-label');
|
|
294
|
+
const name = el('span', 'ab-name');
|
|
295
|
+
const size = el('span', 'ab-size');
|
|
296
|
+
const expand = el('button', 'ab-expand');
|
|
297
|
+
expand.type = 'button';
|
|
298
|
+
expand.dataset.tip = 'Expand · E';
|
|
299
|
+
expand.setAttribute('aria-label', 'Expand artboard');
|
|
300
|
+
expand.append(icon('expand'));
|
|
301
|
+
const more = el('button', 'ab-more');
|
|
302
|
+
more.type = 'button';
|
|
303
|
+
more.dataset.tip = 'More';
|
|
304
|
+
more.setAttribute('aria-label', 'Artboard actions');
|
|
305
|
+
more.append(icon('more'));
|
|
306
|
+
const error = el('span', 'ab-error');
|
|
307
|
+
error.textContent = '!';
|
|
308
|
+
error.setAttribute('role', 'img');
|
|
309
|
+
error.setAttribute('aria-label', 'Preview unavailable');
|
|
310
|
+
error.title = 'Preview unavailable';
|
|
311
|
+
label.append(error, name, size, expand, more);
|
|
312
|
+
const body = el('div', 'ab-body');
|
|
313
|
+
const placeholder = el('div', 'frame-placeholder');
|
|
314
|
+
const cover = el('div', 'frame-cover');
|
|
315
|
+
body.append(placeholder, cover);
|
|
316
|
+
const handles = el('div', 'ab-handles');
|
|
317
|
+
for (const dir of ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']) {
|
|
318
|
+
const handle = el('span', `ab-handle h-${dir}`);
|
|
319
|
+
handle.dataset.dir = dir;
|
|
320
|
+
handles.append(handle);
|
|
321
|
+
}
|
|
322
|
+
wrapper.append(label, body, handles);
|
|
323
|
+
stage.append(wrapper);
|
|
324
|
+
const entry = { el: wrapper, label, name, size, expand, more, body, placeholder, cover, doc, iframe: null, loaded: false, renderedVersion: 0, awaitingVersion: 0, pendingFetch: false, abort: null, reportTimer: null, latestReport: null, deliveryGeneration: 0, expectedNavigationVersion: 0, pendingFlash: null, pendingEdit: null };
|
|
325
|
+
expand.addEventListener('click', event => { event.stopPropagation(); openExpanded(entry.doc.id, expand); });
|
|
326
|
+
more.addEventListener('click', event => {
|
|
327
|
+
event.stopPropagation();
|
|
328
|
+
if (!store.selected.has(entry.doc.id)) selectArtboards([entry.doc.id]);
|
|
329
|
+
emit('context', { kind: 'artboard', id: entry.doc.id, anchor: more });
|
|
330
|
+
});
|
|
331
|
+
for (const control of [expand, more]) control.addEventListener('pointerdown', event => event.stopPropagation());
|
|
332
|
+
wrapper.inert = !!store.expanded;
|
|
333
|
+
entries.set(doc.id, entry);
|
|
334
|
+
visibility.observe(wrapper);
|
|
335
|
+
return entry;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function renameArtboard(entry) {
|
|
339
|
+
if (entry.name.querySelector('input')) return;
|
|
340
|
+
const input = el('input', 'ab-rename');
|
|
341
|
+
input.value = entry.doc.name || entry.doc.id;
|
|
342
|
+
input.setAttribute('aria-label', 'Artboard name');
|
|
343
|
+
entry.name.replaceChildren(input);
|
|
344
|
+
input.focus();
|
|
345
|
+
input.select();
|
|
346
|
+
const done = save => {
|
|
347
|
+
if (!input.isConnected) return;
|
|
348
|
+
document.removeEventListener('pointerdown', outside, true);
|
|
349
|
+
const value = input.value.trim();
|
|
350
|
+
entry.name.textContent = entry.doc.name || entry.doc.id;
|
|
351
|
+
if (save && value && value !== entry.doc.name) commit(entry.doc.id, { name: value }, { label: `rename to “${value}”` }).catch(() => {});
|
|
352
|
+
if (document.activeElement === document.body) viewport.focus({ preventScroll: true });
|
|
353
|
+
};
|
|
354
|
+
// A field that lost focus without a blur (a board shortcut, a click on the canvas) must not stay open: while it is open the
|
|
355
|
+
// label's own press types into it instead of dragging the artboard.
|
|
356
|
+
const outside = event => { if (!input.contains(event.target)) done(true); };
|
|
357
|
+
document.addEventListener('pointerdown', outside, true);
|
|
358
|
+
input.addEventListener('keydown', event => { event.stopPropagation(); if (event.key === 'Enter') done(true); else if (event.key === 'Escape') done(false); });
|
|
359
|
+
input.addEventListener('blur', () => done(true));
|
|
360
|
+
input.addEventListener('pointerdown', event => event.stopPropagation());
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Type the size above an artboard: `390 × 800`, `1280` (width only) or `16:9` (height from the width). */
|
|
364
|
+
function editSize(entry) {
|
|
365
|
+
if (entry.size.querySelector('input')) return;
|
|
366
|
+
const doc = entry.doc;
|
|
367
|
+
const input = el('input', 'ab-rename ab-size-input');
|
|
368
|
+
input.value = `${doc.artboard.width} × ${doc.artboard.height}`;
|
|
369
|
+
input.setAttribute('aria-label', 'Artboard size, width × height');
|
|
370
|
+
input.dataset.tip = '390 × 800 · 1280 · 16:9';
|
|
371
|
+
entry.size.replaceChildren(input);
|
|
372
|
+
entry.size.style.opacity = '1';
|
|
373
|
+
input.focus();
|
|
374
|
+
input.select();
|
|
375
|
+
let finished = false;
|
|
376
|
+
const done = save => {
|
|
377
|
+
if (finished || !input.isConnected) return;
|
|
378
|
+
finished = true;
|
|
379
|
+
const text = input.value.trim().toLowerCase();
|
|
380
|
+
entry.size.style.opacity = '';
|
|
381
|
+
entry.size.textContent = `${round(rectOf(entry.doc).width)} × ${round(rectOf(entry.doc).height)}`;
|
|
382
|
+
if (save) {
|
|
383
|
+
const pair = /^(\d+(?:\.\d+)?)\s*[x×*,]\s*(\d+(?:\.\d+)?)$/.exec(text);
|
|
384
|
+
const ratio = /^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/.exec(text);
|
|
385
|
+
const single = /^(\d+(?:\.\d+)?)$/.exec(text);
|
|
386
|
+
let next = null;
|
|
387
|
+
if (pair) next = { width: Math.round(Number(pair[1])), height: Math.round(Number(pair[2])) };
|
|
388
|
+
else if (ratio && Number(ratio[1]) > 0) next = { height: Math.round(doc.artboard.width * Number(ratio[2]) / Number(ratio[1])) };
|
|
389
|
+
else if (single) next = { width: Math.round(Number(single[1])) };
|
|
390
|
+
else if (text) toast('Type a size like 390 × 800, a width like 1280, or a ratio like 16:9');
|
|
391
|
+
if (next && Object.entries(next).some(([key, value]) => value >= 1 && value <= 16384 && value !== doc.artboard[key])) commit(doc.id, { artboard: next }, { label: 'resize artboard', expectedVersion: doc.version, expectedRender: doc.render }).catch(() => {});
|
|
392
|
+
}
|
|
393
|
+
if (document.activeElement === document.body) viewport.focus({ preventScroll: true });
|
|
394
|
+
};
|
|
395
|
+
input.addEventListener('keydown', event => { event.stopPropagation(); if (event.key === 'Enter') done(true); else if (event.key === 'Escape') done(false); });
|
|
396
|
+
input.addEventListener('blur', () => done(true));
|
|
397
|
+
input.addEventListener('pointerdown', event => event.stopPropagation());
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Where an element's lower-left corner is on screen (for a menu opened from the keyboard), or the artboard's. */
|
|
401
|
+
export function anchorPoint(documentId, nodeId) {
|
|
402
|
+
const entry = entries.get(documentId);
|
|
403
|
+
if (!entry) return { x: innerWidth / 2, y: innerHeight / 2 };
|
|
404
|
+
const frame = entry.iframe?.getBoundingClientRect() ?? entry.el.getBoundingClientRect();
|
|
405
|
+
const rect = nodeId ? store.observations.get(documentId)?.nodes.find(item => item.id === nodeId)?.rect : null;
|
|
406
|
+
if (!rect) return { x: frame.left + 24, y: frame.top + 24 };
|
|
407
|
+
return { x: Math.min(frame.right - 8, frame.left + rect.x * camera.k + 8), y: Math.min(frame.bottom - 8, frame.top + (rect.y + rect.height) * camera.k + 4) };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function reconcileArtboards() {
|
|
411
|
+
const current = new Set(documents().map(doc => doc.id));
|
|
412
|
+
for (const [id, entry] of entries) {
|
|
413
|
+
if (current.has(id)) continue;
|
|
414
|
+
visibility.unobserve(entry.el);
|
|
415
|
+
unmount(entry);
|
|
416
|
+
entry.el.remove();
|
|
417
|
+
entries.delete(id);
|
|
418
|
+
overrides.delete(id);
|
|
419
|
+
pendingOverrides.delete(id);
|
|
420
|
+
store.observations.delete(id);
|
|
421
|
+
}
|
|
422
|
+
for (const doc of documents()) {
|
|
423
|
+
const entry = entries.get(doc.id) || createArtboard(doc);
|
|
424
|
+
entry.doc = doc;
|
|
425
|
+
const pending = pendingOverrides.get(doc.id);
|
|
426
|
+
if (pending && doc.version >= pending.version) { pendingOverrides.delete(doc.id); overrides.delete(doc.id); }
|
|
427
|
+
if (!entry.name.querySelector('input')) entry.name.textContent = doc.name || doc.id;
|
|
428
|
+
if (!entry.size.querySelector('input')) entry.size.textContent = `${round(rectOf(doc).width)} × ${round(rectOf(doc).height)}`;
|
|
429
|
+
entry.el.setAttribute('aria-label', doc.name || doc.id);
|
|
430
|
+
entry.expand.setAttribute('aria-label', `Expand ${doc.name || doc.id}`);
|
|
431
|
+
entry.more.setAttribute('aria-label', `Actions for ${doc.name || doc.id}`);
|
|
432
|
+
entry.el.classList.toggle('selected', store.selected.has(doc.id));
|
|
433
|
+
place(entry);
|
|
434
|
+
if (entry.iframe) { entry.iframe.title = `${doc.name || doc.id} preview`; updateFrame(entry); }
|
|
435
|
+
}
|
|
436
|
+
if (!initialFit && documents().length && viewport.clientWidth) {
|
|
437
|
+
initialFit = true;
|
|
438
|
+
const saved = viewState(store.state.projectId);
|
|
439
|
+
if (saved && Number.isFinite(saved.k) && Number.isFinite(saved.x) && Number.isFinite(saved.y)) setCamera(saved);
|
|
440
|
+
else fit(null, { animate: false });
|
|
441
|
+
}
|
|
442
|
+
updateHandles();
|
|
443
|
+
applyCamera();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function updateSelectionClasses(detail) {
|
|
447
|
+
for (const [id, entry] of entries) entry.el.classList.toggle('selected', store.selected.has(id));
|
|
448
|
+
updateHandles();
|
|
449
|
+
for (const entry of entries.values()) syncNodeSelection(entry, detail?.reveal);
|
|
450
|
+
scheduleFrames();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function updateHandles() {
|
|
454
|
+
const single = store.selected.size === 1 ? [...store.selected][0] : null;
|
|
455
|
+
for (const [id, entry] of entries) entry.el.classList.toggle('sole', id === single);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/* ---- frames --------------------------------------------------------------- */
|
|
459
|
+
export const postToFrame = (id, message) => entries.get(id)?.port?.postMessage(message);
|
|
460
|
+
|
|
461
|
+
function setFrameStatus(entry, value) {
|
|
462
|
+
entry.el.dataset.status = value;
|
|
463
|
+
entry.el.dataset.renderedVersion = String(entry.renderedVersion);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Where the agent says it is working: the whole artboard glows, or elements inside the page do. */
|
|
467
|
+
export function syncWork(entry) {
|
|
468
|
+
const marks = (store.state?.work || []).filter(item => item.documentId === entry.doc.id);
|
|
469
|
+
const whole = marks.some(item => !item.nodeIds.length);
|
|
470
|
+
entry.el.classList.toggle('working', whole);
|
|
471
|
+
const note = marks.find(item => item.note)?.note;
|
|
472
|
+
if (note) entry.el.dataset.work = note; else delete entry.el.dataset.work;
|
|
473
|
+
if (!entry.iframe || !entry.loaded) return;
|
|
474
|
+
const ids = [...new Set(marks.flatMap(item => item.nodeIds))];
|
|
475
|
+
entry.port?.postMessage({ type: 'divlet:work', nodeIds: store.expanded ? [] : ids });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function syncNodeSelection(entry, reveal = false) {
|
|
479
|
+
if (!entry.iframe || !entry.loaded) return;
|
|
480
|
+
const ids = store.node?.documentId === entry.doc.id ? store.node.nodeIds : [];
|
|
481
|
+
entry.port?.postMessage({ type: 'divlet:select', nodeIds: ids, reveal: reveal && ids.length > 0 });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Start inline text editing of a node once its frame shows `version`. */
|
|
485
|
+
export function editText(documentId, nodeId, version = 0) {
|
|
486
|
+
const entry = entries.get(documentId);
|
|
487
|
+
if (!entry || store.expanded) return;
|
|
488
|
+
if (store.mode !== 'select') setMode('select');
|
|
489
|
+
reveal(documentId);
|
|
490
|
+
entry.pendingEdit = { version, nodeId };
|
|
491
|
+
deliverEdit(entry);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function deliverEdit(entry) {
|
|
495
|
+
const pending = entry.pendingEdit;
|
|
496
|
+
if (!pending || !entry.iframe || !entry.port || !entry.loaded || entry.renderedVersion < pending.version) return;
|
|
497
|
+
entry.pendingEdit = null;
|
|
498
|
+
entry.iframe.focus();
|
|
499
|
+
entry.port?.postMessage({ type: 'divlet:edit', nodeId: pending.nodeId });
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const needsEagerLoad = entry => store.expanded?.entry === entry || store.selected.has(entry.doc.id);
|
|
503
|
+
|
|
504
|
+
function inspectFlag() { return !store.expanded && store.mode === 'select'; }
|
|
505
|
+
|
|
506
|
+
function mount(entry, { revision = false } = {}) {
|
|
507
|
+
if (entry.iframe) return;
|
|
508
|
+
const frame = document.createElement('iframe');
|
|
509
|
+
frame.title = `${entry.doc.name || entry.doc.id} preview`;
|
|
510
|
+
frame.setAttribute('sandbox', 'allow-scripts');
|
|
511
|
+
frame.dataset.documentId = entry.doc.id;
|
|
512
|
+
frame.id = `divlet-preview-${entry.doc.id}`;
|
|
513
|
+
entry.bridgeToken = crypto.randomUUID();
|
|
514
|
+
frame.referrerPolicy = 'no-referrer';
|
|
515
|
+
// The browser owns when an off-screen page loads; frames are never torn down and rebuilt by scrolling or zooming.
|
|
516
|
+
frame.loading = needsEagerLoad(entry) ? 'eager' : 'lazy';
|
|
517
|
+
frame.src = apiUrl(`preview/${encodeURIComponent(entry.doc.id)}?${revision ? `revision=${entry.doc.render}&` : ''}bridge=${entry.bridgeToken}`);
|
|
518
|
+
frame.addEventListener('load', () => {
|
|
519
|
+
if (entry.iframe !== frame) return;
|
|
520
|
+
entry.loaded = true;
|
|
521
|
+
paintLiveCount();
|
|
522
|
+
entry.port?.postMessage({ type: 'divlet:inspect', enabled: inspectFlag() });
|
|
523
|
+
syncNodeSelection(entry);
|
|
524
|
+
syncWork(entry);
|
|
525
|
+
if (store.expanded?.entry === entry) return;
|
|
526
|
+
updateFrame(entry);
|
|
527
|
+
});
|
|
528
|
+
entry.iframe = frame;
|
|
529
|
+
entry.loaded = false;
|
|
530
|
+
entry.renderedVersion = 0;
|
|
531
|
+
entry.awaitingVersion = 0;
|
|
532
|
+
entry.expectedNavigationVersion = entry.doc.render;
|
|
533
|
+
entry.deliveryGeneration++;
|
|
534
|
+
setFrameStatus(entry, 'loading');
|
|
535
|
+
entry.placeholder.hidden = false;
|
|
536
|
+
entry.body.insertBefore(frame, entry.cover);
|
|
537
|
+
place(entry);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function dropGhost(entry) {
|
|
541
|
+
clearTimeout(entry.ghostTimer);
|
|
542
|
+
entry.ghostTimer = null;
|
|
543
|
+
entry.ghost?.remove();
|
|
544
|
+
entry.ghost = null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function unmount(entry) {
|
|
548
|
+
dropGhost(entry);
|
|
549
|
+
entry.shotImage?.remove();
|
|
550
|
+
if (entry.shot) URL.revokeObjectURL(entry.shot.url);
|
|
551
|
+
entry.shot = null;
|
|
552
|
+
finishCapture(entry);
|
|
553
|
+
if (!entry.iframe) return;
|
|
554
|
+
entry.deliveryGeneration++;
|
|
555
|
+
entry.abort?.abort();
|
|
556
|
+
entry.abort = null;
|
|
557
|
+
entry.port?.close();
|
|
558
|
+
entry.iframe.remove();
|
|
559
|
+
Object.assign(entry, { iframe: null, port: null, bridgeToken: null, loaded: false, renderedVersion: 0, awaitingVersion: 0, pendingFetch: false, latestReport: null });
|
|
560
|
+
clearTimeout(entry.reportTimer);
|
|
561
|
+
entry.reportTimer = null;
|
|
562
|
+
entry.placeholder.hidden = false;
|
|
563
|
+
setFrameStatus(entry, 'sleeping');
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function updateFrame(entry) {
|
|
567
|
+
// While a pill is being dragged the page shows its own inline preview; delivering a render would replace the DOM under
|
|
568
|
+
// the pointer. The model still moves (the drag commits live) and this frame catches up when the drag ends.
|
|
569
|
+
if (gesture?.type === 'space' && gesture.id === entry.doc.id) return;
|
|
570
|
+
if (store.expanded?.entry === entry || !entry.iframe || !entry.port || !entry.loaded || entry.pendingFetch || entry.awaitingVersion >= entry.doc.render || entry.renderedVersion >= entry.doc.render) return;
|
|
571
|
+
const frame = entry.iframe;
|
|
572
|
+
const generation = entry.deliveryGeneration;
|
|
573
|
+
entry.pendingFetch = true;
|
|
574
|
+
entry.abort = new AbortController();
|
|
575
|
+
setFrameStatus(entry, 'updating');
|
|
576
|
+
try {
|
|
577
|
+
const rendered = await request(`api/render/${encodeURIComponent(entry.doc.id)}`, { signal: entry.abort.signal });
|
|
578
|
+
if (entry.iframe !== frame || generation !== entry.deliveryGeneration || store.expanded?.entry === entry || rendered.documentId !== entry.doc.id || rendered.render < entry.renderedVersion) return;
|
|
579
|
+
entry.awaitingVersion = rendered.render;
|
|
580
|
+
entry.port?.postMessage({ type: 'divlet:update', documentId: entry.doc.id, version: rendered.render, html: rendered.html });
|
|
581
|
+
} catch (error) {
|
|
582
|
+
if (error.name !== 'AbortError' && entry.iframe === frame && generation === entry.deliveryGeneration) setFrameStatus(entry, 'error');
|
|
583
|
+
} finally {
|
|
584
|
+
if (entry.iframe === frame && generation === entry.deliveryGeneration) { entry.pendingFetch = false; entry.abort = null; }
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function scheduleFrames() {
|
|
589
|
+
if (frameScheduled) return;
|
|
590
|
+
frameScheduled = true;
|
|
591
|
+
requestAnimationFrame(() => {
|
|
592
|
+
frameScheduled = false;
|
|
593
|
+
// Every artboard owns one long-lived iframe; the browser defers loading and painting of off-screen ones.
|
|
594
|
+
// Only frames the user is working with are promoted to an immediate load.
|
|
595
|
+
for (const entry of entries.values()) {
|
|
596
|
+
if (!entry.iframe) mount(entry);
|
|
597
|
+
if (needsEagerLoad(entry) && entry.iframe.loading !== 'eager') entry.iframe.loading = 'eager';
|
|
598
|
+
syncLod(entry);
|
|
599
|
+
}
|
|
600
|
+
paintLiveCount();
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Loaded frames over total; repainted whenever a frame finishes loading, not only when the schedule runs. */
|
|
605
|
+
function paintLiveCount() {
|
|
606
|
+
let loaded = 0;
|
|
607
|
+
for (const entry of entries.values()) if (entry.loaded) loaded++;
|
|
608
|
+
$('#live-count').textContent = `${loaded}/${entries.size}`;
|
|
609
|
+
emit('frames', { live: loaded, total: entries.size });
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const visibility = new IntersectionObserver(scheduleFrames, { root: viewport, rootMargin: `${OVERSCAN}px` });
|
|
613
|
+
let lastSize = '';
|
|
614
|
+
new ResizeObserver(() => {
|
|
615
|
+
scheduleFrames();
|
|
616
|
+
emit('viewport');
|
|
617
|
+
const size = `${viewport.clientWidth}x${viewport.clientHeight}`;
|
|
618
|
+
// After the window changes size, bring the work back if it ended up entirely off screen.
|
|
619
|
+
if (initialFit && lastSize && size !== lastSize && documents().length && !store.expanded && !anyVisible()) fit(null, { animate: false });
|
|
620
|
+
lastSize = size;
|
|
621
|
+
}).observe(viewport);
|
|
622
|
+
|
|
623
|
+
/* ---- mode ----------------------------------------------------------------- */
|
|
624
|
+
function hideBadge() {
|
|
625
|
+
if (badge.hidden) return;
|
|
626
|
+
badge.hidden = true;
|
|
627
|
+
emit('hover-node', null);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function showBadge(entry, node) {
|
|
631
|
+
if (!node || store.mode !== 'select' || store.expanded || !entry.iframe) { hideBadge(); return; }
|
|
632
|
+
const frame = entry.iframe.getBoundingClientRect();
|
|
633
|
+
const v = viewport.getBoundingClientRect();
|
|
634
|
+
const x = frame.left - v.left + node.rect.x * camera.k;
|
|
635
|
+
const y = frame.top - v.top + node.rect.y * camera.k;
|
|
636
|
+
badge.replaceChildren(el('b', '', node.tag), el('span', '', node.nodeId), el('i', '', `${Math.round(node.rect.width)} × ${Math.round(node.rect.height)}`));
|
|
637
|
+
badge.hidden = false;
|
|
638
|
+
const w = badge.offsetWidth;
|
|
639
|
+
badge.style.left = `${clamp(x, 4, v.width - w - 4)}px`;
|
|
640
|
+
badge.style.top = `${y - 26 < 4 ? y + 4 : y - 26}px`;
|
|
641
|
+
emit('hover-node', { documentId: entry.doc.id, nodeId: node.nodeId });
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Two tools: `select` (artboards and elements, the default) and `interact` (use the page itself). */
|
|
645
|
+
export function setMode(next) {
|
|
646
|
+
if (next !== 'interact') next = 'select';
|
|
647
|
+
hideBadge();
|
|
648
|
+
clearHover();
|
|
649
|
+
store.mode = next;
|
|
650
|
+
viewport.classList.remove('mode-select', 'mode-interact');
|
|
651
|
+
viewport.classList.add(`mode-${next}`);
|
|
652
|
+
for (const entry of entries.values()) entry.port?.postMessage({ type: 'divlet:inspect', enabled: !store.expanded && next === 'select' });
|
|
653
|
+
emit('mode', next);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/* ---- pointing into pages ---------------------------------------------------- */
|
|
657
|
+
// The board keeps the pointer over artboards; the page answers what is under it.
|
|
658
|
+
const framePoint = (entry, event) => {
|
|
659
|
+
const frame = entry.iframe?.getBoundingClientRect();
|
|
660
|
+
if (!frame || !entry.loaded) return null;
|
|
661
|
+
return { x: (event.clientX - frame.left) / camera.k, y: (event.clientY - frame.top) / camera.k };
|
|
662
|
+
};
|
|
663
|
+
let hoverEntry = null;
|
|
664
|
+
let hoverFrame = 0;
|
|
665
|
+
function clearHover() {
|
|
666
|
+
cancelAnimationFrame(hoverFrame);
|
|
667
|
+
if (hoverEntry) postToFrame(hoverEntry.doc.id, { type: 'divlet:hover-at', x: null, y: null });
|
|
668
|
+
hoverEntry = null;
|
|
669
|
+
}
|
|
670
|
+
function hoverAt(entry, event) {
|
|
671
|
+
if (hoverEntry && hoverEntry !== entry) clearHover();
|
|
672
|
+
hoverEntry = entry;
|
|
673
|
+
const point = { clientX: event.clientX, clientY: event.clientY };
|
|
674
|
+
cancelAnimationFrame(hoverFrame);
|
|
675
|
+
hoverFrame = requestAnimationFrame(() => {
|
|
676
|
+
const at = framePoint(entry, point);
|
|
677
|
+
if (at) postToFrame(entry.doc.id, { type: 'divlet:hover-at', ...at });
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
function pickAt(entry, event, additive) {
|
|
681
|
+
// A frame that is only a thumbnail at this zoom: a click brings it up close, where its live page takes over.
|
|
682
|
+
if (!additive && !store.selected.has(entry.doc.id) && entry.el.classList.contains('lod')) { selectArtboards([entry.doc.id]); fit([entry.doc.id], { animate: true }); return; }
|
|
683
|
+
const at = framePoint(entry, event);
|
|
684
|
+
if (!at) { selectArtboards([entry.doc.id]); return; }
|
|
685
|
+
if (!store.selected.has(entry.doc.id) || store.selected.size > 1) selectArtboards([entry.doc.id], { keepNode: store.node?.documentId === entry.doc.id });
|
|
686
|
+
postToFrame(entry.doc.id, { type: 'divlet:pick', ...at, additive: !!additive && store.node?.documentId === entry.doc.id });
|
|
687
|
+
}
|
|
688
|
+
function editAt(entry, event) {
|
|
689
|
+
const at = framePoint(entry, event);
|
|
690
|
+
if (!at) return;
|
|
691
|
+
entry.iframe.focus();
|
|
692
|
+
postToFrame(entry.doc.id, { type: 'divlet:edit-at', ...at });
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/* ---- expanded interactive view -------------------------------------------- */
|
|
696
|
+
function setChromeInert(active, entry) {
|
|
697
|
+
for (const node of $$('.chrome')) node.inert = active;
|
|
698
|
+
for (const item of entries.values()) { item.el.inert = active && item !== entry; item.label.inert = active; }
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function cancelDelivery(entry) {
|
|
702
|
+
entry.deliveryGeneration++;
|
|
703
|
+
entry.abort?.abort();
|
|
704
|
+
Object.assign(entry, { abort: null, pendingFetch: false, awaitingVersion: 0, latestReport: null });
|
|
705
|
+
clearTimeout(entry.reportTimer);
|
|
706
|
+
entry.reportTimer = null;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export function openExpanded(documentId, focusTarget) {
|
|
710
|
+
const entry = entries.get(documentId);
|
|
711
|
+
if (!entry || store.expanded) return;
|
|
712
|
+
store.expanded = { entry, mode: store.mode, focusTarget, camera: { ...camera } };
|
|
713
|
+
cancelDelivery(entry);
|
|
714
|
+
store.node = null;
|
|
715
|
+
selectArtboards([documentId]);
|
|
716
|
+
setMode('interact');
|
|
717
|
+
entry.el.classList.add('expanded');
|
|
718
|
+
entry.el.style.cssText = '';
|
|
719
|
+
entry.body.style.cssText = '';
|
|
720
|
+
mount(entry);
|
|
721
|
+
entry.iframe.style.width = '';
|
|
722
|
+
entry.iframe.style.height = '';
|
|
723
|
+
const site = store.state?.site;
|
|
724
|
+
const viewUrl = site?.templateId === documentId ? site.url : entry.doc.viewUrl;
|
|
725
|
+
if (viewUrl) {
|
|
726
|
+
// Browsing is a public document, not an editable preview with links that need a second mode switch.
|
|
727
|
+
entry.port?.close();
|
|
728
|
+
entry.port = null;
|
|
729
|
+
entry.bridgeToken = crypto.randomUUID();
|
|
730
|
+
entry.loaded = false;
|
|
731
|
+
entry.iframe.src = viewUrl;
|
|
732
|
+
} else entry.port?.postMessage({ type: 'divlet:inspect', enabled: false });
|
|
733
|
+
const overlay = $('#expanded-preview');
|
|
734
|
+
overlay.hidden = false;
|
|
735
|
+
overlay.setAttribute('aria-owns', entry.iframe.id);
|
|
736
|
+
$('#expanded-title').textContent = entry.doc.name || entry.doc.id;
|
|
737
|
+
setChromeInert(true, entry);
|
|
738
|
+
document.body.classList.add('is-expanded');
|
|
739
|
+
$('#expanded-back').focus({ preventScroll: true });
|
|
740
|
+
scheduleFrames();
|
|
741
|
+
emit('expanded', true);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export function closeExpanded({ reset = true } = {}) {
|
|
745
|
+
const previous = store.expanded;
|
|
746
|
+
if (!previous) return;
|
|
747
|
+
const entry = previous.entry;
|
|
748
|
+
store.expanded = null;
|
|
749
|
+
$('#expanded-preview').hidden = true;
|
|
750
|
+
$('#expanded-preview').removeAttribute('aria-owns');
|
|
751
|
+
entry.el.classList.remove('expanded');
|
|
752
|
+
setChromeInert(false, entry);
|
|
753
|
+
document.body.classList.remove('is-expanded');
|
|
754
|
+
cancelDelivery(entry);
|
|
755
|
+
store.observations.delete(entry.doc.id);
|
|
756
|
+
store.models.delete(entry.doc.id);
|
|
757
|
+
entry.loaded = false;
|
|
758
|
+
entry.renderedVersion = 0;
|
|
759
|
+
entry.expectedNavigationVersion = entry.doc.render;
|
|
760
|
+
setMode(previous.mode);
|
|
761
|
+
if (reset && entry.iframe) {
|
|
762
|
+
// Double-buffer: the interacted page stays on screen as a ghost until a fresh, authenticated frame has painted.
|
|
763
|
+
const ghost = entry.iframe;
|
|
764
|
+
entry.port?.close();
|
|
765
|
+
entry.port = null;
|
|
766
|
+
entry.iframe = null;
|
|
767
|
+
entry.ghost?.remove();
|
|
768
|
+
entry.ghost = ghost;
|
|
769
|
+
ghost.classList.add('frame-ghost');
|
|
770
|
+
ghost.removeAttribute('id');
|
|
771
|
+
ghost.removeAttribute('data-document-id');
|
|
772
|
+
ghost.tabIndex = -1;
|
|
773
|
+
ghost.setAttribute('aria-hidden', 'true');
|
|
774
|
+
mount(entry, { revision: true });
|
|
775
|
+
place(entry);
|
|
776
|
+
ghost.style.width = entry.iframe.style.width;
|
|
777
|
+
ghost.style.height = entry.iframe.style.height;
|
|
778
|
+
clearTimeout(entry.ghostTimer);
|
|
779
|
+
entry.ghostTimer = setTimeout(() => dropGhost(entry), 4000);
|
|
780
|
+
} else place(entry);
|
|
781
|
+
Object.assign(camera, previous.camera);
|
|
782
|
+
applyCamera();
|
|
783
|
+
if (previous.focusTarget?.isConnected) previous.focusTarget.focus({ preventScroll: true });
|
|
784
|
+
else viewport.focus({ preventScroll: true });
|
|
785
|
+
scheduleFrames();
|
|
786
|
+
emit('expanded', false);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/* ---- observations from frames --------------------------------------------- */
|
|
790
|
+
const validFiles = files => (Array.isArray(files) ? files.filter(file => file instanceof File && file.type.startsWith('image/')).slice(0, 12) : []);
|
|
791
|
+
const validNodes = nodes => (Array.isArray(nodes) ? nodes.slice(0, 300).filter(node => typeof node.id === 'string' && node.rect && ['x', 'y', 'width', 'height'].every(key => Number.isFinite(node.rect[key]))) : []);
|
|
792
|
+
|
|
793
|
+
function queueObservation(entry, report) {
|
|
794
|
+
if (!publishObservations || store.expanded?.entry === entry) return;
|
|
795
|
+
entry.latestReport = report;
|
|
796
|
+
if (entry.reportTimer) return;
|
|
797
|
+
entry.reportTimer = setTimeout(() => {
|
|
798
|
+
entry.reportTimer = null;
|
|
799
|
+
const latest = entry.latestReport;
|
|
800
|
+
entry.latestReport = null;
|
|
801
|
+
if (!latest || !entry.iframe || store.expanded?.entry === entry || !descriptor(entry.doc.id)) return; // a deleted (or leaving) artboard has no document left to report on
|
|
802
|
+
publishObservations(latest).catch(() => {});
|
|
803
|
+
}, 180);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export function flashNodes(documentId, version, nodeIds) {
|
|
807
|
+
const entry = entries.get(documentId);
|
|
808
|
+
if (!entry?.iframe || !nodeIds?.length) return;
|
|
809
|
+
if (entry.renderedVersion >= version) postToFrame(documentId, { type: 'divlet:flash', nodeIds });
|
|
810
|
+
else entry.pendingFlash = { version, nodeIds };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
let handleKey = () => false;
|
|
814
|
+
export const setKeyHandler = fn => { handleKey = fn; };
|
|
815
|
+
|
|
816
|
+
window.addEventListener('message', event => {
|
|
817
|
+
const entry = [...entries.values()].find(item => item.iframe?.contentWindow === event.source);
|
|
818
|
+
const data = event.data;
|
|
819
|
+
if (!entry || !data || typeof data !== 'object') return;
|
|
820
|
+
if (data.type === 'divlet:connect' && data.bridgeToken === entry.bridgeToken && event.ports[0]) {
|
|
821
|
+
const frame = entry.iframe;
|
|
822
|
+
const port = event.ports[0];
|
|
823
|
+
entry.port?.close();
|
|
824
|
+
entry.port = port;
|
|
825
|
+
port.onmessage = message => {
|
|
826
|
+
if (entry.iframe === frame && entry.port === port) handlePreviewMessage(entry, message.data);
|
|
827
|
+
};
|
|
828
|
+
port.start();
|
|
829
|
+
port.postMessage({ type: 'divlet:writable', enabled: store.state?.project?.capabilities?.properties === true });
|
|
830
|
+
port.postMessage({ type: 'divlet:inspect', enabled: inspectFlag() });
|
|
831
|
+
port.postMessage({ type: 'divlet:handles-enabled', enabled: editHandles() });
|
|
832
|
+
syncNodeSelection(entry);
|
|
833
|
+
syncWork(entry);
|
|
834
|
+
} else if (store.expanded?.entry === entry) {
|
|
835
|
+
if (data.type === 'divlet:escape') closeExpanded();
|
|
836
|
+
else if (data.type === 'divlet:site-ready' && store.state?.site) entry.iframe.contentWindow.postMessage({ type: 'divlet:site-state', site: { version: store.state.site.version } }, '*');
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Play: a link in one artboard names a page of the project. When that page is another artboard's, reveal it, select it and
|
|
842
|
+
* take its page to the fragment (a plain page link lands at the top, like a navigation would). The frames stay loaded; nothing
|
|
843
|
+
* is swapped, and a link to no artboard says so rather than leave for a missing file.
|
|
844
|
+
*/
|
|
845
|
+
function followLink(entry, href) {
|
|
846
|
+
const base = entry.doc.viewUrl;
|
|
847
|
+
if (!base) return;
|
|
848
|
+
const page = url => { try { return url.origin + decodeURIComponent(url.pathname); } catch { return url.origin + url.pathname; } };
|
|
849
|
+
let target;
|
|
850
|
+
try { target = new URL(href, new URL(base, location.href)); } catch { return; }
|
|
851
|
+
const destination = [...entries.values()].find(item => item.doc.viewUrl && page(new URL(item.doc.viewUrl, location.href)) === page(target));
|
|
852
|
+
if (!destination) {
|
|
853
|
+
const name = page(target).split('/').pop() || target.pathname;
|
|
854
|
+
toast(`“${name.length > 60 ? name.slice(0, 57) + '…' : name}” is not an artboard of this project`);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const id = destination.doc.id;
|
|
858
|
+
if (id !== entry.doc.id) { selectArtboards([id]); reveal(id); }
|
|
859
|
+
const message = { type: 'divlet:scroll-to', fragment: target.hash };
|
|
860
|
+
if (destination.port && destination.loaded) destination.port.postMessage(message);
|
|
861
|
+
else destination.pendingScroll = message;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function handlePreviewMessage(entry, data) {
|
|
865
|
+
if (!data || typeof data !== 'object') return;
|
|
866
|
+
if (data.type === 'divlet:snapshot') { receiveShot(entry, data); return; }
|
|
867
|
+
if (data.type === 'divlet:snapshot-failed') { finishCapture(entry, data.captureId); return; }
|
|
868
|
+
if (store.expanded?.entry === entry) {
|
|
869
|
+
if (data.type === 'divlet:escape') closeExpanded();
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
if (data.type === 'divlet:link' && typeof data.href === 'string' && data.href.length <= 2048) { followLink(entry, data.href); return; }
|
|
873
|
+
if (data.type === 'divlet:wheel' && Number.isFinite(data.deltaY)) {
|
|
874
|
+
const frame = entry.iframe.getBoundingClientRect();
|
|
875
|
+
const px = frame.left + (Number(data.clientX) || 0) * camera.k;
|
|
876
|
+
const py = frame.top + (Number(data.clientY) || 0) * camera.k;
|
|
877
|
+
zoomAt(Math.exp(-clamp(data.deltaY * (data.deltaMode === 1 ? 16 : 1), -120, 120) * 0.0022), px, py);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (data.type === 'divlet:key') { handleKey({ ...data, fromFrame: true }); return; }
|
|
881
|
+
if (data.type === 'divlet:paste') {
|
|
882
|
+
if (typeof data.text === 'string' && data.text.length <= 512 * 1024) emit('frame-paste', { documentId: entry.doc.id, text: data.text, files: validFiles(data.files) });
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (data.type === 'divlet:drop') {
|
|
886
|
+
const files = validFiles(data.files);
|
|
887
|
+
if (files.length) emit('images', { documentId: entry.doc.id, files, nodeId: typeof data.nodeId === 'string' ? data.nodeId : null });
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (data.type === 'divlet:escape') { handleKey({ key: 'Escape' }); return; }
|
|
891
|
+
if (data.type === 'divlet:edit-step' && (data.dir === 1 || data.dir === -1)) { emit('edit-step', { documentId: entry.doc.id, dir: data.dir, create: !!data.create, nodeId: typeof data.nodeId === 'string' ? data.nodeId : null }); return; }
|
|
892
|
+
if (data.type === 'divlet:edit-fallback' && typeof data.nodeId === 'string' && typeof data.tag === 'string') { emit('edit-fallback', { documentId: entry.doc.id, nodeId: data.nodeId, tag: data.tag.slice(0, 20) }); return; }
|
|
893
|
+
if (data.type === 'divlet:editing') { entry.el.classList.toggle('editing', !!data.active); if (!data.active && document.activeElement === entry.iframe) viewport.focus({ preventScroll: true }); return; }
|
|
894
|
+
if (data.documentId !== entry.doc.id || !Number.isInteger(data.version) || data.version < entry.expectedNavigationVersion) return;
|
|
895
|
+
if (data.type === 'divlet:context') {
|
|
896
|
+
const frame = entry.iframe.getBoundingClientRect();
|
|
897
|
+
emit('context', { kind: 'node', id: entry.doc.id, x: frame.left + (Number(data.clientX) || 0) * camera.k, y: frame.top + (Number(data.clientY) || 0) * camera.k });
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (data.type === 'divlet:spacing-edit' && typeof data.nodeId === 'string' && (data.kind === 'padding' || data.kind === 'margin' || data.kind === 'gap' || data.kind === 'size') && Array.isArray(data.sides) && (data.kind === 'size' || Number.isFinite(data.value))) {
|
|
901
|
+
// A side (or the gap) dragged on the canvas becomes a declarative style on the element (the style engine turns it into a class).
|
|
902
|
+
const names = { t: 'top', r: 'right', b: 'bottom', l: 'left' };
|
|
903
|
+
const value = Math.round(clamp(Number(data.value) || 0, data.kind === 'margin' ? -2000 : 0, 2000));
|
|
904
|
+
// A drag commits while it runs so the model, inspector and built pages follow it; the drag's group keeps one undo step.
|
|
905
|
+
// Trailing messages of a finished drag still belong to it, so a stray late edit never becomes its own undo step.
|
|
906
|
+
const dragging = gesture?.type === 'space' && gesture.id === entry.doc.id;
|
|
907
|
+
const drag = dragging ? gesture : null;
|
|
908
|
+
if (drag) drag.live = true;
|
|
909
|
+
const landed = result => { if (drag) drag.liveCommitted = true; return result; };
|
|
910
|
+
// The bridge's own messages only ever come from a drag on this document, so its last drag group is the right one even
|
|
911
|
+
// when the message arrives long after the release (a busy main thread must not split a gesture into two undo steps).
|
|
912
|
+
const group = dragging ? gesture.group : entry.spaceGroup;
|
|
913
|
+
if (data.kind === 'size') {
|
|
914
|
+
const styles = {};
|
|
915
|
+
for (const key of ['width', 'height']) if (Number.isFinite(data[key])) styles[key] = `${Math.round(clamp(data[key], 8, 16384))}px`;
|
|
916
|
+
if (Object.keys(styles).length) commit(entry.doc.id, { nodes: { [data.nodeId]: { styles } } }, { label: `size ${Object.entries(styles).map(([key, value]) => `${key} ${value}`).join(', ')} on ${data.nodeId}`, group }).then(landed).catch(() => {});
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (data.kind === 'gap') {
|
|
920
|
+
commit(entry.doc.id, { nodes: { [data.nodeId]: { styles: { gap: `${value}px` } } } }, { label: `gap ${value}px on ${data.nodeId}`, group }).then(landed).catch(() => {});
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const sides = [...new Set(data.sides)].filter(side => names[side]);
|
|
924
|
+
if (!sides.length) return;
|
|
925
|
+
const styles = Object.fromEntries(sides.map(side => [`${data.kind}-${names[side]}`, `${value}px`]));
|
|
926
|
+
const where = sides.length === 4 ? 'all sides' : sides.map(side => names[side]).join(' + ');
|
|
927
|
+
commit(entry.doc.id, { nodes: { [data.nodeId]: { styles } } }, { label: `${data.kind} ${where} ${value}px on ${data.nodeId}`, group }).then(landed).catch(() => {});
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
if (data.type === 'divlet:handles') {
|
|
931
|
+
// Measurements belong to the render that produced them: an older render's report can arrive after a newer one and would
|
|
932
|
+
// leave the inspector quoting numbers the page no longer has (a pill's radius after its class was removed).
|
|
933
|
+
if (Number.isInteger(data.version) && data.version < entry.renderedVersion) return;
|
|
934
|
+
const source = data.box;
|
|
935
|
+
const sides = key => Array.isArray(source?.[key]) && source[key].length === 4 && source[key].every(Number.isFinite);
|
|
936
|
+
const box = typeof source?.nodeId === 'string' && sides('margin') && sides('padding') ? { nodeId: source.nodeId, margin: [...source.margin], padding: [...source.padding] } : null;
|
|
937
|
+
if (box) {
|
|
938
|
+
if (sides('border') && source.border.every(value => value >= 0)) box.border = [...source.border];
|
|
939
|
+
if (['static', 'relative', 'absolute', 'fixed', 'sticky'].includes(source.position)) box.position = source.position;
|
|
940
|
+
if (['content-box', 'border-box'].includes(source.boxSizing)) box.boxSizing = source.boxSizing;
|
|
941
|
+
if (Number.isInteger(data.version)) box.render = data.version;
|
|
942
|
+
if (Array.isArray(source.inset) && source.inset.length === 4 && source.inset.every(value => typeof value === 'string' && value.length <= 64)) box.inset = [...source.inset];
|
|
943
|
+
if (source.content && ['width', 'height'].every(key => source.content[key] === null || Number.isFinite(source.content[key]) && source.content[key] >= 0)) box.content = { width: source.content.width, height: source.content.height };
|
|
944
|
+
if (source.colors && typeof source.colors === 'object') box.colors = Object.fromEntries(['background', 'color', 'radius', 'fontSize', 'borderColor', 'borderStyle'].filter(key => typeof source.colors[key] === 'string' && source.colors[key].length <= 64).map(key => [key, source.colors[key]]));
|
|
945
|
+
}
|
|
946
|
+
const changed = JSON.stringify(box) !== JSON.stringify(entry.box ?? null);
|
|
947
|
+
entry.box = box;
|
|
948
|
+
if (changed) emit('spacing-box', { documentId: entry.doc.id, nodeId: box?.nodeId ?? null });
|
|
949
|
+
}
|
|
950
|
+
if (data.type === 'divlet:handles') {
|
|
951
|
+
if (Number.isInteger(data.version) && data.version < entry.renderedVersion) return;
|
|
952
|
+
entry.handlesNode = typeof data.nodeId === 'string' ? data.nodeId : null; entry.handles = Array.isArray(data.rects) ? data.rects.filter(item => typeof item?.id === 'string' && [item.x, item.y, item.w, item.h].every(Number.isFinite)).slice(0, 12) : []; return; }
|
|
953
|
+
if (data.type === 'divlet:reorder' && typeof data.nodeId === 'string' && typeof data.targetId === 'string') {
|
|
954
|
+
// A live reorder belongs to the running grip drag, so it commits in the drag's own undo group. The release message can
|
|
955
|
+
// arrive after the pointer is up, so the drag's group stays known to the frame for a moment (same rule as the pills).
|
|
956
|
+
const dragging = gesture?.type === 'space' && gesture.id === entry.doc.id;
|
|
957
|
+
// The bridge's own messages only ever come from a drag on this document, so its last drag group is the right one even
|
|
958
|
+
// when the message arrives long after the release (a busy main thread must not split a gesture into two undo steps).
|
|
959
|
+
const group = dragging ? gesture.group : entry.spaceGroup;
|
|
960
|
+
const drag = dragging ? gesture : null;
|
|
961
|
+
if (drag) drag.live = true;
|
|
962
|
+
// The reorder commits through the actions layer; it reports back so a cancelled drag only undoes what really landed.
|
|
963
|
+
const committed = () => { if (drag) drag.liveCommitted = true; };
|
|
964
|
+
emit('reorder', { documentId: entry.doc.id, nodeId: data.nodeId, targetId: data.targetId, after: !!data.after, copy: !!data.copy, group, committed });
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (data.type === 'divlet:hover-node') { showBadge(entry, data.node && typeof data.node.nodeId === 'string' && data.node.rect ? data.node : null); return; }
|
|
968
|
+
if (data.type === 'divlet:rendered') {
|
|
969
|
+
if (data.version < entry.renderedVersion) return;
|
|
970
|
+
entry.renderedVersion = data.version;
|
|
971
|
+
entry.awaitingVersion = 0;
|
|
972
|
+
entry.placeholder.hidden = true;
|
|
973
|
+
if (data.version >= entry.doc.render) queueCapture(entry);
|
|
974
|
+
if (entry.ghost) requestAnimationFrame(() => requestAnimationFrame(() => dropGhost(entry)));
|
|
975
|
+
setFrameStatus(entry, data.version >= entry.doc.render ? 'live' : 'updating');
|
|
976
|
+
entry.port?.postMessage({ type: 'divlet:inspect', enabled: inspectFlag() });
|
|
977
|
+
syncNodeSelection(entry);
|
|
978
|
+
syncWork(entry);
|
|
979
|
+
if (entry.pendingFlash && entry.pendingFlash.version <= data.version) { postToFrame(entry.doc.id, { type: 'divlet:flash', nodeIds: entry.pendingFlash.nodeIds }); entry.pendingFlash = null; }
|
|
980
|
+
if (entry.pendingScroll) { entry.port?.postMessage(entry.pendingScroll); entry.pendingScroll = null; }
|
|
981
|
+
deliverEdit(entry);
|
|
982
|
+
updateFrame(entry);
|
|
983
|
+
} else if (data.type === 'divlet:observations') {
|
|
984
|
+
if (data.version < entry.renderedVersion) return;
|
|
985
|
+
// `version` here is the render revision the frame shows; the server checks it against the frame's render.
|
|
986
|
+
const report = { documentId: data.documentId, version: data.version, render: data.version, nodes: validNodes(data.nodes) };
|
|
987
|
+
for (const key of ['contentHeight', 'scrollHeight']) if (Number.isFinite(data[key]) && data[key] >= 0 && data[key] < 1e7) report[key] = Math.round(data[key]);
|
|
988
|
+
store.observations.set(data.documentId, report);
|
|
989
|
+
queueObservation(entry, report);
|
|
990
|
+
emit('observations', report);
|
|
991
|
+
} else if (data.type === 'divlet:node-selection') {
|
|
992
|
+
if (data.version < entry.renderedVersion || !Array.isArray(data.nodeIds)) return;
|
|
993
|
+
selectNodes(data.documentId, data.nodeIds.filter(id => typeof id === 'string').slice(0, 50));
|
|
994
|
+
} else if (data.type === 'divlet:override-text' && typeof data.instance === 'string' && typeof data.address === 'string' && typeof data.content === 'string') {
|
|
995
|
+
// Text typed into an instance becomes an override of that instance, never an edit of the component.
|
|
996
|
+
emit('override-text', { documentId: data.documentId, instance: data.instance, address: data.address, content: data.content });
|
|
997
|
+
} else if (data.type === 'divlet:text-edit' && typeof data.nodeId === 'string' && typeof data.content === 'string') {
|
|
998
|
+
edit(data.documentId, { type: 'text', id: data.nodeId, content: data.content }, { label: `edit text of ${data.nodeId}`, expectedRender: Number.isInteger(data.renderVersion) ? data.renderVersion : data.version }).catch(() => {});
|
|
999
|
+
} else if (data.type === 'divlet:render-error' || data.type === 'divlet:error') {
|
|
1000
|
+
entry.awaitingVersion = 0;
|
|
1001
|
+
setFrameStatus(entry, 'error');
|
|
1002
|
+
toast(data.message || 'The preview could not apply this revision.');
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/* ---- gestures ------------------------------------------------------------- */
|
|
1007
|
+
function snapMove(ids, dx, dy, startRects) {
|
|
1008
|
+
const moving = ids.map(descriptor).filter(Boolean);
|
|
1009
|
+
// Snapping is measured from where the drag started, not from a rect a live commit has already moved.
|
|
1010
|
+
const origin = new Map(moving.map(doc => [doc.id, startRects?.get(doc.id) ?? doc.artboard]));
|
|
1011
|
+
const b = boundsOf(moving.map(doc => ({ ...doc, artboard: origin.get(doc.id) })));
|
|
1012
|
+
const others = documents().filter(doc => !ids.includes(doc.id));
|
|
1013
|
+
const threshold = 6 / camera.k;
|
|
1014
|
+
const lines = [];
|
|
1015
|
+
let sx = dx;
|
|
1016
|
+
let sy = dy;
|
|
1017
|
+
if (others.length && !snapDisabled) {
|
|
1018
|
+
for (const [pos, size, cross, crossSize] of [['x', 'width', 'y', 'height'], ['y', 'height', 'x', 'width']]) {
|
|
1019
|
+
const delta = pos === 'x' ? dx : dy;
|
|
1020
|
+
const edges = [b[pos] + delta, b[pos] + delta + b[size] / 2, b[pos] + delta + b[size]];
|
|
1021
|
+
let best = null;
|
|
1022
|
+
for (const other of others) {
|
|
1023
|
+
const r = other.artboard;
|
|
1024
|
+
for (const target of [r[pos], r[pos] + r[size] / 2, r[pos] + r[size]]) for (const edge of edges) {
|
|
1025
|
+
const diff = target - edge;
|
|
1026
|
+
if (Math.abs(diff) <= threshold && (!best || Math.abs(diff) < Math.abs(best.diff))) best = { diff, target, other };
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
if (best) {
|
|
1030
|
+
if (pos === 'x') sx = dx + best.diff; else sy = dy + best.diff;
|
|
1031
|
+
lines.push({ axis: pos, at: best.target, other: best.other.artboard, cross, crossSize });
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return { dx: Math.round(sx), dy: Math.round(sy), lines, bounds: b };
|
|
1036
|
+
}
|
|
1037
|
+
/** Snap resized edges to neighbouring artboards and the moving width to common device widths. */
|
|
1038
|
+
function snapResize(current, rect) {
|
|
1039
|
+
if (snapDisabled) return { rect, lines: [] };
|
|
1040
|
+
const threshold = 6 / camera.k;
|
|
1041
|
+
const others = documents().filter(doc => doc.id !== current.id).map(doc => doc.artboard);
|
|
1042
|
+
const out = { ...rect };
|
|
1043
|
+
const lines = [];
|
|
1044
|
+
const nearest = (value, targets) => {
|
|
1045
|
+
let best = null;
|
|
1046
|
+
for (const target of targets) { const diff = target.at - value; if (Math.abs(diff) <= threshold && (!best || Math.abs(diff) < Math.abs(best.diff))) best = { ...target, diff }; }
|
|
1047
|
+
return best;
|
|
1048
|
+
};
|
|
1049
|
+
const edges = axis => others.flatMap(o => (axis === 'x' ? [o.x, o.x + o.width] : [o.y, o.y + o.height]).map(at => ({ at, other: o })));
|
|
1050
|
+
const right = rect.x + rect.width;
|
|
1051
|
+
const bottom = rect.y + rect.height;
|
|
1052
|
+
if (current.dir.includes('e')) {
|
|
1053
|
+
const hit = nearest(right, edges('x'));
|
|
1054
|
+
const preset = nearest(rect.width, WIDTH_PRESETS.map(at => ({ at })));
|
|
1055
|
+
if (preset) out.width = preset.at;
|
|
1056
|
+
else if (hit) { out.width = Math.round(hit.at - rect.x); lines.push({ axis: 'x', at: hit.at, other: hit.other }); }
|
|
1057
|
+
}
|
|
1058
|
+
if (current.dir.includes('w')) {
|
|
1059
|
+
const hit = nearest(rect.x, edges('x'));
|
|
1060
|
+
const preset = nearest(rect.width, WIDTH_PRESETS.map(at => ({ at })));
|
|
1061
|
+
if (preset) { out.width = preset.at; out.x = right - preset.at; }
|
|
1062
|
+
else if (hit) { out.x = Math.round(hit.at); out.width = right - out.x; lines.push({ axis: 'x', at: hit.at, other: hit.other }); }
|
|
1063
|
+
}
|
|
1064
|
+
if (current.dir.includes('s')) {
|
|
1065
|
+
const hit = nearest(bottom, edges('y'));
|
|
1066
|
+
if (hit) { out.height = Math.round(hit.at - rect.y); lines.push({ axis: 'y', at: hit.at, other: hit.other }); }
|
|
1067
|
+
}
|
|
1068
|
+
if (current.dir.includes('n')) {
|
|
1069
|
+
const hit = nearest(rect.y, edges('y'));
|
|
1070
|
+
if (hit) { out.y = Math.round(hit.at); out.height = bottom - out.y; lines.push({ axis: 'y', at: hit.at, other: hit.other }); }
|
|
1071
|
+
}
|
|
1072
|
+
if (out.width < MIN_SIZE || out.height < MIN_SIZE) return { rect, lines: [] };
|
|
1073
|
+
return { rect: out, lines };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function drawGuides(lines, rect) {
|
|
1077
|
+
guides.replaceChildren();
|
|
1078
|
+
for (const line of lines) {
|
|
1079
|
+
const node = el('div', `guide guide-${line.axis}`);
|
|
1080
|
+
const s = camera;
|
|
1081
|
+
if (line.axis === 'x') {
|
|
1082
|
+
const x = s.x + line.at * s.k;
|
|
1083
|
+
const y0 = s.y + Math.min(line.other.y, rect.y) * s.k;
|
|
1084
|
+
const y1 = s.y + Math.max(line.other.y + line.other.height, rect.y + rect.height) * s.k;
|
|
1085
|
+
Object.assign(node.style, { left: `${x}px`, top: `${y0}px`, height: `${y1 - y0}px` });
|
|
1086
|
+
} else {
|
|
1087
|
+
const y = s.y + line.at * s.k;
|
|
1088
|
+
const x0 = s.x + Math.min(line.other.x, rect.x) * s.k;
|
|
1089
|
+
const x1 = s.x + Math.max(line.other.x + line.other.width, rect.x + rect.width) * s.k;
|
|
1090
|
+
Object.assign(node.style, { top: `${y}px`, left: `${x0}px`, width: `${x1 - x0}px` });
|
|
1091
|
+
}
|
|
1092
|
+
guides.append(node);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function applyOverrides(map) {
|
|
1097
|
+
overrides.clear();
|
|
1098
|
+
for (const [id, rect] of map) { overrides.set(id, rect); emit('live-rect', { id, rect }); }
|
|
1099
|
+
for (const entry of entries.values()) {
|
|
1100
|
+
place(entry);
|
|
1101
|
+
if (!entry.size.querySelector('input')) entry.size.textContent = `${round(rectOf(entry.doc).width)} × ${round(rectOf(entry.doc).height)}`;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Drag steps that reach the project while the gesture runs (the same idea as the bridge's pill drags): the artboard keeps
|
|
1106
|
+
// its own preview rect, the model and everything built from it follow at most every DRAG_MS, and one group keeps one undo step.
|
|
1107
|
+
const DRAG_MS = 90;
|
|
1108
|
+
function liveArtboards(done, items) {
|
|
1109
|
+
const stamp = JSON.stringify(items.map(item => [item.id, item.patch]));
|
|
1110
|
+
if (done.liveStamp === stamp) return;
|
|
1111
|
+
const now = performance.now();
|
|
1112
|
+
if (now - (done.liveAt || 0) < DRAG_MS) return;
|
|
1113
|
+
done.liveAt = now;
|
|
1114
|
+
done.liveStamp = stamp;
|
|
1115
|
+
for (const { id, patch, label } of items) {
|
|
1116
|
+
const doc = descriptor(id);
|
|
1117
|
+
if (!doc) continue;
|
|
1118
|
+
// The preview rect stays authoritative for the whole gesture: while a landed commit is being reconciled the model can sit a
|
|
1119
|
+
// throttling step behind the pointer, and releasing the override there made the artboard step backwards mid-drag. The
|
|
1120
|
+
// release, a cancel and a failed commit all clear it, so it never outlives the gesture.
|
|
1121
|
+
pendingOverrides.set(id, { version: Number.POSITIVE_INFINITY });
|
|
1122
|
+
commit(id, { artboard: patch }, { label, group: done.group })
|
|
1123
|
+
.then(() => { done.liveCommitted = true; })
|
|
1124
|
+
.catch(() => { pendingOverrides.delete(id); overrides.delete(id); });
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function finishOverrides(results) {
|
|
1129
|
+
Promise.allSettled(results).then(() => {
|
|
1130
|
+
for (const [id, pending] of [...pendingOverrides]) {
|
|
1131
|
+
const doc = descriptor(id);
|
|
1132
|
+
if (!doc || pending.failed || doc.version >= pending.version) { pendingOverrides.delete(id); overrides.delete(id); }
|
|
1133
|
+
}
|
|
1134
|
+
reconcileArtboards();
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function startPan(event) {
|
|
1139
|
+
event.preventDefault();
|
|
1140
|
+
cancelAnimationFrame(animation);
|
|
1141
|
+
gesture = { type: 'pan', pointerId: event.pointerId, x: event.clientX, y: event.clientY, cx: camera.x, cy: camera.y };
|
|
1142
|
+
viewport.classList.add('panning');
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function startPinch() {
|
|
1146
|
+
const [a, b] = [...pointers.values()];
|
|
1147
|
+
cancelAnimationFrame(animation);
|
|
1148
|
+
gesture = { type: 'pinch', distance: Math.hypot(a.x - b.x, a.y - b.y) || 1, k: camera.k, mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, cx: camera.x, cy: camera.y };
|
|
1149
|
+
marquee.hidden = true;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// The middle button pans the canvas wherever the board owns the pointer (everywhere but inside
|
|
1153
|
+
// a page in Play mode, where the page may use it). No browser autoscroll or paste on top of it.
|
|
1154
|
+
viewport.addEventListener('mousedown', event => { if (event.button === 1 && !store.expanded) event.preventDefault(); });
|
|
1155
|
+
viewport.addEventListener('auxclick', event => { if (event.button === 1) event.preventDefault(); });
|
|
1156
|
+
|
|
1157
|
+
viewport.addEventListener('pointerdown', event => {
|
|
1158
|
+
if (store.expanded || (event.button !== 0 && event.button !== 1)) return;
|
|
1159
|
+
if (event.target.closest('#empty button')) return;
|
|
1160
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
1161
|
+
if (pointers.size === 2) { startPinch(); return; }
|
|
1162
|
+
if (pointers.size > 2) return;
|
|
1163
|
+
// Pressing the canvas takes focus from a field (committing it), so keys act on the board again.
|
|
1164
|
+
const active = document.activeElement;
|
|
1165
|
+
if (active && active !== viewport && active !== document.body) active.blur?.();
|
|
1166
|
+
viewport.focus({ preventScroll: true });
|
|
1167
|
+
viewport.setPointerCapture(event.pointerId);
|
|
1168
|
+
snapDisabled = event.altKey;
|
|
1169
|
+
if (spaceDown || event.button === 1) return startPan(event);
|
|
1170
|
+
const handle = event.target.closest('.ab-handle');
|
|
1171
|
+
const wrapper = event.target.closest('.artboard');
|
|
1172
|
+
if (handle && wrapper) {
|
|
1173
|
+
const entry = entries.get(wrapper.dataset.artboardId);
|
|
1174
|
+
event.preventDefault();
|
|
1175
|
+
gesture = { type: 'resize', pointerId: event.pointerId, dir: handle.dataset.dir, id: entry.doc.id, start: { ...entry.doc.artboard }, x: event.clientX, y: event.clientY, group: newGroup() };
|
|
1176
|
+
entry.el.classList.add('resizing');
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
const grab = event.target.closest('.ab-label, .frame-cover');
|
|
1180
|
+
if (grab && wrapper && !event.target.closest('.ab-expand, .ab-more, .ab-rename')) {
|
|
1181
|
+
const id = wrapper.dataset.artboardId;
|
|
1182
|
+
event.preventDefault();
|
|
1183
|
+
const onName = !!event.target.closest('.ab-name');
|
|
1184
|
+
const onSize = !!event.target.closest('.ab-size');
|
|
1185
|
+
const onPage = grab.classList.contains('frame-cover');
|
|
1186
|
+
// A side pill of the selected element (margin or padding) is dragged instead of the artboard.
|
|
1187
|
+
const pill = onPage ? spaceHandleAt(entries.get(id), event) : null;
|
|
1188
|
+
if (pill) {
|
|
1189
|
+
// One group for the whole drag: the live commits and the release fold into a single undo step.
|
|
1190
|
+
gesture = { type: 'space', pointerId: event.pointerId, id, x: event.clientX, y: event.clientY, group: newGroup() };
|
|
1191
|
+
entries.get(id).port.postMessage({ type: 'divlet:space-drag', phase: 'start', id: pill });
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (!event.shiftKey && doubleTap(event, `${onName ? 'name' : onSize ? 'size' : onPage ? 'cover' : 'label'}:${id}`)) {
|
|
1195
|
+
const entry = entries.get(id);
|
|
1196
|
+
if (onName) renameArtboard(entry);
|
|
1197
|
+
else if (onSize) editSize(entry);
|
|
1198
|
+
else if (onPage) editAt(entry, event);
|
|
1199
|
+
else fit([id]);
|
|
1200
|
+
gesture = { type: 'idle', pointerId: event.pointerId };
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
// On the page: a click picks the element under the pointer, a drag moves the artboard.
|
|
1204
|
+
if (onPage) {
|
|
1205
|
+
gesture = { type: 'press', pointerId: event.pointerId, x: event.clientX, y: event.clientY, id, additive: event.shiftKey || event.metaKey || event.ctrlKey, group: newGroup() };
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (event.shiftKey) {
|
|
1209
|
+
if (store.selected.has(id)) selectArtboards([...store.selected].filter(item => item !== id));
|
|
1210
|
+
else selectArtboards([id], { additive: true });
|
|
1211
|
+
gesture = { type: 'idle', pointerId: event.pointerId };
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
const wasSelected = store.selected.has(id);
|
|
1215
|
+
if (!wasSelected) selectArtboards([id]);
|
|
1216
|
+
const ids = [...store.selected];
|
|
1217
|
+
// Where every moving artboard started: live commits re-publish the model, so deltas are measured from here, not from it.
|
|
1218
|
+
const startRects = new Map(ids.map(item => [item, { ...rectOf(descriptor(item) || { artboard: {} }) }]));
|
|
1219
|
+
gesture = { type: 'move', pointerId: event.pointerId, x: event.clientX, y: event.clientY, id, ids, wasSelected, moved: false, group: newGroup(), startRects };
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
if (store.mode === 'select' && !wrapper) {
|
|
1223
|
+
event.preventDefault();
|
|
1224
|
+
store.node = null;
|
|
1225
|
+
const b = viewport.getBoundingClientRect();
|
|
1226
|
+
gesture = { type: 'marquee', pointerId: event.pointerId, start: { x: event.clientX - b.left, y: event.clientY - b.top }, end: { x: event.clientX - b.left, y: event.clientY - b.top }, initial: event.shiftKey ? [...store.selected] : [] };
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
if (!wrapper) startPan(event);
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
viewport.addEventListener('pointermove', event => {
|
|
1233
|
+
if (pointers.has(event.pointerId)) pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
1234
|
+
if (gesture?.type === 'pinch' && pointers.size >= 2) {
|
|
1235
|
+
const [a, b] = [...pointers.values()];
|
|
1236
|
+
const distance = Math.hypot(a.x - b.x, a.y - b.y) || 1;
|
|
1237
|
+
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
1238
|
+
const bounds = viewport.getBoundingClientRect();
|
|
1239
|
+
const k = clamp(gesture.k * distance / gesture.distance, MIN_K, MAX_K);
|
|
1240
|
+
const ratio = k / gesture.k;
|
|
1241
|
+
const px = gesture.mid.x - bounds.left;
|
|
1242
|
+
const py = gesture.mid.y - bounds.top;
|
|
1243
|
+
setCamera({ k, x: px - (px - gesture.cx) * ratio + (mid.x - gesture.mid.x), y: py - (py - gesture.cy) * ratio + (mid.y - gesture.mid.y) });
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
if (!gesture) {
|
|
1247
|
+
const cover = event.target.closest?.('.frame-cover');
|
|
1248
|
+
const wrapper = cover?.closest('.artboard');
|
|
1249
|
+
const pillId = wrapper ? spaceHandleAt(entries.get(wrapper.dataset.artboardId), event) : null;
|
|
1250
|
+
viewport.title = pillId ? handleHint(pillId) : '';
|
|
1251
|
+
viewport.style.cursor = pillId ? (pillId === 'move' ? 'grab' : pillId === 'size' ? 'nwse-resize' : /-(?:[lr]|x)$/.test(pillId) ? 'ew-resize' : 'ns-resize') : '';
|
|
1252
|
+
if (wrapper && store.mode === 'select' && !wrapper.classList.contains('editing') && !pillId) hoverAt(entries.get(wrapper.dataset.artboardId), event);
|
|
1253
|
+
else if (hoverEntry) { clearHover(); hideBadge(); }
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
if (gesture.pointerId !== event.pointerId) return;
|
|
1257
|
+
if (gesture.type === 'press') {
|
|
1258
|
+
if (Math.hypot(event.clientX - gesture.x, event.clientY - gesture.y) < 4) return;
|
|
1259
|
+
clearHover();
|
|
1260
|
+
hideBadge();
|
|
1261
|
+
const { id } = gesture;
|
|
1262
|
+
const wasSelected = store.selected.has(id);
|
|
1263
|
+
if (!wasSelected) selectArtboards([id]);
|
|
1264
|
+
// Where every moving artboard started: a live commit re-publishes the model, so deltas must be measured from here.
|
|
1265
|
+
const startRects = new Map([...store.selected].map(item => [item, { ...rectOf(descriptor(item) || { artboard: {} }) }]));
|
|
1266
|
+
gesture = { type: 'move', pointerId: gesture.pointerId, x: gesture.x, y: gesture.y, id, ids: [...store.selected], wasSelected, moved: false, group: gesture.group, startRects };
|
|
1267
|
+
}
|
|
1268
|
+
if (gesture.type === 'space') {
|
|
1269
|
+
entries.get(gesture.id)?.port.postMessage({ type: 'divlet:space-drag', phase: 'move', dx: (event.clientX - gesture.x) / camera.k, dy: (event.clientY - gesture.y) / camera.k, ...pagePoint(gesture.id, event), shift: event.shiftKey, ctrl: event.ctrlKey || event.metaKey, alt: event.altKey });
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
if (gesture.type === 'pan') {
|
|
1273
|
+
setCamera({ x: gesture.cx + event.clientX - gesture.x, y: gesture.cy + event.clientY - gesture.y });
|
|
1274
|
+
} else if (gesture.type === 'move') {
|
|
1275
|
+
const dist = Math.hypot(event.clientX - gesture.x, event.clientY - gesture.y);
|
|
1276
|
+
if (!gesture.moved && dist < 4) return;
|
|
1277
|
+
gesture.moved = true;
|
|
1278
|
+
viewport.classList.add('moving');
|
|
1279
|
+
snapDisabled = event.altKey;
|
|
1280
|
+
const raw = { dx: (event.clientX - gesture.x) / camera.k, dy: (event.clientY - gesture.y) / camera.k };
|
|
1281
|
+
// Every rect is measured from where the drag started, never from the model a live commit has already moved.
|
|
1282
|
+
const origin = id => gesture.startRects?.get(id) ?? descriptor(id).artboard;
|
|
1283
|
+
const snapped = snapMove(gesture.ids, raw.dx, raw.dy, gesture.startRects);
|
|
1284
|
+
const map = new Map();
|
|
1285
|
+
for (const id of gesture.ids) { const a = origin(id); map.set(id, { ...a, x: a.x + snapped.dx, y: a.y + snapped.dy }); }
|
|
1286
|
+
gesture.delta = snapped;
|
|
1287
|
+
applyOverrides(map);
|
|
1288
|
+
liveArtboards(gesture, [...gesture.ids].map(id => { const a = origin(id); return { id, patch: { x: Math.round(a.x + snapped.dx), y: Math.round(a.y + snapped.dy) }, label: gesture.ids.length > 1 ? `move ${gesture.ids.length} artboards` : 'move artboard' }; }));
|
|
1289
|
+
drawGuides(snapped.lines, { x: snapped.bounds.x + snapped.dx, y: snapped.bounds.y + snapped.dy, width: snapped.bounds.width, height: snapped.bounds.height });
|
|
1290
|
+
} else if (gesture.type === 'resize') {
|
|
1291
|
+
const s = gesture.start;
|
|
1292
|
+
const dx = (event.clientX - gesture.x) / camera.k;
|
|
1293
|
+
const dy = (event.clientY - gesture.y) / camera.k;
|
|
1294
|
+
let { x, y, width, height } = s;
|
|
1295
|
+
if (gesture.dir.includes('e')) width = Math.max(MIN_SIZE, s.width + dx);
|
|
1296
|
+
if (gesture.dir.includes('s')) height = Math.max(MIN_SIZE, s.height + dy);
|
|
1297
|
+
if (gesture.dir.includes('w')) { width = Math.max(MIN_SIZE, s.width - dx); x = s.x + s.width - width; }
|
|
1298
|
+
if (gesture.dir.includes('n')) { height = Math.max(MIN_SIZE, s.height - dy); y = s.y + s.height - height; }
|
|
1299
|
+
snapDisabled = event.altKey;
|
|
1300
|
+
const snapped = snapResize(gesture, { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) });
|
|
1301
|
+
gesture.rect = snapped.rect;
|
|
1302
|
+
applyOverrides(new Map([[gesture.id, snapped.rect]]));
|
|
1303
|
+
const patch = {};
|
|
1304
|
+
for (const key of ['x', 'y', 'width', 'height']) if (snapped.rect[key] !== gesture.start[key]) patch[key] = snapped.rect[key];
|
|
1305
|
+
if (Object.keys(patch).length) liveArtboards(gesture, [{ id: gesture.id, patch, label: 'resize artboard' }]);
|
|
1306
|
+
drawGuides(snapped.lines, snapped.rect);
|
|
1307
|
+
} else if (gesture.type === 'marquee') {
|
|
1308
|
+
gesture.end = { x: event.clientX - viewport.getBoundingClientRect().left, y: event.clientY - viewport.getBoundingClientRect().top };
|
|
1309
|
+
const r = { x: Math.min(gesture.start.x, gesture.end.x), y: Math.min(gesture.start.y, gesture.end.y), width: Math.abs(gesture.end.x - gesture.start.x), height: Math.abs(gesture.end.y - gesture.start.y) };
|
|
1310
|
+
marquee.hidden = false;
|
|
1311
|
+
Object.assign(marquee.style, { left: `${r.x}px`, top: `${r.y}px`, width: `${r.width}px`, height: `${r.height}px` });
|
|
1312
|
+
const ids = documents().filter(doc => {
|
|
1313
|
+
const s = screenRect(rectOf(doc));
|
|
1314
|
+
return s.x <= r.x + r.width && s.x + s.width >= r.x && s.y - 24 <= r.y + r.height && s.y + s.height >= r.y;
|
|
1315
|
+
}).map(doc => doc.id);
|
|
1316
|
+
store.selected = new Set([...gesture.initial, ...ids]);
|
|
1317
|
+
for (const [id, entry] of entries) entry.el.classList.toggle('selected', store.selected.has(id));
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
/** The editing handles on the selected element (spacing, gap, size, grip) are a preference; the pages are told when it changes. */
|
|
1322
|
+
export function editHandles() {
|
|
1323
|
+
try { return localStorage.getItem('divlet:handles') !== 'off'; } catch { return true; }
|
|
1324
|
+
}
|
|
1325
|
+
export function setEditHandles(enabled) {
|
|
1326
|
+
try { if (enabled) localStorage.removeItem('divlet:handles'); else localStorage.setItem('divlet:handles', 'off'); } catch {}
|
|
1327
|
+
for (const entry of entries.values()) entry.port?.postMessage({ type: 'divlet:handles-enabled', enabled });
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/** The page's measured CSS box (edges, offsets and untransformed content size), or null. */
|
|
1331
|
+
export function measuredBox(documentId, nodeId) {
|
|
1332
|
+
const box = entries.get(documentId)?.box;
|
|
1333
|
+
return box && box.nodeId === nodeId ? box : null;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/** What a pill does, for the browser's tooltip while the pointer rests on it. */
|
|
1337
|
+
function handleHint(id) {
|
|
1338
|
+
const names = { t: 'top', r: 'right', b: 'bottom', l: 'left' };
|
|
1339
|
+
if (id === 'move') return 'Drag to move this element before or after another · Alt copies';
|
|
1340
|
+
if (id === 'size') return 'Drag to resize · Shift width only · Ctrl height only · Alt 1px steps';
|
|
1341
|
+
const [kind, side] = id.split('-');
|
|
1342
|
+
if (kind === 'gap') return 'Drag to change the gap between children · Alt 1px steps';
|
|
1343
|
+
return `Drag to change the ${names[side]} ${kind} · Shift all sides · Ctrl opposite side · Alt 1px steps`;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/** The pointer in the page's own coordinates (the frame is scaled by the camera). */
|
|
1347
|
+
function pagePoint(id, event) {
|
|
1348
|
+
const frame = entries.get(id)?.iframe.getBoundingClientRect();
|
|
1349
|
+
return frame ? { x: (event.clientX - frame.left) / camera.k, y: (event.clientY - frame.top) / camera.k } : {};
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/** The id of the margin/padding pill (reported by the page's bridge) under the pointer, or null. */
|
|
1353
|
+
function spaceHandleAt(entry, event) {
|
|
1354
|
+
if (!entry?.handles?.length || store.mode !== 'select') return null;
|
|
1355
|
+
const frame = entry.iframe.getBoundingClientRect();
|
|
1356
|
+
const x = (event.clientX - frame.left) / camera.k, y = (event.clientY - frame.top) / camera.k;
|
|
1357
|
+
// Zoomed out, a pill is a few screen pixels: its hit area grows so that it is at least 16 screen pixels across.
|
|
1358
|
+
const grow = item => Math.max(0, (16 / camera.k - Math.min(item.w, item.h)) / 2);
|
|
1359
|
+
return entry.handles.find(item => { const g = grow(item); return x >= item.x - g && x <= item.x + item.w + g && y >= item.y - g && y <= item.y + item.h + g; })?.id ?? null;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* After a pill drag: the dragged frame held its preview while the pointer was down, so let it catch up once the final
|
|
1364
|
+
* commit has landed (the drag's own last commit may still be in flight when the pointer comes up).
|
|
1365
|
+
*/
|
|
1366
|
+
function releaseDragFrame(id) {
|
|
1367
|
+
// The drag's last commit may still be queued; the frame catches up once the queue is empty.
|
|
1368
|
+
idle(id).then(() => {
|
|
1369
|
+
const entry = entries.get(id);
|
|
1370
|
+
if (entry && !gesture) updateFrame(entry);
|
|
1371
|
+
});
|
|
1372
|
+
for (const delay of [120, 600]) {
|
|
1373
|
+
setTimeout(() => {
|
|
1374
|
+
const entry = entries.get(id);
|
|
1375
|
+
if (entry && !gesture) updateFrame(entry);
|
|
1376
|
+
}, delay);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function endGesture(event) {
|
|
1381
|
+
pointers.delete(event.pointerId);
|
|
1382
|
+
if (gesture?.type === 'pinch') { if (pointers.size < 2) gesture = null; return; }
|
|
1383
|
+
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
|
1384
|
+
const done = gesture;
|
|
1385
|
+
gesture = null;
|
|
1386
|
+
viewport.classList.remove('panning', 'moving');
|
|
1387
|
+
guides.replaceChildren();
|
|
1388
|
+
marquee.hidden = true;
|
|
1389
|
+
if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId);
|
|
1390
|
+
if (done.type === 'space') {
|
|
1391
|
+
const entry = entries.get(done.id);
|
|
1392
|
+
entry?.port.postMessage({ type: 'divlet:space-drag', phase: 'end' });
|
|
1393
|
+
if (entry) entry.spaceGroup = done.group;
|
|
1394
|
+
releaseDragFrame(done.id);
|
|
1395
|
+
} else if (done.type === 'press') {
|
|
1396
|
+
const entry = entries.get(done.id);
|
|
1397
|
+
if (entry) pickAt(entry, event, done.additive);
|
|
1398
|
+
} else if (done.type === 'marquee') {
|
|
1399
|
+
const dist = Math.hypot(done.end.x - done.start.x, done.end.y - done.start.y);
|
|
1400
|
+
selectArtboards(dist < 3 ? done.initial : [...store.selected]);
|
|
1401
|
+
} else if (done.type === 'move') {
|
|
1402
|
+
if (!done.moved) {
|
|
1403
|
+
if (done.wasSelected && done.ids.length > 1) selectArtboards([done.id]);
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
const { dx, dy } = done.delta || { dx: 0, dy: 0 };
|
|
1407
|
+
const results = [];
|
|
1408
|
+
for (const id of done.ids) {
|
|
1409
|
+
const doc = descriptor(id), from = done.startRects?.get(id) ?? doc?.artboard;
|
|
1410
|
+
if (!doc || !from || (!dx && !dy)) continue;
|
|
1411
|
+
const next = { x: Math.round(from.x + dx), y: Math.round(from.y + dy) };
|
|
1412
|
+
// The live commits already put it there: a second identical patch would only add a "no change" history row.
|
|
1413
|
+
if (doc.artboard.x === next.x && doc.artboard.y === next.y) { pendingOverrides.delete(id); overrides.delete(id); continue; }
|
|
1414
|
+
pendingOverrides.set(id, { version: doc.version + 1 });
|
|
1415
|
+
results.push(commit(id, { artboard: next }, { label: done.ids.length > 1 ? `move ${done.ids.length} artboards` : 'move artboard', group: done.group }).then(result => { pendingOverrides.set(id, { version: result.version }); }, () => { pendingOverrides.get(id).failed = true; }));
|
|
1416
|
+
}
|
|
1417
|
+
if (!results.length) { overrides.clear(); reconcileArtboards(); return; }
|
|
1418
|
+
finishOverrides(results);
|
|
1419
|
+
} else if (done.type === 'resize') {
|
|
1420
|
+
const entry = entries.get(done.id);
|
|
1421
|
+
entry?.el.classList.remove('resizing');
|
|
1422
|
+
const rect = done.rect;
|
|
1423
|
+
if (!rect) return;
|
|
1424
|
+
const patch = {};
|
|
1425
|
+
for (const key of ['x', 'y', 'width', 'height']) if (rect[key] !== done.start[key]) patch[key] = rect[key];
|
|
1426
|
+
if (!Object.keys(patch).length) { overrides.clear(); reconcileArtboards(); return; }
|
|
1427
|
+
const doc = descriptor(done.id);
|
|
1428
|
+
pendingOverrides.set(done.id, { version: (doc?.version ?? 0) + 1 });
|
|
1429
|
+
finishOverrides([commit(done.id, { artboard: patch }, { label: 'resize artboard', group: done.group }).then(result => { pendingOverrides.set(done.id, { version: result.version }); }, () => { pendingOverrides.get(done.id).failed = true; })]);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
viewport.addEventListener('pointerup', endGesture);
|
|
1433
|
+
viewport.addEventListener('pointerleave', () => { if (!gesture) { clearHover(); hideBadge(); } });
|
|
1434
|
+
viewport.addEventListener('pointercancel', endGesture);
|
|
1435
|
+
|
|
1436
|
+
/** Escape during a drag puts everything back where it was. */
|
|
1437
|
+
function cancelGesture() {
|
|
1438
|
+
if (!gesture || !['move', 'resize', 'marquee', 'space'].includes(gesture.type)) return false;
|
|
1439
|
+
const done = gesture;
|
|
1440
|
+
if (done.type === 'space') {
|
|
1441
|
+
const entry = entries.get(done.id);
|
|
1442
|
+
entry?.port.postMessage({ type: 'divlet:space-drag', phase: 'cancel' });
|
|
1443
|
+
if (entry) entry.spaceGroup = done.group;
|
|
1444
|
+
releaseDragFrame(done.id);
|
|
1445
|
+
}
|
|
1446
|
+
gesture = null;
|
|
1447
|
+
if (done.pointerId !== undefined && viewport.hasPointerCapture(done.pointerId)) viewport.releasePointerCapture(done.pointerId);
|
|
1448
|
+
pointers.clear();
|
|
1449
|
+
viewport.classList.remove('panning', 'moving');
|
|
1450
|
+
entries.get(done.id)?.el.classList.remove('resizing');
|
|
1451
|
+
guides.replaceChildren();
|
|
1452
|
+
marquee.hidden = true;
|
|
1453
|
+
if (done.type === 'marquee') { store.selected = new Set(done.initial); updateSelectionClasses(); }
|
|
1454
|
+
else {
|
|
1455
|
+
const ids = done.ids ?? (done.id ? [done.id] : []);
|
|
1456
|
+
for (const id of ids) {
|
|
1457
|
+
pendingOverrides.delete(id);
|
|
1458
|
+
// The group was just undone; a message trailing the cancelled drag must not open a new step in it.
|
|
1459
|
+
if (entries.get(id)) entries.get(id).spaceGroup = null;
|
|
1460
|
+
}
|
|
1461
|
+
// The preview goes at once: Escape must not leave the artboard floating at the dragged position while the undo travels, and
|
|
1462
|
+
// a gesture starting in that window would snapshot the preview instead of the model.
|
|
1463
|
+
const settle = () => { overrides.clear(); reconcileArtboards(); };
|
|
1464
|
+
settle();
|
|
1465
|
+
if (done.live || done.liveAt) {
|
|
1466
|
+
// The drag's commits may still be queued: undoing before they land would revert the previous, unrelated step and then
|
|
1467
|
+
// let this drag's commit arrive as a new one. Wait for the queue, and undo only if a live commit actually landed.
|
|
1468
|
+
Promise.all(ids.map(id => idle(id)))
|
|
1469
|
+
.then(() => (done.liveCommitted ? undo() : null))
|
|
1470
|
+
.then(settle, settle);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
return true;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
viewport.addEventListener('contextmenu', event => {
|
|
1477
|
+
event.preventDefault();
|
|
1478
|
+
if (store.expanded || gesture?.type === 'move' || gesture?.type === 'resize') return;
|
|
1479
|
+
const wrapper = event.target.closest('.artboard');
|
|
1480
|
+
if (wrapper) {
|
|
1481
|
+
const id = wrapper.dataset.artboardId;
|
|
1482
|
+
const entry = entries.get(id);
|
|
1483
|
+
// The menu is about what is selected: with the artboard itself selected (or nothing yet) it is the
|
|
1484
|
+
// artboard's menu (Delete, Duplicate…); once an element of it is selected, right-clicking
|
|
1485
|
+
// the page opens that element's menu (the page selects what is under the pointer and answers).
|
|
1486
|
+
const inside = store.node?.documentId === id && store.node.nodeIds.length > 0;
|
|
1487
|
+
const at = inside && event.target.closest('.frame-cover') && store.mode === 'select' ? framePoint(entry, event) : null;
|
|
1488
|
+
if (!store.selected.has(id)) selectArtboards([id]);
|
|
1489
|
+
if (at) postToFrame(id, { type: 'divlet:context-at', ...at });
|
|
1490
|
+
else emit('context', { kind: 'artboard', id, x: event.clientX, y: event.clientY });
|
|
1491
|
+
} else emit('context', { kind: 'canvas', x: event.clientX, y: event.clientY, world: worldPoint(event.clientX, event.clientY) });
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
/* ---- dropping images from the desktop -------------------------------------- */
|
|
1495
|
+
const hasFiles = event => [...(event.dataTransfer?.types || [])].includes('Files');
|
|
1496
|
+
let dropTarget = null;
|
|
1497
|
+
function setDropTarget(entry) {
|
|
1498
|
+
if (dropTarget === entry) return;
|
|
1499
|
+
dropTarget?.el.classList.remove('drop-target');
|
|
1500
|
+
dropTarget = entry;
|
|
1501
|
+
dropTarget?.el.classList.add('drop-target');
|
|
1502
|
+
}
|
|
1503
|
+
viewport.addEventListener('dragover', event => {
|
|
1504
|
+
if (!hasFiles(event) || store.expanded) return;
|
|
1505
|
+
event.preventDefault();
|
|
1506
|
+
const wrapper = event.target.closest?.('.artboard');
|
|
1507
|
+
setDropTarget(wrapper ? entries.get(wrapper.dataset.artboardId) : null);
|
|
1508
|
+
event.dataTransfer.dropEffect = wrapper ? 'copy' : 'none';
|
|
1509
|
+
});
|
|
1510
|
+
viewport.addEventListener('dragleave', event => { if (!viewport.contains(event.relatedTarget)) setDropTarget(null); });
|
|
1511
|
+
viewport.addEventListener('drop', event => {
|
|
1512
|
+
if (!hasFiles(event)) return;
|
|
1513
|
+
event.preventDefault();
|
|
1514
|
+
const entry = dropTarget;
|
|
1515
|
+
setDropTarget(null);
|
|
1516
|
+
const files = [...event.dataTransfer.files].filter(file => file.type.startsWith('image/'));
|
|
1517
|
+
if (!files.length) { toast('Only images can be dropped onto an artboard'); return; }
|
|
1518
|
+
if (!entry) { toast('Drop images onto an artboard'); return; }
|
|
1519
|
+
emit('images', { documentId: entry.doc.id, files });
|
|
1520
|
+
});
|
|
1521
|
+
window.addEventListener('dragover', event => { if (hasFiles(event) && !viewport.contains(event.target)) { event.preventDefault(); event.dataTransfer.dropEffect = 'none'; } });
|
|
1522
|
+
window.addEventListener('drop', event => { if (hasFiles(event) && !viewport.contains(event.target)) event.preventDefault(); });
|
|
1523
|
+
|
|
1524
|
+
viewport.addEventListener('wheel', event => {
|
|
1525
|
+
if (store.expanded) return;
|
|
1526
|
+
event.preventDefault();
|
|
1527
|
+
cancelAnimationFrame(animation);
|
|
1528
|
+
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1;
|
|
1529
|
+
if (event.ctrlKey || event.metaKey) zoomAt(Math.exp(-clamp(event.deltaY * unit, -120, 120) * (event.ctrlKey && !event.metaKey && Math.abs(event.deltaY) < 20 ? 0.01 : 0.0022)), event.clientX, event.clientY);
|
|
1530
|
+
else setCamera({ x: camera.x - (event.shiftKey && !event.deltaX ? event.deltaY : event.deltaX) * unit, y: camera.y - (event.shiftKey && !event.deltaX ? 0 : event.deltaY) * unit });
|
|
1531
|
+
}, { passive: false });
|
|
1532
|
+
// Page zoom must never rescale the interface: Ctrl+wheel anywhere zooms the canvas only.
|
|
1533
|
+
window.addEventListener('wheel', event => { if ((event.ctrlKey || event.metaKey) && !viewport.contains(event.target)) event.preventDefault(); }, { passive: false });
|
|
1534
|
+
|
|
1535
|
+
window.addEventListener('keydown', event => {
|
|
1536
|
+
if (event.key === 'Escape' && cancelGesture()) { event.preventDefault(); event.stopImmediatePropagation(); return; }
|
|
1537
|
+
if (event.code === 'Space' && !(event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target.isContentEditable || event.target.closest?.('button, summary, select, a[href]')) && !store.expanded) {
|
|
1538
|
+
spaceDown = true;
|
|
1539
|
+
viewport.classList.add('space-ready');
|
|
1540
|
+
event.preventDefault();
|
|
1541
|
+
}
|
|
1542
|
+
});
|
|
1543
|
+
window.addEventListener('keyup', event => { if (event.code === 'Space') { spaceDown = false; viewport.classList.remove('space-ready'); } });
|
|
1544
|
+
// A window that loses focus mid-gesture gets no pointerup: cancel it properly (cancel the page's drag, drop the previews and
|
|
1545
|
+
// undo what the gesture already committed), otherwise a stale preview rect would outlive the gesture and later be written back.
|
|
1546
|
+
window.addEventListener('blur', () => {
|
|
1547
|
+
spaceDown = false;
|
|
1548
|
+
viewport.classList.remove('space-ready', 'panning', 'moving');
|
|
1549
|
+
if (cancelGesture()) return;
|
|
1550
|
+
// No gesture to cancel: leave previews that belong to something else (an arrow-key nudge) alone.
|
|
1551
|
+
if (!gesture) { pointers.clear(); return; }
|
|
1552
|
+
gesture = null;
|
|
1553
|
+
pointers.clear();
|
|
1554
|
+
marquee.hidden = true;
|
|
1555
|
+
guides.replaceChildren();
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
// Arrow-key nudges in quick succession form one undo step; positions accumulate locally so fast repeats never lose a step.
|
|
1559
|
+
let nudging = { group: null, at: 0, targets: new Map() };
|
|
1560
|
+
export function nudge(dx, dy, ids = [...store.selected]) {
|
|
1561
|
+
const now = performance.now();
|
|
1562
|
+
if (now - nudging.at > 800) nudging = { group: newGroup(), at: now, targets: new Map() };
|
|
1563
|
+
nudging.at = now;
|
|
1564
|
+
for (const id of ids) {
|
|
1565
|
+
const doc = descriptor(id);
|
|
1566
|
+
if (!doc) continue;
|
|
1567
|
+
const from = nudging.targets.get(id) || rectOf(doc);
|
|
1568
|
+
const next = { ...doc.artboard, x: from.x + dx, y: from.y + dy };
|
|
1569
|
+
nudging.targets.set(id, next);
|
|
1570
|
+
overrides.set(id, next);
|
|
1571
|
+
pendingOverrides.set(id, { version: Infinity });
|
|
1572
|
+
const entry = entries.get(id);
|
|
1573
|
+
if (entry) { place(entry); emit('live-rect', { id, rect: next }); }
|
|
1574
|
+
commit(id, { artboard: { x: next.x, y: next.y } }, { label: ids.length > 1 ? `nudge ${ids.length} artboards` : 'nudge artboard', group: nudging.group })
|
|
1575
|
+
.then(result => { if (nudging.targets.get(id) === next) pendingOverrides.set(id, { version: result.version }); })
|
|
1576
|
+
.catch(() => { pendingOverrides.delete(id); overrides.delete(id); nudging.targets.delete(id); })
|
|
1577
|
+
.finally(() => reconcileArtboards());
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
export function disposeCanvas() {
|
|
1582
|
+
visibility.disconnect();
|
|
1583
|
+
for (const entry of entries.values()) unmount(entry);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
on('state', () => {
|
|
1587
|
+
reconcileArtboards();
|
|
1588
|
+
for (const entry of entries.values()) {
|
|
1589
|
+
syncWork(entry);
|
|
1590
|
+
entry.port?.postMessage({ type: 'divlet:writable', enabled: store.state?.project?.capabilities?.properties === true });
|
|
1591
|
+
}
|
|
1592
|
+
});
|
|
1593
|
+
on('selection', detail => updateSelectionClasses(detail));
|
|
1594
|
+
on('rename-artboard', id => { const entry = entries.get(id); if (entry) renameArtboard(entry); });
|