@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,156 @@
1
+ // The conversation with Pi: your requests and its answers, oldest first, in the Agent pane. Changes made to the
2
+ // project (by anyone) are the History pane's business, not this one's.
3
+ import { $, el, icon, ago, button } from './util.js';
4
+ import { store, toast, descriptor, selectArtboards, selectNodes, loadModel, cachedModel } from './store.js';
5
+ import { reveal } from './canvas.js';
6
+ import * as sidebar from './sidebar.js';
7
+
8
+ const CHAT = new Set(['ask', 'reply']);
9
+ export const isChat = change => CHAT.has(change.kind);
10
+
11
+ export const list = el('div', 'chat-list');
12
+ list.setAttribute('role', 'log');
13
+ list.setAttribute('aria-label', 'Conversation with Pi');
14
+ const expanded = new Set();
15
+ const unreadBadge = $('#agent-count');
16
+ let unread = 0;
17
+ let focusSeq = null;
18
+ let followLatest = true;
19
+ // An explicitly focused message stays in view through the layout changes that follow (the pane finishing its open/resize,
20
+ // the composer settling) until the reader scrolls it away; it is the one place `scrollTop` is re-applied after a resize.
21
+ let pinnedSeq = null, pinnedTop = 0;
22
+ let readWidth = 0, readHeight = 0;
23
+ const atBottom = () => list.scrollHeight - list.scrollTop - list.clientHeight < 40;
24
+ const scrollToLatest = () => { if (followLatest && list.clientHeight) list.scrollTop = list.scrollHeight; };
25
+ const revealPinned = () => {
26
+ const target = pinnedSeq === null ? null : [...list.querySelectorAll('[data-seq]')].find(node => node.dataset.seq === String(pinnedSeq));
27
+ if (!target || !list.clientHeight) return false;
28
+ followLatest = false;
29
+ target.scrollIntoView({ block: 'nearest' });
30
+ followLatest = atBottom();
31
+ pinnedTop = list.scrollTop;
32
+ return true;
33
+ };
34
+ list.addEventListener('scroll', () => {
35
+ // A queued scroll from the old layout is not a reader leaving the bottom after the composer/status resizes.
36
+ if (list.clientHeight && list.clientWidth === readWidth && list.clientHeight === readHeight) {
37
+ followLatest = atBottom();
38
+ if (pinnedSeq !== null && Math.abs(list.scrollTop - pinnedTop) > 1) pinnedSeq = null;
39
+ }
40
+ }, { passive: true });
41
+ new ResizeObserver(() => {
42
+ readWidth = list.clientWidth; readHeight = list.clientHeight;
43
+ if (pinnedSeq !== null && revealPinned()) return;
44
+ scrollToLatest();
45
+ }).observe(list);
46
+
47
+ /** Plain text with `code`, **bold** and line breaks, built as DOM (never as HTML). */
48
+ function richText(text) {
49
+ const box = el('div', 'rich');
50
+ for (const [index, line] of text.split('\n').entries()) {
51
+ if (index) box.append(document.createElement('br'));
52
+ for (const part of line.split(/(`[^`\n]+`|\*\*[^*\n]+\*\*)/)) {
53
+ if (!part) continue;
54
+ if (part.startsWith('`') && part.endsWith('`') && part.length > 2) box.append(el('code', '', part.slice(1, -1)));
55
+ else if (part.startsWith('**') && part.endsWith('**') && part.length > 4) box.append(el('b', '', part.slice(2, -2)));
56
+ else box.append(document.createTextNode(part));
57
+ }
58
+ }
59
+ return box;
60
+ }
61
+
62
+ function contextLabel(context) {
63
+ if (!context) return '';
64
+ if (context.nodes) {
65
+ const doc = descriptor(context.nodes.documentId);
66
+ return `${doc?.name || context.nodes.documentId} › ${context.nodes.ids.slice(0, 2).map(id => `#${id}`).join(', ')}${context.nodes.ids.length > 2 ? ` +${context.nodes.ids.length - 2}` : ''}`;
67
+ }
68
+ if (context.documents?.length) return context.documents.length === 1 ? context.documents[0].name : `${context.documents.length} artboards`;
69
+ return 'whole project';
70
+ }
71
+
72
+ async function focusContext(context) {
73
+ const ids = (context.documents || []).map(doc => doc.id).filter(id => descriptor(id));
74
+ if (!ids.length) return;
75
+ if (context.nodes && descriptor(context.nodes.documentId)) {
76
+ const model = cachedModel(context.nodes.documentId) || await loadModel(context.nodes.documentId).catch(() => null);
77
+ const alive = context.nodes.ids.filter(id => model?.nodes[id]);
78
+ if (alive.length) { selectNodes(context.nodes.documentId, alive, { reveal: true }); reveal(context.nodes.documentId); return; }
79
+ }
80
+ selectArtboards(ids);
81
+ reveal(ids[0]);
82
+ }
83
+
84
+ function bubble(change) {
85
+ const mine = change.kind === 'ask';
86
+ const line = el('div', `bubble ${mine ? 'from-user' : 'from-agent'}`);
87
+ line.dataset.seq = String(change.seq);
88
+ const head = el('div', 'bubble-head');
89
+ head.append(icon(mine ? 'user' : 'sparkle'), el('b', '', mine ? 'You' : 'Pi'));
90
+ if (mine && change.context) {
91
+ const label = contextLabel(change.context);
92
+ const about = el('button', 'bubble-about', label);
93
+ about.type = 'button';
94
+ // One line in the panel (it ellipsizes when the sidebar is narrow); the whole label is on hover.
95
+ about.title = label;
96
+ about.dataset.tip = `Select what this request was about · ${label}`;
97
+ about.disabled = !(change.context.documents || []).some(doc => descriptor(doc.id));
98
+ about.addEventListener('click', () => focusContext(change.context));
99
+ head.append(about);
100
+ }
101
+ const when = el('time', 'when', ago(change.at));
102
+ when.dateTime = change.at;
103
+ when.title = new Date(change.at).toLocaleString();
104
+ head.append(el('span', 'grow'), when);
105
+ if (!mine) {
106
+ const copy = button('copy', 'Copy this reply', async () => {
107
+ try { await navigator.clipboard.writeText(change.message || change.summary || ''); toast('Reply copied'); }
108
+ catch { toast('The clipboard is not available here'); }
109
+ }, 'mini bubble-copy');
110
+ head.append(copy);
111
+ }
112
+ const body = richText(change.message || change.summary || '');
113
+ const long = (change.message || '').length > 280 || (change.message || '').split('\n').length > 6;
114
+ const open = expanded.has(change.seq);
115
+ body.classList.toggle('clamped', long && !open);
116
+ line.append(head, body);
117
+ if (long) {
118
+ const more = el('button', 'link-button more', open ? 'Show less' : 'Show more');
119
+ more.type = 'button';
120
+ more.addEventListener('click', () => { if (expanded.has(change.seq)) expanded.delete(change.seq); else expanded.add(change.seq); render(); });
121
+ line.append(more);
122
+ }
123
+ return line;
124
+ }
125
+
126
+ function paintUnread() {
127
+ unreadBadge.hidden = !unread;
128
+ unreadBadge.textContent = String(Math.min(unread, 99));
129
+ }
130
+
131
+ /** A new request or answer arrived: unseen answers are counted on the Agent tab until it is opened. */
132
+ export function note(change) {
133
+ if (!isChat(change)) return;
134
+ if (change.kind === 'ask') { followLatest = true; pinnedSeq = null; }
135
+ if (change.kind === 'reply' && !sidebar.isOpen('agent')) { unread++; paintUnread(); }
136
+ render();
137
+ }
138
+
139
+ export function render() {
140
+ const top = list.scrollTop;
141
+ const messages = store.recent.filter(isChat);
142
+ list.replaceChildren(...(messages.length ? messages.map(bubble) : [el('div', 'quiet', 'No conversation yet. Ask the agent to change the layout below; its answers appear here, and every edit it makes is in History.')]));
143
+ if (sidebar.isOpen('agent')) { unread = 0; paintUnread(); }
144
+ if (focusSeq !== null) {
145
+ const target = [...list.querySelectorAll('[data-seq]')].find(node => node.dataset.seq === String(focusSeq));
146
+ focusSeq = null;
147
+ if (target) { pinnedSeq = Number(target.dataset.seq); revealPinned(); target.classList.add('flash'); setTimeout(() => target.classList.remove('flash'), 1200); return; }
148
+ }
149
+ if (followLatest) scrollToLatest();
150
+ else if (list.clientHeight) list.scrollTop = top;
151
+ }
152
+
153
+ /** Scroll to one message once the pane shows it. */
154
+ export function focus(seq) { focusSeq = seq; render(); }
155
+
156
+ setInterval(() => { if (sidebar.isOpen('agent')) for (const node of list.querySelectorAll('time.when')) node.textContent = ago(node.dateTime); }, 15000);
@@ -0,0 +1,231 @@
1
+ import { store, documents, descriptor, edit, toast, cachedModel, loadModel, selectNodes, parentOf, canContain, apiUrl, request } from './store.js';
2
+ import * as canvas from './canvas.js';
3
+ import { duplicateArtboards } from './artboards.js';
4
+
5
+ /* Copy, cut and paste of elements and artboards, plus images from the clipboard or a drop.
6
+ The system clipboard receives clean HTML; the board keeps a lossless copy (names, metadata,
7
+ IDs as hints) and uses it whenever the clipboard still holds what the board put there. */
8
+
9
+ const STRUCTURE = new Set('div section main article header footer nav aside form figure fieldset details dialog ul ol menu dl table tbody thead tfoot tr hgroup search blockquote'.split(' '));
10
+ const VOID = new Set('area br col hr img input source track wbr'.split(' '));
11
+ const INLINE = new Set('a abbr b bdi bdo br cite code data dfn em i img input kbd label mark q s samp small span strong sub sup time u var wbr'.split(' '));
12
+ let clip = null;
13
+
14
+ export const hasClip = () => !!clip;
15
+ export const clipKind = () => clip?.kind ?? null;
16
+
17
+ const escapeText = value => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
18
+ const escapeAttribute = value => escapeText(value).replaceAll('"', '&quot;');
19
+
20
+ /** A node and its subtree as an insert spec that keeps names, classes, styles and metadata. */
21
+ export function toSpec(model, id, top = true) {
22
+ const node = model.nodes[id];
23
+ if (node.type === 'text') return top ? { tag: 'span', text: node.content } : node.content;
24
+ const spec = { tag: node.tag, id: node.id };
25
+ if (node.className) spec.className = node.className;
26
+ for (const key of ['attrs', 'style', 'metadata']) if (Object.keys(node[key] || {}).length) spec[key] = structuredClone(node[key]);
27
+ if (node.children.length) spec.children = node.children.map(child => toSpec(model, child, false));
28
+ return spec;
29
+ }
30
+
31
+ /** Readable HTML of nodes without divlet bookkeeping (what other apps receive). */
32
+ export function htmlOf(model, ids) {
33
+ const open = node => {
34
+ const attrs = Object.entries(node.attrs).map(([key, value]) => `${key}="${escapeAttribute(value)}"`);
35
+ if (node.className) attrs.push(`class="${escapeAttribute(node.className)}"`);
36
+ const style = Object.entries(node.style).map(([key, value]) => `${key}: ${value}`).join('; ');
37
+ if (style) attrs.push(`style="${escapeAttribute(style)}"`);
38
+ return `<${node.tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>`;
39
+ };
40
+ const inline = id => {
41
+ const node = model.nodes[id];
42
+ if (node.type === 'text') return escapeText(node.content);
43
+ if (VOID.has(node.tag)) return open(node);
44
+ return `${open(node)}${node.children.map(inline).join('')}</${node.tag}>`;
45
+ };
46
+ const block = (id, depth) => {
47
+ const node = model.nodes[id];
48
+ const pad = ' '.repeat(depth);
49
+ if (node.type === 'text') return node.content.trim() ? pad + escapeText(node.content) : '';
50
+ const inlineOnly = node.children.every(child => model.nodes[child].type === 'text' || INLINE.has(model.nodes[child].tag));
51
+ if (VOID.has(node.tag) || !node.children.length || inlineOnly) return pad + inline(id);
52
+ return `${pad}${open(node)}\n${node.children.map(child => block(child, depth + 1)).filter(Boolean).join('\n')}\n${pad}</${node.tag}>`;
53
+ };
54
+ return ids.map(id => block(id, 0)).join('\n') + '\n';
55
+ }
56
+
57
+ /** Selected node IDs without the root and without nodes whose ancestor is also selected, in tree order. */
58
+ function topLevel(model, ids) {
59
+ const chosen = new Set(ids.filter(id => model.nodes[id] && id !== model.rootId));
60
+ const parents = new Map();
61
+ for (const node of Object.values(model.nodes)) if (node.type === 'element') for (const child of node.children) parents.set(child, node.id);
62
+ const order = [];
63
+ const walk = id => { order.push(id); const node = model.nodes[id]; if (node.type === 'element') node.children.forEach(walk); };
64
+ walk(model.rootId);
65
+ return order.filter(id => {
66
+ if (!chosen.has(id)) return false;
67
+ for (let p = parents.get(id); p; p = parents.get(p)) if (chosen.has(p)) return false;
68
+ return true;
69
+ });
70
+ }
71
+
72
+ async function writeSystem(text, event) {
73
+ if (event?.clipboardData) {
74
+ event.clipboardData.setData('text/plain', text);
75
+ event.clipboardData.setData('text/html', text);
76
+ event.preventDefault();
77
+ return true;
78
+ }
79
+ try {
80
+ // A key forwarded from a preview frame leaves the board document unfocused.
81
+ if (!document.hasFocus()) document.querySelector('#viewport')?.focus({ preventScroll: true });
82
+ await navigator.clipboard.writeText(text);
83
+ return true;
84
+ } catch { return false; }
85
+ }
86
+
87
+ /**
88
+ * Copy (or cut) the selection. With a DOM copy/cut `event` the clipboard is written synchronously
89
+ * when the model is cached; returns false when there is nothing to copy so the browser default runs.
90
+ */
91
+ export function copySelection({ event, cut = false } = {}) {
92
+ const node = store.node?.nodeIds.length ? store.node : null;
93
+ if (node) {
94
+ const model = cachedModel(node.documentId);
95
+ if (!model) {
96
+ // The newest model is still on its way (e.g. right after a paste): finish once it is here.
97
+ event?.preventDefault();
98
+ loadModel(node.documentId).then(() => copySelection({ cut })).catch(error => toast(error.message));
99
+ return true;
100
+ }
101
+ const ids = topLevel(model, node.nodeIds);
102
+ if (!ids.length) { toast('The page root cannot be copied. Copy the artboard instead.'); event?.preventDefault(); return true; }
103
+ const text = htmlOf(model, ids);
104
+ clip = { kind: 'nodes', documentId: node.documentId, specs: ids.map(id => toSpec(model, id)), text, count: ids.length, preferInternal: false };
105
+ const current = clip;
106
+ const declared = (store.state?.project?.resources?.length ?? 0) > 0;
107
+ if (declared) { event?.preventDefault(); current.preferInternal = true; }
108
+ const written = declared ? request(`api/export/${encodeURIComponent(node.documentId)}?${ids.map(id => `nodes=${encodeURIComponent(id)}`).join('&')}&version=${model.version}&projectVersion=${store.state.project.version}`).then(async result => {
109
+ if (clip !== current) return false;
110
+ current.text = result.html;
111
+ return writeSystem(result.html);
112
+ }) : writeSystem(text, event);
113
+ Promise.resolve(written).then(ok => { if (clip === current) current.preferInternal = !ok; }).catch(error => toast(error.message));
114
+ const what = ids.length === 1 ? `<${model.nodes[ids[0]].tag ?? 'text'}>` : `${ids.length} elements`;
115
+ if (cut) {
116
+ const expectedRender = descriptor(node.documentId)?.render;
117
+ const remove = () => {
118
+ if (clip !== current) return;
119
+ edit(node.documentId, declared ? latest => {
120
+ if (latest.version !== model.version) throw new Error('The copied frame changed. Copy it again before cutting.');
121
+ return { type: 'remove', ids };
122
+ } : { type: 'remove', ids }, { label: `cut ${what}`, ...(declared ? { expectedRender } : {}) }).then(() => {
123
+ selectNodes(node.documentId, []);
124
+ toast(`Cut ${what} — paste with ${navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'} V`);
125
+ }).catch(() => {});
126
+ };
127
+ if (declared) Promise.resolve(written).then(remove).catch(() => {});
128
+ else remove();
129
+ } else toast(`Copied ${what}`);
130
+ return true;
131
+ }
132
+ if (store.selected.size) {
133
+ const ids = [...store.selected];
134
+ clip = { kind: 'artboards', ids, text: null, count: ids.length, preferInternal: true };
135
+ event?.preventDefault();
136
+ toast(ids.length === 1 ? `Copied “${descriptor(ids[0])?.name || ids[0]}”` : `Copied ${ids.length} artboards`);
137
+ return true;
138
+ }
139
+ return false;
140
+ }
141
+
142
+ /** Where pasted content goes: inside a selected container, after a selected element, or at the end of the page. */
143
+ async function target() {
144
+ const node = store.node?.nodeIds.length ? store.node : null;
145
+ const documentId = node?.documentId ?? (store.selected.size === 1 ? [...store.selected][0] : null);
146
+ if (!documentId) return null;
147
+ const model = cachedModel(documentId) || await loadModel(documentId);
148
+ let anchor = node ? model.nodes[node.nodeIds.at(-1)] : null;
149
+ if (anchor?.type === 'text') anchor = parentOf(model, anchor.id);
150
+ if (!anchor || anchor.id === model.rootId) return { documentId, place: { parentId: model.rootId } };
151
+ if (canContain(anchor) && STRUCTURE.has(anchor.tag)) return { documentId, place: { parentId: anchor.id } };
152
+ return { documentId, place: { after: anchor.id } };
153
+ }
154
+
155
+ const looksLikeHtml = text => /^\s*<([a-z][\w-]*)[\s>/][\s\S]*>\s*$/i.test(text) && /<\/[a-z][\w-]*\s*>|\/>\s*$|<(img|br|hr|input)\b/i.test(text);
156
+
157
+ async function insert(op, label, group) {
158
+ const spot = await target();
159
+ if (!spot) { toast('Select an artboard or an element to paste into'); return null; }
160
+ const result = await edit(spot.documentId, { type: 'insert', ...spot.place, ...op }, { label, group }).catch(() => null);
161
+ if (!result?.ids?.length) return null;
162
+ if (store.mode !== 'select') canvas.setMode('select');
163
+ selectNodes(spot.documentId, result.ids, { reveal: true });
164
+ canvas.flashNodes(spot.documentId, result.render, result.ids);
165
+ return result;
166
+ }
167
+
168
+ /**
169
+ * Paste. `text` comes from a paste event (or is undefined for a key forwarded by a preview frame,
170
+ * where only the board's own clipboard is available). `files` may carry images.
171
+ */
172
+ export async function paste({ text, files } = {}) {
173
+ const images = [...(files || [])].filter(file => file.type.startsWith('image/'));
174
+ if (images.length) return addImages(images);
175
+ const normalize = value => value?.replace(/\r\n?/g, '\n');
176
+ const own = clip && (clip.preferInternal || text === undefined || normalize(text) === normalize(clip.text));
177
+ if (own && clip.kind === 'artboards') {
178
+ const alive = clip.ids.filter(id => descriptor(id));
179
+ if (!alive.length) { toast('The copied artboards no longer exist'); return null; }
180
+ return duplicateArtboards(alive);
181
+ }
182
+ if (own && clip.kind === 'nodes') return insert({ spec: clip.specs }, clip.count > 1 ? `paste ${clip.count} elements` : 'paste element');
183
+ const value = (text ?? '').trim();
184
+ if (!value) { toast('The clipboard is empty — copy elements with Ctrl C first'); return null; }
185
+ if (looksLikeHtml(value)) return insert({ html: value }, 'paste HTML');
186
+ const paragraphs = value.split(/\n\s*\n/).map(part => part.trim()).filter(Boolean).slice(0, 50);
187
+ return insert({ spec: paragraphs.map(content => ({ preset: 'text', text: content })) }, paragraphs.length > 1 ? `paste ${paragraphs.length} paragraphs` : 'paste text');
188
+ }
189
+
190
+ /* ---- images --------------------------------------------------------------- */
191
+ async function upload(file, documentId, group) {
192
+ const name = file.name && /\.[a-z0-9]+$/i.test(file.name) ? file.name : `pasted.${(file.type.split('/')[1] || 'png').replace('svg+xml', 'svg').replace('jpeg', 'jpg')}`;
193
+ const response = await fetch(apiUrl(`api/assets?name=${encodeURIComponent(name)}&documentId=${encodeURIComponent(documentId)}&group=${encodeURIComponent(group)}`), { method: 'POST', headers: { 'Content-Type': file.type || 'image/png' }, body: file });
194
+ const body = await response.json().catch(() => ({}));
195
+ if (!response.ok) throw new Error(body.message || `Upload failed (${response.status})`);
196
+ return { ...body, alt: name.replace(/\.[a-z0-9]+$/i, '').replace(/[-_]+/g, ' ') };
197
+ }
198
+
199
+ /** Store images in the project and add an <img> for each at the paste/drop target. */
200
+ export async function addImages(files, documentId) {
201
+ if (documentId && !(store.selected.size === 1 && store.selected.has(documentId))) {
202
+ const { selectArtboards } = await import('./store.js');
203
+ selectArtboards([documentId]);
204
+ }
205
+ const spot = await target();
206
+ if (!spot) { toast('Select an artboard first, or drop the image onto one'); return null; }
207
+ try {
208
+ const saved = [], group = `images:${crypto.randomUUID()}`;
209
+ for (const file of files.slice(0, 12)) saved.push(await upload(file, spot.documentId, group));
210
+ toast(saved.length === 1 ? `Added ${saved[0].file}` : `Added ${saved.length} project images`);
211
+ return insert({ spec: saved.map(item => ({ tag: 'img', className: 'block max-w-full h-auto', attrs: { src: item.path, alt: item.alt } })) }, saved.length > 1 ? `add ${saved.length} images` : 'add image', group);
212
+ } catch (error) { toast(error.message); return null; }
213
+ }
214
+
215
+ /** Text selected where the shortcut happens (e.g. in History) keeps the browser's own copy. */
216
+ const copiesText = event => {
217
+ const selection = getSelection();
218
+ if (!selection || selection.isCollapsed || !selection.toString()) return false;
219
+ const anchor = selection.anchorNode instanceof Element ? selection.anchorNode : selection.anchorNode?.parentElement;
220
+ return !!anchor && event.target instanceof Node && event.target.contains(anchor) && event.target !== document.querySelector('#viewport');
221
+ };
222
+
223
+ export const isEditable = target => target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || !!target?.isContentEditable;
224
+
225
+ document.addEventListener('copy', event => { if (!isEditable(event.target) && !store.expanded && !copiesText(event)) copySelection({ event }); });
226
+ document.addEventListener('cut', event => { if (!isEditable(event.target) && !store.expanded && !copiesText(event)) copySelection({ event, cut: true }); });
227
+ document.addEventListener('paste', event => {
228
+ if (isEditable(event.target) || store.expanded || !documents().length) return;
229
+ event.preventDefault();
230
+ paste({ text: event.clipboardData?.getData('text/plain') ?? '', files: event.clipboardData?.files });
231
+ });
@@ -0,0 +1,56 @@
1
+ // The path of the selection, always bottom centre between the left column and the toolbar. When that span is narrow the path
2
+ // wraps onto more lines; on a phone the sidebars simply sit on top of it.
3
+ import { $, el, uiScale } from './util.js';
4
+ import { store, on } from './store.js';
5
+ import { pathInto, pathKey } from './crumbs.js';
6
+
7
+ const bar = $('#crumbs');
8
+ const track = el('div', 'crumbs-track');
9
+ bar.append(track);
10
+ const tabs = $('#side-tabs');
11
+ const dock = $('#dock');
12
+ const MIN_WIDTH = 90;
13
+ let shown = '';
14
+
15
+ function context() {
16
+ const ids = [...store.selected];
17
+ if (!ids.length) return null;
18
+ const node = store.node;
19
+ if (node?.nodeIds.length && ids.length === 1 && node.documentId === ids[0]) return { ids, nodes: { documentId: node.documentId, nodeIds: [...node.nodeIds] } };
20
+ return { ids };
21
+ }
22
+
23
+ /** Fit the bar between the tabs and the toolbar; with no room at all it hides rather than overlap them. */
24
+ function place() {
25
+ if (bar.hidden) return;
26
+ // The tabs live in the sidebar footer; compare viewport rectangles, not offsets from different parents.
27
+ const a = tabs.getBoundingClientRect(), b = dock.getBoundingClientRect(), scale = uiScale();
28
+ const sameRow = Math.abs(a.bottom - b.bottom) / scale < 24;
29
+ const left = (sameRow ? a.right / scale : 0) + 10;
30
+ const width = b.left / scale - 10 - left;
31
+ bar.style.left = `${left}px`;
32
+ bar.style.width = `${Math.max(0, width)}px`;
33
+ const minimum = track.querySelector('.root-entry') ? track.scrollWidth + 2 : MIN_WIDTH;
34
+ bar.classList.toggle('cramped', width < minimum);
35
+ }
36
+
37
+ function paint() {
38
+ const ctx = store.expanded ? null : context();
39
+ if (!ctx) { bar.hidden = true; shown = ''; track.replaceChildren(); return; }
40
+ const key = pathKey(ctx);
41
+ bar.hidden = false;
42
+ if (key !== shown || !track.childElementCount) {
43
+ shown = key;
44
+ const focused = track.contains(document.activeElement);
45
+ track.replaceChildren();
46
+ pathInto(track, ctx, { all: true, repaint: () => { shown = ''; paint(); } });
47
+ if (focused) (track.querySelector('.crumb.current') ?? track.querySelector('.crumb'))?.focus({ preventScroll: true });
48
+ }
49
+ place();
50
+ }
51
+
52
+ for (const name of ['selection', 'model', 'state', 'expanded', 'sidebar']) on(name, paint);
53
+ addEventListener('resize', place);
54
+ new ResizeObserver(place).observe(dock);
55
+ new ResizeObserver(place).observe(tabs);
56
+ paint();
@@ -0,0 +1,63 @@
1
+ // The path of what is selected — artboard › container › … › element — as buttons that re-point the
2
+ // selection. Shown in the bottom-centre bar (crumbbar.js); the Agent composer states its own one-line scope.
3
+ import { el, icon } from './util.js';
4
+ import { cachedModel, loadModel, descriptor, ancestorsOf, selectNodes, selectArtboards } from './store.js';
5
+ import { openMenu } from './menu.js';
6
+ import { selectRoot } from './actions.js';
7
+
8
+ /**
9
+ * Fill the bottom path for `ctx` ({ ids, nodes? }). A selected artboard collapses to its root-entry icon;
10
+ * selecting that root opens the path. Container crumbs select ancestors; the artboard crumb folds it again.
11
+ * `after()` runs after activation; `repaint()` is called when element data arrives late.
12
+ */
13
+ export function pathInto(container, ctx, { after, repaint, all = false } = {}) {
14
+ const model = ctx.nodes ? cachedModel(ctx.nodes.documentId) : null;
15
+ if (ctx.nodes && !model) loadModel(ctx.nodes.documentId).then(() => repaint?.()).catch(() => {});
16
+ const crumb = (label, run, tip, className = '') => {
17
+ const item = el('button', `crumb ${className}`.trim(), label);
18
+ item.type = 'button';
19
+ if (tip) item.dataset.tip = tip;
20
+ item.addEventListener('click', () => { run(); after?.(); });
21
+ return item;
22
+ };
23
+ const sep = () => el('i', 'crumb-sep');
24
+ if (!ctx.nodes) {
25
+ if (ctx.ids.length === 1) {
26
+ const root = crumb('', () => selectRoot(ctx.ids[0]), `Select root element · ${descriptor(ctx.ids[0])?.name || ctx.ids[0]}`, 'root-entry');
27
+ root.setAttribute('aria-label', 'Select root element');
28
+ root.append(icon('inspect'));
29
+ container.append(root);
30
+ } else container.append(crumb(`${ctx.ids.length} artboards`, () => {}, 'These artboards', 'current'));
31
+ return;
32
+ }
33
+ const { documentId, nodeIds } = ctx.nodes;
34
+ const home = crumb('', () => selectArtboards([documentId]), 'Select the whole artboard', 'home');
35
+ home.append(icon('artboard'), el('span', '', descriptor(documentId)?.name || documentId));
36
+ container.append(home);
37
+ const first = nodeIds[0];
38
+ const path = model ? ancestorsOf(model, first) : [];
39
+ const labelOf = (id, last) => {
40
+ const node = model?.nodes[id];
41
+ if (!node) return `#${id}`;
42
+ const tag = node.type === 'text' ? 'text' : node.type === 'ref' ? 'instance' : node.tag;
43
+ return last ? `${tag}#${id}` : (node.metadata?.name || tag);
44
+ };
45
+ // A long path keeps the artboard, an ellipsis that lists the rest, and the last three — unless the bar scrolls (`all`).
46
+ const shown = !all && path.length > 3 ? path.slice(-3) : path;
47
+ if (!all && path.length > 3) {
48
+ const hidden = path.slice(0, -3);
49
+ const more = crumb('…', () => openMenu(hidden.map(id => ({ label: labelOf(id), run: () => selectNodes(documentId, [id], { reveal: true }) })), { anchor: more, label: 'Containers' }), hidden.map(id => labelOf(id)).join(' › '));
50
+ container.append(sep(), more);
51
+ }
52
+ for (const id of shown) container.append(sep(), crumb(labelOf(id), () => selectNodes(documentId, [id], { reveal: true }), `Select this container · #${id}`));
53
+ container.append(sep(), crumb(labelOf(first, true), () => {}, 'The selected element', 'current'));
54
+ if (nodeIds.length > 1) container.append(el('span', 'ask-more', `+${nodeIds.length - 1}`));
55
+ }
56
+
57
+ /** What the path is made from, so a bar only repaints when it would look different. */
58
+ export function pathKey(ctx) {
59
+ if (!ctx) return '';
60
+ const model = ctx.nodes ? cachedModel(ctx.nodes.documentId) : null;
61
+ const names = ctx.nodes && model ? [...ctx.nodes.nodeIds, ...ancestorsOf(model, ctx.nodes.nodeIds[0])].map(id => model.nodes[id]?.metadata?.name ?? model.nodes[id]?.tag) : null;
62
+ return JSON.stringify([ctx, !!model, names, (ctx.nodes ? [ctx.nodes.documentId] : ctx.ids).map(id => descriptor(id)?.name)]);
63
+ }