@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,273 @@
1
+ // Settings (the gear in the bottom-right toolbar, or Ctrl+,).
2
+ import { $, el, icon } from './util.js';
3
+ import { filePicker } from './file-picker.js';
4
+ import { on, emit, store, post, request, receiveState } from './store.js';
5
+ import * as canvas from './canvas.js';
6
+
7
+ const scrim = $('#help');
8
+ const dialog = scrim.querySelector('.dialog');
9
+ let returnFocus = null;
10
+ let outputPicker = null;
11
+ let outputDraft = null;
12
+ let pending = false;
13
+ let outputStatus = '';
14
+ const staleOutput = () => outputDraft && Number.isFinite(store.state?.project?.version) && store.state.project.version !== outputDraft.version;
15
+ const outputMessage = () => store.state?.project?.capabilities?.settings === false ? 'The source has an unaccepted external edit. Restore a valid source before changing settings.' : !pending && staleOutput() ? 'Project changed. Your draft is kept; reload current settings to discard it and use the latest version.' : outputStatus;
16
+
17
+ function loadOutput() {
18
+ outputDraft = { value: store.state?.project?.outputDir || '', version: store.state?.project?.version };
19
+ outputStatus = '';
20
+ }
21
+
22
+ async function saveOutput() {
23
+ if (store.state?.project?.capabilities?.settings === false || pending || !outputDraft || !Number.isFinite(outputDraft.version)) return;
24
+ pending = true;
25
+ outputStatus = 'Saving…';
26
+ render();
27
+ try {
28
+ const result = await post('api/project', { expectedVersion: outputDraft.version, patch: { outputDir: outputDraft.value.trim() || null }, label: 'Change build output directory' });
29
+ outputDraft = { value: outputDraft.value.trim(), version: result.version };
30
+ outputStatus = 'Build output saved.';
31
+ receiveState(await request('api/project'));
32
+ } catch (error) {
33
+ outputStatus = `${error.message} Your draft is kept. Reload current settings before trying again if the project changed.`;
34
+ } finally {
35
+ pending = false;
36
+ render();
37
+ if (isOpen()) dialog.querySelector('#settings-output')?.focus({ preventScroll: true });
38
+ }
39
+ }
40
+
41
+ export const isOpen = () => !scrim.hidden;
42
+
43
+ /* Interface size: 100–200%. The chrome (panels, menus, dialogs) is scaled as one; the canvas is not. */
44
+ export const UI_SIZES = [100, 125, 150, 175, 200];
45
+ export const uiPercent = () => Math.round((Number.parseFloat(document.documentElement.style.getPropertyValue('--ui')) || 1) * 100);
46
+ export function setUiSize(percent) {
47
+ const value = UI_SIZES.includes(percent) ? percent / 100 : 1;
48
+ if (value === 1) document.documentElement.style.removeProperty('--ui'); else document.documentElement.style.setProperty('--ui', String(value));
49
+ try { if (value === 1) localStorage.removeItem('divlet:ui'); else localStorage.setItem('divlet:ui', String(value)); } catch {}
50
+ emit('ui-size', value);
51
+ if (isOpen()) { render(); dialog.querySelector(`.seg[data-ui="${Math.round(value * 100)}"]`)?.focus({ preventScroll: true }); }
52
+ window.__divletFit?.();
53
+ requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
54
+ }
55
+ /** One step up or down the list (Ctrl+Alt + / −). */
56
+ export const stepUiSize = direction => setUiSize(UI_SIZES[Math.max(0, Math.min(UI_SIZES.length - 1, UI_SIZES.indexOf(uiPercent()) + direction))]);
57
+
58
+ function theme() {
59
+ try { return localStorage.getItem('divlet:theme') || 'auto'; } catch { return 'auto'; }
60
+ }
61
+
62
+ export function setTheme(value) {
63
+ if (value === 'light' || value === 'dark') document.documentElement.dataset.theme = value;
64
+ else delete document.documentElement.dataset.theme;
65
+ try { if (value === 'auto') localStorage.removeItem('divlet:theme'); else localStorage.setItem('divlet:theme', value); } catch {}
66
+ emit('theme', value);
67
+ if (isOpen()) { render(); dialog.querySelector(`.seg[data-theme="${value}"]`)?.focus({ preventScroll: true }); }
68
+ }
69
+
70
+ /** One settings block: the divider lives between blocks, so every block has the same space above and below the line. */
71
+ function block(content) {
72
+ const node = el('div', 'settings-block');
73
+ node.append(content);
74
+ return node;
75
+ }
76
+
77
+ function render() {
78
+ outputPicker?.dispose();
79
+ outputPicker = null;
80
+ const hadFocus = dialog.contains(document.activeElement);
81
+ const head = el('div', 'dialog-head');
82
+ const title = el('h2', '', 'Settings');
83
+ title.id = 'help-title';
84
+ const close = el('button', 'tool mini', '');
85
+ close.type = 'button';
86
+ close.setAttribute('aria-label', 'Close');
87
+ close.dataset.tip = 'Close · Esc';
88
+ close.append(icon('close'));
89
+ close.addEventListener('click', () => toggle(false));
90
+ head.append(title, close);
91
+
92
+ const themes = el('div', 'segmented');
93
+ themes.setAttribute('role', 'radiogroup');
94
+ themes.setAttribute('aria-label', 'Theme');
95
+ for (const [value, iconName, text] of [['auto', 'auto', 'Auto'], ['light', 'sun', 'Light'], ['dark', 'moon', 'Dark']]) {
96
+ const option = el('button', `seg${theme() === value ? ' on' : ''}`);
97
+ option.type = 'button';
98
+ option.setAttribute('role', 'radio');
99
+ option.setAttribute('aria-checked', String(theme() === value));
100
+ option.dataset.theme = value;
101
+ option.append(icon(iconName), el('span', '', text));
102
+ option.addEventListener('click', () => setTheme(value));
103
+ themes.append(option);
104
+ }
105
+ const appearance = el('div', 'settings-row');
106
+ appearance.append(el('span', 'settings-label', 'Theme'), themes);
107
+
108
+ const sizes = el('div', 'segmented');
109
+ sizes.setAttribute('role', 'radiogroup');
110
+ sizes.setAttribute('aria-label', 'Interface size');
111
+ for (const percent of UI_SIZES) {
112
+ const option = el('button', `seg${uiPercent() === percent ? ' on' : ''}`, `${percent}%`);
113
+ option.type = 'button';
114
+ option.setAttribute('role', 'radio');
115
+ option.setAttribute('aria-checked', String(uiPercent() === percent));
116
+ option.dataset.ui = String(percent);
117
+ option.addEventListener('click', () => setUiSize(percent));
118
+ sizes.append(option);
119
+ }
120
+ const scale = el('div', 'settings-row');
121
+ scale.append(el('span', 'settings-label', 'Interface size'), sizes, el('span', 'caption', 'Menus, panels and text. The canvas keeps its own zoom.'));
122
+
123
+ const handlesOn = canvas.editHandles();
124
+ const handles = el('div', 'segmented');
125
+ handles.setAttribute('role', 'radiogroup');
126
+ handles.setAttribute('aria-label', 'Canvas editing handles');
127
+ for (const [value, text] of [[true, 'On'], [false, 'Off']]) {
128
+ const option = el('button', `seg${handlesOn === value ? ' on' : ''}`, text);
129
+ option.type = 'button';
130
+ option.setAttribute('role', 'radio');
131
+ option.setAttribute('aria-checked', String(handlesOn === value));
132
+ option.dataset.handles = value ? 'on' : 'off';
133
+ option.addEventListener('click', () => { canvas.setEditHandles(value); render(); dialog.querySelector(`.seg[data-handles="${value ? 'on' : 'off'}"]`)?.focus({ preventScroll: true }); });
134
+ handles.append(option);
135
+ }
136
+ const handleRow = el('div', 'settings-row');
137
+ handleRow.append(el('span', 'settings-label', 'Canvas handles'), handles, el('span', 'caption', 'Pills for margin, padding, gap and size, and the grip, on the selected element.'));
138
+
139
+ const output = el('form', 'settings-output');
140
+ output.addEventListener('submit', event => { event.preventDefault(); saveOutput(); });
141
+ output.append(el('h3', '', 'Build output'));
142
+ for (const [label, value] of [['Source file', store.state?.project?.file || store.state?.projectPath || 'Not available'], ['Effective build path', store.state?.outputDir || 'Not available']]) {
143
+ const row = el('div', 'settings-path');
144
+ row.append(el('span', 'settings-label', label), el('code', '', value));
145
+ output.append(row);
146
+ }
147
+ const label = el('label', 'settings-label', 'Relative output directory');
148
+ label.htmlFor = 'settings-output';
149
+ const input = el('input', '');
150
+ input.id = 'settings-output';
151
+ input.type = 'text';
152
+ input.value = outputDraft?.value || '';
153
+ input.disabled = store.state?.project?.capabilities?.settings === false || pending || !Number.isFinite(outputDraft?.version);
154
+ input.autocomplete = 'off';
155
+ input.spellcheck = false;
156
+ input.setAttribute('aria-describedby', 'settings-output-hint settings-output-status');
157
+ input.addEventListener('input', () => { outputDraft.value = input.value; });
158
+ // The path can be typed or picked: the picker walks the same server-side listing the project dialog uses and turns the chosen
159
+ // absolute folder into the relative directory this field wants.
160
+ const browse = el('button', 'tool mini', '');
161
+ browse.type = 'button';
162
+ browse.setAttribute('aria-label', 'Choose the output folder');
163
+ browse.dataset.tip = 'Choose a folder in a list; typing stays possible';
164
+ browse.append(icon('folder'));
165
+ browse.disabled = input.disabled;
166
+ const field = el('div', 'settings-field');
167
+ field.append(input, browse);
168
+ const picker = el('div', 'settings-picker');
169
+ picker.hidden = true;
170
+ browse.addEventListener('click', () => {
171
+ if (!picker.hidden) { outputPicker?.dispose(); outputPicker = null; picker.replaceChildren(); picker.hidden = true; return; }
172
+ picker.hidden = false;
173
+ openPicker();
174
+ });
175
+ const relative = (from, to) => {
176
+ const a = String(from).split(/[\\/]+/).filter(Boolean), b = String(to).split(/[\\/]+/).filter(Boolean);
177
+ let at = 0;
178
+ while (at < a.length && at < b.length && a[at] === b[at]) at++;
179
+ return [...Array(a.length - at).fill('..'), ...b.slice(at)].join('/');
180
+ };
181
+ const openPicker = () => {
182
+ const use = el('button', 'tool', 'Use this folder');
183
+ use.type = 'button';
184
+ use.disabled = true;
185
+ const status = el('p', 'caption file-picker-status');
186
+ status.setAttribute('role', 'status');
187
+ const choose = info => {
188
+ if (!info || info.kind === 'missing') return;
189
+ outputDraft.value = relative(store.state?.projectDir || '.', info.directory || info.path);
190
+ render();
191
+ if (isOpen()) dialog.querySelector('#settings-output')?.focus({ preventScroll: true });
192
+ };
193
+ const browser = filePicker({
194
+ read: path => request(`api/fs?path=${encodeURIComponent(path)}`),
195
+ includeFiles: false,
196
+ label: 'Folder to browse',
197
+ onChange: state => {
198
+ use.disabled = state.pending || !state.info || state.info.kind === 'missing';
199
+ status.textContent = state.error || (state.pending ? 'Loading…' : state.info?.kind === 'missing' ? 'This folder does not exist.' : '');
200
+ },
201
+ onSubmit: choose,
202
+ });
203
+ outputPicker = browser;
204
+ use.addEventListener('click', () => { if (!browser.state.pending) choose(browser.state.info); });
205
+ picker.replaceChildren(browser.element, use, status);
206
+ browser.load(store.state?.projectDir || '.');
207
+ };
208
+ const stem = String(store.state?.project?.file || 'project.divlet').split(/[\\/]/).pop().replace(/\.(?:divlet|pen)$/i, '');
209
+ const hint = el('p', 'caption', `Relative to the source folder. Blank uses .divlet/${stem}. Apply saves explicitly.`);
210
+ hint.id = 'settings-output-hint';
211
+ const actions = el('div', 'settings-actions');
212
+ const apply = el('button', 'tool primary', 'Apply');
213
+ apply.type = 'submit';
214
+ const reset = el('button', 'tool', 'Reset to default');
215
+ reset.type = 'button';
216
+ reset.addEventListener('click', () => { outputDraft.value = ''; saveOutput(); });
217
+ const reload = el('button', 'tool', 'Reload current settings');
218
+ reload.type = 'button';
219
+ reload.addEventListener('click', async () => {
220
+ pending = true;
221
+ outputStatus = 'Loading current settings…';
222
+ render();
223
+ try { receiveState(await request('api/project')); loadOutput(); }
224
+ catch (error) { outputStatus = error.message; }
225
+ finally { pending = false; render(); if (isOpen()) dialog.querySelector('#settings-output')?.focus({ preventScroll: true }); }
226
+ });
227
+ for (const button of [apply, reset, reload]) { button.disabled = pending || !Number.isFinite(outputDraft?.version) || button !== reload && store.state?.project?.capabilities?.settings === false; actions.append(button); }
228
+ const status = el('p', 'caption settings-status', outputMessage());
229
+ status.id = 'settings-output-status';
230
+ status.setAttribute('role', 'status');
231
+ // The status keeps its reserved line above the buttons, so its text never moves them: the actions stay in the bottom-right.
232
+ output.append(label, field, picker, hint, status, actions);
233
+ dialog.replaceChildren(head, block(appearance), block(scale), block(handleRow), block(output));
234
+ if (isOpen() && hadFocus) dialog.querySelector('button:not(:disabled), input:not(:disabled)')?.focus({ preventScroll: true });
235
+ }
236
+
237
+ export function toggle(force) {
238
+ const open = force ?? scrim.hidden;
239
+ if (open === !scrim.hidden) return;
240
+ if (open) {
241
+ returnFocus = document.activeElement;
242
+ if (!outputDraft) loadOutput();
243
+ render();
244
+ scrim.hidden = false;
245
+ dialog.querySelector('.seg.on, button')?.focus({ preventScroll: true });
246
+ } else {
247
+ scrim.hidden = true;
248
+ outputPicker?.dispose();
249
+ outputPicker = null;
250
+ if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
251
+ else $('#viewport').focus({ preventScroll: true });
252
+ }
253
+ }
254
+
255
+ scrim.addEventListener('pointerdown', event => { if (event.target === scrim) toggle(false); });
256
+ scrim.addEventListener('keydown', event => {
257
+ event.stopPropagation();
258
+ if (event.key === 'Escape') { event.preventDefault(); toggle(false); }
259
+ if (event.key === 'Tab') {
260
+ const focusable = [...dialog.querySelectorAll('button:not(:disabled), input:not(:disabled)')].filter(node => node.getClientRects().length);
261
+ const index = focusable.indexOf(document.activeElement);
262
+ event.preventDefault();
263
+ focusable[(index + (event.shiftKey ? -1 : 1) + focusable.length) % focusable.length]?.focus();
264
+ }
265
+ });
266
+ on('help', value => toggle(value));
267
+ on('state', () => {
268
+ if (!isOpen()) return;
269
+ const paths = dialog.querySelectorAll('.settings-path code');
270
+ if (paths[0]) paths[0].textContent = store.state?.project?.file || store.state?.projectPath || 'Not available';
271
+ if (paths[1]) paths[1].textContent = store.state?.outputDir || 'Not available';
272
+ if (!pending) dialog.querySelector('#settings-output-status').textContent = outputMessage();
273
+ });
@@ -0,0 +1,16 @@
1
+ // The native board's composition point. Alternate hosts implement the read-session contract, not synthetic HTTP routes.
2
+ import { createJsonTransport } from './http.js';
3
+ import { createHttpReadSession, createHttpSelectionPublisher, createHttpObservationPublisher } from './http-session.js';
4
+ import { createHttpHistoryService, createHttpSourceUpdates } from './http-services.js';
5
+
6
+ export const base = new URL('./', location.href);
7
+ export const { apiUrl, request, post } = createJsonTransport(base);
8
+ export const readSession = createHttpReadSession({ request, events: () => new EventSource(apiUrl('events')) });
9
+ /** @type {import('@divlet/web/session').BoardSelectionPublisher | undefined} */
10
+ export const shareSelection = createHttpSelectionPublisher(post);
11
+ /** @type {import('@divlet/web/session').BoardObservationPublisher | undefined} */
12
+ export const publishObservations = createHttpObservationPublisher(post);
13
+ /** @type {import('@divlet/web/host').BoardHistoryService | undefined} */
14
+ export const historyService = createHttpHistoryService({ request, post });
15
+ /** @type {import('@divlet/web/host').BoardSourceUpdates | undefined} */
16
+ export const sourceUpdates = createHttpSourceUpdates({ request, post });
@@ -0,0 +1,24 @@
1
+ // @ts-check
2
+ /** @typedef {(path: string) => Promise<unknown>} Request */
3
+ /** @typedef {(path: string, body: unknown) => Promise<unknown>} Post */
4
+
5
+ /** @param {{ request: Request, post: Post }} transport @returns {import('@divlet/web/host').BoardHistoryService} */
6
+ export function createHttpHistoryService({ request, post }) {
7
+ /** @type {import('@divlet/web/host').BoardHistoryService} */
8
+ const service = {
9
+ readRecent: () => request('api/history?limit=100'),
10
+ navigate: target => post('api/history/navigate', target),
11
+ };
12
+ return Object.freeze(service);
13
+ }
14
+
15
+ /** @param {{ request: Request, post: Post }} transport @returns {import('@divlet/web/host').BoardSourceUpdates} */
16
+ export function createHttpSourceUpdates({ request, post }) {
17
+ /** @type {import('@divlet/web/host').BoardSourceUpdates} */
18
+ const service = {
19
+ readStatus: () => request('api/import-status'),
20
+ reimport: () => post('api/reimport', {}),
21
+ openProject: path => post('api/open-project', { path }),
22
+ };
23
+ return Object.freeze(service);
24
+ }
@@ -0,0 +1,45 @@
1
+ // @ts-check
2
+ /** @typedef {import('@divlet/web/session').BoardReadSession} BoardReadSession */
3
+ /** @typedef {import('@divlet/web/session').BoardReadEvent} BoardReadEvent */
4
+ /** @typedef {import('@divlet/web/session').BoardSelectionPublisher} BoardSelectionPublisher */
5
+ /** @typedef {import('@divlet/web/session').BoardObservationPublisher} BoardObservationPublisher */
6
+ /** @typedef {{ addEventListener: (name: string, listener: (event: unknown) => void) => void, close: () => void }} ReadStream */
7
+
8
+ /** Native ephemeral-selection adapter; absence of this capability means browser-local selection.
9
+ * @param {(path: string, body: unknown) => Promise<unknown>} post
10
+ * @returns {BoardSelectionPublisher}
11
+ */
12
+ export const createHttpSelectionPublisher = post => selection => post('api/selection', selection);
13
+
14
+ /** @param {(path: string, body: unknown) => Promise<unknown>} post @returns {BoardObservationPublisher} */
15
+ export const createHttpObservationPublisher = post => report => post('api/observations', report);
16
+
17
+ /**
18
+ * Native HTTP/SSE adapter. Parsing belongs here; model acceptance and bootstrap/plugin ordering stay with the board.
19
+ * @param {{ request: (path: string) => Promise<unknown>, events: () => ReadStream }} options
20
+ * @returns {BoardReadSession}
21
+ */
22
+ export function createHttpReadSession({ request, events }) {
23
+ return Object.freeze({
24
+ readState: () => request('api/project'),
25
+ /** @param {string} id */
26
+ readFrame: id => request(`api/documents/${encodeURIComponent(id)}`),
27
+ /** @param {(event: BoardReadEvent) => void} listener */
28
+ subscribe(listener) {
29
+ const source = events();
30
+ let active = true;
31
+ /** @param {BoardReadEvent} event */
32
+ const emit = event => { if (active) listener(event); };
33
+ source.addEventListener('open', () => emit({ type: 'connection', status: 'connected' }));
34
+ source.addEventListener('error', () => emit({ type: 'connection', status: 'offline' }));
35
+ for (const type of /** @type {const} */ (['state', 'change'])) source.addEventListener(type, event => {
36
+ let value;
37
+ const data = event && typeof event === 'object' && 'data' in event ? event.data : undefined;
38
+ try { value = JSON.parse(typeof data === 'string' ? data : ''); }
39
+ catch { if (type === 'state') emit({ type: 'invalid-state' }); return; }
40
+ emit({ type, value });
41
+ });
42
+ return () => { if (!active) return; active = false; source.close(); };
43
+ },
44
+ });
45
+ }
@@ -0,0 +1,17 @@
1
+ // @ts-check
2
+ /** JSON transport for the native board's existing routes; no state, cache or accepted-model authority. */
3
+ /** @param {URL} base @param {typeof fetch} [fetcher] */
4
+ export function createJsonTransport(base, fetcher = (...args) => fetch(...args)) {
5
+ /** @param {string} path */
6
+ const apiUrl = path => new URL(path, base).href;
7
+ /** @param {string} path @param {RequestInit} [options] @returns {Promise<unknown>} */
8
+ async function request(path, options = {}) {
9
+ const response = await fetcher(apiUrl(path), { ...options, headers: { 'Content-Type': 'application/json', ...options.headers } });
10
+ const body = /** @type {any} */ (await response.json().catch(() => ({})));
11
+ if (!response.ok) throw Object.assign(new Error(body.message || body.error?.message || body.error || `Request failed (${response.status})`), { code: body.error, details: body.details, status: response.status });
12
+ return body;
13
+ }
14
+ /** @param {string} path @param {unknown} body */
15
+ const post = (path, body) => request(path, { method: 'POST', body: JSON.stringify(body) });
16
+ return { apiUrl, request, post };
17
+ }