@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,472 @@
1
+ import { keys } from './util.js';
2
+ import { store, emit, on, documents, descriptor, edit, commit, undo, toast, newGroup, selectArtboards, selectNodes, cachedModel, freshModel, parentOf, canContain, textChild, request, apiUrl } from './store.js';
3
+ import * as canvas from './canvas.js';
4
+ import { openMenu } from './menu.js';
5
+ import * as clip from './clip.js';
6
+ import { newArtboard, duplicateArtboards, deleteArtboards } from './artboards.js';
7
+ export { newArtboard, duplicateArtboards, deleteArtboards };
8
+
9
+ /* Command facade for the toolbar, keyboard, context menus, inspector and layers.
10
+ Artboard lifecycle commands also serve clipboard without depending on this facade. */
11
+
12
+ export const SIZES = [['Phone', 390, 844], ['Tablet', 768, 1024], ['Laptop', 1280, 800], ['Desktop', 1440, 900]];
13
+ export const PRESETS = [
14
+ ['heading', 'heading', 'Heading'], ['text', 'text', 'Paragraph'], ['button', 'button', 'Button'], ['link', 'link', 'Link'],
15
+ ['image', 'image', 'Image'], ['list', 'list', 'List'], '-',
16
+ ['div', 'box', 'Box'], ['row', 'row', 'Row'], ['grid', 'grid', 'Grid'], ['section', 'artboard', 'Section'],
17
+ ];
18
+ export const insertionPresets = () => PRESETS;
19
+ const TAGS = [
20
+ ['Structure', ['div', 'section', 'article', 'header', 'footer', 'nav', 'main', 'aside']],
21
+ ['Text', ['h1', 'h2', 'h3', 'h4', 'p', 'span', 'strong', 'em', 'small', 'blockquote', 'code']],
22
+ ['Interactive', ['a', 'button', 'label']],
23
+ ['Lists', ['ul', 'ol', 'li', 'figure', 'figcaption']],
24
+ ];
25
+ const TEXT_PRESETS = new Set(['heading', 'text', 'button', 'link']);
26
+ 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'.split(' '));
27
+ const quiet = promise => promise.catch(() => null);
28
+ export const modelOf = id => freshModel(id);
29
+ const nameOf = id => descriptor(id)?.name || id;
30
+
31
+ export async function copyText(text, message = 'Copied') {
32
+ try { await navigator.clipboard.writeText(text); toast(`${message} · ${text.length > 40 ? `${text.slice(0, 40)}…` : text}`); }
33
+ catch { toast('The clipboard is not available here'); }
34
+ }
35
+
36
+ /** Clean HTML of elements (built from the model) or of a whole artboard (with its compiled CSS). */
37
+ export async function copyHtml(documentId, nodeIds) {
38
+ try {
39
+ let text;
40
+ if (nodeIds?.length) {
41
+ const model = await modelOf(documentId);
42
+ const ids = nodeIds.filter(id => model.nodes[id]);
43
+ text = clip.htmlOf(model, ids);
44
+ } else text = (await request(`api/export/${encodeURIComponent(documentId)}`)).html;
45
+ if (!document.hasFocus()) document.querySelector('#viewport')?.focus({ preventScroll: true });
46
+ await navigator.clipboard.writeText(text);
47
+ toast(nodeIds?.length ? 'HTML copied' : `Page HTML copied · ${Math.max(1, Math.round(text.length / 1024))} KB with CSS`);
48
+ } catch (error) { toast(error?.name === 'NotAllowedError' ? 'The clipboard is not available here' : error.message); }
49
+ }
50
+
51
+ /** Save an artboard as a standalone HTML file. */
52
+ export function downloadHtml(documentId) {
53
+ const link = document.createElement('a');
54
+ link.href = apiUrl(`api/export/${encodeURIComponent(documentId)}?download=1`);
55
+ const fileName = descriptor(documentId)?.htmlPath?.split(/[\\/]/).at(-1) || `${documentId}.html`;
56
+ link.download = fileName;
57
+ document.body.append(link);
58
+ link.click();
59
+ link.remove();
60
+ toast(`Downloading ${fileName}`);
61
+ }
62
+
63
+
64
+
65
+ /* ---- artboards ------------------------------------------------------------ */
66
+ export const renameArtboard = id => { if (descriptor(id)) { canvas.reveal(id); emit('rename-artboard', id); } };
67
+ export const setArtboard = (id, patch, label) => quiet(commit(id, { artboard: patch }, { label }));
68
+
69
+ export function canFitHeight(id) {
70
+ const doc = descriptor(id);
71
+ const seen = store.observations.get(id);
72
+ return !!(doc && seen?.render === doc.render && seen.contentHeight > 0 && Math.abs(seen.contentHeight - canvas.rectOf(doc).height) > 1);
73
+ }
74
+ export function fitHeightToContent(id) {
75
+ const seen = store.observations.get(id);
76
+ if (!canFitHeight(id)) { toast(seen?.contentHeight ? 'The artboard already fits its content' : 'Waiting for the page to be measured'); return; }
77
+ return setArtboard(id, { height: Math.min(16384, seen.contentHeight) }, 'fit height to content');
78
+ }
79
+ export function rotateArtboard(id) {
80
+ const doc = descriptor(id), a = doc && canvas.rectOf(doc);
81
+ if (a) return setArtboard(id, { width: a.height, height: a.width }, 'rotate artboard');
82
+ }
83
+
84
+ export function alignArtboards(ids, axis, mode) {
85
+ const docs = ids.map(descriptor).filter(Boolean).map(doc => ({ ...doc, artboard: canvas.rectOf(doc) }));
86
+ if (docs.length < 2) return;
87
+ const group = newGroup();
88
+ const size = axis === 'x' ? 'width' : 'height';
89
+ const start = Math.min(...docs.map(d => d.artboard[axis]));
90
+ const end = Math.max(...docs.map(d => d.artboard[axis] + d.artboard[size]));
91
+ for (const doc of docs) {
92
+ const a = doc.artboard;
93
+ const target = mode === 'start' ? start : mode === 'end' ? end - a[size] : Math.round((start + end) / 2 - a[size] / 2);
94
+ if (target !== a[axis]) quiet(commit(doc.id, { artboard: { [axis]: target } }, { label: `align ${docs.length} artboards`, group }));
95
+ }
96
+ }
97
+
98
+ export function distributeArtboards(ids, axis) {
99
+ const size = axis === 'x' ? 'width' : 'height';
100
+ const docs = ids.map(descriptor).filter(Boolean).map(doc => ({ ...doc, artboard: canvas.rectOf(doc) })).sort((a, b) => a.artboard[axis] - b.artboard[axis]);
101
+ if (docs.length < 3) { toast('Select at least three artboards to distribute'); return; }
102
+ const first = docs[0].artboard[axis];
103
+ const last = docs.at(-1).artboard[axis] + docs.at(-1).artboard[size];
104
+ const gap = (last - first - docs.reduce((sum, d) => sum + d.artboard[size], 0)) / (docs.length - 1);
105
+ const group = newGroup();
106
+ let cursor = first;
107
+ for (const doc of docs) {
108
+ const target = Math.round(cursor);
109
+ if (target !== doc.artboard[axis]) quiet(commit(doc.id, { artboard: { [axis]: target } }, { label: `distribute ${docs.length} artboards`, group }));
110
+ cursor += doc.artboard[size] + gap;
111
+ }
112
+ }
113
+
114
+ /** Lay artboards out in one row, left to right, top-aligned with an even gap. */
115
+ export function tidyArtboards(ids = []) {
116
+ const docs = (ids.length > 1 ? ids.map(descriptor) : documents()).filter(Boolean).map(doc => ({ ...doc, artboard: canvas.rectOf(doc) })).sort((a, b) => a.artboard.x - b.artboard.x || a.artboard.y - b.artboard.y);
117
+ if (docs.length < 2) return;
118
+ const group = newGroup();
119
+ let x = Math.min(...docs.map(d => d.artboard.x));
120
+ const y = Math.min(...docs.map(d => d.artboard.y));
121
+ for (const doc of docs) {
122
+ const a = doc.artboard;
123
+ if (a.x !== x || a.y !== y) quiet(commit(doc.id, { artboard: { x, y } }, { label: `tidy ${docs.length} artboards`, group }));
124
+ x += a.width + 80;
125
+ }
126
+ }
127
+
128
+ /** Visual reading order: rows top to bottom, then left to right. */
129
+ function readingOrder() {
130
+ return [...documents()].sort((a, b) => {
131
+ const rowA = Math.round(a.artboard.y / 200);
132
+ const rowB = Math.round(b.artboard.y / 200);
133
+ return rowA - rowB || a.artboard.x - b.artboard.x;
134
+ });
135
+ }
136
+
137
+ export function cycleArtboards(step) {
138
+ const list = readingOrder();
139
+ if (!list.length) return;
140
+ const current = store.selected.size ? list.findIndex(doc => store.selected.has(doc.id)) : -1;
141
+ const next = list[current < 0 ? (step > 0 ? 0 : list.length - 1) : (current + step + list.length) % list.length];
142
+ selectArtboards([next.id]);
143
+ canvas.reveal(next.id);
144
+ }
145
+
146
+ /* ---- nodes ---------------------------------------------------------------- */
147
+ const selection = () => (store.node?.nodeIds.length ? store.node : null);
148
+
149
+ export async function deleteNodes(documentId, ids) {
150
+ const model = await modelOf(documentId);
151
+ const valid = ids.filter(id => model.nodes[id] && id !== model.rootId);
152
+ if (!valid.length) { toast('The page root cannot be deleted. Delete the artboard instead.'); return; }
153
+ const result = await quiet(edit(documentId, { type: 'remove', ids: valid }, { label: valid.length > 1 ? `delete ${valid.length} elements` : undefined }));
154
+ if (result) {
155
+ selectNodes(documentId, []);
156
+ toast(valid.length > 1 ? `${valid.length} elements deleted` : 'Element deleted', { label: 'Undo', run: () => undo() });
157
+ }
158
+ }
159
+
160
+ export async function duplicateNodes(documentId, ids) {
161
+ const model = await modelOf(documentId);
162
+ const valid = ids.filter(id => model.nodes[id] && id !== model.rootId);
163
+ if (!valid.length) { toast('The page root cannot be duplicated. Duplicate the artboard instead.'); return; }
164
+ const result = await quiet(edit(documentId, { type: 'duplicate', ids: valid }));
165
+ if (result?.ids?.length) selectNodes(documentId, result.ids, { reveal: true });
166
+ }
167
+
168
+ export async function moveNode(documentId, id, direction) {
169
+ const derive = model => {
170
+ const parent = parentOf(model, id);
171
+ if (!parent) return null;
172
+ const index = parent.children.indexOf(id) + direction;
173
+ if (index < 0 || index >= parent.children.length) { toast(direction < 0 ? 'Already first in its container' : 'Already last in its container'); return null; }
174
+ return { type: 'move', id, parentId: parent.id, index };
175
+ };
176
+ const op = derive;
177
+ if (op) await quiet(edit(documentId, op, { label: direction < 0 ? `move ${id} up` : `move ${id} down` }));
178
+ }
179
+
180
+ export async function wrapNodes(documentId, ids, spec) {
181
+ const result = await quiet(edit(documentId, { type: 'wrap', ids, ...(spec === undefined ? {} : { spec }) }));
182
+ if (result?.ids?.length) selectNodes(documentId, result.ids);
183
+ }
184
+
185
+ export async function unwrapNode(documentId, id) {
186
+ const result = await quiet(edit(documentId, { type: 'unwrap', id }));
187
+ if (result) selectNodes(documentId, result.ids.slice(0, 50));
188
+ }
189
+
190
+ /**
191
+ * Text typed into an instance: an override on that instance. When the element holds one text
192
+ * node, the override targets that text node (and drops an older element-level text override).
193
+ */
194
+ export async function overrideText(documentId, instance, address, content) {
195
+ try {
196
+ const model = await modelOf(documentId);
197
+ const ref = model.nodes[instance];
198
+ if (ref?.type !== 'ref') return;
199
+ const segments = address.split('/');
200
+ let frameId = ref.ref.includes('/') ? ref.ref.split('/')[0] : documentId;
201
+ let component = await modelOf(frameId);
202
+ for (const segment of segments.slice(0, -1)) {
203
+ const nested = component.nodes[segment];
204
+ if (nested?.type !== 'ref') { component = null; break; }
205
+ frameId = nested.ref.includes('/') ? nested.ref.split('/')[0] : frameId;
206
+ component = await modelOf(frameId);
207
+ }
208
+ const target = component?.nodes[segments.at(-1)];
209
+ const only = target?.type === 'element' && target.children.length === 1 && component.nodes[target.children[0]]?.type === 'text' ? target.children[0] : null;
210
+ const prefix = segments.slice(0, -1).map(segment => `${segment}/`).join('');
211
+ const descendants = only ? { [`${prefix}${only}`]: { content }, ...(ref.descendants[address]?.text !== undefined ? { [address]: { text: null } } : {}) } : { [address]: { text: content } };
212
+ await commit(documentId, { nodes: { [instance]: { descendants } } }, { label: `override text of ${instance}` });
213
+ } catch { /* reported by commit */ }
214
+ }
215
+
216
+ /** Mark an element as a component (reusable) or turn it back into a plain element. */
217
+ export async function toggleComponent(documentId, id) {
218
+ const model = await modelOf(documentId);
219
+ const node = model.nodes[id];
220
+ if (node?.type !== 'element') return;
221
+ const result = await quiet(commit(documentId, { nodes: { [id]: { reusable: node.reusable ? null : true } } }, { label: node.reusable ? `${id} is no longer a component` : `make ${id} a component` }));
222
+ if (result) toast(node.reusable ? 'No longer a component' : `“${node.metadata?.name || id}” is now a component — insert it anywhere from Add element`);
223
+ }
224
+
225
+ /** Insert an instance of a component next to (or inside) the selection. */
226
+ export async function insertInstance(documentId, ref, name) {
227
+ return insertElement(documentId, { ref }, name || 'instance');
228
+ }
229
+
230
+ /** Replace an instance by editable copies of its elements. */
231
+ export async function detachInstance(documentId, id) {
232
+ const result = await quiet(edit(documentId, { type: 'detach', id }, { label: `detach ${id}` }));
233
+ if (result) { selectNodes(documentId, [id], { reveal: true }); toast('Detached — the elements are now independent of the component'); }
234
+ }
235
+
236
+ /** Select the component an instance comes from. */
237
+ export async function goToComponent(documentId, id) {
238
+ const model = await modelOf(documentId);
239
+ const ref = model.nodes[id];
240
+ if (ref?.type !== 'ref') return;
241
+ const [frameId, nodeId] = ref.ref.includes('/') ? ref.ref.split('/') : [documentId, ref.ref];
242
+ if (!descriptor(frameId)) { toast(`The component’s frame ${frameId} is missing`); return; }
243
+ if (store.mode !== 'select') canvas.setMode('select');
244
+ selectNodes(frameId, [nodeId], { reveal: true });
245
+ canvas.reveal(frameId);
246
+ }
247
+
248
+ export const setTag = (documentId, id, tag) => quiet(commit(documentId, { nodes: { [id]: { tag } } }, { label: `change ${id} to <${tag}>` }));
249
+ export const renameLayer = (documentId, id, name) => quiet(commit(documentId, { nodes: { [id]: { metadata: { name: name || null } } } }, { label: name ? `name ${id} “${name}”` : `clear name of ${id}` }));
250
+
251
+ /** Where a new element goes: inside the selected container, otherwise right after the selected element. */
252
+ export async function insertElement(documentId, spec, label) {
253
+ const selected = store.node?.documentId === documentId ? store.node.nodeIds : [];
254
+ const selectedId = selected.at(-1);
255
+ const model = await modelOf(documentId);
256
+ const targetId = store.node?.documentId === documentId ? store.node.nodeIds.at(-1) : null;
257
+ let target = targetId ? model.nodes[targetId] : null;
258
+ if (target?.type === 'text') target = parentOf(model, target.id);
259
+ let op;
260
+ if (!target || target.id === model.rootId) op = { type: 'insert', parentId: model.rootId, spec };
261
+ else if (canContain(target) && STRUCTURE.has(target.tag)) op = { type: 'insert', parentId: target.id, spec };
262
+ else op = { type: 'insert', after: target.id, spec };
263
+ const result = await quiet(edit(documentId, op, { label: `add ${label.toLowerCase()}` }));
264
+ if (!result?.ids?.length) return;
265
+ if (store.mode !== 'select') canvas.setMode('select');
266
+ selectNodes(documentId, result.ids, { reveal: true });
267
+ if (TEXT_PRESETS.has(spec)) canvas.editText(documentId, result.ids[0], result.render);
268
+ return result;
269
+ }
270
+
271
+ export function selectParent() {
272
+ const current = selection();
273
+ if (!current) return false;
274
+ const model = cachedModel(current.documentId);
275
+ if (!model) return false;
276
+ const parent = parentOf(model, current.nodeIds[0]);
277
+ selectNodes(current.documentId, parent ? [parent.id] : [], { reveal: true });
278
+ return true;
279
+ }
280
+
281
+ /** Select the shown artboard's root; a late read cannot take selection back after navigation. */
282
+ export async function selectRoot(id) {
283
+ if (store.selected.size !== 1 || !store.selected.has(id) || selection()) return;
284
+ let changed = false;
285
+ const stop = on('selection', () => { changed = true; });
286
+ try {
287
+ const model = await modelOf(id);
288
+ if (changed || !descriptor(id) || model !== cachedModel(id) || store.selected.size !== 1 || !store.selected.has(id) || selection() || store.expanded) return;
289
+ if (store.mode !== 'select') canvas.setMode('select');
290
+ selectNodes(id, [model.rootId]);
291
+ } catch (error) { if (!changed && descriptor(id)) toast(error.message); }
292
+ finally { stop(); }
293
+ }
294
+
295
+ /** Into the selection: its first child element; with `edit`, text-only elements start text editing instead. */
296
+ export async function selectChild({ edit: editText = true } = {}) {
297
+ const current = selection();
298
+ if (current) {
299
+ const model = await modelOf(current.documentId);
300
+ const node = model.nodes[current.nodeIds[0]];
301
+ const child = node?.type === 'element' ? node.children.find(id => model.nodes[id]?.type === 'element') : null;
302
+ if (child) selectNodes(current.documentId, [child], { reveal: true });
303
+ else if (editText && textChild(model, node)) canvas.editText(current.documentId, node.id, descriptor(current.documentId)?.render ?? 0);
304
+ return;
305
+ }
306
+ if (store.selected.size !== 1) return;
307
+ return selectRoot([...store.selected][0]);
308
+ }
309
+
310
+ /**
311
+ * Keyboard flow while editing text: Tab / Shift+Tab save and select the next / previous element;
312
+ * Enter in a list item saves it and starts the next one.
313
+ */
314
+ export async function stepEdit({ documentId, dir, create, nodeId }) {
315
+ if (create && nodeId) {
316
+ const result = await quiet(edit(documentId, { type: 'insert', after: nodeId, spec: { tag: 'li', text: 'New item' } }, { label: 'add list item' }));
317
+ if (!result?.ids?.length) return;
318
+ selectNodes(documentId, result.ids, { reveal: true });
319
+ canvas.editText(documentId, result.ids[0], result.render);
320
+ return;
321
+ }
322
+ await selectSibling(dir);
323
+ }
324
+
325
+ export async function selectSibling(step) {
326
+ const current = selection();
327
+ if (!current) { cycleArtboards(step); return; }
328
+ const model = await modelOf(current.documentId);
329
+ const parent = parentOf(model, current.nodeIds[0]);
330
+ if (!parent) return;
331
+ const siblings = parent.children.filter(id => model.nodes[id]?.type === 'element');
332
+ const index = siblings.indexOf(current.nodeIds[0]);
333
+ const next = siblings[(index + step + siblings.length) % siblings.length];
334
+ if (next) selectNodes(current.documentId, [next], { reveal: true });
335
+ }
336
+
337
+ /* ---- menus ---------------------------------------------------------------- */
338
+ export async function insertMenu(documentId, position) {
339
+ if (!documentId) { toast('Select an artboard first'); return; }
340
+ let components = [];
341
+ try { components = (await request('api/components')).components; } catch {}
342
+ openMenu([
343
+ { heading: store.node?.documentId === documentId ? 'Add after or inside the selection' : `Add to “${nameOf(documentId)}”` },
344
+ ...insertionPresets().map(item => (item === '-' ? '-' : { label: item[2], icon: item[1], run: () => insertElement(documentId, item[0], item[2]) })),
345
+ components.length > 0 && '-',
346
+ components.length > 0 && { heading: 'Components' },
347
+ ...components.slice(0, 30).map(component => ({ label: component.name, icon: 'component', hint: component.ref, keys: component.frameId === documentId ? undefined : nameOf(component.frameId), run: () => insertInstance(documentId, component.frameId === documentId ? component.nodeId : component.ref, component.name) })),
348
+ ], { ...position, label: 'Add element', className: 'menu-insert' });
349
+ }
350
+
351
+ export function tagMenu(documentId, id, position) {
352
+ const node = cachedModel(documentId)?.nodes[id];
353
+ if (!node || node.type !== 'element') return;
354
+ const items = [];
355
+ for (const [heading, tags] of TAGS) {
356
+ items.push('-', { heading });
357
+ for (const tag of tags) items.push({ label: `<${tag}>`, checked: tag === node.tag, run: () => tag !== node.tag && setTag(documentId, id, tag) });
358
+ }
359
+ openMenu(items, { ...position, label: 'Change tag', className: 'menu-tags' });
360
+ }
361
+
362
+ /** The actions on an artboard (or on the selected artboards): the context menu lists them all, the inspector's bar fits as many as it can. */
363
+ export function artboardItems(id) {
364
+ const ids = store.selected.has(id) ? [...store.selected] : [id];
365
+ const one = ids.length === 1;
366
+ return [
367
+ one && { label: 'Fit height to content', icon: 'hug', disabled: !canFitHeight(id), run: () => fitHeightToContent(id) },
368
+ one && { label: 'Rotate', icon: 'rotate', run: () => rotateArtboard(id) },
369
+ !one && { label: 'Tidy up in a row', icon: 'tidy', run: () => tidyArtboards(ids) },
370
+ '-',
371
+ { label: 'Duplicate', icon: 'copy', keys: keys('Mod+D'), run: () => duplicateArtboards(ids) },
372
+ { label: 'Copy', icon: 'copy', keys: keys('Mod+C'), run: () => { selectArtboards(ids); clip.copySelection(); } },
373
+ one && clip.clipKind() === 'nodes' && { label: 'Paste into', icon: 'paste', keys: keys('Mod+V'), run: () => { selectArtboards([id]); return clip.paste(); } },
374
+ '-',
375
+ one && { label: 'Copy HTML', icon: 'code', run: () => copyHtml(id) },
376
+ one && { label: 'Download HTML', icon: 'download', run: () => downloadHtml(id) },
377
+ one && { label: 'Copy ID', icon: 'hash', run: () => copyText(id, 'Artboard ID copied') },
378
+ '-',
379
+ { label: one ? 'Delete artboard' : `Delete ${ids.length} artboards`, icon: 'trash', keys: 'Del', danger: true, run: () => deleteArtboards(ids) },
380
+ ];
381
+ }
382
+
383
+ export function artboardMenu(id, position) {
384
+ openMenu(artboardItems(id), { ...position, label: 'Artboard' });
385
+ }
386
+
387
+ export async function nodeMenu(documentId, position) {
388
+ const current = selection();
389
+ if (!current || current.documentId !== documentId) return;
390
+ const model = await modelOf(documentId);
391
+ const ids = current.nodeIds.filter(id => model.nodes[id]);
392
+ if (!ids.length) return;
393
+ const node = model.nodes[ids[0]];
394
+ const one = ids.length === 1;
395
+ const isRoot = one && ids[0] === model.rootId;
396
+ const parent = parentOf(model, ids[0]);
397
+ const siblings = parent ? parent.children : [];
398
+ const index = siblings.indexOf(ids[0]);
399
+ const parents = new Set(ids.map(id => parentOf(model, id)?.id));
400
+ openMenu([
401
+ one && parent && { label: 'Select parent', icon: 'parent', keys: keys('Shift+Enter'), run: selectParent },
402
+ one && node.type === 'element' && node.children.some(id => model.nodes[id]?.type === 'element') && { label: 'Select first child', icon: 'child', keys: keys('Enter'), run: selectChild },
403
+ one && textChild(model, node) && { label: 'Edit text', icon: 'text', hint: 'Or double-click the text', run: () => canvas.editText(documentId, node.id, descriptor(documentId)?.render ?? 0) },
404
+ { label: 'Add element…', icon: 'plus', run: () => insertMenu(documentId, position) },
405
+ '-',
406
+ one && node.type === 'element' && !isRoot && { label: node.reusable ? 'Stop being a component' : 'Make component', icon: 'component', run: () => toggleComponent(documentId, node.id) },
407
+ one && node.type === 'ref' && { label: 'Go to component', icon: 'component', run: () => goToComponent(documentId, node.id) },
408
+ one && node.type === 'ref' && Object.keys(node.descendants || {}).length > 0 && { label: 'Reset overrides', icon: 'undo', run: () => quiet(commit(documentId, { nodes: { [node.id]: { descendants: null } } }, { label: `reset overrides of ${node.id}` })) },
409
+ '-',
410
+ !isRoot && parents.size === 1 && { label: one ? 'Wrap in container' : `Wrap ${ids.length} in container`, icon: 'wrap', keys: keys('Mod+G'), hint: store.state?.project?.format === 'pen' ? 'An explicit PEN frame spec is required; automatic grouping is unavailable.' : undefined, run: () => wrapNodes(documentId, ids) },
411
+ one && !isRoot && node.type === 'element' && { label: 'Unwrap', icon: 'unwrap', keys: keys('Mod+Shift+G'), run: () => unwrapNode(documentId, node.id) },
412
+ one && !isRoot && { label: 'Move up', icon: 'arrowUp', keys: keys('Alt+Up'), disabled: index <= 0, run: () => moveNode(documentId, ids[0], -1) },
413
+ one && !isRoot && { label: 'Move down', icon: 'arrowDown', keys: keys('Alt+Down'), disabled: index >= siblings.length - 1, run: () => moveNode(documentId, ids[0], 1) },
414
+ '-',
415
+ !isRoot && { label: 'Duplicate', icon: 'copy', keys: keys('Mod+D'), run: () => duplicateNodes(documentId, ids) },
416
+ !isRoot && { label: 'Copy', icon: 'copy', keys: keys('Mod+C'), run: () => clip.copySelection() },
417
+ !isRoot && { label: 'Cut', icon: 'cut', keys: keys('Mod+X'), run: () => clip.copySelection({ cut: true }) },
418
+ clip.hasClip() && { label: 'Paste', icon: 'paste', keys: keys('Mod+V'), run: () => clip.paste() },
419
+ '-',
420
+ { label: 'Copy HTML', icon: 'code', run: () => copyHtml(documentId, ids) },
421
+ one && { label: 'Copy ID', icon: 'hash', run: () => copyText(ids[0], 'Node ID copied') },
422
+ '-',
423
+ !isRoot && { label: one ? 'Delete' : `Delete ${ids.length} elements`, icon: 'trash', keys: 'Del', danger: true, run: () => deleteNodes(documentId, ids) },
424
+ ], { ...position, label: 'Element' });
425
+ }
426
+
427
+ export function canvasMenu(position, world) {
428
+ const all = documents().map(doc => doc.id);
429
+ openMenu([
430
+ { label: 'New artboard here', icon: 'artboard', keys: 'N', run: () => newArtboard({ x: world.x, y: world.y }) },
431
+ clip.clipKind() === 'artboards' && { label: 'Paste artboards', icon: 'paste', keys: keys('Mod+V'), run: () => { selectArtboards([]); return clip.paste(); } },
432
+ '-',
433
+ { label: 'Zoom to fit all', icon: 'fit', keys: keys('Shift+1'), disabled: !all.length, run: () => canvas.fit(null) },
434
+ { label: 'Zoom to 100%', icon: 'search', keys: '0', run: () => canvas.zoomTo(1) },
435
+ { label: 'Select all artboards', icon: 'select', keys: keys('Mod+A'), disabled: !all.length, run: () => selectArtboards(all) },
436
+ { label: 'Tidy up all artboards', icon: 'tidy', disabled: all.length < 2, run: () => tidyArtboards(all) },
437
+ '-',
438
+ { label: 'Search and commands', icon: 'command', keys: keys('Mod+K'), run: () => emit('palette', true) },
439
+ { label: 'Keyboard shortcuts', icon: 'keyboard', keys: '?', run: () => emit('shortcuts', true) },
440
+ ], { ...position, label: 'Canvas' });
441
+ }
442
+
443
+ /**
444
+ * A grip dragged on the canvas drops an element before or after another one: the same container reorders, a different one moves it.
445
+ * The new index counts the container's children without the moved element, as the move operation expects.
446
+ */
447
+ on('reorder', async ({ documentId, nodeId, targetId, after, copy, group: shared, committed }) => {
448
+ // A grip drag reports its insertion point while it moves, so it arrives many times; one group makes it one undo step.
449
+ const group = shared ?? newGroup();
450
+ let id = nodeId;
451
+ // Alt: the copy is what moves (one undo step for both).
452
+ if (copy) {
453
+ const made = await quiet(edit(documentId, { type: 'duplicate', ids: [nodeId] }, { label: `duplicate ${nodeId}`, group }));
454
+ id = made?.ids?.[0];
455
+ if (!id) return;
456
+ }
457
+ // The move names the sibling it goes beside, not an index: repeating the same drop while a drag moves is then a no-op
458
+ // rather than a second, index-relative move of a tree that has already changed.
459
+ const derive = model => {
460
+ const target = model.nodes[targetId], parent = target && parentOf(model, targetId);
461
+ if (!parent || !model.nodes[id]) return null;
462
+ for (let at = target; at; at = parentOf(model, at.id)) if (at.id === id) { toast('An element cannot be moved into itself'); return null; }
463
+ const siblings = parent.children.filter(child => child !== id);
464
+ const index = siblings.indexOf(targetId) + (after ? 1 : 0);
465
+ const current = parentOf(model, id);
466
+ if (current?.id === parent.id && current.children.indexOf(id) === index) return null;
467
+ return after ? { type: 'move', id, after: targetId } : { type: 'move', id, before: targetId };
468
+ };
469
+ const applied = await quiet(edit(documentId, derive, { label: `${copy ? 'copy' : 'move'} ${id} ${after ? 'after' : 'before'} ${targetId}`, group }));
470
+ if (applied) committed?.();
471
+ if (copy) selectNodes(documentId, [id], { reveal: true });
472
+ });