@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,292 @@
1
+ import { $, el, icon, keys } from './util.js';
2
+ import { store, on, documents, descriptor, cachedModel, loadModel, selectArtboards, selectNodes, undo, redo, textChild, parentOf } from './store.js';
3
+ import * as canvas from './canvas.js';
4
+ import * as actions from './actions.js';
5
+ import * as sidebar from './sidebar.js';
6
+ import * as help from './help.js';
7
+ import * as shortcuts from './shortcuts.js';
8
+ import * as project from './project.js';
9
+ import * as ask from './ask.js';
10
+ import * as clip from './clip.js';
11
+
12
+ /* Search and commands (Ctrl K): every action of the board, the artboards and the elements
13
+ of the selected artboard in one list; free text can go straight to Pi. */
14
+
15
+ const scrim = $('#palette');
16
+ const dialog = scrim.querySelector('.dialog');
17
+ const field = el('label', 'palette-field');
18
+ const input = el('input', 'palette-input');
19
+ input.type = 'text';
20
+ input.placeholder = 'Search commands, artboards and layers…';
21
+ input.autocomplete = 'off';
22
+ input.spellcheck = false;
23
+ input.dataset.key = 'palette';
24
+ input.setAttribute('role', 'combobox');
25
+ input.setAttribute('aria-expanded', 'true');
26
+ input.setAttribute('aria-controls', 'palette-list');
27
+ input.setAttribute('aria-label', 'Search commands');
28
+ field.append(icon('search'), input, el('kbd', '', 'Esc'));
29
+ const list = el('div', 'palette-list');
30
+ list.id = 'palette-list';
31
+ list.setAttribute('role', 'listbox');
32
+ const foot = el('div', 'palette-foot');
33
+ foot.append(el('span', '', '↑↓ to move'), el('span', '', `${keys('Enter')} to run`), el('span', 'grow'), el('span', 'palette-count'));
34
+ dialog.append(field, list, foot);
35
+
36
+ let shown = [];
37
+ let active = 0;
38
+ let returnFocus = null;
39
+ const MENU_SPOT = () => ({ x: Math.round(innerWidth / 2 - 110), y: Math.round(Math.min(innerHeight * 0.22, 180)) });
40
+
41
+ export const isOpen = () => !scrim.hidden;
42
+
43
+ /* ---- what can be run right now -------------------------------------------- */
44
+ function commands() {
45
+ const out = [];
46
+ const add = (group, label, run, extra = {}) => { if (extra.when !== false) out.push({ group, label, run, icon: extra.icon, keys: extra.keys, hint: extra.hint, words: extra.words || '' }); };
47
+ const node = store.node?.nodeIds.length ? store.node : null;
48
+ const single = store.selected.size === 1 ? [...store.selected][0] : null;
49
+ const multi = store.selected.size > 1 ? [...store.selected] : null;
50
+ const agentReady = ask.available();
51
+
52
+ if (node) {
53
+ const model = cachedModel(node.documentId);
54
+ const target = model?.nodes[node.nodeIds[0]];
55
+ const one = node.nodeIds.length === 1;
56
+ const isRoot = one && target?.id === model?.rootId;
57
+ const group = 'Element';
58
+ add(group, 'Edit text', () => canvas.editText(node.documentId, target.id, descriptor(node.documentId)?.render ?? 0), { icon: 'text', when: !!(one && model && textChild(model, target)) });
59
+ add(group, 'Select parent', () => actions.selectParent(), { icon: 'parent', keys: keys('Shift+Enter'), when: !!(one && model && parentOf(model, target?.id)) });
60
+ add(group, 'Select first child', () => actions.selectChild(), { icon: 'child', keys: keys('Enter'), when: !!(one && target?.type === 'element' && target.children.some(id => model.nodes[id]?.type === 'element')) });
61
+ add(group, 'Change tag…', () => actions.tagMenu(node.documentId, target.id, MENU_SPOT()), { icon: 'tag', when: !!(one && target?.type === 'element') });
62
+ add(group, target?.reusable ? 'Stop being a component' : 'Make component', () => actions.toggleComponent(node.documentId, target.id), { icon: 'component', words: 'reusable symbol', when: !!(one && target?.type === 'element' && !isRoot) });
63
+ add(group, 'Go to component', () => actions.goToComponent(node.documentId, target.id), { icon: 'component', when: !!(one && target?.type === 'ref') });
64
+ add(group, 'Detach instance', () => actions.detachInstance(node.documentId, target.id), { icon: 'unwrap', when: !!(one && target?.type === 'ref') });
65
+ for (const preset of actions.insertionPresets()) if (preset !== '-') add(group, `Add ${preset[2].toLowerCase()}`, () => actions.insertElement(node.documentId, preset[0], preset[2]), { icon: preset[1], words: 'insert new element', when: store.state?.project?.format !== 'pen' || store.state.project.capabilities.nodeOperations?.includes('insert') });
66
+ add(group, 'Wrap in container', () => actions.wrapNodes(node.documentId, node.nodeIds), { icon: 'wrap', keys: keys('Mod+G'), when: !isRoot });
67
+ add(group, 'Unwrap', () => actions.unwrapNode(node.documentId, target.id), { icon: 'unwrap', keys: keys('Mod+Shift+G'), when: !!(one && !isRoot && target?.type === 'element') });
68
+ add(group, 'Move up', () => actions.moveNode(node.documentId, target.id, -1), { icon: 'arrowUp', keys: keys('Alt+Up'), when: one && !isRoot });
69
+ add(group, 'Move down', () => actions.moveNode(node.documentId, target.id, 1), { icon: 'arrowDown', keys: keys('Alt+Down'), when: one && !isRoot });
70
+ add(group, 'Duplicate', () => actions.duplicateNodes(node.documentId, node.nodeIds), { icon: 'copy', keys: keys('Mod+D'), when: !isRoot });
71
+ add(group, 'Copy', () => clip.copySelection(), { icon: 'copy', keys: keys('Mod+C'), when: !isRoot });
72
+ add(group, 'Cut', () => clip.copySelection({ cut: true }), { icon: 'cut', keys: keys('Mod+X'), when: !isRoot });
73
+ add(group, 'Paste', () => clip.paste(), { icon: 'paste', keys: keys('Mod+V'), when: clip.hasClip() });
74
+ add(group, 'Copy HTML', () => actions.copyHtml(node.documentId, node.nodeIds), { icon: 'code', words: 'export markup code' });
75
+ add(group, 'Zoom to element', () => canvas.fitNode(node.documentId, node.nodeIds[0]), { icon: 'fit', keys: 'F' });
76
+ add(group, 'Copy ID', () => actions.copyText(node.nodeIds[0], 'Node ID copied'), { icon: 'hash', when: one });
77
+ add(group, one ? 'Delete element' : `Delete ${node.nodeIds.length} elements`, () => actions.deleteNodes(node.documentId, node.nodeIds), { icon: 'trash', keys: 'Del', when: !isRoot });
78
+ } else if (single) {
79
+ const doc = descriptor(single);
80
+ const group = 'Artboard';
81
+ add(group, 'Select root element', () => actions.selectChild(), { icon: 'inspect', keys: keys('Enter'), words: 'inspect elements child' });
82
+ add(group, 'Rename', () => actions.renameArtboard(single), { icon: 'pencil', keys: 'R' });
83
+ add(group, 'Expand and browse', () => canvas.openExpanded(single), { icon: 'expand', keys: 'E' });
84
+ add(group, 'Zoom to artboard', () => canvas.fit([single]), { icon: 'fit', keys: 'F' });
85
+ add(group, 'Fit height to content', () => actions.fitHeightToContent(single), { icon: 'hug', when: actions.canFitHeight(single) });
86
+ add(group, 'Rotate', () => actions.rotateArtboard(single), { icon: 'rotate', words: 'swap width height orientation' });
87
+ for (const [name, width] of actions.SIZES) add(group, `Set width to ${width} (${name})`, () => actions.setArtboard(single, { width }, `width ${width}`), { icon: 'artboard', words: 'resize device breakpoint', when: doc?.artboard.width !== width });
88
+ add(group, 'Duplicate artboard', () => actions.duplicateArtboards([single]), { icon: 'copy', keys: keys('Mod+D') });
89
+ add(group, 'Copy artboard', () => clip.copySelection(), { icon: 'copy', keys: keys('Mod+C') });
90
+ add(group, 'Paste into artboard', () => clip.paste(), { icon: 'paste', keys: keys('Mod+V'), when: clip.clipKind() === 'nodes' });
91
+ add(group, 'Copy HTML', () => actions.copyHtml(single), { icon: 'code', words: 'export markup code page' });
92
+ add(group, 'Download HTML', () => actions.downloadHtml(single), { icon: 'download', words: 'export save file' });
93
+ add(group, 'Copy ID', () => actions.copyText(single, 'Artboard ID copied'), { icon: 'hash' });
94
+ add(group, 'Delete artboard', () => actions.deleteArtboards([single]), { icon: 'trash', keys: 'Del' });
95
+ } else if (multi) {
96
+ const group = `${multi.length} artboards`;
97
+ for (const [label, axis, mode, name] of [['Align left edges', 'x', 'start', 'alignL'], ['Align horizontal centers', 'x', 'center', 'alignCH'], ['Align right edges', 'x', 'end', 'alignR'], ['Align top edges', 'y', 'start', 'alignT'], ['Align vertical centers', 'y', 'center', 'alignCV'], ['Align bottom edges', 'y', 'end', 'alignB']]) add(group, label, () => actions.alignArtboards(multi, axis, mode), { icon: name });
98
+ add(group, 'Distribute horizontally', () => actions.distributeArtboards(multi, 'x'), { icon: 'distH', when: multi.length > 2 });
99
+ add(group, 'Distribute vertically', () => actions.distributeArtboards(multi, 'y'), { icon: 'distV', when: multi.length > 2 });
100
+ add(group, 'Tidy up in a row', () => actions.tidyArtboards(multi), { icon: 'tidy' });
101
+ add(group, 'Zoom to selection', () => canvas.fit(multi), { icon: 'fit', keys: 'F' });
102
+ add(group, 'Duplicate artboards', () => actions.duplicateArtboards(multi), { icon: 'copy', keys: keys('Mod+D') });
103
+ add(group, `Delete ${multi.length} artboards`, () => actions.deleteArtboards(multi), { icon: 'trash', keys: 'Del' });
104
+ }
105
+
106
+ const board = 'Board';
107
+ const head = store.state?.history;
108
+ add(board, 'Ask the agent…', () => ask.toggle(true), { icon: 'sparkle', keys: 'A', when: agentReady, words: 'agent request prompt about selection pi' });
109
+ add(board, 'New artboard', () => actions.newArtboard(), { icon: 'artboard', keys: 'N' });
110
+ for (const [name, width, height] of actions.SIZES) add(board, `New ${name.toLowerCase()} artboard`, () => actions.newArtboard({ width, height, name }), { icon: 'artboard', hint: `${width} × ${height}`, words: 'create device' });
111
+ add(board, head?.undoLabel ? `Undo ${head.undoLabel}` : 'Undo', () => undo(), { icon: 'undo', keys: keys('Mod+Z'), when: !!head?.canUndo });
112
+ add(board, head?.redoLabel ? `Redo ${head.redoLabel}` : 'Redo', () => redo(), { icon: 'redo', keys: keys('Mod+Shift+Z'), when: !!head?.canRedo });
113
+ add(board, 'Paste artboards', () => clip.paste(), { icon: 'paste', keys: keys('Mod+V'), when: clip.clipKind() === 'artboards' && !store.selected.size });
114
+ add(board, 'Fit everything', () => canvas.fit(null), { icon: 'fit', keys: keys('Shift+1'), when: documents().length > 0, words: 'zoom' });
115
+ add(board, 'Zoom to 100%', () => canvas.zoomTo(1), { icon: 'search', keys: '0' });
116
+ add(board, 'Zoom in', () => canvas.zoomAt(1.25, undefined, undefined, { animate: true }), { icon: 'plus', keys: '+' });
117
+ add(board, 'Zoom out', () => canvas.zoomAt(1 / 1.25, undefined, undefined, { animate: true }), { icon: 'minus', keys: '−' });
118
+ add(board, 'Select all artboards', () => selectArtboards(documents().map(doc => doc.id)), { icon: 'select', keys: keys('Mod+A'), when: documents().length > 0 });
119
+ add(board, 'Tidy up all artboards', () => actions.tidyArtboards(documents().map(doc => doc.id)), { icon: 'tidy', when: documents().length > 1 });
120
+ add(board, 'Select tool', () => canvas.setMode('select'), { icon: 'select', keys: 'V', words: 'mode move artboards elements inspect' });
121
+ add(board, 'Interact with the page', () => canvas.setMode('interact'), { icon: 'play', keys: 'P', words: 'tool mode click page play' });
122
+ add(board, sidebar.isOpen('layers') ? 'Hide layers' : 'Show layers', () => sidebar.toggle('layers'), { icon: 'layers', keys: 'L' });
123
+ add(board, sidebar.isOpen('activity') ? 'Hide history' : 'Show history', () => sidebar.toggle('activity'), { icon: 'chat', keys: 'H', words: 'activity log changes replies' });
124
+ add(board, 'Open the site', () => window.open(store.state.site.url, '_blank', 'noopener'), { icon: 'site', when: !!store.state?.site, words: 'markdown pages' });
125
+ add(board, 'Open project…', () => project.show('open'), { icon: 'folder', keys: keys('Mod+O'), words: 'switch folder load' });
126
+ add(board, 'Save a copy as…', () => project.show('save'), { icon: 'download', keys: keys('Mod+Shift+S'), words: 'save as copy export project folder', when: project.canShowCopy() });
127
+ for (const percent of help.UI_SIZES) add(board, `Interface size ${percent}%`, () => help.setUiSize(percent), { icon: 'search', words: 'scale zoom text bigger smaller ui' });
128
+ add(board, 'Keyboard shortcuts', () => shortcuts.toggle(true), { icon: 'keyboard', keys: '?', words: 'keys hotkeys help' });
129
+ add(board, 'Settings', () => help.toggle(true), { icon: 'gear', keys: keys('Mod') + ' ,', words: 'preferences theme interface size' });
130
+ for (const [value, name, label] of [['auto', 'auto', 'Theme: auto'], ['light', 'sun', 'Theme: light'], ['dark', 'moon', 'Theme: dark']]) add(board, label, () => help.setTheme(value), { icon: name, words: 'appearance color scheme' });
131
+ return out;
132
+ }
133
+
134
+ function artboardItems() {
135
+ return documents().map(doc => ({ group: 'Artboards', label: doc.name || doc.id, hint: `${Math.round(doc.artboard.width)} × ${Math.round(doc.artboard.height)}`, icon: 'artboard', words: doc.id, run: () => { selectArtboards([doc.id]); canvas.reveal(doc.id); } }));
136
+ }
137
+
138
+ /** Open an element everywhere at once: on the canvas (its artboard comes into view) and in the Layers tree. */
139
+ function openElement(documentId, nodeId) {
140
+ if (store.mode !== 'select') canvas.setMode('select');
141
+ if (!store.selected.has(documentId) || store.selected.size !== 1) selectArtboards([documentId]);
142
+ canvas.reveal(documentId);
143
+ selectNodes(documentId, [nodeId], { reveal: true });
144
+ sidebar.show('layers');
145
+ }
146
+
147
+ /** Elements of every artboard whose model is loaded (the palette loads them when it opens). */
148
+ function elementItems() {
149
+ const out = [];
150
+ const many = documents().length > 1;
151
+ for (const doc of documents()) {
152
+ const model = cachedModel(doc.id);
153
+ if (!model) continue;
154
+ let count = 0;
155
+ for (const node of Object.values(model.nodes)) {
156
+ if (node.type !== 'element' && node.type !== 'ref') continue;
157
+ const where = many ? `${doc.name || doc.id} › #${node.id}` : `#${node.id}`;
158
+ const run = () => openElement(doc.id, node.id);
159
+ if (node.type === 'ref') { out.push({ group: 'Elements', label: `instance · ${node.metadata?.name || node.ref}`, hint: where, icon: 'instance', words: `${node.id} ${node.ref} component ${doc.name}`, run }); continue; }
160
+ const text = textChild(model, node)?.content.replace(/\s+/g, ' ').trim() ?? '';
161
+ const name = typeof node.metadata?.name === 'string' ? node.metadata.name : '';
162
+ out.push({ group: 'Elements', label: `${node.tag}${name ? ` · ${name}` : text ? ` · ${text.slice(0, 60)}` : ''}`, hint: where, icon: 'inspect', words: `${node.id} ${node.className} ${text} ${doc.name}`, run });
163
+ if (++count >= 600) break;
164
+ }
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /* ---- matching ------------------------------------------------------------- */
170
+ function score(query, item) {
171
+ const text = `${item.label} ${item.hint || ''} ${item.words} ${item.group}`.toLowerCase();
172
+ const label = item.label.toLowerCase();
173
+ let total = 0;
174
+ for (const word of query.toLowerCase().split(/\s+/).filter(Boolean)) {
175
+ const at = label.indexOf(word);
176
+ if (at >= 0) { total += 40 - Math.min(at, 20) + (at === 0 || /[\s·(“#<-]/.test(label[at - 1]) ? 25 : 0); continue; }
177
+ if (text.includes(word)) { total += 12; continue; }
178
+ let from = 0;
179
+ let gained = 0;
180
+ for (const ch of word) {
181
+ const found = label.indexOf(ch, from);
182
+ if (found < 0) return 0;
183
+ gained += found === from ? 2 : 1;
184
+ from = found + 1;
185
+ }
186
+ total += gained;
187
+ }
188
+ return total;
189
+ }
190
+
191
+ function build(query) {
192
+ const all = [...commands(), ...artboardItems(), ...elementItems()];
193
+ const trimmed = query.trim();
194
+ let result;
195
+ if (!trimmed) {
196
+ const selectionGroup = all.filter(item => item.group !== 'Board' && item.group !== 'Artboards' && item.group !== 'Elements');
197
+ result = [...selectionGroup.slice(0, 14), ...all.filter(item => item.group === 'Board'), ...all.filter(item => item.group === 'Artboards')];
198
+ }
199
+ let strong = false;
200
+ if (trimmed) {
201
+ const scored = all.map(item => ({ item, value: score(trimmed, item) })).filter(entry => entry.value > 0).sort((a, b) => b.value - a.value).slice(0, 60);
202
+ result = scored.map(entry => entry.item);
203
+ // Every word found in the best label: that command is what was meant.
204
+ strong = !!scored[0] && scored[0].value >= trimmed.split(/\s+/).length * 30;
205
+ }
206
+ if (trimmed && ask.available()) {
207
+ const askItem = { group: 'Agent', label: `Ask the agent: “${trimmed.length > 70 ? `${trimmed.slice(0, 69)}…` : trimmed}”`, icon: 'sparkle', keys: keys('Mod+Enter'), words: 'pi', ask: true, run: () => ask.submit(trimmed) };
208
+ const sentence = trimmed.split(/\s+/).length >= 3;
209
+ if (!result.length || (sentence && !strong)) result.unshift(askItem); else result.push(askItem);
210
+ }
211
+ return result;
212
+ }
213
+
214
+ function paint() {
215
+ const query = input.value;
216
+ shown = build(query);
217
+ active = Math.min(active, Math.max(0, shown.length - 1));
218
+ list.replaceChildren();
219
+ let group = null;
220
+ shown.forEach((item, index) => {
221
+ if (!query.trim() && item.group !== group) { group = item.group; list.append(el('div', 'palette-group', group)); }
222
+ const row = el('button', `palette-item${index === active ? ' on' : ''}${item.ask ? ' pal-ask' : ''}`);
223
+ row.type = 'button';
224
+ row.id = `palette-item-${index}`;
225
+ row.tabIndex = -1;
226
+ row.setAttribute('role', 'option');
227
+ row.setAttribute('aria-selected', String(index === active));
228
+ row.append(icon(item.icon || 'chevron'), el('span', 'palette-label', item.label));
229
+ if (item.hint) row.append(el('span', 'palette-hint', item.hint));
230
+ if (query.trim() && !item.ask) row.append(el('span', 'palette-tag', item.group));
231
+ if (item.keys) row.append(el('span', 'menu-keys', item.keys));
232
+ row.addEventListener('pointermove', () => { if (active !== index) { active = index; mark(); } });
233
+ row.addEventListener('click', () => run(index));
234
+ list.append(row);
235
+ });
236
+ if (!shown.length) list.append(el('div', 'quiet', ask.available() ? 'Nothing found. Type a full sentence to ask Pi.' : 'Nothing found.'));
237
+ foot.querySelector('.palette-count').textContent = shown.length ? `${shown.length} result${shown.length === 1 ? '' : 's'}` : '';
238
+ mark();
239
+ }
240
+
241
+ function mark() {
242
+ for (const row of list.querySelectorAll('.palette-item')) {
243
+ const on = row.id === `palette-item-${active}`;
244
+ row.classList.toggle('on', on);
245
+ row.setAttribute('aria-selected', String(on));
246
+ if (on) row.scrollIntoView({ block: 'nearest' });
247
+ }
248
+ input.setAttribute('aria-activedescendant', shown.length ? `palette-item-${active}` : '');
249
+ }
250
+
251
+ function run(index) {
252
+ const item = shown[index];
253
+ if (!item) return;
254
+ toggle(false, { restore: !item.ask });
255
+ requestAnimationFrame(() => { try { const result = item.run(); result?.catch?.(() => {}); } catch (error) { console.error(error); } });
256
+ }
257
+
258
+ export function toggle(open = scrim.hidden, { restore = true } = {}) {
259
+ if (open === !scrim.hidden) { if (open) input.focus(); return; }
260
+ if (open) {
261
+ returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
262
+ input.value = '';
263
+ active = 0;
264
+ scrim.hidden = false;
265
+ paint();
266
+ input.focus();
267
+ // Every artboard's elements are searchable: load the models that are not here yet.
268
+ for (const doc of documents()) if (!cachedModel(doc.id)) loadModel(doc.id).then(() => { if (isOpen()) paint(); }).catch(() => {});
269
+ } else {
270
+ scrim.hidden = true;
271
+ if (restore) {
272
+ if (returnFocus?.isConnected && returnFocus !== document.body) returnFocus.focus({ preventScroll: true });
273
+ else $('#viewport').focus({ preventScroll: true });
274
+ }
275
+ }
276
+ }
277
+
278
+ input.addEventListener('input', () => { active = 0; paint(); });
279
+ input.addEventListener('keydown', event => {
280
+ event.stopPropagation();
281
+ const step = { ArrowDown: 1, ArrowUp: -1, PageDown: 8, PageUp: -8 }[event.key];
282
+ if (step) { event.preventDefault(); if (shown.length) { active = Math.max(0, Math.min(shown.length - 1, active + step)); mark(); } }
283
+ else if (event.key === 'Home' && !input.value) { event.preventDefault(); active = 0; mark(); }
284
+ else if (event.key === 'End' && !input.value) { event.preventDefault(); active = Math.max(0, shown.length - 1); mark(); }
285
+ else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); const index = shown.findIndex(item => item.ask); if (index >= 0) run(index); }
286
+ else if (event.key === 'Enter') { event.preventDefault(); run(active); }
287
+ else if (event.key === 'Escape') { event.preventDefault(); toggle(false); }
288
+ else if (event.key === 'Tab') event.preventDefault();
289
+ });
290
+ scrim.addEventListener('pointerdown', event => { if (event.target === scrim) toggle(false); });
291
+ scrim.addEventListener('keydown', event => event.stopPropagation());
292
+ on('state', () => { if (isOpen()) paint(); });
@@ -0,0 +1,27 @@
1
+ /*
2
+ * Inspector panels contributed by plugins. A plugin ships a client module (served at `plugins/<id>/<file>` and listed in the
3
+ * project info as `clientPlugins`) that calls `registerPanel`; the inspector asks the registry for its sections and never
4
+ * names a plugin. With no panel registered the inspector still edits tags, text, attributes and inline style.
5
+ *
6
+ * panel = { id, node?(ctx) → sections, nodes?(ctx) → sections }
7
+ * node: one selected element. ctx = { doc, node, parent, saveClasses(list, label), applyClasses(transform, label, { group?, refresh?, style? }), spacingFocus(focus), measuredBox(id), freshMeasuredBox(id) }
8
+ * nodes: two or more selected elements. ctx = { doc, nodes, applyClasses(transform, label, options) }
9
+ * applyClasses returns the queued commit promise; optional style is an inline patch or async node → patch.
10
+ * Linked property views may observe completion to refresh inactive controls, never to commit a second copy.
11
+ * freshMeasuredBox waits for the current render's geometry and refuses if it never arrives.
12
+ * section = { name, title?, content }: with a title the content is a fold, without one it is the bare section.
13
+ * Appearance may expose a [data-inspector-slot="typography"] element; the core inserts native family controls there.
14
+ * Without that slot it prepends them to Appearance; without a provider it owns a minimal Appearance section.
15
+ */
16
+ const panels = [];
17
+
18
+ export const registerPanel = panel => { if (!panels.some(item => item.id === panel.id)) panels.push(panel); };
19
+ export const panelSections = (kind, ctx) => panels.flatMap(panel => panel[kind]?.(ctx) ?? []);
20
+
21
+ /** Load the client modules the server lists for the active plugins; a module that fails to load only loses its own panels. */
22
+ export async function loadClientPlugins(list, resolve) {
23
+ for (const item of list || []) {
24
+ try { await Promise.race([import(resolve(item.module)), new Promise((_, reject) => setTimeout(() => reject(new Error('timed out after 5s')), 5000))]); }
25
+ catch (error) { console.warn(`Client plugin ${item.id} failed to load:`, error); }
26
+ }
27
+ }
@@ -0,0 +1,178 @@
1
+ // Open a project from anywhere, or save a copy of this one somewhere else. The browser cannot see the
2
+ // disk, so project files and folders are listed by the server; the address is a path.
3
+ import { $, el, icon } from './util.js';
4
+ import { filePicker, directoryPath } from './file-picker.js';
5
+ import { store, request, post, toast } from './store.js';
6
+ import { sourceUpdates } from './host.js';
7
+
8
+ const scrim = el('div', 'scrim chrome');
9
+ scrim.id = 'project';
10
+ scrim.hidden = true;
11
+ const dialog = el('section', 'dialog project-dialog');
12
+ dialog.setAttribute('role', 'dialog');
13
+ dialog.setAttribute('aria-modal', 'true');
14
+ scrim.append(dialog);
15
+ ($('#ui') || document.body).append(scrim);
16
+
17
+ /**
18
+ * A project that came from an import (a .pen) notices when its source file changed afterwards and offers to import it again
19
+ * as a new project beside it; the current project is never overwritten.
20
+ */
21
+ const noticed = new Set();
22
+ export async function checkImport() {
23
+ if (!sourceUpdates) return;
24
+ let status;
25
+ try { status = (await sourceUpdates.readStatus()).status; } catch { return; }
26
+ if (!status?.changed || noticed.has(status.source)) return;
27
+ noticed.add(status.source);
28
+ toast(`“${status.source}” changed after it was imported. This project is untouched.`, {
29
+ label: 'Import again',
30
+ run: async () => {
31
+ try {
32
+ const { file } = await sourceUpdates.reimport();
33
+ toast(`Imported again as ${file.split(/[\\/]/).pop()}`, { label: 'Open', run: () => sourceUpdates.openProject(file).then(result => { location.href = result.url; }).catch(error => toast(error.message)) }, { sticky: true });
34
+ } catch (error) { toast(error.message); }
35
+ },
36
+ }, { sticky: true });
37
+ }
38
+
39
+ let mode = 'open';
40
+ let picker;
41
+ let busy = false;
42
+ let returnFocus = null;
43
+ let session = 0;
44
+ let recovery;
45
+ let failure = '';
46
+ const clearRecovery = () => { failure = ''; recovery.replaceChildren(); };
47
+ let input;
48
+ let status;
49
+ let primary;
50
+
51
+ export const isOpen = () => !scrim.hidden;
52
+ export const canShowCopy = () => !!store.state?.project?.capabilities?.saveAs;
53
+
54
+ function label() {
55
+ const info = picker?.state.info;
56
+ if (!info) return { text: mode === 'open' ? 'Open' : 'Save here', ok: false, note: '' };
57
+ if (mode === 'open') {
58
+ if (info.kind === 'project') return { text: 'Open project', ok: true, note: 'A project is here.' };
59
+ if (info.kind === 'collection') return { text: 'Open collection', ok: true, note: `${info.projects} project${info.projects === 1 ? '' : 's'} here — pick one on the next page.` };
60
+ if (info.kind === 'new') return { text: 'Create project here', ok: true, note: 'No project in this folder yet.' };
61
+ return { text: 'Open project', ok: false, note: 'This folder does not exist.' };
62
+ }
63
+ if (info.kind === 'project' || info.kind === 'collection') return { text: 'Save here', ok: false, note: 'This folder already holds projects. Choose a new or empty folder.' };
64
+ if (info.kind === 'missing') return { text: 'Save here', ok: true, note: `Will create ${info.missing}.` };
65
+ return { text: 'Save here', ok: true, note: 'The folder must be empty.' };
66
+ }
67
+
68
+ function paintControls() {
69
+ const state = label();
70
+ primary.textContent = busy ? 'Working…' : state.text;
71
+ // Keep keyboard focus inside the modal when pending work disables a focused control.
72
+ const focused = document.activeElement;
73
+ if (focused === primary && (busy || !state.ok)) input.focus({ preventScroll: true });
74
+ primary.disabled = busy || !state.ok;
75
+ status.textContent = failure || (picker.state.pending ? 'Loading…' : state.note);
76
+ }
77
+
78
+ async function submit() {
79
+ const state = label();
80
+ if (busy || !state.ok) return;
81
+ const mine = session, target = input.value.trim();
82
+ const current = () => mine === session && !scrim.hidden && input.value.trim() === target;
83
+ busy = true;
84
+ clearRecovery();
85
+ paintControls();
86
+ try {
87
+ const result = await post(mode === 'open' ? 'api/open-project' : 'api/save-project', mode === 'open' ? { path: target, create: picker.state.info.kind === 'new' } : { path: target, expectedVersion: store.state?.project?.version, expectedFormat: 'divlet' });
88
+ if (!current()) return;
89
+ toast(mode === 'open' ? 'Opening…' : 'Saved — opening the copy');
90
+ location.href = result.url;
91
+ } catch (error) {
92
+ if (!current()) return;
93
+ toast(error.message);
94
+ } finally {
95
+ if (mine === session && !scrim.hidden) { busy = false; paintControls(); }
96
+ }
97
+ }
98
+
99
+ function render() {
100
+ session++;
101
+ picker?.dispose();
102
+ failure = '';
103
+ const head = el('div', 'dialog-head');
104
+ const title = el('h2', '', mode === 'open' ? 'Open project' : 'Save a copy as…');
105
+ title.id = 'project-title';
106
+ dialog.setAttribute('aria-labelledby', 'project-title');
107
+ const close = el('button', 'tool mini');
108
+ close.type = 'button';
109
+ close.setAttribute('aria-label', 'Close');
110
+ close.dataset.tip = 'Close · Esc';
111
+ close.append(icon('close'));
112
+ close.addEventListener('click', () => toggle(false));
113
+ head.append(title, close);
114
+
115
+ const hint = el('p', 'project-hint', mode === 'open'
116
+ ? 'Type or paste a .divlet/.pen file or folder on this computer, or pick one below. One project file opens directly; several open a collection. An empty folder starts project.divlet.'
117
+ : 'Choose a new or empty folder. The selected project source and its resources are copied there; generated pages are rebuilt when the copy opens. This one stays as it is.');
118
+ picker = filePicker({
119
+ read: path => request(`api/fs?path=${encodeURIComponent(path)}`),
120
+ includeFiles: mode === 'open',
121
+ label: mode === 'open' ? 'Project file or folder' : 'Folder for the copy',
122
+ onChange: state => { clearRecovery(); failure = state.error; paintControls(); },
123
+ onSubmit: submit,
124
+ });
125
+ input = picker.input;
126
+ input.dataset.key = 'project-path';
127
+ status = el('span', 'caption project-status');
128
+ status.setAttribute('role', 'status');
129
+ primary = el('button', 'cta project-go');
130
+ primary.type = 'button';
131
+ primary.addEventListener('click', submit);
132
+ const foot = el('div', 'dialog-foot');
133
+ // The action row stays directly under the list; status and errors follow it, so they never push the controls.
134
+ foot.append(el('span', 'grow'), primary);
135
+ recovery = el('div', 'project-recovery');
136
+ dialog.replaceChildren(head, hint, picker.element, foot, status, recovery);
137
+ }
138
+
139
+ /** Open the dialog: `open` (any project) or `save` (a copy of this one). */
140
+ export function show(next = 'open') {
141
+ if (next === 'save' && !canShowCopy()) { toast('Saving a copy is unavailable while this project has errors (see History) or when the board was not started by divlet.'); return; }
142
+ mode = next;
143
+ busy = false;
144
+ returnFocus = document.activeElement;
145
+ render();
146
+ const here = String(store.state?.projectDir || '');
147
+ const parent = here.replace(/[\\/][^\\/]*$/, '') || '/';
148
+ const name = String(store.state?.project?.name || 'project').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '') || 'project';
149
+ input.value = (mode === 'open' ? directoryPath(here) : `${directoryPath(parent)}${name}-copy`);
150
+ scrim.hidden = false;
151
+ primary.disabled = true;
152
+ picker.load(input.value);
153
+ input.focus();
154
+ input.setSelectionRange(input.value.length, input.value.length);
155
+ }
156
+
157
+ export function toggle(open) {
158
+ if (open) { show(typeof open === 'string' ? open : 'open'); return; }
159
+ if (scrim.hidden) return;
160
+ scrim.hidden = true;
161
+ session++;
162
+ picker.dispose();
163
+ if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
164
+ else $('#viewport').focus({ preventScroll: true });
165
+ }
166
+
167
+
168
+ scrim.addEventListener('pointerdown', event => { if (event.target === scrim) toggle(false); });
169
+ scrim.addEventListener('keydown', event => {
170
+ event.stopPropagation();
171
+ if (event.key === 'Escape') { event.preventDefault(); toggle(false); }
172
+ if (event.key === 'Tab') {
173
+ const focusable = [...dialog.querySelectorAll('button:not(:disabled), input, textarea')].filter(node => node.getClientRects().length);
174
+ const index = focusable.indexOf(document.activeElement);
175
+ event.preventDefault();
176
+ focusable[(index + (event.shiftKey ? -1 : 1) + focusable.length) % focusable.length]?.focus();
177
+ }
178
+ });
@@ -0,0 +1,114 @@
1
+ // Side panels with a width the user sets: drag the inner edge (or focus it and use ← →), double-click
2
+ // to reset. The width is remembered per panel and never exceeds what the window can hold.
3
+ import { clamp, uiScale } from './util.js';
4
+
5
+ const phone = () => document.documentElement.classList.contains('w720');
6
+ const store = {
7
+ get: key => { try { const value = Number(localStorage.getItem(`divlet:width:${key}`)); return Number.isFinite(value) && value > 0 ? value : null; } catch { return null; } },
8
+ set: (key, value) => { try { if (value) localStorage.setItem(`divlet:width:${key}`, String(value)); else localStorage.removeItem(`divlet:width:${key}`); } catch {} },
9
+ };
10
+
11
+ /**
12
+ * `panel` hugs `side` of the window; `cssVar` carries its width (the stylesheet reads it).
13
+ * `fallback` is the default width (a number or a function returning one); while the user has not set a width the panel keeps
14
+ * it, and when `follow` is given it tracks that element's width (the inspector is as wide as the toolbar under it).
15
+ * Double-clicking the handle returns to the default. Returns { width() } for tests.
16
+ */
17
+ export function resizable(panel, { side, key, cssVar, min = 260, max = 640, fallback, follow }) {
18
+ const handle = document.createElement('div');
19
+ handle.className = `resize-handle resize-${side}`;
20
+ handle.setAttribute('role', 'separator');
21
+ handle.setAttribute('aria-orientation', 'vertical');
22
+ handle.setAttribute('aria-label', `Resize ${key} panel`);
23
+ handle.tabIndex = 0;
24
+ handle.dataset.tip = 'Drag to resize · double-click to reset';
25
+ handle.dataset.panel = key;
26
+ handle.hidden = true;
27
+ document.body.append(handle);
28
+
29
+ const base = () => (typeof fallback === 'function' ? fallback() : fallback);
30
+ let preferred = store.get(key);
31
+ let width = preferred ?? base();
32
+ // Widths are layout pixels of the scaled interface; the pointer and rectangles are screen pixels.
33
+ const limit = () => Math.max(min, Math.min(max, innerWidth / uiScale() - 20 - 120));
34
+ const apply = value => { width = clamp(Math.round(value), min, limit()); document.documentElement.style.setProperty(cssVar, `${width}px`); place(); };
35
+ // Constraints affect the rendered width, never the remembered user choice.
36
+ const restore = () => apply(preferred ?? base());
37
+ const remember = () => { preferred = width; store.set(key, preferred); };
38
+
39
+ function place() {
40
+ const hidden = panel.hidden || phone();
41
+ handle.hidden = hidden;
42
+ if (hidden) return;
43
+ const r = panel.getBoundingClientRect();
44
+ handle.style.top = `${r.top}px`;
45
+ handle.style.height = `${r.height}px`;
46
+ handle.style.left = `${(side === 'right' ? r.left : r.right) - 4}px`;
47
+ handle.setAttribute('aria-valuenow', String(width));
48
+ handle.setAttribute('aria-valuemin', String(min));
49
+ handle.setAttribute('aria-valuemax', String(limit()));
50
+ }
51
+
52
+ handle.addEventListener('pointerdown', event => {
53
+ if (event.button !== 0 || !event.isPrimary || handle.classList.contains('dragging')) return;
54
+ event.preventDefault();
55
+ handle.setPointerCapture(event.pointerId);
56
+ const x0 = event.clientX;
57
+ const scale = uiScale();
58
+ const w0 = width;
59
+ let moved = false;
60
+ handle.classList.add('dragging');
61
+ document.body.classList.add('resizing-panel');
62
+ const move = e => { if (e.pointerId !== event.pointerId) return; moved = true; apply(w0 + (side === 'right' ? x0 - e.clientX : e.clientX - x0) / scale); };
63
+ const finish = cancelled => {
64
+ handle.removeEventListener('pointermove', move);
65
+ handle.removeEventListener('pointerup', up);
66
+ handle.removeEventListener('pointercancel', cancel);
67
+ handle.removeEventListener('lostpointercapture', cancel);
68
+ window.removeEventListener('blur', cancel);
69
+ document.removeEventListener('keydown', keydown, true);
70
+ handle.classList.remove('dragging');
71
+ document.body.classList.remove('resizing-panel');
72
+ if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId);
73
+ if (cancelled) restore();
74
+ // Only a completed drag makes the width the user's own; clicks/cancellation never replace the preference.
75
+ else if (moved) remember();
76
+ };
77
+ const up = e => { if (e.pointerId === event.pointerId) finish(false); };
78
+ const cancel = e => { if (e?.pointerId === undefined || e.pointerId === event.pointerId) finish(true); };
79
+ const keydown = e => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancel(); } };
80
+ handle.addEventListener('pointermove', move);
81
+ handle.addEventListener('pointerup', up);
82
+ handle.addEventListener('pointercancel', cancel);
83
+ handle.addEventListener('lostpointercapture', cancel);
84
+ window.addEventListener('blur', cancel);
85
+ document.addEventListener('keydown', keydown, true);
86
+ });
87
+ handle.addEventListener('dblclick', () => { preferred = null; store.set(key, null); restore(); });
88
+ handle.addEventListener('keydown', event => {
89
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
90
+ event.preventDefault();
91
+ event.stopPropagation();
92
+ if (handle.classList.contains('dragging')) return;
93
+ const grow = (event.key === 'ArrowLeft') === (side === 'right');
94
+ apply(width + (grow ? 1 : -1) * (event.shiftKey ? 48 : 16));
95
+ remember();
96
+ });
97
+
98
+ restore();
99
+ new ResizeObserver(place).observe(panel);
100
+ // Intro transforms do not resize the layout box; place the grip again at the final painted edge.
101
+ for (const type of ['animationend', 'animationcancel']) panel.addEventListener(type, event => { if (event.target === panel) place(); });
102
+ new MutationObserver(place).observe(panel, { attributes: true, attributeFilter: ['hidden'] });
103
+ if (follow) new ResizeObserver(() => { if (preferred === null) restore(); }).observe(follow);
104
+ addEventListener('resize', restore);
105
+ addEventListener('storage', event => {
106
+ if (event.key !== null && event.key !== `divlet:width:${key}`) return;
107
+ try { if (event.storageArea !== localStorage) return; } catch { return; }
108
+ preferred = store.get(key);
109
+ restore();
110
+ });
111
+ addEventListener('resize', () => requestAnimationFrame(place));
112
+ document.addEventListener('scroll', place, true);
113
+ return { width: () => width, handle };
114
+ }