@divlet/web 0.0.2 → 0.0.3
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.
- package/dist/board.css +2 -0
- package/dist/board.js +10 -8
- package/dist/index.d.ts +1 -1
- package/dist/lib/actions.js +27 -4
- package/dist/lib/artboards.js +5 -4
- package/dist/lib/ask.js +18 -5
- package/dist/lib/board-host.d.ts +54 -0
- package/dist/lib/canvas.js +18 -11
- package/dist/lib/clip.js +2 -1
- package/dist/lib/crumbs.js +10 -5
- package/dist/lib/help.js +3 -1
- package/dist/lib/host.js +27 -7
- package/dist/lib/http-services.js +33 -0
- package/dist/lib/inspector.js +6 -4
- package/dist/lib/mcp-provider.d.ts +70 -0
- package/dist/lib/mcp-provider.js +179 -0
- package/dist/lib/mcp-tools.d.ts +71 -0
- package/dist/lib/mcp-tools.js +277 -0
- package/dist/lib/palette.js +3 -2
- package/dist/lib/store.js +12 -9
- package/dist/lib/webmcp.d.ts +34 -0
- package/dist/lib/webmcp.js +37 -0
- package/package.json +5 -1
package/dist/board.css
CHANGED
|
@@ -718,6 +718,8 @@ html.w440 .shortcut-group dd { text-align: left; padding-bottom: 5px; white-spac
|
|
|
718
718
|
.crumbs-bar .crumb { height: 24px; flex: none; max-width: 100%; }
|
|
719
719
|
.crumbs-bar .crumb.home { flex: 0 1 auto; min-width: 0; }
|
|
720
720
|
.crumb.current { background: var(--field); color: var(--ink); }
|
|
721
|
+
/* The next level after a selected artboard: a step you can take, quieter than the current crumb. */
|
|
722
|
+
.crumb.root-entry { color: var(--muted); }
|
|
721
723
|
.ask-more { padding: 0 6px; font: 11px var(--mono); color: var(--muted); }
|
|
722
724
|
.ask-input { width: 100%; min-height: 40px; max-height: 220px; padding: 9px 11px; border: 0; border-radius: var(--radius-inner); background: var(--field); color: var(--ink); font: 13.5px/1.45 var(--font); resize: none; outline: none; }
|
|
723
725
|
.ask-input:focus { outline-offset: 1px; outline: var(--focus-ring) solid var(--accent); }
|
package/dist/board.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { $, el, icon, button, clamp, keys, shortcutKey, uiScale } from './lib/util.js';
|
|
2
2
|
import { store, on, emit, documents, descriptor, apiUrl, request, receiveState, setConnection, selectArtboards, selectNodes, undo, redo, toast } from './lib/store.js';
|
|
3
3
|
import * as canvas from './lib/canvas.js';
|
|
4
|
-
import { readSession } from './lib/host.js';
|
|
4
|
+
import { readSession, native } from './lib/host.js';
|
|
5
5
|
import './lib/inspector.js';
|
|
6
6
|
import { loadClientPlugins } from './lib/panels.js';
|
|
7
7
|
import * as layers from './lib/layers.js';
|
|
@@ -103,10 +103,12 @@ $('#status').setAttribute('aria-haspopup', 'menu');
|
|
|
103
103
|
$('#status').addEventListener('click', () => {
|
|
104
104
|
openMenu([
|
|
105
105
|
{ heading: String(store.state?.project?.name || 'Project') },
|
|
106
|
-
|
|
107
|
-
{ label: '
|
|
108
|
-
'
|
|
109
|
-
|
|
106
|
+
// A browser host has no filesystem: opening, copying and folder paths belong to the local host.
|
|
107
|
+
native && { label: 'Open project…', icon: 'folder', keys: keys('Mod+O'), run: () => project.show('open') },
|
|
108
|
+
native && { label: 'Save a copy as…', icon: 'download', disabled: !project.canShowCopy(), keys: keys('Mod+Shift+S'), run: () => project.show('save') },
|
|
109
|
+
native && '-',
|
|
110
|
+
native && { label: 'Copy project folder path', icon: 'copy', run: () => actions.copyText(String(store.state?.projectDir || ''), 'Folder path copied') },
|
|
111
|
+
!native && { label: 'Download project file', icon: 'download', run: () => actions.downloadProject() },
|
|
110
112
|
], { anchor: $('#status'), label: 'Project' });
|
|
111
113
|
});
|
|
112
114
|
$('#empty-new').addEventListener('click', () => actions.newArtboard());
|
|
@@ -124,7 +126,7 @@ on('state', state => {
|
|
|
124
126
|
const doc = descriptor(store.expanded.entry.doc.id);
|
|
125
127
|
if (!doc) canvas.closeExpanded({ reset: false });
|
|
126
128
|
else {
|
|
127
|
-
$('#expanded-title').textContent = doc.name || doc.id;
|
|
129
|
+
$('#expanded-title').textContent = store.expanded.title || doc.name || doc.id;
|
|
128
130
|
if (state.site) store.expanded.entry.iframe?.contentWindow.postMessage({ type: 'divlet:site-state', site: { version: state.site.version } }, '*');
|
|
129
131
|
}
|
|
130
132
|
}
|
|
@@ -223,8 +225,8 @@ function handleKey(event) {
|
|
|
223
225
|
if (menuOpen()) { if (key === 'escape') { prevent(); closeMenu(); return true; } return false; }
|
|
224
226
|
if (mod && event.altKey && (key === '=' || key === '+' || key === '-')) { prevent(); help.stepUiSize(key === '-' ? -1 : 1); return true; }
|
|
225
227
|
if (mod && !event.altKey && !event.shiftKey && (key === ',' || event.code === 'Comma')) { prevent(); help.toggle(true); return true; }
|
|
226
|
-
if (mod && !event.altKey && key === 'o') { prevent(); project.show('open'); return true; }
|
|
227
|
-
if (mod && event.shiftKey && !event.altKey && key === 's') { prevent(); project.show('save'); return true; }
|
|
228
|
+
if (native && mod && !event.altKey && key === 'o') { prevent(); project.show('open'); return true; }
|
|
229
|
+
if (native && mod && event.shiftKey && !event.altKey && key === 's') { prevent(); project.show('save'); return true; }
|
|
228
230
|
if (mod && !event.altKey && key === 'k') { prevent(); palette.toggle(true); return true; }
|
|
229
231
|
// Copy, cut and paste from the board run through the native clipboard events (see clip.js);
|
|
230
232
|
// keys forwarded by a preview frame have no clipboard event, so they act on the board's own copy.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export declare const clientRoot: URL;
|
|
2
2
|
export { createSnapshotReadSession } from './lib/board-session.ts';
|
|
3
3
|
export type { BoardReadSession, BoardReadEvent, BoardSelection, BoardSelectionPublisher, BoardObservation, BoardObservationPublisher } from './lib/board-session.ts';
|
|
4
|
-
export type { BoardHistoryService, BoardSourceUpdates } from './lib/board-host.ts';
|
|
4
|
+
export type { BoardHistoryService, BoardSourceUpdates, BoardWriter, BoardPreviews, BoardComponents, BoardExports, BoardHostComposition } from './lib/board-host.ts';
|
package/dist/lib/actions.js
CHANGED
|
@@ -4,6 +4,7 @@ import * as canvas from './canvas.js';
|
|
|
4
4
|
import { openMenu } from './menu.js';
|
|
5
5
|
import * as clip from './clip.js';
|
|
6
6
|
import { newArtboard, duplicateArtboards, deleteArtboards } from './artboards.js';
|
|
7
|
+
import { components as componentSource, exports as exportSource, native } from './host.js';
|
|
7
8
|
export { newArtboard, duplicateArtboards, deleteArtboards };
|
|
8
9
|
|
|
9
10
|
/* Command facade for the toolbar, keyboard, context menus, inspector and layers.
|
|
@@ -41,22 +42,44 @@ export async function copyHtml(documentId, nodeIds) {
|
|
|
41
42
|
const model = await modelOf(documentId);
|
|
42
43
|
const ids = nodeIds.filter(id => model.nodes[id]);
|
|
43
44
|
text = clip.htmlOf(model, ids);
|
|
44
|
-
} else text = (await
|
|
45
|
+
} else if (exportSource) text = (await exportSource.page(documentId)).html;
|
|
46
|
+
else throw new Error('This host cannot export HTML.');
|
|
45
47
|
if (!document.hasFocus()) document.querySelector('#viewport')?.focus({ preventScroll: true });
|
|
46
48
|
await navigator.clipboard.writeText(text);
|
|
47
49
|
toast(nodeIds?.length ? 'HTML copied' : `Page HTML copied · ${Math.max(1, Math.round(text.length / 1024))} KB with CSS`);
|
|
48
50
|
} catch (error) { toast(error?.name === 'NotAllowedError' ? 'The clipboard is not available here' : error.message); }
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
/** Save the browser host's copy of the project as a .divlet file the user can open with the local host. */
|
|
54
|
+
export async function downloadProject() {
|
|
55
|
+
try {
|
|
56
|
+
const { text, fileName } = await exportSource.project();
|
|
57
|
+
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
|
58
|
+
const link = Object.assign(document.createElement('a'), { href: url, download: fileName });
|
|
59
|
+
document.body.append(link);
|
|
60
|
+
link.click();
|
|
61
|
+
link.remove();
|
|
62
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
63
|
+
toast(`Downloading ${fileName} · open it with npx -y @divlet/cli ${fileName}`);
|
|
64
|
+
} catch (error) { toast(error?.message || 'This host cannot save the project.'); }
|
|
65
|
+
}
|
|
66
|
+
|
|
51
67
|
/** Save an artboard as a standalone HTML file. */
|
|
52
|
-
export function downloadHtml(documentId) {
|
|
68
|
+
export async function downloadHtml(documentId) {
|
|
53
69
|
const link = document.createElement('a');
|
|
54
|
-
|
|
70
|
+
let blob = null;
|
|
71
|
+
if (native) link.href = apiUrl(`api/export/${encodeURIComponent(documentId)}?download=1`);
|
|
72
|
+
else {
|
|
73
|
+
// A browser host builds the file itself; nothing is requested from a server.
|
|
74
|
+
try { const page = await exportSource.page(documentId); blob = URL.createObjectURL(new Blob([page.html], { type: 'text/html' })); link.href = blob; }
|
|
75
|
+
catch (error) { toast(error.message); return; }
|
|
76
|
+
}
|
|
55
77
|
const fileName = descriptor(documentId)?.htmlPath?.split(/[\\/]/).at(-1) || `${documentId}.html`;
|
|
56
78
|
link.download = fileName;
|
|
57
79
|
document.body.append(link);
|
|
58
80
|
link.click();
|
|
59
81
|
link.remove();
|
|
82
|
+
if (blob) setTimeout(() => URL.revokeObjectURL(blob), 1000);
|
|
60
83
|
toast(`Downloading ${fileName}`);
|
|
61
84
|
}
|
|
62
85
|
|
|
@@ -338,7 +361,7 @@ export async function selectSibling(step) {
|
|
|
338
361
|
export async function insertMenu(documentId, position) {
|
|
339
362
|
if (!documentId) { toast('Select an artboard first'); return; }
|
|
340
363
|
let components = [];
|
|
341
|
-
try { components = (await
|
|
364
|
+
try { if (componentSource) components = (await componentSource.list()).components; } catch {}
|
|
342
365
|
openMenu([
|
|
343
366
|
{ heading: store.node?.documentId === documentId ? 'Add after or inside the selection' : `Add to “${nameOf(documentId)}”` },
|
|
344
367
|
...insertionPresets().map(item => (item === '-' ? '-' : { label: item[2], icon: item[1], run: () => insertElement(documentId, item[0], item[2]) })),
|
package/dist/lib/artboards.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Artboard lifecycle commands; selection, reveal, grouping and failure feedback stay with each accepted operation.
|
|
2
|
-
import { descriptor,
|
|
2
|
+
import { descriptor, undo, toast, newGroup, selectArtboards, whenDocument, leave } from './store.js';
|
|
3
|
+
import { writer } from './host.js';
|
|
3
4
|
import * as canvas from './canvas.js';
|
|
4
5
|
|
|
5
6
|
export async function newArtboard({ x, y, width, height, name } = {}) {
|
|
@@ -8,7 +9,7 @@ export async function newArtboard({ x, y, width, height, name } = {}) {
|
|
|
8
9
|
if (name) body.name = name;
|
|
9
10
|
if (Number.isFinite(x) && Number.isFinite(y)) Object.assign(body, { x: Math.round(x), y: Math.round(y) });
|
|
10
11
|
try {
|
|
11
|
-
const result = await
|
|
12
|
+
const result = await writer.createArtboard(body);
|
|
12
13
|
await whenDocument(result.documentId);
|
|
13
14
|
selectArtboards([result.documentId]);
|
|
14
15
|
canvas.reveal(result.documentId);
|
|
@@ -21,7 +22,7 @@ export async function duplicateArtboards(ids) {
|
|
|
21
22
|
const group = newGroup();
|
|
22
23
|
const created = [];
|
|
23
24
|
for (const from of ids) {
|
|
24
|
-
try { created.push((await
|
|
25
|
+
try { created.push((await writer.createArtboard({ from, group, expectedVersion: descriptor(from)?.version, label: ids.length > 1 ? `duplicate ${ids.length} artboards` : 'duplicate artboard' })).documentId); }
|
|
25
26
|
catch (error) { toast(error.message); }
|
|
26
27
|
}
|
|
27
28
|
if (!created.length) return;
|
|
@@ -49,7 +50,7 @@ export async function deleteArtboards(ids) {
|
|
|
49
50
|
}
|
|
50
51
|
leave(order.map(doc => doc.id));
|
|
51
52
|
for (const doc of order) {
|
|
52
|
-
try { await
|
|
53
|
+
try { await writer.deleteArtboard({ documentId: doc.id, group, expectedVersion: doc.version, label: order.length > 1 ? `delete ${order.length} artboards` : `delete “${doc.name}”` }); removed.push(doc); }
|
|
53
54
|
catch (error) { leave([doc.id], true); toast(error.message); }
|
|
54
55
|
}
|
|
55
56
|
if (removed.length) toast(removed.length > 1 ? `${removed.length} artboards deleted` : `“${removed[0].name}” deleted`, { label: 'Undo', run: () => undo() });
|
package/dist/lib/ask.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { $, el, icon, button, keys } from './util.js';
|
|
2
2
|
import { openMenu } from './menu.js';
|
|
3
3
|
import { store, on, emit, post, toast, descriptor } from './store.js';
|
|
4
|
+
import { native } from './host.js';
|
|
4
5
|
import * as sidebar from './sidebar.js';
|
|
5
6
|
import * as chat from './chat.js';
|
|
6
7
|
|
|
@@ -16,7 +17,10 @@ let draft = '';
|
|
|
16
17
|
let watchdog = 0;
|
|
17
18
|
let returnFocus = null;
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
/** A board request needs a host that relays conversation; agents of a browser host work through its MCP tools only. */
|
|
21
|
+
export const available = () => !!store.state?.agent?.connected && store.state.agent.conversation !== false;
|
|
22
|
+
/** The command an MCP host runs to connect an agent to this page (browser hosts only). */
|
|
23
|
+
const connectCommand = () => `npx -y @divlet/mcp --connect ${new URL('./', location.href).href}`;
|
|
20
24
|
export const isOpen = () => sidebar.isOpen('agent');
|
|
21
25
|
const agent = () => store.state?.agent || {};
|
|
22
26
|
|
|
@@ -162,11 +166,19 @@ function paintStatus() {
|
|
|
162
166
|
input.disabled = !available();
|
|
163
167
|
// The sidebar can be dragged narrow, so the texts follow its own width (sidebar.js measures it).
|
|
164
168
|
const tight = (document.documentElement.dataset.sidebar ?? 'wide') !== 'wide';
|
|
165
|
-
input.placeholder = !
|
|
169
|
+
input.placeholder = !native ? (tight ? 'Agents connect over MCP' : 'Agents work on this page through its MCP tools')
|
|
170
|
+
: !available() ? (tight ? 'Open from Pi (/divlet)' : 'Open this board from Pi (/divlet) to talk to it')
|
|
166
171
|
: tight ? 'Ask the agent…' : 'Ask the agent to change the layout…';
|
|
167
172
|
const keysHint = tight ? [`${keys('Enter')} send`] : [`${keys('Enter')} send`, `${keys('Shift+Enter')} new line`, '↑ previous'];
|
|
168
173
|
// Each key with its meaning is one unit: a narrow composer wraps between units, never inside one.
|
|
169
|
-
if (!
|
|
174
|
+
if (!native) {
|
|
175
|
+
// A browser host: no conversation relay; offer the command that connects an MCP host to this very page.
|
|
176
|
+
const copy = button('copy', 'Copy the MCP command that connects an agent to this page', async () => {
|
|
177
|
+
try { await navigator.clipboard.writeText(connectCommand()); toast('Copied · register it as an MCP server in your agent'); }
|
|
178
|
+
catch { toast(connectCommand()); }
|
|
179
|
+
}, 'mini ask-copy');
|
|
180
|
+
hint.replaceChildren(el('span', '', store.state?.agent?.connected ? 'An agent is working through this page' : 'Connect an agent to this page'), copy);
|
|
181
|
+
} else if (!available()) {
|
|
170
182
|
// Not connected: say so, and offer the exact command that connects this project in Pi.
|
|
171
183
|
const file = store.state?.project?.file;
|
|
172
184
|
const copy = button('copy', 'Copy the command that opens this project in Pi', async () => {
|
|
@@ -193,7 +205,7 @@ function focusInput() {
|
|
|
193
205
|
|
|
194
206
|
export function toggle(open = !isOpen(), { text, fromFrame = false } = {}) {
|
|
195
207
|
if (open && !available()) {
|
|
196
|
-
toast('No agent is connected to this board. Open it from Pi with /divlet, or connect an MCP client (divlet-mcp).');
|
|
208
|
+
toast(native ? 'No agent is connected to this board. Open it from Pi with /divlet, or connect an MCP client (divlet-mcp).' : `Agents connect to this page over MCP: ${connectCommand()}`);
|
|
197
209
|
return false;
|
|
198
210
|
}
|
|
199
211
|
if (open === isOpen()) { if (open) { if (text !== undefined) { input.value = text; grow(); } input.focus(); } return open; }
|
|
@@ -324,7 +336,8 @@ function paintChip() {
|
|
|
324
336
|
const state = agent();
|
|
325
337
|
if (!state.busy) stopping = false;
|
|
326
338
|
status.hidden = !state.busy;
|
|
327
|
-
|
|
339
|
+
// Only a host that relays the conversation can stop its agent; a browser host's agent is stopped by its own host.
|
|
340
|
+
stopButton.hidden = !state.busy || !native;
|
|
328
341
|
placeStatus();
|
|
329
342
|
status.classList.toggle('stopping', stopping);
|
|
330
343
|
document.body.classList.toggle('agent-busy', !!state.busy);
|
package/dist/lib/board-host.d.ts
CHANGED
|
@@ -12,3 +12,57 @@ export interface BoardSourceUpdates {
|
|
|
12
12
|
readonly reimport: () => Promise<unknown>;
|
|
13
13
|
readonly openProject: (path: string) => Promise<unknown>;
|
|
14
14
|
}
|
|
15
|
+
/** The board's project writes. Each method takes the request body the native route takes and resolves with its accepted result. */
|
|
16
|
+
export interface BoardWriter {
|
|
17
|
+
readonly patch: (body: unknown) => Promise<unknown>;
|
|
18
|
+
readonly edit: (body: unknown) => Promise<unknown>;
|
|
19
|
+
readonly undo: (body: unknown) => Promise<unknown>;
|
|
20
|
+
readonly redo: (body: unknown) => Promise<unknown>;
|
|
21
|
+
readonly createArtboard: (body: unknown) => Promise<unknown>;
|
|
22
|
+
readonly deleteArtboard: (body: unknown) => Promise<unknown>;
|
|
23
|
+
}
|
|
24
|
+
/** Where frame previews come from: an iframe address carrying the bridge token, and later renders delivered over its channel. */
|
|
25
|
+
export interface BoardPreviews {
|
|
26
|
+
readonly frameUrl: (id: string, options: {
|
|
27
|
+
readonly revision?: number;
|
|
28
|
+
readonly bridge: string;
|
|
29
|
+
}) => string;
|
|
30
|
+
readonly readRender: (id: string, options?: {
|
|
31
|
+
readonly signal?: AbortSignal;
|
|
32
|
+
}) => Promise<unknown>;
|
|
33
|
+
/** Called when a preview iframe is discarded, so a host can release what `frameUrl` allocated. */
|
|
34
|
+
readonly release?: (id: string, url: string) => void;
|
|
35
|
+
}
|
|
36
|
+
/** Clean standalone HTML of one frame (`{ html, fileName }`), for Copy/Download HTML. */
|
|
37
|
+
export interface BoardExports {
|
|
38
|
+
readonly page: (id: string) => Promise<unknown>;
|
|
39
|
+
/** The whole project source (`{ text, fileName }`), where the host's copy is not already a file the user owns. */
|
|
40
|
+
readonly project?: () => Promise<unknown>;
|
|
41
|
+
}
|
|
42
|
+
/** The project's reusable components, for insertion menus. Absent hosts offer no component insertion list. */
|
|
43
|
+
export interface BoardComponents {
|
|
44
|
+
readonly list: () => Promise<unknown>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A complete alternative composition, published before the board starts at `globalThis[Symbol.for('divlet.board-host/1')]`.
|
|
48
|
+
* Absent members are absent capabilities; nothing falls back to the native HTTP routes. `label` names the host in the chrome.
|
|
49
|
+
*/
|
|
50
|
+
export interface BoardHostComposition {
|
|
51
|
+
readonly label: string;
|
|
52
|
+
readonly readSession: import('./board-session.ts').BoardReadSession;
|
|
53
|
+
readonly writer: BoardWriter;
|
|
54
|
+
readonly previews: BoardPreviews;
|
|
55
|
+
readonly components?: BoardComponents;
|
|
56
|
+
readonly exports?: BoardExports;
|
|
57
|
+
readonly historyService?: BoardHistoryService;
|
|
58
|
+
readonly shareSelection?: import('./board-session.ts').BoardSelectionPublisher;
|
|
59
|
+
readonly publishObservations?: import('./board-session.ts').BoardObservationPublisher;
|
|
60
|
+
/**
|
|
61
|
+
* What a visitor with no saved view of this project sees first: `expand` opens that frame as a readable page, and `title`
|
|
62
|
+
* names the way back to the board. Absent: the board fits all frames, as the native host does.
|
|
63
|
+
*/
|
|
64
|
+
readonly initialView?: {
|
|
65
|
+
readonly expand: string;
|
|
66
|
+
readonly title?: string;
|
|
67
|
+
};
|
|
68
|
+
}
|
package/dist/lib/canvas.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { $, $$, el, icon, clamp, round } from './util.js';
|
|
2
|
-
import { publishObservations } from './host.js';
|
|
2
|
+
import { publishObservations, previews, initialView } from './host.js';
|
|
3
3
|
import { store, emit, on, documents, descriptor, request, post, apiUrl, commit, edit, idle, undo, newGroup, selectArtboards, selectNodes, toast, viewState, saveViewState } from './store.js';
|
|
4
4
|
|
|
5
5
|
const PREFETCH_TARGET = 8;
|
|
@@ -437,7 +437,11 @@ export function reconcileArtboards() {
|
|
|
437
437
|
initialFit = true;
|
|
438
438
|
const saved = viewState(store.state.projectId);
|
|
439
439
|
if (saved && Number.isFinite(saved.k) && Number.isFinite(saved.x) && Number.isFinite(saved.y)) setCamera(saved);
|
|
440
|
-
else
|
|
440
|
+
else {
|
|
441
|
+
fit(null, { animate: false });
|
|
442
|
+
// A first visit to a published board reads the page itself; the board is one step back.
|
|
443
|
+
if (initialView?.expand && descriptor(initialView.expand)) requestAnimationFrame(() => openExpanded(initialView.expand, undefined, { title: initialView.title }));
|
|
444
|
+
}
|
|
441
445
|
}
|
|
442
446
|
updateHandles();
|
|
443
447
|
applyCamera();
|
|
@@ -499,6 +503,8 @@ function deliverEdit(entry) {
|
|
|
499
503
|
entry.port?.postMessage({ type: 'divlet:edit', nodeId: pending.nodeId });
|
|
500
504
|
}
|
|
501
505
|
|
|
506
|
+
/** An expanded frame browses its published page (no delivery) unless the host has none, where it reads the live preview. */
|
|
507
|
+
const browsingPublished = entry => store.expanded?.entry === entry && !!entry.doc.viewUrl;
|
|
502
508
|
const needsEagerLoad = entry => store.expanded?.entry === entry || store.selected.has(entry.doc.id);
|
|
503
509
|
|
|
504
510
|
function inspectFlag() { return !store.expanded && store.mode === 'select'; }
|
|
@@ -514,7 +520,7 @@ function mount(entry, { revision = false } = {}) {
|
|
|
514
520
|
frame.referrerPolicy = 'no-referrer';
|
|
515
521
|
// The browser owns when an off-screen page loads; frames are never torn down and rebuilt by scrolling or zooming.
|
|
516
522
|
frame.loading = needsEagerLoad(entry) ? 'eager' : 'lazy';
|
|
517
|
-
frame.src =
|
|
523
|
+
frame.src = previews.frameUrl(entry.doc.id, { revision: revision ? entry.doc.render : undefined, bridge: entry.bridgeToken });
|
|
518
524
|
frame.addEventListener('load', () => {
|
|
519
525
|
if (entry.iframe !== frame) return;
|
|
520
526
|
entry.loaded = true;
|
|
@@ -522,7 +528,7 @@ function mount(entry, { revision = false } = {}) {
|
|
|
522
528
|
entry.port?.postMessage({ type: 'divlet:inspect', enabled: inspectFlag() });
|
|
523
529
|
syncNodeSelection(entry);
|
|
524
530
|
syncWork(entry);
|
|
525
|
-
if (
|
|
531
|
+
if (browsingPublished(entry)) return;
|
|
526
532
|
updateFrame(entry);
|
|
527
533
|
});
|
|
528
534
|
entry.iframe = frame;
|
|
@@ -555,6 +561,7 @@ function unmount(entry) {
|
|
|
555
561
|
entry.abort?.abort();
|
|
556
562
|
entry.abort = null;
|
|
557
563
|
entry.port?.close();
|
|
564
|
+
previews.release?.(entry.doc.id, entry.iframe.src);
|
|
558
565
|
entry.iframe.remove();
|
|
559
566
|
Object.assign(entry, { iframe: null, port: null, bridgeToken: null, loaded: false, renderedVersion: 0, awaitingVersion: 0, pendingFetch: false, latestReport: null });
|
|
560
567
|
clearTimeout(entry.reportTimer);
|
|
@@ -567,15 +574,15 @@ async function updateFrame(entry) {
|
|
|
567
574
|
// While a pill is being dragged the page shows its own inline preview; delivering a render would replace the DOM under
|
|
568
575
|
// the pointer. The model still moves (the drag commits live) and this frame catches up when the drag ends.
|
|
569
576
|
if (gesture?.type === 'space' && gesture.id === entry.doc.id) return;
|
|
570
|
-
if (
|
|
577
|
+
if (browsingPublished(entry) || !entry.iframe || !entry.port || !entry.loaded || entry.pendingFetch || entry.awaitingVersion >= entry.doc.render || entry.renderedVersion >= entry.doc.render) return;
|
|
571
578
|
const frame = entry.iframe;
|
|
572
579
|
const generation = entry.deliveryGeneration;
|
|
573
580
|
entry.pendingFetch = true;
|
|
574
581
|
entry.abort = new AbortController();
|
|
575
582
|
setFrameStatus(entry, 'updating');
|
|
576
583
|
try {
|
|
577
|
-
const rendered = await
|
|
578
|
-
if (entry.iframe !== frame || generation !== entry.deliveryGeneration ||
|
|
584
|
+
const rendered = await previews.readRender(entry.doc.id, { signal: entry.abort.signal });
|
|
585
|
+
if (entry.iframe !== frame || generation !== entry.deliveryGeneration || browsingPublished(entry) || rendered.documentId !== entry.doc.id || rendered.render < entry.renderedVersion) return;
|
|
579
586
|
entry.awaitingVersion = rendered.render;
|
|
580
587
|
entry.port?.postMessage({ type: 'divlet:update', documentId: entry.doc.id, version: rendered.render, html: rendered.html });
|
|
581
588
|
} catch (error) {
|
|
@@ -706,10 +713,10 @@ function cancelDelivery(entry) {
|
|
|
706
713
|
entry.reportTimer = null;
|
|
707
714
|
}
|
|
708
715
|
|
|
709
|
-
export function openExpanded(documentId, focusTarget) {
|
|
716
|
+
export function openExpanded(documentId, focusTarget, { title } = {}) {
|
|
710
717
|
const entry = entries.get(documentId);
|
|
711
718
|
if (!entry || store.expanded) return;
|
|
712
|
-
store.expanded = { entry, mode: store.mode, focusTarget, camera: { ...camera } };
|
|
719
|
+
store.expanded = { entry, mode: store.mode, focusTarget, camera: { ...camera }, title };
|
|
713
720
|
cancelDelivery(entry);
|
|
714
721
|
store.node = null;
|
|
715
722
|
selectArtboards([documentId]);
|
|
@@ -729,11 +736,11 @@ export function openExpanded(documentId, focusTarget) {
|
|
|
729
736
|
entry.bridgeToken = crypto.randomUUID();
|
|
730
737
|
entry.loaded = false;
|
|
731
738
|
entry.iframe.src = viewUrl;
|
|
732
|
-
} else entry.port?.postMessage({ type: 'divlet:inspect', enabled: false });
|
|
739
|
+
} else { entry.port?.postMessage({ type: 'divlet:inspect', enabled: false }); updateFrame(entry); }
|
|
733
740
|
const overlay = $('#expanded-preview');
|
|
734
741
|
overlay.hidden = false;
|
|
735
742
|
overlay.setAttribute('aria-owns', entry.iframe.id);
|
|
736
|
-
$('#expanded-title').textContent = entry.doc.name || entry.doc.id;
|
|
743
|
+
$('#expanded-title').textContent = title || entry.doc.name || entry.doc.id;
|
|
737
744
|
setChromeInert(true, entry);
|
|
738
745
|
document.body.classList.add('is-expanded');
|
|
739
746
|
$('#expanded-back').focus({ preventScroll: true });
|
package/dist/lib/clip.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { store, documents, descriptor, edit, toast, cachedModel, loadModel, selectNodes, parentOf, canContain, apiUrl, request } from './store.js';
|
|
1
|
+
import { store, capable, documents, descriptor, edit, toast, cachedModel, loadModel, selectNodes, parentOf, canContain, apiUrl, request } from './store.js';
|
|
2
2
|
import * as canvas from './canvas.js';
|
|
3
3
|
import { duplicateArtboards } from './artboards.js';
|
|
4
4
|
|
|
@@ -198,6 +198,7 @@ async function upload(file, documentId, group) {
|
|
|
198
198
|
|
|
199
199
|
/** Store images in the project and add an <img> for each at the paste/drop target. */
|
|
200
200
|
export async function addImages(files, documentId) {
|
|
201
|
+
if (!capable('assets')) { toast('Images need the local divlet host: this browser copy stores no image files.'); return; }
|
|
201
202
|
if (documentId && !(store.selected.size === 1 && store.selected.has(documentId))) {
|
|
202
203
|
const { selectArtboards } = await import('./store.js');
|
|
203
204
|
selectArtboards([documentId]);
|
package/dist/lib/crumbs.js
CHANGED
|
@@ -6,8 +6,9 @@ import { openMenu } from './menu.js';
|
|
|
6
6
|
import { selectRoot } from './actions.js';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Fill the bottom path for `ctx` ({ ids, nodes? }).
|
|
10
|
-
*
|
|
9
|
+
* Fill the bottom path for `ctx` ({ ids, nodes? }). The artboard is always the first crumb; with only the artboard selected
|
|
10
|
+
* it is the current one, followed by a `root` crumb that previews the next level and selects the root element.
|
|
11
|
+
* Container crumbs select ancestors; the artboard crumb selects the whole artboard again.
|
|
11
12
|
* `after()` runs after activation; `repaint()` is called when element data arrives late.
|
|
12
13
|
*/
|
|
13
14
|
export function pathInto(container, ctx, { after, repaint, all = false } = {}) {
|
|
@@ -23,10 +24,14 @@ export function pathInto(container, ctx, { after, repaint, all = false } = {}) {
|
|
|
23
24
|
const sep = () => el('i', 'crumb-sep');
|
|
24
25
|
if (!ctx.nodes) {
|
|
25
26
|
if (ctx.ids.length === 1) {
|
|
26
|
-
const
|
|
27
|
+
const id = ctx.ids[0];
|
|
28
|
+
const home = crumb('', () => {}, 'The selected artboard', 'home current');
|
|
29
|
+
home.append(icon('artboard'), el('span', '', descriptor(id)?.name || id));
|
|
30
|
+
// The one exception to "the path ends at the selection": the next level is shown, so the way into the page is visible.
|
|
31
|
+
const root = crumb('', () => selectRoot(id), `Select the root element of ${descriptor(id)?.name || id}`, 'root-entry');
|
|
27
32
|
root.setAttribute('aria-label', 'Select root element');
|
|
28
|
-
root.append(icon('inspect'));
|
|
29
|
-
container.append(root);
|
|
33
|
+
root.append(icon('inspect'), el('span', '', 'root'));
|
|
34
|
+
container.append(home, sep(), root);
|
|
30
35
|
} else container.append(crumb(`${ctx.ids.length} artboards`, () => {}, 'These artboards', 'current'));
|
|
31
36
|
return;
|
|
32
37
|
}
|
package/dist/lib/help.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { $, el, icon } from './util.js';
|
|
3
3
|
import { filePicker } from './file-picker.js';
|
|
4
4
|
import { on, emit, store, post, request, receiveState } from './store.js';
|
|
5
|
+
import { native } from './host.js';
|
|
5
6
|
import * as canvas from './canvas.js';
|
|
6
7
|
|
|
7
8
|
const scrim = $('#help');
|
|
@@ -230,7 +231,8 @@ function render() {
|
|
|
230
231
|
status.setAttribute('role', 'status');
|
|
231
232
|
// The status keeps its reserved line above the buttons, so its text never moves them: the actions stay in the bottom-right.
|
|
232
233
|
output.append(label, field, picker, hint, status, actions);
|
|
233
|
-
|
|
234
|
+
// The build output directory is a filesystem setting of the local host; a browser host has none.
|
|
235
|
+
dialog.replaceChildren(head, block(appearance), block(scale), block(handleRow), ...(native ? [block(output)] : []));
|
|
234
236
|
if (isOpen() && hadFocus) dialog.querySelector('button:not(:disabled), input:not(:disabled)')?.focus({ preventScroll: true });
|
|
235
237
|
}
|
|
236
238
|
|
package/dist/lib/host.js
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
|
-
//
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The board's composition point. The native host composes its HTTP/SSE adapters here; an alternative host publishes a complete
|
|
3
|
+
// `BoardHostComposition` before the board starts, and then no native route is used or synthesized.
|
|
2
4
|
import { createJsonTransport } from './http.js';
|
|
3
5
|
import { createHttpReadSession, createHttpSelectionPublisher, createHttpObservationPublisher } from './http-session.js';
|
|
4
|
-
import { createHttpHistoryService, createHttpSourceUpdates } from './http-services.js';
|
|
6
|
+
import { createHttpHistoryService, createHttpSourceUpdates, createHttpWriter, createHttpPreviews, createHttpComponents, createHttpExports } from './http-services.js';
|
|
7
|
+
|
|
8
|
+
/** @type {import('@divlet/web/host').BoardHostComposition | undefined} */
|
|
9
|
+
const composed = /** @type {any} */ (globalThis)[Symbol.for('divlet.board-host/1')];
|
|
10
|
+
/** True when the board talks to the local divlet host over its token-scoped routes. */
|
|
11
|
+
export const native = !composed;
|
|
12
|
+
/** The host's display name, for chrome that says where the project lives. */
|
|
13
|
+
export const hostLabel = composed?.label ?? 'local';
|
|
5
14
|
|
|
6
15
|
export const base = new URL('./', location.href);
|
|
7
16
|
export const { apiUrl, request, post } = createJsonTransport(base);
|
|
8
|
-
|
|
17
|
+
/** @type {import('@divlet/web/session').BoardReadSession} */
|
|
18
|
+
export const readSession = composed ? composed.readSession : createHttpReadSession({ request, events: () => new EventSource(apiUrl('events')) });
|
|
9
19
|
/** @type {import('@divlet/web/session').BoardSelectionPublisher | undefined} */
|
|
10
|
-
export const shareSelection = createHttpSelectionPublisher(post);
|
|
20
|
+
export const shareSelection = composed ? composed.shareSelection : createHttpSelectionPublisher(post);
|
|
11
21
|
/** @type {import('@divlet/web/session').BoardObservationPublisher | undefined} */
|
|
12
|
-
export const publishObservations = createHttpObservationPublisher(post);
|
|
22
|
+
export const publishObservations = composed ? composed.publishObservations : createHttpObservationPublisher(post);
|
|
13
23
|
/** @type {import('@divlet/web/host').BoardHistoryService | undefined} */
|
|
14
|
-
export const historyService = createHttpHistoryService({ request, post });
|
|
24
|
+
export const historyService = composed ? composed.historyService : createHttpHistoryService({ request, post });
|
|
15
25
|
/** @type {import('@divlet/web/host').BoardSourceUpdates | undefined} */
|
|
16
|
-
export const sourceUpdates = createHttpSourceUpdates({ request, post });
|
|
26
|
+
export const sourceUpdates = composed ? undefined : createHttpSourceUpdates({ request, post });
|
|
27
|
+
/** @type {import('@divlet/web/host').BoardWriter} */
|
|
28
|
+
export const writer = composed ? composed.writer : createHttpWriter(post);
|
|
29
|
+
/** @type {import('@divlet/web/host').BoardPreviews} */
|
|
30
|
+
export const previews = composed ? composed.previews : createHttpPreviews({ apiUrl, request });
|
|
31
|
+
/** @type {import('@divlet/web/host').BoardComponents | undefined} */
|
|
32
|
+
export const components = composed ? composed.components : createHttpComponents(request);
|
|
33
|
+
/** @type {import('@divlet/web/host').BoardExports | undefined} */
|
|
34
|
+
export const exports = composed ? composed.exports : createHttpExports(request);
|
|
35
|
+
/** @type {import('@divlet/web/host').BoardHostComposition['initialView']} */
|
|
36
|
+
export const initialView = composed?.initialView;
|
|
@@ -22,3 +22,36 @@ export function createHttpSourceUpdates({ request, post }) {
|
|
|
22
22
|
};
|
|
23
23
|
return Object.freeze(service);
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
/** @param {Post} post @returns {import('@divlet/web/host').BoardWriter} */
|
|
27
|
+
export function createHttpWriter(post) {
|
|
28
|
+
/** @type {import('@divlet/web/host').BoardWriter} */
|
|
29
|
+
const writer = {
|
|
30
|
+
patch: body => post('api/patch', body),
|
|
31
|
+
edit: body => post('api/edit', body),
|
|
32
|
+
undo: body => post('api/undo', body),
|
|
33
|
+
redo: body => post('api/redo', body),
|
|
34
|
+
createArtboard: body => post('api/artboards', body),
|
|
35
|
+
deleteArtboard: body => post('api/artboards/delete', body),
|
|
36
|
+
};
|
|
37
|
+
return Object.freeze(writer);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {{ apiUrl: (path: string) => string, request: (path: string, options?: RequestInit) => Promise<unknown> }} transport
|
|
42
|
+
* @returns {import('@divlet/web/host').BoardPreviews}
|
|
43
|
+
*/
|
|
44
|
+
export function createHttpPreviews({ apiUrl, request }) {
|
|
45
|
+
/** @type {import('@divlet/web/host').BoardPreviews} */
|
|
46
|
+
const previews = {
|
|
47
|
+
frameUrl: (id, { revision, bridge }) => apiUrl(`preview/${encodeURIComponent(id)}?${revision ? `revision=${revision}&` : ''}bridge=${bridge}`),
|
|
48
|
+
readRender: (id, { signal } = {}) => request(`api/render/${encodeURIComponent(id)}`, signal ? { signal } : {}),
|
|
49
|
+
};
|
|
50
|
+
return Object.freeze(previews);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @param {Request} request @returns {import('@divlet/web/host').BoardExports} */
|
|
54
|
+
export const createHttpExports = request => Object.freeze({ page: (/** @type {string} */ id) => request(`api/export/${encodeURIComponent(id)}`) });
|
|
55
|
+
|
|
56
|
+
/** @param {Request} request @returns {import('@divlet/web/host').BoardComponents} */
|
|
57
|
+
export const createHttpComponents = request => Object.freeze({ list: () => request('api/components') });
|
package/dist/lib/inspector.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { $, el, icon, button, clamp, round, debounce, keys, pressGuard } from './util.js';
|
|
2
|
-
import { store, on, documents, descriptor, loadModel, cachedModel, commit, edit, toast, newGroup, selectNodes, parentOf, textChild, whenVersion, request, apiUrl, receiveState } from './store.js';
|
|
2
|
+
import { store, capable, on, documents, descriptor, loadModel, cachedModel, commit, edit, toast, newGroup, selectNodes, parentOf, textChild, whenVersion, request, apiUrl, receiveState } from './store.js';
|
|
3
3
|
import { fit, rectOf, postToFrame, measuredBox } from './canvas.js';
|
|
4
4
|
import * as actions from './actions.js';
|
|
5
5
|
import { openMenu, menuAnchor, closeMenu } from './menu.js';
|
|
6
6
|
import { overflowBar } from './toolbar.js';
|
|
7
7
|
import { panelSections } from './panels.js';
|
|
8
|
+
import { components as componentSource } from './host.js';
|
|
8
9
|
import { fontFamilyControl } from './typography.js';
|
|
9
10
|
import { attributesEditor } from './attrs.js';
|
|
10
11
|
import { formatCss, highlightCss } from './css.js';
|
|
@@ -478,7 +479,8 @@ function imageEditor(doc, node) {
|
|
|
478
479
|
upload.dataset.tip = 'Add an image file to divlet/assets — or drop it onto the artboard';
|
|
479
480
|
upload.addEventListener('click', () => uploadInto(doc, node));
|
|
480
481
|
const buttons = el('div', 'image-buttons');
|
|
481
|
-
|
|
482
|
+
// Choosing or uploading project images needs a host that stores asset bytes; elsewhere the source stays a plain field.
|
|
483
|
+
if (capable('assets')) buttons.append(replace, upload);
|
|
482
484
|
const alt = textField('image-alt', node.attrs.alt ?? '', value => refreshed(commit(doc.id, { nodes: { [node.id]: { attrs: { alt: value } } } }, { label: `alt text of ${node.id}` })), { placeholder: 'Alt text (describe the image)', label: 'Alt text' });
|
|
483
485
|
side.append(el('span', 'image-src', node.attrs.src || 'No source yet'), buttons);
|
|
484
486
|
box.append(preview, side);
|
|
@@ -491,9 +493,9 @@ function imageEditor(doc, node) {
|
|
|
491
493
|
let componentsCache = { revision: -1, list: [], loading: false };
|
|
492
494
|
function componentList() {
|
|
493
495
|
const revision = store.state?.revision ?? 0;
|
|
494
|
-
if (componentsCache.revision !== revision && !componentsCache.loading) {
|
|
496
|
+
if (componentSource && componentsCache.revision !== revision && !componentsCache.loading) {
|
|
495
497
|
componentsCache.loading = true;
|
|
496
|
-
|
|
498
|
+
componentSource.list().then(result => { componentsCache = { revision, list: result.components, loading: false }; schedule(); }).catch(() => { componentsCache.loading = false; });
|
|
497
499
|
}
|
|
498
500
|
return componentsCache.list;
|
|
499
501
|
}
|