@divlet/web 0.0.0-stage → 0.0.1

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 +948 -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 +178 -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,91 @@
1
+ // The keyboard shortcuts, read-only: ? or Ctrl+K → Keyboard shortcuts. Settings has only settings.
2
+ import { $, el, icon, keys } from './util.js';
3
+ import { on } from './store.js';
4
+
5
+ const scrim = el('div', 'scrim chrome');
6
+ scrim.id = 'shortcuts';
7
+ scrim.hidden = true;
8
+ const dialog = el('section', 'dialog shortcuts-dialog');
9
+ dialog.setAttribute('role', 'dialog');
10
+ dialog.setAttribute('aria-modal', 'true');
11
+ dialog.setAttribute('aria-labelledby', 'shortcuts-title');
12
+ scrim.append(dialog);
13
+ ($('#ui') || document.body).append(scrim);
14
+ let returnFocus = null;
15
+
16
+ const GROUPS = [
17
+ ['Pi and search', [
18
+ ['Ask the agent about the selection', 'A'], ['Send the request', keys('Enter')], ['Previous requests', '↑ in the Agent tab'],
19
+ ['Search everything', keys('Mod+K')], ['Ask the agent from the search', keys('Mod+Enter')],
20
+ ]],
21
+ ['Canvas', [
22
+ ['Pan', 'Scroll · Space+drag'], ['Zoom', `${keys('Mod')}+scroll · pinch`], ['Zoom in / out', '+ / −'],
23
+ ['Zoom to 100%', '0'], ['Fit everything', keys('Shift+1')], ['Zoom to selection', `F · ${keys('Shift+2')}`],
24
+ ]],
25
+ ['Tools', [['Select', 'V'], ['Interact with the page', 'P'], ['Expand and browse', 'E']]],
26
+ ['Selection', [
27
+ ['Element', 'Click'], ['Add to selection', `${keys('Shift')}+click`], ['Artboards', 'Drag on canvas'],
28
+ ['Child · edit text', keys('Enter')], ['Parent, up to the artboard', `${keys('Escape')} · ${keys('Shift+Enter')}`],
29
+ ['Walk the tree', '↑ ↓ siblings · ← parent · → child'], ['Next / previous (also while editing)', 'Tab · ⇧ Tab'], ['New list item (while editing one)', keys('Enter')], ['All artboards', keys('Mod+A')],
30
+ ]],
31
+ ['Editing', [
32
+ ['Undo / redo', `${keys('Mod+Z')} · ${keys('Mod+Shift+Z')}`], ['Duplicate', keys('Mod+D')], ['Delete', 'Del'],
33
+ ['Copy, cut, paste', `${keys('Mod+C')} ${keys('Mod+X')} ${keys('Mod+V')}`], ['Add an image', 'Drop or paste'],
34
+ ['Wrap / unwrap', `${keys('Mod+G')} · ${keys('Mod+Shift+G')}`], ['Reorder element', `${keys('Alt+Up')} ${keys('Alt+Down')}`],
35
+ ['Edit text', 'Double-click'], ['Move artboard', `Drag · arrows`], ['Rename artboard', 'R'],
36
+ ['Margin / padding of the selection', 'Drag a green or orange pill'], ['Move an element', 'Drag its grip (top-left corner) onto another element'], ['Copy while moving', `${keys('Alt')} on drop`], ['Resize an element', 'Drag the square at its bottom-right corner'], ['… all sides · opposite sides · 1px', `${keys('Shift')} · ${keys('Mod')} · ${keys('Alt')}`], ['Resize: width only · height only', `${keys('Shift')} · ${keys('Mod')}`],
37
+ ['New artboard', 'N'], ['Cancel a drag', keys('Escape')], ['More actions', `Right-click · ${keys('Shift+F10')}`],
38
+ ]],
39
+ ['Panels', [['Layers', 'L'], ['History', 'H'], ['Keyboard shortcuts', '?'], ['Settings', `${keys('Mod')} ,`], ['Interface size', `${keys('Mod+Alt')} + / −`], ['Open / save a project', `${keys('Mod+O')} · ${keys('Mod+Shift+S')}`]]],
40
+ ];
41
+
42
+
43
+ export const isOpen = () => !scrim.hidden;
44
+
45
+ function render() {
46
+ const head = el('div', 'dialog-head');
47
+ const title = el('h2', '', 'Keyboard shortcuts');
48
+ title.id = 'shortcuts-title';
49
+ const close = el('button', 'tool mini');
50
+ close.type = 'button';
51
+ close.setAttribute('aria-label', 'Close');
52
+ close.dataset.tip = 'Close · Esc';
53
+ close.append(icon('close'));
54
+ close.addEventListener('click', () => toggle(false));
55
+ head.append(title, close);
56
+ const grid = el('div', 'shortcut-grid');
57
+ for (const [name, rows] of GROUPS) {
58
+ const group = el('section', 'shortcut-group');
59
+ group.append(el('h3', '', name));
60
+ const list = el('dl');
61
+ for (const [what, how] of rows) list.append(el('dt', '', what), el('dd', '', how));
62
+ group.append(list);
63
+ grid.append(group);
64
+ }
65
+ const foot = el('div', 'dialog-foot');
66
+ foot.append(el('span', 'caption', 'Shortcuts follow the key, not the keyboard layout. Right-click anything for its actions.'));
67
+ dialog.replaceChildren(head, grid, foot);
68
+ }
69
+
70
+ export function toggle(force) {
71
+ const open = force ?? scrim.hidden;
72
+ if (open === !scrim.hidden) return;
73
+ if (open) {
74
+ returnFocus = document.activeElement;
75
+ render();
76
+ scrim.hidden = false;
77
+ dialog.querySelector('button')?.focus({ preventScroll: true });
78
+ } else {
79
+ scrim.hidden = true;
80
+ if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
81
+ else $('#viewport').focus({ preventScroll: true });
82
+ }
83
+ }
84
+
85
+ scrim.addEventListener('pointerdown', event => { if (event.target === scrim) toggle(false); });
86
+ scrim.addEventListener('keydown', event => {
87
+ event.stopPropagation();
88
+ if (event.key === 'Escape' || event.key === '?') { event.preventDefault(); toggle(false); }
89
+ if (event.key === 'Tab') { event.preventDefault(); dialog.querySelector('button')?.focus(); }
90
+ });
91
+ on('shortcuts', value => toggle(value));
@@ -0,0 +1,85 @@
1
+ import { $, uiScale } from './util.js';
2
+ import { emit } from './store.js';
3
+
4
+ /* The left sidebar hosts three panes: Layers, History (changes only) and Agent (the conversation with Pi).
5
+ Their tabs sit under it and never go away: the active tab is lit, and pressing it again (or Esc) folds the
6
+ sidebar, so a tab both switches and hides. */
7
+ const panel = $('#sidebar');
8
+ const buttons = { layers: $('#open-layers'), activity: $('#open-history'), agent: $('#open-agent') };
9
+ const panes = { layers: $('#layers'), activity: $('#activity'), agent: $('#agent') };
10
+ const LABELS = { layers: 'Layers', activity: 'History', agent: 'Agent' };
11
+ let current = 'layers';
12
+
13
+ export const tab = () => current;
14
+ export const isOpen = name => !panel.hidden && (!name || current === name);
15
+ export const widthSize = () => (panel.hidden ? 'wide' : panel.getBoundingClientRect().width / uiScale() < 265 ? 'tiny' : panel.getBoundingClientRect().width / uiScale() < 320 ? 'narrow' : 'wide');
16
+
17
+ export function show(name = current) {
18
+ current = name;
19
+ panel.hidden = false;
20
+ for (const [key, pane] of Object.entries(panes)) pane.hidden = key !== name;
21
+ panel.setAttribute('aria-label', LABELS[name]);
22
+ paint();
23
+ emit('sidebar', { open: true, tab: name });
24
+ }
25
+
26
+ export function close() {
27
+ if (panel.hidden) return;
28
+ const was = current;
29
+ panel.hidden = true;
30
+ paint();
31
+ emit('sidebar', { open: false, tab: was });
32
+ }
33
+
34
+ /**
35
+ * The sidebar is resizable, so how much room its content has is a property of the panel, not of the viewport: a media query
36
+ * cannot see it. `data-sidebar` on the document tells the panes how tight they are, and the width event lets text adapt
37
+ * (the composer's placeholder and key hints are length-sensitive).
38
+ */
39
+ const inspector = $('#inspector');
40
+
41
+ /**
42
+ * The pinned chrome has two looks, chosen by the panels themselves: with every panel folded it floats over the canvas (frosted,
43
+ * translucent); as soon as a panel is open the chrome belongs to one opaque body, so the pills and the dock turn solid. `none`,
44
+ * `left`, `right` or `both` says which side is open.
45
+ */
46
+ function paintPanels() {
47
+ const left = !panel.hidden, right = !!inspector && !inspector.hidden;
48
+ const state = left && right ? 'both' : left ? 'left' : right ? 'right' : 'none';
49
+ if (document.documentElement.dataset.panels !== state) document.documentElement.dataset.panels = state;
50
+ }
51
+ new MutationObserver(paintPanels).observe(panel, { attributes: true, attributeFilter: ['hidden'] });
52
+ if (inspector) new MutationObserver(paintPanels).observe(inspector, { attributes: true, attributeFilter: ['hidden'] });
53
+
54
+ function measure() {
55
+ // Density thresholds are interface pixels, not the scaled screen pixels returned by the rectangle.
56
+ const width = panel.hidden ? 0 : panel.getBoundingClientRect().width / uiScale();
57
+ // The sidebar's own floor is 240px (resize.js), so the steps live inside that range: tiny 240–264, narrow 265–319.
58
+ const size = width && width < 265 ? 'tiny' : width && width < 320 ? 'narrow' : 'wide';
59
+ if (document.documentElement.dataset.sidebar !== size) {
60
+ document.documentElement.dataset.sidebar = size;
61
+ emit('sidebar-width', { size, width });
62
+ }
63
+ }
64
+ new ResizeObserver(measure).observe(panel);
65
+ // The folded activity plaque belongs to the tab footer; phone sheets/breadcrumbs stay above its real height.
66
+ const footer = $('#sidebar-foot');
67
+ new ResizeObserver(() => {
68
+ const extra = Math.max(0, footer.offsetHeight - $('#side-tabs').offsetHeight);
69
+ document.documentElement.style.setProperty('--agent-float-h', `${extra}px`);
70
+ }).observe(footer);
71
+
72
+ /** Toggle a pane: opening it, switching to it, or folding the sidebar when it is already showing. */
73
+ export function toggle(name) {
74
+ if (isOpen(name)) close();
75
+ else show(name);
76
+ }
77
+
78
+ function paint() {
79
+ for (const [name, button] of Object.entries(buttons)) {
80
+ button?.classList.toggle('on', isOpen(name));
81
+ button?.setAttribute('aria-selected', String(isOpen(name)));
82
+ }
83
+ document.body.classList.toggle('has-sidebar', !panel.hidden);
84
+ document.body.classList.toggle('has-ask', isOpen('agent'));
85
+ }
@@ -0,0 +1,249 @@
1
+ import { clamp } from './util.js';
2
+ import { post, readSession, shareSelection } from './host.js';
3
+ export { base, apiUrl, request, post } from './host.js';
4
+
5
+ const listeners = new Map();
6
+ export const on = (name, fn) => { (listeners.get(name) || listeners.set(name, new Set()).get(name)).add(fn); return () => listeners.get(name).delete(fn); };
7
+ export const emit = (name, detail) => { for (const fn of listeners.get(name) || []) { try { fn(detail); } catch (error) { console.error(error); } } };
8
+
9
+ export const store = {
10
+ state: null,
11
+ selected: new Set(),
12
+ node: null,
13
+ mode: 'board',
14
+ connection: 'connecting',
15
+ observations: new Map(),
16
+ models: new Map(),
17
+ recent: [],
18
+ expanded: null,
19
+ /** Frames being deleted: hidden at once, shown again if the deletion fails. */
20
+ leaving: new Set(),
21
+ };
22
+
23
+ export const documents = () => (store.state?.documents || []).filter(doc => !store.leaving.has(doc.id));
24
+ export const descriptor = id => documents().find(doc => doc.id === id);
25
+
26
+ /** Short notice. `action` adds one button; `sticky` keeps it until replaced or dismissed with `toast.clear(id)`. */
27
+ export function toast(message, action, { sticky = false, id } = {}) { emit('toast', { message, action, sticky, id }); }
28
+ toast.clear = id => emit('toast-clear', id);
29
+
30
+ /** Resolve once the project state lists `id` (new documents arrive over SSE after the POST returns). */
31
+ export function whenDocument(id, timeout = 4000) {
32
+ if (descriptor(id)) return Promise.resolve(descriptor(id));
33
+ return new Promise(resolve => {
34
+ const stop = on('state', () => { if (descriptor(id)) { stop(); clearTimeout(timer); resolve(descriptor(id)); } });
35
+ const timer = setTimeout(() => { stop(); resolve(descriptor(id) || null); }, timeout);
36
+ });
37
+ }
38
+
39
+ /** Resolve once the project state shows document `id` at `version` or later. */
40
+ export function whenVersion(id, version, timeout = 4000) {
41
+ const ready = () => (descriptor(id)?.version ?? 0) >= version;
42
+ if (ready()) return Promise.resolve(true);
43
+ return new Promise(resolve => {
44
+ const stop = on('state', () => { if (ready()) { stop(); clearTimeout(timer); resolve(true); } });
45
+ const timer = setTimeout(() => { stop(); resolve(ready()); }, timeout);
46
+ });
47
+ }
48
+
49
+ /** The model including this client's latest accepted commit (waits for the state that announces it). */
50
+ export async function freshModel(id) {
51
+ const target = known.get(id) || 0;
52
+ if ((descriptor(id)?.version ?? 0) < target) await whenVersion(id, target);
53
+ return cachedModel(id) || loadModel(id);
54
+ }
55
+
56
+ /* ---- tree helpers --------------------------------------------------------- */
57
+ export function parentMap(model) {
58
+ const parents = new Map();
59
+ for (const node of Object.values(model.nodes)) if (node.type === 'element') for (const child of node.children) parents.set(child, node.id);
60
+ return parents;
61
+ }
62
+ export const parentOf = (model, id) => model.nodes[parentMap(model).get(id)] || null;
63
+ export function ancestorsOf(model, id) {
64
+ const parents = parentMap(model);
65
+ const path = [];
66
+ for (let current = parents.get(id); current; current = parents.get(current)) path.unshift(current);
67
+ return path;
68
+ }
69
+ export const CONTAINERS_EXCLUDED = new Set(['img', 'input', 'br', 'hr', 'area', 'col', 'source', 'track', 'wbr', 'textarea', 'select', 'option']);
70
+ export const canContain = node => node?.type === 'element' && !CONTAINERS_EXCLUDED.has(node.tag);
71
+ export function textChild(model, node) {
72
+ if (node?.type === 'text') return node;
73
+ if (node?.type === 'element' && node.children.length === 1 && model.nodes[node.children[0]]?.type === 'text') return model.nodes[node.children[0]];
74
+ return null;
75
+ }
76
+
77
+ /* ---- model cache --------------------------------------------------------- */
78
+ const loading = new Map();
79
+ const modelRenders = new WeakMap();
80
+ export function cachedModel(id) {
81
+ const model = store.models.get(id);
82
+ return model && model.version === descriptor(id)?.version && modelRenders.get(model) === descriptor(id)?.render ? model : null;
83
+ }
84
+ export async function loadModel(id) {
85
+ const cached = cachedModel(id);
86
+ if (cached) return cached;
87
+ if (!descriptor(id)) throw Object.assign(new Error(`The frame ${id} is gone`), { code: 'NOT_FOUND', quiet: true });
88
+ const version = descriptor(id)?.version;
89
+ const render = descriptor(id)?.render;
90
+ const key = `${id}@${version}:${render}`;
91
+ if (!loading.has(key)) {
92
+ loading.set(key, readSession.readFrame(id).then(model => {
93
+ if (descriptor(id)?.version !== version || descriptor(id)?.render !== render) return loadModel(id);
94
+ if (model.version === version) {
95
+ modelRenders.set(model, render);
96
+ store.models.set(id, model);
97
+ pruneNodeSelection(model);
98
+ emit('model', model);
99
+ }
100
+ return model;
101
+ }).finally(() => loading.delete(key)));
102
+ }
103
+ return loading.get(key);
104
+ }
105
+
106
+ /* ---- committing edits (serialised per document) -------------------------- */
107
+ const queues = new Map();
108
+ const known = new Map();
109
+ let groupSeed = 0;
110
+ export const newGroup = () => `u${Date.now().toString(36)}${(groupSeed++).toString(36)}`;
111
+ /** Resolves once every commit queued for one document has finished, whether it succeeded or not. */
112
+ export const idle = documentId => (queues.get(documentId) || Promise.resolve()).then(() => undefined);
113
+
114
+ function enqueue(documentId, task) {
115
+ const result = (queues.get(documentId) || Promise.resolve()).then(task);
116
+ queues.set(documentId, result.catch(() => {}));
117
+ return result;
118
+ }
119
+
120
+ async function send(route, documentId, build, { version = Math.max(known.get(documentId) || 0, descriptor(documentId)?.version || 0), retry = true } = {}) {
121
+ for (let attempt = 0; ; attempt++) {
122
+ try {
123
+ const result = await post(route, build(version));
124
+ known.set(documentId, result.version);
125
+ return result;
126
+ } catch (error) {
127
+ if (error.code === 'REVISION_CONFLICT' && error.details?.actualVersion) known.set(documentId, error.details.actualVersion);
128
+ if (retry && error.code === 'REVISION_CONFLICT' && attempt < 2 && error.details?.actualVersion) { version = error.details.actualVersion; continue; }
129
+ throw error;
130
+ }
131
+ }
132
+ }
133
+
134
+ /** A literal patch, or async relative intent derived after prior local writes; derived patches never retry a stale model. */
135
+ export function commit(documentId, patch, { label, group, expectedVersion, expectedRender } = {}) {
136
+ const guard = expectedVersion === undefined ? {} : { version: expectedVersion, retry: false };
137
+ const format = store.state?.project?.format;
138
+ return enqueue(documentId, async () => {
139
+ if (typeof patch !== 'function') return send('api/patch', documentId, version => ({ documentId, expectedVersion: version, expectedRender, patch, label, group }), guard);
140
+ if (store.state?.project?.format !== format) throw Object.assign(new Error('The source format changed. Read it again before retrying.'), { code: 'REVISION_CONFLICT' });
141
+ const model = await freshModel(documentId);
142
+ if (store.state?.project?.format !== format || model !== cachedModel(documentId) || model.version < (known.get(documentId) || 0)) throw Object.assign(new Error('The frame model is not current. Read it again before retrying.'), { code: 'REVISION_CONFLICT' });
143
+ const derived = await patch(model);
144
+ if (store.state?.project?.format !== format || model !== cachedModel(documentId)) throw Object.assign(new Error('The frame changed while preparing this edit. Read it again before retrying.'), { code: 'REVISION_CONFLICT' });
145
+ if (!derived) return null;
146
+ return send('api/patch', documentId, version => ({ documentId, expectedVersion: version, expectedRender, patch: derived, label, group }), { version: expectedVersion ?? model.version, retry: false });
147
+ }).catch(error => { toast(error.message); throw error; });
148
+ }
149
+ export function edit(documentId, op, { label, group, expectedRender } = {}) {
150
+ const format = store.state?.project?.format;
151
+ return enqueue(documentId, async () => {
152
+ if (typeof op !== 'function') return send('api/edit', documentId, expectedVersion => ({ documentId, expectedVersion, expectedRender, op, label, group }));
153
+ // Derive relative legacy intent after local commits; native operations keep their shown-view guards.
154
+ if (store.state?.project?.format !== format) throw Object.assign(new Error('The source format changed. Read it again before retrying.'), { code: 'REVISION_CONFLICT' });
155
+ const model = await freshModel(documentId);
156
+ if (store.state?.project?.format !== format || model !== cachedModel(documentId) || model.version < (known.get(documentId) || 0)) throw Object.assign(new Error('The frame model is not current. Read it again before retrying.'), { code: 'REVISION_CONFLICT' });
157
+ const derived = op(model);
158
+ if (!derived) return null;
159
+ return send('api/edit', documentId, expectedVersion => ({ documentId, expectedVersion, expectedRender, op: derived, label, group }), { version: model.version, retry: false });
160
+ }).catch(error => { toast(error.message); throw error; });
161
+ }
162
+
163
+ export const undo = () => post('api/undo', {}).catch(error => { toast(error.message); return null; });
164
+ export const redo = () => post('api/redo', {}).catch(error => { toast(error.message); return null; });
165
+
166
+ /* ---- selection ------------------------------------------------------------ */
167
+ let selectionSequence = 0;
168
+ let pendingSelection = null;
169
+ const same = (a, b) => a.length === b.length && a.every(id => b.includes(id));
170
+ export const isSelectionPending = () => !!pendingSelection;
171
+
172
+ function syncSelection() {
173
+ if (!shareSelection) return;
174
+ const seq = ++selectionSequence;
175
+ const ids = [...store.selected];
176
+ const nodes = store.node && ids.length === 1 && ids[0] === store.node.documentId ? { documentId: store.node.documentId, nodeIds: store.node.nodeIds } : undefined;
177
+ pendingSelection = { seq, ids };
178
+ shareSelection({ ids, nodes }).then(result => {
179
+ if (seq !== selectionSequence) return;
180
+ pendingSelection = null;
181
+ receiveState(result);
182
+ }).catch(error => {
183
+ if (seq === selectionSequence) pendingSelection = null;
184
+ toast(`Selection could not be shared: ${error.message}`);
185
+ });
186
+ }
187
+
188
+ export function selectArtboards(ids, { additive = false, keepNode = false } = {}) {
189
+ const valid = new Set(documents().map(doc => doc.id));
190
+ const next = new Set(additive ? store.selected : []);
191
+ for (const id of ids) if (valid.has(id)) next.add(id);
192
+ store.selected = next;
193
+ if (!keepNode || (store.node && !next.has(store.node.documentId))) store.node = null;
194
+ emit('selection');
195
+ syncSelection();
196
+ }
197
+
198
+ export function selectNodes(documentId, nodeIds, { reveal = false } = {}) {
199
+ store.node = nodeIds.length ? { documentId, nodeIds: [...new Set(nodeIds)] } : null;
200
+ store.selected = new Set([documentId]);
201
+ emit('selection', { reveal });
202
+ syncSelection();
203
+ }
204
+
205
+ /** Drop selected node IDs that no longer exist (after delete, undo or an agent edit). */
206
+ function pruneNodeSelection(model) {
207
+ if (store.node?.documentId !== model.id) return;
208
+ const kept = store.node.nodeIds.filter(id => model.nodes[id]);
209
+ if (kept.length !== store.node.nodeIds.length) selectNodes(model.id, kept);
210
+ }
211
+
212
+ export function receiveState(next) {
213
+ if (!next || !Array.isArray(next.documents) || !Number.isSafeInteger(next.stateVersion) || next.stateVersion < 1) return;
214
+ const previous = store.state;
215
+ if (previous && (next.projectId !== previous.projectId || next.url !== previous.url || next.project?.file !== previous.project?.file || next.stateVersion < previous.stateVersion || Number.isFinite(next.revision) && next.revision < previous.revision)) return;
216
+ const oldSelection = previous?.selection;
217
+ store.state = next;
218
+ if (oldSelection && (next.selection?.revision ?? 0) < (oldSelection.revision ?? 0)) next.selection = oldSelection;
219
+ const ids = Array.isArray(next.selection?.ids) ? next.selection.ids : [];
220
+ // Only the initial host state seeds a local selection; later snapshot reads cannot overwrite the user's view.
221
+ if ((!previous || shareSelection) && (!pendingSelection || same(ids, pendingSelection.ids))) store.selected = new Set(ids);
222
+ store.selected = new Set([...store.selected].filter(id => next.documents.some(doc => doc.id === id)));
223
+ if (!previous && next.selection?.selectedNodes && store.selected.has(next.selection.selectedNodes.documentId)) store.node = next.selection.selectedNodes;
224
+ if (store.node && !store.selected.has(store.node.documentId)) store.node = null;
225
+ for (const id of [...store.models.keys()]) if (!next.documents.some(doc => doc.id === id)) store.models.delete(id);
226
+ for (const id of [...store.leaving]) if (!next.documents.some(doc => doc.id === id)) store.leaving.delete(id);
227
+ emit('state', next);
228
+ }
229
+
230
+ /** Hide frames optimistically while they are deleted; `undo` shows them again. */
231
+ export function leave(ids, undo = false) {
232
+ for (const id of ids) { if (undo) store.leaving.delete(id); else store.leaving.add(id); }
233
+ if (!undo) { store.selected = new Set([...store.selected].filter(id => !store.leaving.has(id))); if (store.node && store.leaving.has(store.node.documentId)) store.node = null; }
234
+ if (store.state) emit('state', store.state);
235
+ emit('selection');
236
+ }
237
+
238
+ export function setConnection(value) {
239
+ store.connection = value;
240
+ emit('connection', value);
241
+ }
242
+
243
+ export function viewState(projectId) {
244
+ try { return JSON.parse(localStorage.getItem(`divlet:${projectId}`) || 'null'); } catch { return null; }
245
+ }
246
+ export function saveViewState(projectId, value) {
247
+ try { localStorage.setItem(`divlet:${projectId}`, JSON.stringify(value)); } catch {}
248
+ }
249
+ export { clamp };
@@ -0,0 +1,130 @@
1
+ // One floating suggestion list for the inspector's inputs (classes, attributes, CSS properties).
2
+ // ↑↓ choose · Tab completes · Enter takes the chosen item (or what was typed) · Esc closes.
3
+ import { el, clamp, debounce, uiScale } from './util.js';
4
+
5
+ const pop = el('div', 'suggest');
6
+ pop.id = 'suggest';
7
+ pop.setAttribute('role', 'listbox');
8
+ pop.hidden = true;
9
+ document.body.append(pop);
10
+ let owner = null;
11
+ let options = null;
12
+ let items = [];
13
+ let active = -1;
14
+ let seq = 0;
15
+
16
+ function close() {
17
+ seq++;
18
+ if (owner) { owner.setAttribute('aria-expanded', 'false'); owner.removeAttribute('aria-activedescendant'); }
19
+ owner = null;
20
+ options = null;
21
+ items = [];
22
+ active = -1;
23
+ pop.hidden = true;
24
+ pop.replaceChildren();
25
+ }
26
+
27
+ function place() {
28
+ if (!owner?.isConnected) return close();
29
+ const r = owner.getBoundingClientRect();
30
+ const w = pop.offsetWidth * uiScale();
31
+ const h = pop.offsetHeight * uiScale();
32
+ pop.style.left = `${clamp(r.left - 6, 6, innerWidth - w - 6)}px`;
33
+ pop.style.top = `${r.bottom + 6 + h > innerHeight - 6 ? Math.max(6, r.top - h - 6) : r.bottom + 6}px`;
34
+ }
35
+
36
+ function paint() {
37
+ pop.replaceChildren(...items.map((item, index) => {
38
+ const row = el('div', `suggest-item${index === active ? ' on' : ''}`);
39
+ row.id = `suggest-${index}`;
40
+ row.setAttribute('role', 'option');
41
+ row.setAttribute('aria-selected', String(index === active));
42
+ const mark = el('i', `suggest-mark ${item.kind || ''}`);
43
+ if (item.color) mark.style.background = item.color;
44
+ row.append(mark, el('span', 'suggest-name', item.label ?? item.name), el('span', 'suggest-hint', item.hint || ''));
45
+ row.addEventListener('pointerdown', event => { event.preventDefault(); accept(index); });
46
+ row.addEventListener('pointermove', () => { if (active !== index) { active = index; paint(); } });
47
+ return row;
48
+ }));
49
+ pop.hidden = !items.length;
50
+ if (owner) {
51
+ owner.setAttribute('aria-expanded', String(!!items.length));
52
+ if (active >= 0) owner.setAttribute('aria-activedescendant', `suggest-${active}`); else owner.removeAttribute('aria-activedescendant');
53
+ }
54
+ place();
55
+ }
56
+
57
+ const lastWord = value => value.split(/\s+/).at(-1);
58
+
59
+ function accept(index) {
60
+ const item = items[index];
61
+ const input = owner;
62
+ const settings = options;
63
+ if (!item || !input) return;
64
+ if (settings.words) { const words = input.value.split(/\s+/); words[words.length - 1] = item.name; input.value = words.join(' '); }
65
+ else input.value = item.name;
66
+ close();
67
+ input.dispatchEvent(new Event('suggest-accept'));
68
+ // A partial pick (a variant such as `hover:`) keeps typing; a whole one is taken at once.
69
+ if (item.partial) { input.focus(); input.dispatchEvent(new Event('input')); return; }
70
+ settings.onAccept?.(input, item);
71
+ }
72
+
73
+ /**
74
+ * Offer `fetch(word)` results under `input`. `words: true` completes the last space-separated
75
+ * word (class lists); otherwise the whole value. `onAccept(input, item)` runs on a pick.
76
+ */
77
+ export function attachSuggest(input, { fetch, onAccept, words = false, empty = false }) {
78
+ input.setAttribute('role', 'combobox');
79
+ input.setAttribute('aria-autocomplete', 'list');
80
+ input.setAttribute('aria-controls', 'suggest');
81
+ input.setAttribute('aria-expanded', 'false');
82
+ input.autocomplete = 'off';
83
+ const show = async (force = false) => {
84
+ if (document.activeElement !== input) return;
85
+ const word = words ? lastWord(input.value) : input.value.trim();
86
+ if (!word && !empty && !force) { if (owner === input) close(); return; }
87
+ const mine = ++seq;
88
+ let list;
89
+ try { list = await fetch(word); } catch { return; }
90
+ if (mine !== seq || document.activeElement !== input) return;
91
+ list = (list || []).filter(item => item.name !== word).slice(0, 8);
92
+ if (!list.length) { if (owner === input) close(); return; }
93
+ owner = input;
94
+ options = { onAccept, words };
95
+ items = list;
96
+ active = -1;
97
+ paint();
98
+ };
99
+ const update = debounce(() => show(), 60);
100
+ input.addEventListener('input', update);
101
+ if (empty) input.addEventListener('focus', update);
102
+ input.addEventListener('keydown', event => {
103
+ // ↓ opens the list on an empty field; it never opens by itself over other controls unless `empty`.
104
+ if (event.key === 'ArrowDown' && (owner !== input || pop.hidden)) { event.preventDefault(); show(true); return; }
105
+ if (owner !== input || pop.hidden) return;
106
+ const take = () => { event.preventDefault(); event.stopImmediatePropagation(); };
107
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
108
+ take();
109
+ active = (active + (event.key === 'ArrowDown' ? 1 : -1) + items.length + 1) % (items.length + 1);
110
+ if (active === items.length) active = -1;
111
+ paint();
112
+ } else if (event.key === 'Tab' && !event.shiftKey) { take(); accept(Math.max(0, active)); }
113
+ else if (event.key === 'Enter' && active >= 0) { take(); accept(active); }
114
+ else if (event.key === 'Escape') { take(); close(); }
115
+ });
116
+ input.addEventListener('blur', () => { if (owner === input) close(); });
117
+ }
118
+
119
+ /** Local suggestions: names starting with the word first, then names containing it. */
120
+ export function filterLocal(list, word, limit = 8) {
121
+ const needle = word.toLowerCase();
122
+ const starts = list.filter(item => item.name.toLowerCase().startsWith(needle));
123
+ const inside = needle.length > 1 ? list.filter(item => !item.name.toLowerCase().startsWith(needle) && item.name.toLowerCase().includes(needle)) : [];
124
+ return [...starts, ...inside].slice(0, limit);
125
+ }
126
+
127
+ // A field that is replaced while its list is open (the inspector redraws) never gets a blur.
128
+ new MutationObserver(() => { if (owner && !owner.isConnected) close(); }).observe(document.body, { childList: true, subtree: true });
129
+ window.addEventListener('resize', () => { if (owner) place(); });
130
+ document.addEventListener('scroll', event => { if (owner && !pop.contains(event.target)) place(); }, true);
@@ -0,0 +1,54 @@
1
+ // A toolbar that fits itself: every action is a button in one row, and whatever does not fit moves, in order, into a
2
+ // trailing "More" dropdown that is rebuilt whenever the available width changes. Nothing is hard-coded about which
3
+ // actions overflow; the order of `items` is the priority (the last ones leave first).
4
+ import { el, button } from './util.js';
5
+ import { openMenu } from './menu.js';
6
+
7
+ /**
8
+ * items: menu-style entries `{ label, icon, keys, run, disabled, danger, hint, checked }` (falsy entries and '-' are
9
+ * skipped in the row and kept as separators in the dropdown). Returns the bar element.
10
+ */
11
+ export function overflowBar(items, { label = 'More' } = {}) {
12
+ const list = items.filter(Boolean);
13
+ const actions = list.filter(item => item !== '-' && !item.heading);
14
+ const bar = el('div', 'actions smart-bar');
15
+ const buttons = actions.map(item => {
16
+ const node = button(item.icon, item.keys ? `${item.label} · ${item.keys}` : item.label, () => item.run?.(), item.danger ? 'danger' : '');
17
+ node.disabled = !!item.disabled;
18
+ return node;
19
+ });
20
+ const more = button('more', label, event => {
21
+ const rest = [];
22
+ let pendingRule = false;
23
+ for (const entry of list) {
24
+ if (entry === '-' || entry.heading) { pendingRule = rest.length > 0; continue; }
25
+ const index = actions.indexOf(entry);
26
+ if (!buttons[index].hidden) continue;
27
+ if (pendingRule) { rest.push('-'); pendingRule = false; }
28
+ rest.push(entry);
29
+ }
30
+ openMenu(rest, { anchor: event.currentTarget, label });
31
+ });
32
+ more.hidden = true;
33
+ more.classList.add('more');
34
+ bar.append(...buttons, more);
35
+
36
+ // One read phase (every width with everything shown), then one write phase: no layout thrash while buttons are hidden.
37
+ const fit = () => {
38
+ for (const node of buttons) node.hidden = false;
39
+ more.hidden = false;
40
+ const room = bar.clientWidth;
41
+ if (!room) { more.hidden = true; return; }
42
+ const gap = parseFloat(getComputedStyle(bar).columnGap) || 0;
43
+ const sizes = buttons.map(node => node.getBoundingClientRect().width);
44
+ const moreSize = more.getBoundingClientRect().width;
45
+ const used = count => sizes.slice(0, count).reduce((sum, size, index) => sum + size + (index ? gap : 0), 0);
46
+ let keep = buttons.length;
47
+ // Without the dropdown everything must fit; otherwise drop buttons from the end until the rest and the dropdown do (the first always stays).
48
+ if (used(keep) > room + 0.5) while (keep > 1 && used(keep) + gap + moreSize > room + 0.5) keep--;
49
+ buttons.forEach((node, index) => { node.hidden = index >= keep; });
50
+ more.hidden = keep === buttons.length;
51
+ };
52
+ new ResizeObserver(fit).observe(bar);
53
+ return bar;
54
+ }