@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.
Files changed (44) hide show
  1. package/README.md +12 -2
  2. package/dist/LICENSE +21 -0
  3. package/dist/board.css +950 -0
  4. package/dist/board.js +427 -0
  5. package/dist/index.d.ts +4 -0
  6. package/dist/index.html +73 -0
  7. package/dist/index.js +3 -0
  8. package/dist/lib/actions.js +472 -0
  9. package/dist/lib/activity.js +311 -0
  10. package/dist/lib/artboards.js +56 -0
  11. package/dist/lib/ask.js +350 -0
  12. package/dist/lib/attrs.js +144 -0
  13. package/dist/lib/board-host.d.ts +14 -0
  14. package/dist/lib/board-host.js +2 -0
  15. package/dist/lib/board-session.d.ts +53 -0
  16. package/dist/lib/board-session.js +24 -0
  17. package/dist/lib/canvas.js +1594 -0
  18. package/dist/lib/chat.js +156 -0
  19. package/dist/lib/clip.js +231 -0
  20. package/dist/lib/crumbbar.js +56 -0
  21. package/dist/lib/crumbs.js +63 -0
  22. package/dist/lib/css.js +238 -0
  23. package/dist/lib/expandbar.js +64 -0
  24. package/dist/lib/file-picker.js +103 -0
  25. package/dist/lib/help.js +273 -0
  26. package/dist/lib/host.js +16 -0
  27. package/dist/lib/http-services.js +24 -0
  28. package/dist/lib/http-session.js +45 -0
  29. package/dist/lib/http.js +17 -0
  30. package/dist/lib/inspector.js +857 -0
  31. package/dist/lib/layers.js +298 -0
  32. package/dist/lib/menu.js +119 -0
  33. package/dist/lib/palette.js +292 -0
  34. package/dist/lib/panels.js +27 -0
  35. package/dist/lib/project.js +188 -0
  36. package/dist/lib/resize.js +114 -0
  37. package/dist/lib/shortcuts.js +91 -0
  38. package/dist/lib/sidebar.js +85 -0
  39. package/dist/lib/store.js +249 -0
  40. package/dist/lib/suggest.js +130 -0
  41. package/dist/lib/toolbar.js +54 -0
  42. package/dist/lib/typography.js +120 -0
  43. package/dist/lib/util.js +177 -0
  44. package/package.json +37 -4
@@ -0,0 +1,857 @@
1
+ import { $, el, icon, button, clamp, round, debounce, keys, pressGuard } from './util.js';
2
+ import { store, on, documents, descriptor, loadModel, cachedModel, commit, edit, toast, newGroup, selectNodes, parentOf, textChild, whenVersion, request, apiUrl, receiveState } from './store.js';
3
+ import { fit, rectOf, postToFrame, measuredBox } from './canvas.js';
4
+ import * as actions from './actions.js';
5
+ import { openMenu, menuAnchor, closeMenu } from './menu.js';
6
+ import { overflowBar } from './toolbar.js';
7
+ import { panelSections } from './panels.js';
8
+ import { fontFamilyControl } from './typography.js';
9
+ import { attributesEditor } from './attrs.js';
10
+ import { formatCss, highlightCss } from './css.js';
11
+ import { attachSuggest, filterLocal } from './suggest.js';
12
+
13
+ /* Suggestions for the free-form fields: CSS properties the browser knows and common attributes.
14
+ Classes have their own suggestions (Tailwind utilities, see classes.js). */
15
+ const CSS_PROPERTIES = [...new Set(Object.keys(document.documentElement.style).filter(name => !/^(webkit|moz|ms)/i.test(name) && Number.isNaN(Number(name))).map(name => name.replace(/[A-Z]/g, ch => `-${ch.toLowerCase()}`)).filter(name => /^[a-z][a-z-]*$/.test(name)))].sort();
16
+ const ATTRIBUTES = 'alt aria-describedby aria-hidden aria-label aria-labelledby dir disabled download for height href hidden id lang loading name placeholder rel required role rows src srcset tabindex target title type value width'.split(' ');
17
+
18
+ const panel = $('#inspector');
19
+ let token = 0;
20
+ let deferred = false;
21
+ let holding = 0;
22
+ let controlContext = null;
23
+ // The operation outlives any selected-node control; replacement views reflect this same pending state.
24
+ const fontUpload = { pending: false, run: uploadFont };
25
+ function paintFontUpload() {
26
+ const button = panel.querySelector('[data-key="font-upload"]');
27
+ if (button) { button.disabled = fontUpload.pending; button.setAttribute('aria-busy', String(fontUpload.pending)); }
28
+ }
29
+
30
+ const HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
31
+ const COLORISH = /^(#[0-9a-f]{3,8}|rgba?\(.+\)|hsla?\(.+\)|oklch\(.+\))$/i;
32
+ const PRESETS = [[390, 'Phone'], [768, 'Tablet'], [1024, 'Small laptop'], [1280, 'Laptop'], [1440, 'Desktop']];
33
+ const quiet = promise => promise?.catch?.(() => null);
34
+ /** Commit from an "add" input that keeps focus: re-render once accepted so the new chip/row appears at once. */
35
+ const refreshed = promise => quiet(promise)?.then(async result => {
36
+ if (!result) return result;
37
+ await whenVersion(result.documentId, result.version);
38
+ render({ force: true });
39
+ return result;
40
+ });
41
+ const TRANSIENT = /(^class-add$|-new-(key|value)$)/;
42
+
43
+ /** Pause re-rendering while a pointer gesture (scrubbing) owns an inspector control. */
44
+ function hold() {
45
+ holding++;
46
+ return () => { holding = Math.max(0, holding - 1); if (!holding && deferred) schedule(); };
47
+ }
48
+
49
+ function toHex(value) {
50
+ if (HEX.test(value)) return value.length === 4 ? `#${[...value.slice(1)].map(c => c + c).join('')}` : value.toLowerCase();
51
+ const probe = document.createElement('i');
52
+ probe.style.color = value;
53
+ if (!probe.style.color) return null;
54
+ document.body.append(probe);
55
+ const rgb = getComputedStyle(probe).color.match(/\d+(\.\d+)?/g);
56
+ probe.remove();
57
+ return rgb ? `#${rgb.slice(0, 3).map(n => Math.round(Number(n)).toString(16).padStart(2, '0')).join('')}` : null;
58
+ }
59
+
60
+ /* ---- small controls ------------------------------------------------------- */
61
+ function numberField(key, label, value, onCommit, { step = 1, min = -1e7, max = 1e7, tip, unit = 'px' } = {}) {
62
+ const wrap = el('div', 'num');
63
+ const name = el('span', 'num-label', label);
64
+ name.dataset.tip = tip || `${label} · drag to scrub, Shift ×10`;
65
+ const input = el('input', 'num-input');
66
+ input.type = 'text';
67
+ input.inputMode = 'decimal';
68
+ input.dataset.key = key;
69
+ input.value = String(round(value, 2));
70
+ input.setAttribute('aria-label', tip || label);
71
+ wrap.append(name, input);
72
+ if (unit) { const suffix = el('span', 'num-unit', unit); suffix.setAttribute('aria-hidden', 'true'); wrap.append(suffix); }
73
+ // Numbers people type are often expressions ("1280/2", "390+24"); anything unparsable waits for Enter or blur.
74
+ const parseValue = raw => {
75
+ let parsed = Number.parseFloat(raw);
76
+ if (/^[\d\s.+\-*/()]+$/.test(raw) && /[+\-*/]/.test(raw.trim().slice(1))) { try { parsed = Function(`"use strict";return (${raw})`)(); } catch {} }
77
+ return Number.isFinite(parsed) ? parsed : undefined;
78
+ };
79
+ const apply = (raw, group) => {
80
+ const parsed = parseValue(raw);
81
+ if (parsed === undefined) { input.value = String(round(value, 2)); return; }
82
+ const next = clamp(round(parsed, 2), min, max);
83
+ input.value = String(next);
84
+ if (next !== value) { value = next; onCommit(next, group); }
85
+ };
86
+ // Typing commits too: a short pause after a value that parses sends it, so the artboard follows the field without Enter.
87
+ // One focus session shares a group (one undo step), and the panel is not redrawn mid-typing (the input keeps the caret).
88
+ let session = null;
89
+ let typing = 0;
90
+ input.addEventListener('focus', () => { session = newGroup(); });
91
+ input.addEventListener('blur', () => { clearTimeout(typing); session = null; });
92
+ input.addEventListener('input', () => {
93
+ clearTimeout(typing);
94
+ typing = setTimeout(() => {
95
+ const raw = input.value.trim();
96
+ if (raw && parseValue(raw) !== undefined) apply(input.value, session);
97
+ }, 140);
98
+ });
99
+ input.addEventListener('change', () => apply(input.value));
100
+ input.addEventListener('focus', () => input.select());
101
+ input.addEventListener('keydown', event => {
102
+ event.stopPropagation();
103
+ if (event.key === 'Enter') { apply(input.value); input.blur(); }
104
+ else if (event.key === 'Escape') { input.value = String(round(value, 2)); input.blur(); }
105
+ else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
106
+ event.preventDefault();
107
+ apply(String((Number.parseFloat(input.value) || 0) + (event.key === 'ArrowUp' ? 1 : -1) * step * (event.shiftKey ? 10 : 1)));
108
+ }
109
+ });
110
+ name.addEventListener('pointerdown', event => {
111
+ event.preventDefault();
112
+ name.setPointerCapture(event.pointerId);
113
+ const release = hold();
114
+ const origin = value;
115
+ const x0 = event.clientX;
116
+ const group = newGroup();
117
+ const scrub = debounce(next => onCommit(next, group), 70);
118
+ wrap.classList.add('scrubbing');
119
+ const move = e => {
120
+ const next = clamp(round(origin + Math.round((e.clientX - x0) / 2) * step * (e.shiftKey ? 10 : 1), 2), min, max);
121
+ if (next === value) return;
122
+ input.value = String(next);
123
+ value = next;
124
+ scrub(next);
125
+ };
126
+ const up = () => {
127
+ name.removeEventListener('pointermove', move);
128
+ name.removeEventListener('pointerup', up);
129
+ name.removeEventListener('pointercancel', up);
130
+ wrap.classList.remove('scrubbing');
131
+ if (value !== origin) scrub.flush(value); else scrub.cancel();
132
+ release();
133
+ };
134
+ name.addEventListener('pointermove', move);
135
+ name.addEventListener('pointerup', up);
136
+ name.addEventListener('pointercancel', up);
137
+ });
138
+ return wrap;
139
+ }
140
+
141
+ function textField(key, value, onCommit, { placeholder = '', mono = false, multiline = false, label } = {}) {
142
+ const input = el(multiline ? 'textarea' : 'input', `field${mono ? ' mono' : ''}`);
143
+ if (!multiline) input.type = 'text';
144
+ else input.rows = 1;
145
+ input.value = value ?? '';
146
+ input.placeholder = placeholder;
147
+ input.dataset.key = key;
148
+ input.spellcheck = false;
149
+ input.setAttribute('aria-label', label || placeholder || key);
150
+ const grow = () => { if (multiline) { input.style.height = 'auto'; input.style.height = `${Math.min(input.scrollHeight + 2, 260)}px`; } };
151
+ input.addEventListener('keydown', event => {
152
+ event.stopPropagation();
153
+ if (event.key === 'Escape') { input.value = value ?? ''; grow(); input.blur(); }
154
+ else if (event.key === 'Enter' && (!multiline || event.metaKey || event.ctrlKey)) { event.preventDefault(); input.blur(); }
155
+ });
156
+ input.addEventListener('input', grow);
157
+ input.addEventListener('change', () => { if (input.value !== (value ?? '')) { value = input.value; onCommit(input.value); } });
158
+ queueMicrotask(grow);
159
+ return input;
160
+ }
161
+
162
+ /**
163
+ * Code in a textarea with colors: the text sits in a transparent textarea over a highlighted copy.
164
+ * The stored text is formatted when it is committed; "Format" does it on demand.
165
+ */
166
+ function codeField(key, value, onCommit, { highlight, format, placeholder = '', label }) {
167
+ const wrap = el('div', 'code');
168
+ const pre = el('pre', 'code-hl');
169
+ pre.setAttribute('aria-hidden', 'true');
170
+ const input = el('textarea', 'code-ta');
171
+ input.spellcheck = false;
172
+ input.dataset.key = key;
173
+ input.placeholder = placeholder;
174
+ input.setAttribute('aria-label', label);
175
+ const shown = format(value ?? '');
176
+ input.value = shown;
177
+ const paint = () => { pre.innerHTML = `${highlight(input.value)}\n`; };
178
+ const grow = () => { input.style.height = 'auto'; input.style.height = `${Math.min(input.scrollHeight + 2, 420)}px`; pre.style.height = input.style.height; pre.scrollTop = input.scrollTop; };
179
+ let saved = value ?? '';
180
+ const commit = () => {
181
+ const next = format(input.value);
182
+ if (next !== input.value) { input.value = next; paint(); grow(); }
183
+ if (next !== saved) { saved = next; onCommit(next); }
184
+ };
185
+ input.addEventListener('input', () => { paint(); grow(); });
186
+ input.addEventListener('scroll', () => { pre.scrollTop = input.scrollTop; pre.scrollLeft = input.scrollLeft; });
187
+ input.addEventListener('change', commit);
188
+ input.addEventListener('keydown', event => {
189
+ event.stopPropagation();
190
+ if (event.key === 'Escape') { input.value = shown; paint(); grow(); input.blur(); }
191
+ else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); input.blur(); }
192
+ });
193
+ paint();
194
+ wrap.append(pre, input);
195
+ queueMicrotask(grow);
196
+ return { wrap, format: () => { input.value = format(input.value); paint(); grow(); commit(); } };
197
+ }
198
+
199
+ function section(name, ...children) {
200
+ const node = el('section', 'group');
201
+ if (name) node.dataset.section = name;
202
+ node.append(...children.filter(Boolean));
203
+ return node;
204
+ }
205
+
206
+ const actionBar = (...buttons) => { const bar = el('div', 'actions'); bar.append(...buttons.filter(Boolean)); return bar; };
207
+
208
+ /* Folds remember being opened or closed (per kind of section, across selections and reloads). */
209
+ const folds = (() => { try { return JSON.parse(localStorage.getItem('divlet:folds') || '{}'); } catch { return {}; } })();
210
+ function details(summary, content, open = false, count) {
211
+ const node = el('details', 'fold');
212
+ node.open = folds[summary] ?? open;
213
+ node.addEventListener('toggle', () => {
214
+ folds[summary] = node.open;
215
+ try { localStorage.setItem('divlet:folds', JSON.stringify(folds)); } catch {}
216
+ });
217
+ const head = el('summary', '', summary);
218
+ if (count) head.append(el('span', 'fold-count', String(count)));
219
+ node.append(head, content);
220
+ return node;
221
+ }
222
+
223
+ function swatch(value, label, onPick, fallback = 'plain') {
224
+ const plain = value.trim();
225
+ const keyword = /^(inherit|initial|unset|revert|currentcolor|transparent|none|auto)$/i.test(plain);
226
+ const hex = !keyword && (COLORISH.test(plain) || /^[a-z]+$/i.test(plain)) ? toHex(plain) : null;
227
+ if (!hex) return el('span', `swatch ${fallback}`);
228
+ const color = el('input', 'swatch');
229
+ color.type = 'color';
230
+ color.value = hex;
231
+ color.style.setProperty('--swatch', value);
232
+ color.setAttribute('aria-label', `${label} color`);
233
+ color.addEventListener('change', () => onPick(color.value));
234
+ return color;
235
+ }
236
+
237
+ /* ---- artboard ------------------------------------------------------------- */
238
+ function artboardView(doc, model) {
239
+ const a = rectOf(doc);
240
+ const id = doc.id;
241
+ // With the lock on, W and H keep the ratio the artboard had when it was selected.
242
+ const locked = () => { try { return localStorage.getItem('divlet:ratio-lock') === '1'; } catch { return false; } };
243
+ const patchArtboard = key => (value, group) => {
244
+ const change = { [key]: value };
245
+ if (locked() && (key === 'width' || key === 'height') && a.width > 0 && a.height > 0) {
246
+ if (key === 'width') change.height = Math.max(1, Math.round(value * a.height / a.width));
247
+ else change.width = Math.max(1, Math.round(value * a.width / a.height));
248
+ }
249
+ return quiet(commit(id, { artboard: change }, { label: key === 'width' || key === 'height' ? 'resize artboard' : 'move artboard', group }));
250
+ };
251
+ const view = [];
252
+ view.push(section('name', textField('name', doc.name, value => quiet(commit(id, { name: value.trim() || doc.name }, { label: `rename to “${value.trim()}”` })), { placeholder: 'Artboard name', label: 'Artboard name' })));
253
+
254
+ const geometry = el('div', 'grid2 geometry-grid');
255
+ geometry.append(
256
+ numberField('x', 'X', a.x, patchArtboard('x'), { tip: 'X position' }),
257
+ numberField('y', 'Y', a.y, patchArtboard('y'), { tip: 'Y position' }),
258
+ );
259
+ geometry.append(
260
+ numberField('width', 'W', a.width, patchArtboard('width'), { min: 1, max: 16384, tip: 'Viewport width' }),
261
+ numberField('height', 'H', a.height, patchArtboard('height'), { min: 1, max: 16384, tip: 'Viewport height' }),
262
+ );
263
+ const presets = el('div', 'chips');
264
+ for (const [width, device] of PRESETS) {
265
+ const chip = el('button', `chip${a.width === width ? ' on' : ''}`, String(width));
266
+ chip.type = 'button';
267
+ chip.dataset.tip = `${device} · ${width} px wide`;
268
+ chip.setAttribute('aria-pressed', String(a.width === width));
269
+ chip.addEventListener('click', () => quiet(commit(id, { artboard: { width } }, { label: `width ${width}` })));
270
+ presets.append(chip);
271
+ }
272
+ presets.append(button('rotate', 'Swap width and height', () => actions.rotateArtboard(id), 'mini'));
273
+ // Aspect ratio: set the height from the width, or lock the ratio while typing W or H.
274
+ const ratios = el('div', 'chips ratio-row');
275
+ const lock = el('button', `chip lock${locked() ? ' on' : ''}`);
276
+ lock.type = 'button';
277
+ lock.dataset.key = 'ratio-lock';
278
+ lock.dataset.tip = 'Keep the aspect ratio when changing W or H';
279
+ lock.setAttribute('aria-label', 'Lock aspect ratio');
280
+ lock.setAttribute('aria-pressed', String(locked()));
281
+ lock.append(icon(locked() ? 'lock' : 'unlock'));
282
+ lock.addEventListener('click', () => { try { localStorage.setItem('divlet:ratio-lock', locked() ? '0' : '1'); } catch {} render({ force: true }); });
283
+ ratios.append(lock);
284
+ for (const [name, w, h] of [['1:1', 1, 1], ['4:3', 4, 3], ['3:2', 3, 2], ['16:9', 16, 9], ['9:16', 9, 16]]) {
285
+ const chip = el('button', `chip${Math.abs(a.width / a.height - w / h) < 0.004 ? ' on' : ''}`, name);
286
+ chip.type = 'button';
287
+ chip.dataset.tip = `Aspect ratio ${name} at the current width`;
288
+ chip.addEventListener('click', () => quiet(commit(id, { artboard: { height: Math.max(1, Math.round(a.width * h / w)) } }, { label: `ratio ${name}` })));
289
+ ratios.append(chip);
290
+ }
291
+ view.push(section('geometry', geometry, presets, ratios));
292
+
293
+ const tokens = model?.tokens || {};
294
+ const tokenBox = el('div', 'rows');
295
+ for (const [name, value] of Object.entries(tokens)) {
296
+ const line = el('div', 'kv');
297
+ line.append(swatch(value, name, hex => quiet(commit(id, { tokens: { [name]: hex } }, { label: `token ${name}` }))));
298
+ line.append(el('span', 'k', name.replace(/^--/, '')), textField(`token:${name}`, value, v => quiet(commit(id, { tokens: { [name]: v } }, { label: `token ${name}` })), { mono: true, label: `${name} value` }));
299
+ line.append(button('close', 'Remove token', () => quiet(commit(id, { tokens: { [name]: null } }, { label: `remove token ${name}` })), 'mini'));
300
+ tokenBox.append(line);
301
+ }
302
+ tokenBox.append(addRow('token', ['--color-brand', '#5b5bf0'], (name, value) => {
303
+ const key = name.replace(/^(?!--)/, '--');
304
+ if (key.length > 2 && value) refreshed(commit(id, { tokens: { [key]: value } }, { label: `token ${key}` }));
305
+ }), el('span', 'caption', '--color-brand → bg-brand, text-brand'));
306
+ view.push(section('tokens', details('Tokens', tokenBox, Object.keys(tokens).length > 0, Object.keys(tokens).length)));
307
+
308
+ if (model) {
309
+ const css = codeField('css', model.css, v => quiet(commit(id, { css: v }, { label: 'edit css' })), { highlight: highlightCss, format: formatCss, placeholder: '/* only what classes cannot do: @keyframes, complex selectors */', label: 'Custom CSS' });
310
+ const cssBox = el('div', 'rows');
311
+ const tidy = el('button', 'link-button', 'Format');
312
+ tidy.type = 'button';
313
+ tidy.dataset.tip = 'Lay the CSS out one declaration per line';
314
+ tidy.addEventListener('click', () => css.format());
315
+ cssBox.append(css.wrap, tidy);
316
+ view.push(section('css', details('Custom CSS', cssBox, !!model.css)));
317
+ view.push(section('meta', details('Metadata', metadataField('metadata', model.metadata, next => quiet(commit(id, { metadata: next }, { label: 'edit metadata' }))), false, Object.keys(model.metadata || {}).length)));
318
+ }
319
+ // Every artboard action in one row; what does not fit moves into a trailing dropdown by itself.
320
+ view.push(overflowBar(actions.artboardItems(id)));
321
+ return view;
322
+ }
323
+
324
+ /** Metadata as JSON; removed keys become null in the patch so they are really deleted. */
325
+ function metadataField(key, current, save) {
326
+ return textField(key, JSON.stringify(current || {}, null, 2), v => {
327
+ let parsed;
328
+ try { parsed = JSON.parse(v || '{}'); } catch (error) { toast(`Metadata must be valid JSON: ${error.message}`); return; }
329
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { toast('Metadata must be a JSON object'); return; }
330
+ const removed = Object.fromEntries(Object.keys(current || {}).filter(k => !(k in parsed)).map(k => [k, null]));
331
+ save({ ...removed, ...parsed });
332
+ }, { mono: true, multiline: true, placeholder: '{ "intent": "…" }', label: 'Metadata JSON' });
333
+ }
334
+
335
+ /** Two inputs that commit together, on Enter in either one or when the second loses focus. */
336
+ function addRow(key, placeholders, onAdd, suggestions) {
337
+ const row = el('div', 'kv add');
338
+ const first = textField(`${key}-new-key`, '', () => {}, { placeholder: placeholders[0], mono: true, label: `New ${key} name` });
339
+ if (suggestions) attachSuggest(first, { fetch: word => filterLocal(suggestions, word), onAccept: () => second.focus() });
340
+ const second = textField(`${key}-new-value`, '', () => {}, { placeholder: placeholders[1], mono: true, label: `New ${key} value` });
341
+ const submit = () => {
342
+ const a = first.value.trim();
343
+ const b = second.value.trim();
344
+ if (!a) return false;
345
+ if (!b && !a.includes(':')) return false;
346
+ onAdd(a, b);
347
+ first.value = '';
348
+ second.value = '';
349
+ return true;
350
+ };
351
+ for (const input of [first, second]) input.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); if (submit()) first.focus(); } });
352
+ second.addEventListener('change', submit);
353
+ row.append(first, second);
354
+ return row;
355
+ }
356
+
357
+ /* ---- multi artboard ------------------------------------------------------- */
358
+ function multiView(docs) {
359
+ const ids = docs.map(doc => doc.id);
360
+ const count = el('div', 'count');
361
+ count.append(el('b', '', String(docs.length)), el('span', '', 'artboards selected'));
362
+ return [
363
+ section('count', count),
364
+ section('align', el('div', 'caption', 'Align and distribute'), actionBar(
365
+ button('alignL', 'Align left edges', () => actions.alignArtboards(ids, 'x', 'start')),
366
+ button('alignCH', 'Align horizontal centers', () => actions.alignArtboards(ids, 'x', 'center')),
367
+ button('alignR', 'Align right edges', () => actions.alignArtboards(ids, 'x', 'end')),
368
+ button('alignT', 'Align top edges', () => actions.alignArtboards(ids, 'y', 'start')),
369
+ button('alignCV', 'Align vertical centers', () => actions.alignArtboards(ids, 'y', 'center')),
370
+ button('alignB', 'Align bottom edges', () => actions.alignArtboards(ids, 'y', 'end')),
371
+ button('distH', 'Distribute horizontally', () => actions.distributeArtboards(ids, 'x')),
372
+ button('distV', 'Distribute vertically', () => actions.distributeArtboards(ids, 'y')),
373
+ )),
374
+ ];
375
+ }
376
+
377
+ /* ---- node ----------------------------------------------------------------- */
378
+ function parseDeclarations(text) {
379
+ const out = {};
380
+ for (const part of text.split(';')) {
381
+ const at = part.indexOf(':');
382
+ if (at < 1) continue;
383
+ const key = part.slice(0, at).trim().toLowerCase();
384
+ const value = part.slice(at + 1).trim();
385
+ if (key && value) out[key] = value;
386
+ }
387
+ return out;
388
+ }
389
+
390
+ function kvEditor(documentId, node, field, save) {
391
+ const box = el('div', 'rows');
392
+ const current = node[field] || {};
393
+ const patch = save || ((value, label) => refreshed(commit(documentId, { nodes: { [node.id]: { [field]: value } } }, { label: `${label} on ${node.id}` })));
394
+ for (const [key, value] of Object.entries(current)) {
395
+ const line = el('div', 'kv');
396
+ line.append(field === 'style' ? swatch(value, key, hex => patch({ [key]: hex }, `set ${key}`), 'none') : el('span', 'swatch none'));
397
+ const name = el('span', 'k', key);
398
+ if (key.length > 10) name.dataset.tip = key;
399
+ line.append(name, textField(`${field}:${key}`, value, v => patch(v === '' ? { [key]: null } : { [key]: v }, `set ${key}`), { mono: true, label: `${key} value` }), button('close', `Remove ${key}`, () => patch({ [key]: null }, `remove ${key}`), 'mini'));
400
+ line.firstChild.dataset.tip = field === 'style' && COLORISH.test(value) ? 'Pick a color' : '';
401
+ box.append(line);
402
+ }
403
+ box.append(addRow(field, field === 'style' ? ['property', 'value'] : ['attribute', 'value'], (key, value) => {
404
+ if (field === 'style' && key.includes(':')) { const all = parseDeclarations(key); if (Object.keys(all).length) patch(all, 'set styles'); return; }
405
+ patch({ [field === 'style' ? key.toLowerCase() : key]: value }, `set ${key}`);
406
+ }, field === 'style' ? CSS_PROPERTIES.map(name => ({ name })) : ATTRIBUTES.map(name => ({ name }))));
407
+ return box;
408
+ }
409
+
410
+ function textEditor(model, node) {
411
+ let target = node;
412
+ if (node.type === 'text') target = node;
413
+ else if (!textChild(model, node) && !(node.type === 'element' && !node.children.length && !['img', 'input', 'br', 'hr'].includes(node.tag))) return null;
414
+ const content = textChild(model, node)?.content ?? '';
415
+ return textField('text', content, value => quiet(edit(model.id, { type: 'text', id: target.id, content: value }, { label: `edit text of ${target.id}` })), { multiline: true, placeholder: 'Text', label: 'Text content' });
416
+ }
417
+
418
+
419
+ /** URL the board can load for an image source written relative to the project source. */
420
+ function imageUrl(doc, src) {
421
+ if (!src) return '';
422
+ if (src.startsWith('asset:')) {
423
+ const at = src.indexOf('#'), before = at < 0 ? src : src.slice(0, at);
424
+ return apiUrl(`build/assets/${before.slice(6)}${before.includes('?') ? '&' : '?'}divlet-resource=${store.state?.project?.version || 0}${at < 0 ? '' : src.slice(at)}`);
425
+ }
426
+ if (/^(https?:|data:)/i.test(src)) return src;
427
+ const projectDir = String(store.state?.projectDir || '').replaceAll('\\', '/');
428
+ const html = String(doc.sourcePath || doc.modelPath || '').replaceAll('\\', '/');
429
+ const folder = html.startsWith(projectDir) ? html.slice(projectDir.length + 1).split('/').slice(0, -1).join('/') : '';
430
+ try { return new URL(src, apiUrl(`files/${folder ? `${folder}/` : ''}`)).href; } catch { return ''; }
431
+ }
432
+
433
+ function uploadInto(doc, node) {
434
+ const picker = document.createElement('input');
435
+ picker.type = 'file';
436
+ picker.accept = 'image/*';
437
+ picker.addEventListener('change', async () => {
438
+ const file = picker.files?.[0];
439
+ if (!file) return;
440
+ try {
441
+ const group = `image:${crypto.randomUUID()}`;
442
+ const response = await fetch(apiUrl(`api/assets?name=${encodeURIComponent(file.name)}&documentId=${encodeURIComponent(doc.id)}&group=${encodeURIComponent(group)}`), { method: 'POST', headers: { 'Content-Type': file.type || 'image/png' }, body: file });
443
+ const body = await response.json();
444
+ if (!response.ok) throw new Error(body.message || 'Upload failed');
445
+ const alt = node.attrs.alt || file.name.replace(/\.[a-z0-9]+$/i, '').replace(/[-_]+/g, ' ');
446
+ await refreshed(commit(doc.id, { nodes: { [node.id]: { attrs: { src: body.path, alt } } } }, { label: `image ${body.file}`, group }));
447
+ toast(`Added ${body.file}`);
448
+ } catch (error) { toast(error.message); }
449
+ });
450
+ picker.click();
451
+ }
452
+
453
+ async function assetMenu(doc, node, anchor) {
454
+ let assets = [];
455
+ try { assets = (await request(`api/assets?documentId=${encodeURIComponent(doc.id)}`)).assets; } catch (error) { toast(error.message); }
456
+ openMenu([
457
+ { heading: assets.length ? 'Images in divlet/' : 'No images in divlet/ yet' },
458
+ ...assets.slice(0, 40).map(asset => ({ label: asset.file, icon: 'image', checked: asset.path === node.attrs.src, run: () => refreshed(commit(doc.id, { nodes: { [node.id]: { attrs: { src: asset.path } } } }, { label: `image ${asset.file}` })) })),
459
+ '-',
460
+ { label: 'Upload image…', icon: 'upload', run: () => uploadInto(doc, node) },
461
+ ], { anchor, label: 'Image', className: 'menu-assets' });
462
+ }
463
+
464
+ function imageEditor(doc, node) {
465
+ const box = el('div', 'image-box');
466
+ const preview = el('div', 'thumb');
467
+ const url = imageUrl(doc, node.attrs.src);
468
+ if (url) { const img = el('img'); img.src = url; img.alt = ''; img.addEventListener('error', () => preview.classList.add('broken')); preview.append(img); }
469
+ else preview.classList.add('empty');
470
+ preview.append(icon('image', 'icon thumb-icon'));
471
+ const side = el('div', 'image-side');
472
+ const replace = el('button', 'link-button', node.attrs.src ? 'Replace…' : 'Choose…');
473
+ replace.type = 'button';
474
+ replace.dataset.key = 'image-replace';
475
+ replace.addEventListener('click', event => assetMenu(doc, node, event.currentTarget));
476
+ const upload = el('button', 'link-button', 'Upload');
477
+ upload.type = 'button';
478
+ upload.dataset.tip = 'Add an image file to divlet/assets — or drop it onto the artboard';
479
+ upload.addEventListener('click', () => uploadInto(doc, node));
480
+ const buttons = el('div', 'image-buttons');
481
+ buttons.append(replace, upload);
482
+ const alt = textField('image-alt', node.attrs.alt ?? '', value => refreshed(commit(doc.id, { nodes: { [node.id]: { attrs: { alt: value } } } }, { label: `alt text of ${node.id}` })), { placeholder: 'Alt text (describe the image)', label: 'Alt text' });
483
+ side.append(el('span', 'image-src', node.attrs.src || 'No source yet'), buttons);
484
+ box.append(preview, side);
485
+ const wrap = el('div', 'rows');
486
+ wrap.append(box, alt);
487
+ return wrap;
488
+ }
489
+
490
+ /* ---- components ---------------------------------------------------------- */
491
+ let componentsCache = { revision: -1, list: [], loading: false };
492
+ function componentList() {
493
+ const revision = store.state?.revision ?? 0;
494
+ if (componentsCache.revision !== revision && !componentsCache.loading) {
495
+ componentsCache.loading = true;
496
+ request('api/components').then(result => { componentsCache = { revision, list: result.components, loading: false }; schedule(); }).catch(() => { componentsCache.loading = false; });
497
+ }
498
+ return componentsCache.list;
499
+ }
500
+
501
+ /** An instance: which component it shows, its text overrides, and the ways out (go to, reset, detach). */
502
+ function instanceEditor(doc, node) {
503
+ const ref = node.ref.includes('/') ? node.ref : `${doc.id}/${node.ref}`;
504
+ const component = componentList().find(item => item.ref === ref);
505
+ const box = el('div', 'rows');
506
+ const head = el('div', 'instance-head');
507
+ head.append(icon('instance', 'icon instance-icon'), el('span', 'instance-name', component?.name ?? ref), el('span', 'instance-from', descriptor(ref.split('/')[0])?.name ?? ref.split('/')[0]));
508
+ const go = el('button', 'link-button', 'Go to');
509
+ go.type = 'button';
510
+ go.dataset.key = 'go-to-component';
511
+ go.dataset.tip = 'Select the component this instance shows';
512
+ go.addEventListener('click', () => actions.goToComponent(doc.id, node.id));
513
+ head.append(go);
514
+ box.append(head);
515
+ const overrides = node.descendants || {};
516
+ for (const text of component?.texts ?? []) {
517
+ const current = overrides[text.id]?.content;
518
+ const line = el('div', 'kv override');
519
+ const name = el('span', 'k', text.id);
520
+ if (text.id.length > 10) name.dataset.tip = text.id;
521
+ const field = textField(`override:${text.id}`, current ?? text.content, value => {
522
+ const next = value === text.content || value === '' ? { [text.id]: null } : { [text.id]: { content: value } };
523
+ refreshed(commit(doc.id, { nodes: { [node.id]: { descendants: next } } }, { label: `override text of ${node.id}` }));
524
+ }, { label: `${text.id} text`, placeholder: text.content });
525
+ line.append(el('span', `swatch ${current !== undefined ? 'set' : 'none'}`), name, field);
526
+ if (current !== undefined) line.append(button('undo', 'Back to the component text', () => refreshed(commit(doc.id, { nodes: { [node.id]: { descendants: { [text.id]: null } } } }, { label: `reset ${text.id} on ${node.id}` })), 'mini'));
527
+ else line.append(el('span'));
528
+ box.append(line);
529
+ }
530
+ const count = Object.keys(overrides).length;
531
+ const foot = el('div', 'instance-foot');
532
+ foot.append(el('span', 'caption', count ? `${count} override${count === 1 ? '' : 's'}` : 'Same as the component'));
533
+ if (count) {
534
+ const reset = el('button', 'link-button', 'Reset');
535
+ reset.type = 'button';
536
+ reset.addEventListener('click', () => refreshed(commit(doc.id, { nodes: { [node.id]: { descendants: null } } }, { label: `reset overrides of ${node.id}` })));
537
+ foot.append(reset);
538
+ }
539
+ const detach = el('button', 'link-button', 'Detach');
540
+ detach.type = 'button';
541
+ detach.dataset.tip = 'Turn the instance into ordinary, editable elements';
542
+ detach.addEventListener('click', () => actions.detachInstance(doc.id, node.id));
543
+ foot.append(detach);
544
+ box.append(foot);
545
+ return box;
546
+ }
547
+
548
+ async function uploadFont(file) {
549
+ if (fontUpload.pending) return;
550
+ fontUpload.pending = true; paintFontUpload();
551
+ try {
552
+ const { version, capabilities } = store.state.project;
553
+ if (!capabilities.fontUpload || !file.size || file.size > capabilities.fontUpload.maxBytes) throw new Error('Choose a nonempty TTF/OTF/WOFF or null-transform WOFF2 font of at most 20 MiB.');
554
+ const bytes = await file.arrayBuffer();
555
+ try { await new FontFace('divlet-device-probe', bytes).load(); }
556
+ catch { throw new Error('The browser could not decode this font. Nothing was uploaded.'); }
557
+ const response = await fetch(apiUrl(`api/fonts?name=${encodeURIComponent(file.name)}&expectedVersion=${version}`), { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: bytes });
558
+ const body = await response.json();
559
+ if (!response.ok) throw new Error(body.message || 'Font upload failed');
560
+ try { receiveState(await request('api/project')); await render({ force: true }); }
561
+ catch { toast(`Added ${body.family}; refresh the board to see its family.`); return; }
562
+ toast(`Added ${body.family}. Choose it in Family.`);
563
+ } catch (error) { toast(error.message); }
564
+ finally { fontUpload.pending = false; paintFontUpload(); }
565
+ }
566
+
567
+ /** Sections the registered panels (plugins) contribute for a selection; each change is one undo step for all selected elements. */
568
+ function panelFolds(kind, doc, nodes, parent) {
569
+ // `refresh: false` is for an edit that lands while a field is being typed into: the panel must not replace the input
570
+ // under the caret, so the change is committed without a forced re-render (the focusout path draws the deferred one).
571
+ const applyClasses = (transform, label, { group, refresh = true, style } = {}) => {
572
+ // Tab can reach several fields before the first write returns. Derive each change inside the writer queue,
573
+ // not from the nodes captured when this panel was built, or the next field would erase the previous one.
574
+ const result = commit(doc.id, async model => {
575
+ const patch = {};
576
+ for (const selected of nodes) {
577
+ const node = model.nodes[selected.id];
578
+ if (node?.type !== 'element') throw new Error(`Element no longer exists: ${selected.id}`);
579
+ const next = transform(node.className || '');
580
+ const inline = typeof style === 'function' ? await style(node) : style;
581
+ const change = inline ? { style: inline } : {};
582
+ if (next !== (node.className || '')) change.className = next;
583
+ if (Object.keys(change).length) patch[node.id] = change;
584
+ }
585
+ return Object.keys(patch).length ? { nodes: patch } : null;
586
+ }, { label, group });
587
+ if (refresh) refreshed(result); else quiet(result);
588
+ return result;
589
+ };
590
+ const ctx = {
591
+ doc, nodes, node: nodes[0], parent, applyClasses,
592
+ saveClasses: (next, label) => refreshed(commit(doc.id, { nodes: { [nodes[0].id]: { className: next.join(' ') } } }, { label: `${label} on ${nodes[0].id}` })),
593
+ spacingFocus: focus => postToFrame(doc.id, { type: 'divlet:spacing-focus', focus }),
594
+ measuredBox: nodeId => measuredBox(doc.id, nodeId),
595
+ freshMeasuredBox: nodeId => {
596
+ const render = descriptor(doc.id)?.render;
597
+ const read = () => { const box = measuredBox(doc.id, nodeId); return box?.render === render ? box : null; };
598
+ if (read()) return Promise.resolve(read());
599
+ return new Promise((resolve, reject) => {
600
+ const stop = on('spacing-box', () => { const box = read(); if (box) { stop(); clearTimeout(timer); resolve(box); } });
601
+ const timer = setTimeout(() => { stop(); reject(new Error('The preview has not measured this render yet. Try the size edit again.')); }, 2000);
602
+ });
603
+ },
604
+ };
605
+ const sections = panelSections(kind, ctx);
606
+ const family = fontFamilyControl(nodes, { css: [store.state?.project?.css, cachedModel(doc.id)?.css], apply: applyClasses, upload: store.state?.project?.capabilities?.fontUpload ? fontUpload : undefined });
607
+ const appearance = sections.find(item => item.name === 'appearance');
608
+ if (appearance) (appearance.content.querySelector('[data-inspector-slot="typography"]') ?? appearance.content).prepend(family);
609
+ else {
610
+ const content = el('div', 'appearance-box');
611
+ content.append(family);
612
+ sections.push({ name: 'appearance', title: 'Appearance', content });
613
+ }
614
+ return sections.map(({ name, title, content }) => section(name, title ? details(title, content, true) : content));
615
+ }
616
+
617
+ function idField(doc, node) {
618
+ const wrap = el('label', 'ident-field');
619
+ wrap.dataset.tip = 'Element ID · click to rename';
620
+ const input = el('input', 'ident-input');
621
+ input.type = 'text';
622
+ input.value = node.id;
623
+ input.spellcheck = false;
624
+ input.autocomplete = 'off';
625
+ input.dataset.key = 'node-id';
626
+ input.setAttribute('aria-label', 'Element ID');
627
+ const size = () => { input.size = Math.max(4, Math.min(28, input.value.length + 1)); };
628
+ size();
629
+ input.addEventListener('input', size);
630
+ const commitId = () => {
631
+ const to = input.value.trim();
632
+ if (to === node.id) { input.value = node.id; size(); return; }
633
+ if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,95}$/.test(to)) { toast(`“${to}” is not a valid ID: start with a letter; letters, digits, - _ . : only`); input.value = node.id; size(); return; }
634
+ quiet(edit(doc.id, { type: 'rename', id: node.id, to }, { label: `rename #${node.id} to #${to}` }))?.then(async result => {
635
+ if (!result) { input.value = node.id; size(); return; }
636
+ await whenVersion(result.documentId, result.version);
637
+ selectNodes(doc.id, [to]);
638
+ });
639
+ };
640
+ input.addEventListener('keydown', event => {
641
+ event.stopPropagation();
642
+ if (event.key === 'Enter') { event.preventDefault(); input.blur(); }
643
+ else if (event.key === 'Escape') { input.value = node.id; size(); input.blur(); }
644
+ });
645
+ input.addEventListener('focus', () => input.select());
646
+ input.addEventListener('change', commitId);
647
+ wrap.append(el('span', 'ident-hash', '#'), input);
648
+ return wrap;
649
+ }
650
+
651
+ function nodeView(doc, model, nodeIds) {
652
+ if (nodeIds.length > 1) {
653
+ const count = el('div', 'count');
654
+ count.append(el('b', '', String(nodeIds.length)), el('span', '', 'elements selected'));
655
+ const elements = nodeIds.map(id => model.nodes[id]).filter(node => node?.type === 'element');
656
+ // What applies to every selected element: layout, spacing and effects change all of them at once.
657
+ const shared = elements.length === nodeIds.length ? panelFolds('nodes', doc, elements, null) : [];
658
+ return [section('count', count), ...shared];
659
+ }
660
+ const node = model.nodes[nodeIds[0]];
661
+ if (!node) return [];
662
+ const view = [];
663
+ const completeMap = (field, delta) => Object.fromEntries(Object.entries({ ...node[field], ...delta }).filter(([, value]) => value !== null));
664
+ const head = el('div', 'node-head');
665
+ if (node.type === 'element') {
666
+ const tag = el('button', 'tag', node.tag);
667
+ tag.type = 'button';
668
+ tag.dataset.tip = 'Change tag';
669
+ tag.setAttribute('aria-label', `Tag ${node.tag}, change`);
670
+ tag.append(icon('chevron', 'icon caret'));
671
+ tag.addEventListener('click', event => actions.tagMenu(doc.id, node.id, { anchor: event.currentTarget }));
672
+ head.append(tag);
673
+ } else if (node.type === 'ref') {
674
+ const badge = el('span', 'tag static instance', 'instance');
675
+ badge.prepend(icon('instance', 'icon caret-left'));
676
+ head.append(badge);
677
+ } else head.append(el('span', 'tag static', 'text'));
678
+ if (node.type === 'element' && node.reusable) {
679
+ const mark = el('button', 'component-mark');
680
+ mark.type = 'button';
681
+ mark.dataset.tip = 'This element is a component · click to stop';
682
+ mark.append(icon('component'), el('span', '', 'Component'));
683
+ mark.addEventListener('click', () => actions.toggleComponent(doc.id, node.id));
684
+ head.append(mark);
685
+ }
686
+ head.append(idField(doc, node));
687
+ view.push(section('head', head));
688
+ // The layer's name (an imported design names its layers; their ids are generated hashes) is what people recognise.
689
+ if (typeof node.metadata?.name === 'string' && node.metadata.name.trim() && node.metadata.name !== node.id) {
690
+ const label = el('div', 'node-name', node.metadata.name.trim());
691
+ label.dataset.tip = 'Layer name';
692
+ view.push(section('node-name', label));
693
+ }
694
+ const text = textEditor(model, node);
695
+ if (text) view.push(section('text', text));
696
+ if (node.type === 'ref') view.push(section('instance', instanceEditor(doc, node)));
697
+ if (node.type === 'element') {
698
+ if (node.tag === 'img') view.push(section('image', imageEditor(doc, node)));
699
+ view.push(...panelFolds('node', doc, [node], parentOf(model, node.id)));
700
+ view.push(section('attrs', details('Attributes', attributesEditor(node, (attrs, label) => refreshed(commit(doc.id, { nodes: { [node.id]: { attrs } } }, { label: `${label} on ${node.id}` }))), true, Object.keys(node.attrs).length)));
701
+ view.push(section('style', details('Inline style', kvEditor(doc.id, node, 'style'), Object.keys(node.style).length > 0, Object.keys(node.style).length)));
702
+ }
703
+ view.push(section('node-meta', details('Metadata', metadataField('node-metadata', node.metadata, next => quiet(commit(doc.id, { nodes: { [node.id]: { metadata: next } } }, { label: `edit metadata of ${node.id}` }))), false, Object.keys(node.metadata || {}).length)));
704
+ const observed = store.observations.get(doc.id);
705
+ const measured = observed?.nodes.find(item => item.id === node.id);
706
+ if (measured && observed.render === doc.render) {
707
+ const values = el('div', 'values');
708
+ values.dataset.tip = 'Measured in the page (CSS px)';
709
+ for (const [label, value] of [['X', measured.rect.x], ['Y', measured.rect.y], ['W', measured.rect.width], ['H', measured.rect.height]]) {
710
+ const cell = el('span', 'value');
711
+ cell.append(el('i', '', label), document.createTextNode(String(Math.round(value))));
712
+ values.append(cell);
713
+ }
714
+ if (measured.overflowX || measured.overflowY) { const flag = el('span', 'value warn', 'overflow'); flag.dataset.tip = `Content overflows ${[measured.overflowX && 'horizontally', measured.overflowY && 'vertically'].filter(Boolean).join(' and ')}`; values.append(flag); }
715
+ view.push(section('measured', values));
716
+ }
717
+ return view;
718
+ }
719
+
720
+ /* ---- rendering ------------------------------------------------------------ */
721
+ function typing() {
722
+ const active = document.activeElement;
723
+ // A colour picker holds an unfinished choice too: re-rendering under it would fire `change` on a detached node.
724
+ return panel.contains(active) && /^(INPUT|TEXTAREA)$/.test(active.tagName) && !active.readOnly;
725
+ }
726
+
727
+ function contextOf(docs) {
728
+ return JSON.stringify([docs.map(doc => [doc.id, doc.version, doc.render]), store.node?.documentId, store.node?.nodeIds, store.state?.project?.format, store.state?.project?.capabilities?.properties]);
729
+ }
730
+
731
+ function invalidateMenu(force = false) {
732
+ if (panel.contains(menuAnchor()) && (force || controlContext !== contextOf(documents().filter(doc => store.selected.has(doc.id))))) {
733
+ deferred = false; closeMenu({ restoreFocus: false });
734
+ }
735
+ }
736
+
737
+ export async function render({ force = false } = {}) {
738
+ const docs = documents().filter(doc => store.selected.has(doc.id));
739
+ const ownedMenu = panel.contains(menuAnchor());
740
+ if (!docs.length || store.expanded) { if (ownedMenu) { deferred = false; closeMenu({ restoreFocus: false }); } ++token; panel.hidden = true; panel.replaceChildren(); delete panel.dataset.view; controlContext = null; document.body.classList.remove('has-inspector'); return; }
741
+ if ((!force && (typing() || holding)) || pressing()) { deferred = true; return; }
742
+ // Geometry-only reports must not discard hover/focus and hide an action before it can
743
+ // receive a press. Even a forced measurement refresh waits for an unchanged-context menu to close.
744
+ // Keep the real controls and callbacks, never rebase their authority.
745
+ if (controlContext === contextOf(docs) && (ownedMenu || !force && (panel.matches(':hover') || panel.contains(document.activeElement)))) { deferred = true; return; }
746
+ if (ownedMenu) { deferred = false; closeMenu({ restoreFocus: false }); }
747
+ const mine = ++token;
748
+ deferred = false;
749
+ let content;
750
+ if (docs.length > 1) content = multiView(docs);
751
+ else {
752
+ const doc = docs[0];
753
+ let model = cachedModel(doc.id);
754
+ if (!model) {
755
+ try { model = await loadModel(doc.id); } catch (error) { if (mine === token && !error.quiet && error.status !== 404) toast(error.message); return; }
756
+ if (mine !== token) return;
757
+ }
758
+ const nodeIds = store.node?.documentId === doc.id ? store.node.nodeIds.filter(id => model.nodes[id]) : [];
759
+ content = nodeIds.length ? nodeView(doc, model, nodeIds) : artboardView(doc, model);
760
+ }
761
+ const focused = document.activeElement;
762
+ const key = panel.contains(focused) ? focused.dataset.key : null;
763
+ const caret = key && 'selectionStart' in focused ? [focused.selectionStart, focused.selectionEnd] : null;
764
+ const pending = key && TRANSIENT.test(key) ? focused.value : null;
765
+ const open = new Map([...panel.querySelectorAll('details')].map(node => [node.parentElement?.dataset.section, node.open]));
766
+ const scroll = panel.scrollTop;
767
+ const sameView = panel.dataset.view === viewKey();
768
+ panel.replaceChildren(...content.filter(Boolean));
769
+ controlContext = contextOf(docs);
770
+ panel.dataset.view = viewKey();
771
+ if (sameView) for (const node of panel.querySelectorAll('details')) { const state = open.get(node.parentElement?.dataset.section); if (state !== undefined) node.open = state; }
772
+ panel.hidden = false;
773
+ document.body.classList.add('has-inspector');
774
+ if (sameView) panel.scrollTop = scroll;
775
+ const claimed = focusWanted();
776
+ if (key && !claimed) {
777
+ const target = [...panel.querySelectorAll('[data-key]')].find(node => node.dataset.key === key);
778
+ if (target) {
779
+ if (pending) target.value = pending;
780
+ target.focus({ preventScroll: true });
781
+ if (caret && target.setSelectionRange) try { target.setSelectionRange(...caret); } catch {}
782
+ }
783
+ }
784
+ }
785
+
786
+ /* Double-click on something without text: the field that matters for that element gets focus. */
787
+ const PRIMARY = { img: 'src', video: 'src', audio: 'src', source: 'src', iframe: 'src', input: 'placeholder', textarea: 'placeholder', a: 'href', form: 'action' };
788
+ let wanted = null;
789
+ function focusWanted() {
790
+ if (!wanted) return false;
791
+ const { documentId, nodeId, tag } = wanted;
792
+ const now = store.node;
793
+ if (!now || now.documentId !== documentId || now.nodeIds.length !== 1 || now.nodeIds[0] !== nodeId || !panel.querySelector('[data-section]')) return false;
794
+ wanted = null;
795
+ const attribute = PRIMARY[tag];
796
+ let target = null;
797
+ if (attribute) {
798
+ target = panel.querySelector(`[data-key="attr:${attribute}"]`);
799
+ if (!target) {
800
+ const name = panel.querySelector('[data-key="attr-new-key"]');
801
+ target = panel.querySelector('[data-key="attr-new-value"]');
802
+ if (name && target) { name.value = attribute; target.placeholder = attribute === 'src' ? 'assets/photo.png or https://…' : attribute === 'href' ? 'https://… or #section' : 'value'; }
803
+ }
804
+ } else target = panel.querySelector('[data-key="text"]');
805
+ if (!target) return false;
806
+ target.closest('details')?.setAttribute('open', '');
807
+ target.focus({ preventScroll: false });
808
+ target.select?.();
809
+ return true;
810
+ }
811
+ on('edit-fallback', info => { wanted = info; requestAnimationFrame(() => setTimeout(focusWanted, 60)); });
812
+
813
+ /** Identity of what is shown, so folds and scroll survive updates of the same subject only. */
814
+ function viewKey() {
815
+ return `${[...store.selected].sort().join(',')}|${store.node?.nodeIds.join(',') || ''}`;
816
+ }
817
+
818
+ const pressing = pressGuard(panel, () => { if (deferred && !typing() && !holding) render(); });
819
+ panel.addEventListener('focusout', () => { setTimeout(() => { if (deferred && !typing() && !holding && !pressing()) render(); }, 0); });
820
+
821
+ const schedule = (() => { let frame = 0; return () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => render()); }; })();
822
+ panel.addEventListener('divlet:menu-close', () => { if (deferred) schedule(); });
823
+ panel.addEventListener('pointerleave', () => { if (deferred) schedule(); });
824
+ const lastMeasurement = new Map();
825
+ on('selection', () => { invalidateMenu(true); schedule(); });
826
+ // Warm the component list so an instance never shows up without its name and texts.
827
+ on('state', () => { invalidateMenu(); if (documents().some(doc => doc.components?.length)) componentList(); schedule(); });
828
+ on('model', schedule);
829
+ on('expanded', schedule);
830
+ on('spacing-box', ({ documentId, nodeId }) => {
831
+ if (store.node?.documentId !== documentId || !store.node.nodeIds.includes(nodeId)) return;
832
+ // Typing (or a captured pointer) keeps the panel as it is; remembering that a render is owed is what makes the later
833
+ // focusout/pointer flush draw it, and the timer below covers a pointer that simply rests on the panel.
834
+ if (!typing() && !holding) schedule(); else deferred = true;
835
+ // A measurement is what the empty fields quote. A normal render keeps deferring while the pointer rests over the panel, which
836
+ // would leave those fields quoting the previous render (a pill's radius after its class was removed), so flush it forced —
837
+ // only when nothing is being typed or held and no menu is open under the pointer.
838
+ setTimeout(() => {
839
+ if (!deferred || typing() || holding || pressing() || panel.contains(menuAnchor())) return;
840
+ render({ force: true });
841
+ }, 600);
842
+ });
843
+ on('observations', report => {
844
+ if (typing() || !store.selected.has(report.documentId)) return;
845
+ // The same height from a fresh render can make Fit height available again after an edit or Undo.
846
+ const measurement = `${report.render}:${report.contentHeight}`;
847
+ const changed = lastMeasurement.get(report.documentId) !== measurement;
848
+ lastMeasurement.set(report.documentId, measurement);
849
+ if (store.node || changed) schedule();
850
+ });
851
+ on('live-rect', ({ id, rect }) => {
852
+ if (store.selected.size !== 1 || !store.selected.has(id)) return;
853
+ for (const key of ['x', 'y', 'width', 'height']) {
854
+ const input = panel.querySelector(`[data-key="${key}"]`);
855
+ if (input && document.activeElement !== input) input.value = String(Math.round(rect[key]));
856
+ }
857
+ });