@webmcp-auto-ui/agent 2.5.27 → 2.5.28
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/package.json +10 -2
- package/src/autoui-server.ts +63 -75
- package/src/index.ts +7 -2
- package/src/loop.ts +48 -21
- package/src/providers/factory.ts +15 -1
- package/src/providers/hawk-models.ts +22 -0
- package/src/providers/hawk.ts +181 -0
- package/src/providers/transformers.worker.ts +5 -32
- package/src/recipes/_generated.ts +81 -17
- package/src/recipes/notebook-playbook.md +81 -17
- package/src/server/hawkProxy.ts +54 -0
- package/src/server/index.ts +2 -0
- package/src/util/opfs-cache.ts +101 -2
- package/src/util/storage-inventory.ts +195 -0
- package/src/notebook-widgets/compact.ts +0 -312
- package/src/notebook-widgets/document.ts +0 -372
- package/src/notebook-widgets/editorial.ts +0 -348
- package/src/notebook-widgets/recipes/compact.md +0 -104
- package/src/notebook-widgets/recipes/document.md +0 -100
- package/src/notebook-widgets/recipes/editorial.md +0 -104
- package/src/notebook-widgets/recipes/workspace.md +0 -94
- package/src/notebook-widgets/shared.ts +0 -1064
- package/src/notebook-widgets/workspace.ts +0 -328
|
@@ -1,1064 +0,0 @@
|
|
|
1
|
-
// @ts-nocheck
|
|
2
|
-
// ---------------------------------------------------------------------------
|
|
3
|
-
// Notebook shared engine — vanilla JS
|
|
4
|
-
// Used by the four notebook layout renderers (compact/workspace/document/editorial)
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
|
|
7
|
-
export type CellType = 'md' | 'sql' | 'js';
|
|
8
|
-
export type RunState = 'idle' | 'running' | 'done' | 'stopped';
|
|
9
|
-
export type NotebookMode = 'edit' | 'view';
|
|
10
|
-
|
|
11
|
-
export interface NotebookCell {
|
|
12
|
-
id: string;
|
|
13
|
-
type: CellType;
|
|
14
|
-
content: string;
|
|
15
|
-
name?: string; // cell name (workspace/compact)
|
|
16
|
-
varname?: string; // named output (compact)
|
|
17
|
-
hideSource?: boolean;
|
|
18
|
-
hideResult?: boolean;
|
|
19
|
-
runState?: RunState;
|
|
20
|
-
lastMs?: number;
|
|
21
|
-
status?: 'fresh' | 'stale';
|
|
22
|
-
comment?: { who: string; when: string; body: string } | null;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface NotebookState {
|
|
26
|
-
id: string;
|
|
27
|
-
title: string;
|
|
28
|
-
mode: NotebookMode;
|
|
29
|
-
cells: NotebookCell[];
|
|
30
|
-
history: HistoryEntry[];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface HistoryEntry {
|
|
34
|
-
ts: number;
|
|
35
|
-
kind: 'add' | 'del' | 'edit' | 'move' | 'run';
|
|
36
|
-
summary: string;
|
|
37
|
-
snapshot?: { cell: NotebookCell; idx: number };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
// Utilities
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
|
|
44
|
-
export function uid(): string {
|
|
45
|
-
return 'c_' + Math.random().toString(36).slice(2, 9);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function formatDuration(ms: number): string {
|
|
49
|
-
if (ms < 1000) return ms + 'ms';
|
|
50
|
-
return (ms / 1000).toFixed(1) + 's';
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function fmtRelTime(ts: number): string {
|
|
54
|
-
const diff = Date.now() - ts;
|
|
55
|
-
if (diff < 5000) return 'now';
|
|
56
|
-
if (diff < 60000) return Math.floor(diff / 1000) + 's';
|
|
57
|
-
if (diff < 3600000) return Math.floor(diff / 60000) + 'm';
|
|
58
|
-
return Math.floor(diff / 3600000) + 'h';
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function autosize(ta: HTMLTextAreaElement): void {
|
|
62
|
-
ta.style.height = 'auto';
|
|
63
|
-
ta.style.height = ta.scrollHeight + 'px';
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function defaultCellContent(type: CellType): string {
|
|
67
|
-
if (type === 'md') return '### new section\n\nwrite here…';
|
|
68
|
-
if (type === 'sql') return 'select *\nfrom source\nlimit 10';
|
|
69
|
-
return '// write js here';
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// ---------------------------------------------------------------------------
|
|
73
|
-
// State factory
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
75
|
-
|
|
76
|
-
export function createState(initial?: Partial<NotebookState>): NotebookState {
|
|
77
|
-
return {
|
|
78
|
-
id: initial?.id ?? uid(),
|
|
79
|
-
title: initial?.title ?? 'Untitled notebook',
|
|
80
|
-
mode: initial?.mode ?? 'edit',
|
|
81
|
-
cells: initial?.cells ?? [
|
|
82
|
-
{ id: uid(), type: 'md', content: '### Untitled notebook\n\nAdd some context here.', hideSource: false, hideResult: false },
|
|
83
|
-
{ id: uid(), type: 'sql', content: 'select *\nfrom source\nlimit 5', varname: 'rows', hideSource: false, hideResult: false, status: 'fresh' },
|
|
84
|
-
{ id: uid(), type: 'js', content: 'console.log(rows)', hideSource: false, hideResult: false, status: 'stale' },
|
|
85
|
-
],
|
|
86
|
-
history: initial?.history ?? [],
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function logHistory(state: NotebookState, kind: HistoryEntry['kind'], summary: string, snapshot?: HistoryEntry['snapshot']): void {
|
|
91
|
-
state.history.unshift({ ts: Date.now(), kind, summary, snapshot: snapshot ? JSON.parse(JSON.stringify(snapshot)) : undefined });
|
|
92
|
-
if (state.history.length > 100) state.history.pop();
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function moveCell(state: NotebookState, fromIdx: number, toIdx: number): void {
|
|
96
|
-
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return;
|
|
97
|
-
const [moved] = state.cells.splice(fromIdx, 1);
|
|
98
|
-
state.cells.splice(toIdx, 0, moved);
|
|
99
|
-
logHistory(state, 'move', `moved ${moved.type} cell`);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// ---------------------------------------------------------------------------
|
|
103
|
-
// Run / Stop live timers
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
|
|
106
|
-
const runningTimers = new Map<string, { intervalId: any; timeoutId: any }>();
|
|
107
|
-
|
|
108
|
-
export function startRun(cell: NotebookCell, onUpdate: () => void): void {
|
|
109
|
-
cell.runState = 'running';
|
|
110
|
-
cell.status = 'stale';
|
|
111
|
-
(cell as any).startedAt = Date.now();
|
|
112
|
-
(cell as any).simulatedDuration = 1500 + Math.floor(Math.random() * 1500);
|
|
113
|
-
onUpdate();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function stopRun(cell: NotebookCell, onUpdate: () => void): void {
|
|
117
|
-
const handles = runningTimers.get(cell.id);
|
|
118
|
-
if (handles) {
|
|
119
|
-
clearInterval(handles.intervalId);
|
|
120
|
-
clearTimeout(handles.timeoutId);
|
|
121
|
-
runningTimers.delete(cell.id);
|
|
122
|
-
}
|
|
123
|
-
cell.lastMs = Date.now() - ((cell as any).startedAt || Date.now());
|
|
124
|
-
cell.runState = 'stopped';
|
|
125
|
-
cell.status = 'stale';
|
|
126
|
-
delete (cell as any).startedAt;
|
|
127
|
-
delete (cell as any).simulatedDuration;
|
|
128
|
-
onUpdate();
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function tickRunningCell(cell: NotebookCell, elapsedEl: HTMLElement, onDone: () => void): void {
|
|
132
|
-
const startedAt = (cell as any).startedAt || Date.now();
|
|
133
|
-
(cell as any).startedAt = startedAt;
|
|
134
|
-
const tick = () => { elapsedEl.textContent = formatDuration(Date.now() - startedAt); };
|
|
135
|
-
tick();
|
|
136
|
-
const intervalId = setInterval(tick, 50);
|
|
137
|
-
const simulatedDuration = (cell as any).simulatedDuration || 2000;
|
|
138
|
-
const remaining = simulatedDuration - (Date.now() - startedAt);
|
|
139
|
-
const timeoutId = setTimeout(() => {
|
|
140
|
-
if (cell.runState !== 'running') return;
|
|
141
|
-
clearInterval(intervalId);
|
|
142
|
-
cell.lastMs = Date.now() - startedAt;
|
|
143
|
-
cell.runState = 'done';
|
|
144
|
-
cell.status = 'fresh';
|
|
145
|
-
delete (cell as any).startedAt;
|
|
146
|
-
delete (cell as any).simulatedDuration;
|
|
147
|
-
runningTimers.delete(cell.id);
|
|
148
|
-
onDone();
|
|
149
|
-
}, Math.max(0, remaining));
|
|
150
|
-
runningTimers.set(cell.id, { intervalId, timeoutId });
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ---------------------------------------------------------------------------
|
|
154
|
-
// Data server descriptors (merged from canvas store + recipe-provided data.servers)
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
|
|
157
|
-
export interface DataServerTool {
|
|
158
|
-
name: string;
|
|
159
|
-
description?: string;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export interface DataServerRecipe {
|
|
163
|
-
name: string;
|
|
164
|
-
description?: string;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export interface DataServerDescriptor {
|
|
168
|
-
name: string;
|
|
169
|
-
url?: string;
|
|
170
|
-
kind?: string;
|
|
171
|
-
tools?: DataServerTool[];
|
|
172
|
-
recipes?: DataServerRecipe[];
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** Return true when the server looks like a UI/webmcp server and should be hidden. */
|
|
176
|
-
function isUiServer(name: string, kind?: string): boolean {
|
|
177
|
-
if (kind === 'ui' || kind === 'webmcp') return true;
|
|
178
|
-
const n = (name || '').toLowerCase();
|
|
179
|
-
return n.includes('autoui') || n.includes('webmcp');
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Merge data servers from two sources:
|
|
184
|
-
* - live canvas store snapshot (single connected MCP server)
|
|
185
|
-
* - recipe-provided `data.servers` (richer objects with recipes/tools)
|
|
186
|
-
* Dedupes by `name`, prioritizing recipe entries for metadata.
|
|
187
|
-
*/
|
|
188
|
-
export function collectDataServers(data: Record<string, unknown>): DataServerDescriptor[] {
|
|
189
|
-
const out: DataServerDescriptor[] = [];
|
|
190
|
-
const seen = new Map<string, number>();
|
|
191
|
-
|
|
192
|
-
const push = (srv: DataServerDescriptor) => {
|
|
193
|
-
if (!srv || !srv.name) return;
|
|
194
|
-
if (isUiServer(srv.name, srv.kind)) return;
|
|
195
|
-
const idx = seen.get(srv.name);
|
|
196
|
-
if (idx == null) {
|
|
197
|
-
seen.set(srv.name, out.length);
|
|
198
|
-
out.push(srv);
|
|
199
|
-
} else {
|
|
200
|
-
// merge metadata (existing has precedence, but fill missing from new)
|
|
201
|
-
const existing = out[idx];
|
|
202
|
-
if (!existing.url && srv.url) existing.url = srv.url;
|
|
203
|
-
if (!existing.kind && srv.kind) existing.kind = srv.kind;
|
|
204
|
-
if ((!existing.tools || !existing.tools.length) && srv.tools) existing.tools = srv.tools;
|
|
205
|
-
if ((!existing.recipes || !existing.recipes.length) && srv.recipes) existing.recipes = srv.recipes;
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
// 1. recipe-provided servers (priority)
|
|
210
|
-
const raw = Array.isArray(data?.servers) ? (data.servers as any[]) : [];
|
|
211
|
-
raw.forEach((s) => {
|
|
212
|
-
if (!s || typeof s !== 'object') return;
|
|
213
|
-
push({
|
|
214
|
-
name: String(s.name ?? ''),
|
|
215
|
-
url: s.url ? String(s.url) : undefined,
|
|
216
|
-
kind: s.kind ? String(s.kind) : undefined,
|
|
217
|
-
tools: Array.isArray(s.tools) ? s.tools.map((t: any) => typeof t === 'string' ? { name: t } : { name: String(t?.name ?? ''), description: t?.description }) : undefined,
|
|
218
|
-
recipes: Array.isArray(s.recipes) ? s.recipes.map((r: any) => typeof r === 'string' ? { name: r } : { name: String(r?.name ?? ''), description: r?.description }) : undefined,
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
// 2. live canvas store (single connected server)
|
|
223
|
-
try {
|
|
224
|
-
// Dynamic import fallback: access via global if available; we don't want to hard-bind
|
|
225
|
-
// to a specific store import path here to keep shared.ts framework-agnostic.
|
|
226
|
-
// The SDK's canvasVanilla is a singleton; apps that inject it expose it as window.__canvasVanilla.
|
|
227
|
-
const canvasAny: any = (globalThis as any).__canvasVanilla || (globalThis as any).canvasVanilla;
|
|
228
|
-
const snap = canvasAny?.getSnapshot?.();
|
|
229
|
-
if (snap && snap.mcpConnected && snap.mcpName) {
|
|
230
|
-
push({
|
|
231
|
-
name: String(snap.mcpName),
|
|
232
|
-
url: snap.mcpUrl ? String(snap.mcpUrl) : undefined,
|
|
233
|
-
tools: Array.isArray(snap.mcpTools)
|
|
234
|
-
? snap.mcpTools.map((t: any) => ({ name: String(t?.name ?? ''), description: t?.description }))
|
|
235
|
-
: undefined,
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
} catch { /* ignore */ }
|
|
239
|
-
|
|
240
|
-
return out;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// ---------------------------------------------------------------------------
|
|
244
|
-
// "Data servers" modal — lists servers, recipes, tools; inserts cells on click
|
|
245
|
-
// ---------------------------------------------------------------------------
|
|
246
|
-
|
|
247
|
-
let serversOverlay: HTMLElement | null = null;
|
|
248
|
-
|
|
249
|
-
function ensureServersOverlay(): HTMLElement {
|
|
250
|
-
if (serversOverlay && document.body.contains(serversOverlay)) return serversOverlay;
|
|
251
|
-
serversOverlay = document.createElement('div');
|
|
252
|
-
serversOverlay.className = 'nbs-overlay';
|
|
253
|
-
serversOverlay.innerHTML = `
|
|
254
|
-
<div class="nbs-modal">
|
|
255
|
-
<div class="nbs-header">
|
|
256
|
-
<div class="nbs-title">Data servers</div>
|
|
257
|
-
<button class="nbs-close" title="close">×</button>
|
|
258
|
-
</div>
|
|
259
|
-
<div class="nbs-body"></div>
|
|
260
|
-
</div>`;
|
|
261
|
-
document.body.appendChild(serversOverlay);
|
|
262
|
-
serversOverlay.addEventListener('click', (e) => {
|
|
263
|
-
if (e.target === serversOverlay) serversOverlay!.classList.remove('open');
|
|
264
|
-
});
|
|
265
|
-
(serversOverlay.querySelector('.nbs-close') as HTMLElement).addEventListener('click', () => {
|
|
266
|
-
serversOverlay!.classList.remove('open');
|
|
267
|
-
});
|
|
268
|
-
return serversOverlay;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function looksLikeSqlTool(name: string): boolean {
|
|
272
|
-
const n = name.toLowerCase();
|
|
273
|
-
return n === 'query_sql' || n === 'list_tables' || n === 'describe_table'
|
|
274
|
-
|| n.endsWith('_query_sql') || n.endsWith('_list_tables') || n.endsWith('_describe_table')
|
|
275
|
-
|| n.includes('query_sql') || n.includes('list_tables') || n.includes('describe_table');
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function openServersModal(
|
|
279
|
-
servers: DataServerDescriptor[],
|
|
280
|
-
onInsertCell: (type: CellType, content: string) => void
|
|
281
|
-
): void {
|
|
282
|
-
const overlay = ensureServersOverlay();
|
|
283
|
-
(overlay.querySelector('.nbs-title') as HTMLElement).textContent = `Data servers (${servers.length})`;
|
|
284
|
-
const body = overlay.querySelector('.nbs-body') as HTMLElement;
|
|
285
|
-
|
|
286
|
-
if (servers.length === 0) {
|
|
287
|
-
body.innerHTML = '<div class="nbs-empty">No data MCP server connected.</div>';
|
|
288
|
-
} else {
|
|
289
|
-
body.innerHTML = servers.map((srv, sidx) => {
|
|
290
|
-
const recipes = srv.recipes || [];
|
|
291
|
-
const tools = srv.tools || [];
|
|
292
|
-
const recipesHtml = recipes.length > 0
|
|
293
|
-
? recipes.map((r, ri) => `
|
|
294
|
-
<div class="nbs-item" data-kind="recipe" data-srv="${sidx}" data-i="${ri}">
|
|
295
|
-
<span class="nbs-item-icon">📜</span>
|
|
296
|
-
<span class="nbs-item-name">${escapeHtml(r.name)}</span>
|
|
297
|
-
${r.description ? `<span class="nbs-item-desc">${escapeHtml(r.description)}</span>` : ''}
|
|
298
|
-
</div>`).join('')
|
|
299
|
-
: '<div class="nbs-empty-sub">Recipes unavailable</div>';
|
|
300
|
-
const toolsHtml = tools.length > 0
|
|
301
|
-
? tools.map((t, ti) => `
|
|
302
|
-
<div class="nbs-item" data-kind="tool" data-srv="${sidx}" data-i="${ti}">
|
|
303
|
-
<span class="nbs-item-icon">${looksLikeSqlTool(t.name) ? '⛁' : '⚙'}</span>
|
|
304
|
-
<span class="nbs-item-name">${escapeHtml(t.name)}</span>
|
|
305
|
-
${t.description ? `<span class="nbs-item-desc">${escapeHtml(t.description)}</span>` : ''}
|
|
306
|
-
</div>`).join('')
|
|
307
|
-
: '<div class="nbs-empty-sub">No tools reported</div>';
|
|
308
|
-
return `
|
|
309
|
-
<div class="nbs-server">
|
|
310
|
-
<div class="nbs-server-head">
|
|
311
|
-
<span class="nbs-plug">🔌</span>
|
|
312
|
-
<span class="nbs-server-name">${escapeHtml(srv.name)}</span>
|
|
313
|
-
${srv.url ? `<span class="nbs-server-url">${escapeHtml(srv.url)}</span>` : ''}
|
|
314
|
-
</div>
|
|
315
|
-
<div class="nbs-section">Recipes (${recipes.length})</div>
|
|
316
|
-
<div class="nbs-list">${recipesHtml}</div>
|
|
317
|
-
<div class="nbs-section">Tools / Tables (${tools.length})</div>
|
|
318
|
-
<div class="nbs-list">${toolsHtml}</div>
|
|
319
|
-
</div>`;
|
|
320
|
-
}).join('');
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Bind click handlers for items
|
|
324
|
-
body.querySelectorAll<HTMLElement>('.nbs-item').forEach((el) => {
|
|
325
|
-
el.addEventListener('click', () => {
|
|
326
|
-
const sidx = parseInt(el.dataset.srv!, 10);
|
|
327
|
-
const i = parseInt(el.dataset.i!, 10);
|
|
328
|
-
const srv = servers[sidx];
|
|
329
|
-
if (!srv) return;
|
|
330
|
-
if (el.dataset.kind === 'recipe') {
|
|
331
|
-
const r = (srv.recipes || [])[i];
|
|
332
|
-
if (!r) return;
|
|
333
|
-
const content = `# Using recipe: ${r.name}\n\nCall \`${srv.name}_get_recipe('${r.name}')\` to load, then follow its instructions.`;
|
|
334
|
-
onInsertCell('md', content);
|
|
335
|
-
} else {
|
|
336
|
-
const t = (srv.tools || [])[i];
|
|
337
|
-
if (!t) return;
|
|
338
|
-
if (looksLikeSqlTool(t.name)) {
|
|
339
|
-
onInsertCell('sql', `-- TODO: write your query\nSELECT * FROM <table> LIMIT 10;`);
|
|
340
|
-
} else {
|
|
341
|
-
onInsertCell('js', `// TODO: call ${t.name}\n// Arguments: see tool schema`);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
overlay.classList.remove('open');
|
|
345
|
-
});
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
overlay.classList.add('open');
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* Build a "data servers" button and attach it to `container`.
|
|
353
|
-
* The button is rendered as a small pill with a 🔌 icon and a (N) badge.
|
|
354
|
-
* Clicking it opens the shared modal; clicking a recipe/tool inserts a cell into `state`.
|
|
355
|
-
*/
|
|
356
|
-
export function buildServersButton(
|
|
357
|
-
state: NotebookState,
|
|
358
|
-
container: HTMLElement,
|
|
359
|
-
data: Record<string, unknown>,
|
|
360
|
-
rerender: () => void
|
|
361
|
-
): HTMLElement {
|
|
362
|
-
const btn = document.createElement('button');
|
|
363
|
-
btn.className = 'nb-btn nbs-trigger';
|
|
364
|
-
btn.type = 'button';
|
|
365
|
-
btn.title = 'Data servers';
|
|
366
|
-
|
|
367
|
-
const refresh = () => {
|
|
368
|
-
const servers = collectDataServers(data);
|
|
369
|
-
const count = servers.length;
|
|
370
|
-
btn.innerHTML = `🔌 <span class="nbs-badge">${count}</span>`;
|
|
371
|
-
btn.classList.toggle('nbs-empty-btn', count === 0);
|
|
372
|
-
btn.disabled = count === 0;
|
|
373
|
-
};
|
|
374
|
-
refresh();
|
|
375
|
-
|
|
376
|
-
btn.addEventListener('click', () => {
|
|
377
|
-
const servers = collectDataServers(data);
|
|
378
|
-
if (!servers.length) return;
|
|
379
|
-
openServersModal(servers, (type, content) => {
|
|
380
|
-
addCell(state, type, { content, status: 'stale' });
|
|
381
|
-
rerender();
|
|
382
|
-
});
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
// Subscribe to canvas changes so the badge updates when the MCP connects
|
|
386
|
-
try {
|
|
387
|
-
const canvasAny: any = (globalThis as any).__canvasVanilla || (globalThis as any).canvasVanilla;
|
|
388
|
-
if (canvasAny?.subscribe) {
|
|
389
|
-
const unsub = canvasAny.subscribe(() => refresh());
|
|
390
|
-
// store unsub on the button for potential cleanup
|
|
391
|
-
(btn as any).__nbsUnsub = unsub;
|
|
392
|
-
}
|
|
393
|
-
} catch { /* ignore */ }
|
|
394
|
-
|
|
395
|
-
container.appendChild(btn);
|
|
396
|
-
return btn;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// ---------------------------------------------------------------------------
|
|
400
|
-
// Modals (shared singletons, created on demand)
|
|
401
|
-
// ---------------------------------------------------------------------------
|
|
402
|
-
|
|
403
|
-
let confirmOverlay: HTMLElement | null = null;
|
|
404
|
-
let shareOverlay: HTMLElement | null = null;
|
|
405
|
-
|
|
406
|
-
function ensureConfirmOverlay(): HTMLElement {
|
|
407
|
-
if (confirmOverlay && document.body.contains(confirmOverlay)) return confirmOverlay;
|
|
408
|
-
confirmOverlay = document.createElement('div');
|
|
409
|
-
confirmOverlay.className = 'nb-confirm-overlay';
|
|
410
|
-
confirmOverlay.innerHTML = `
|
|
411
|
-
<div class="nb-confirm-modal">
|
|
412
|
-
<div class="nb-confirm-title"></div>
|
|
413
|
-
<div class="nb-confirm-msg"></div>
|
|
414
|
-
<div class="nb-confirm-actions">
|
|
415
|
-
<button class="nb-btn nb-btn-cancel">cancel</button>
|
|
416
|
-
<button class="nb-btn nb-btn-danger">delete</button>
|
|
417
|
-
</div>
|
|
418
|
-
</div>`;
|
|
419
|
-
document.body.appendChild(confirmOverlay);
|
|
420
|
-
return confirmOverlay;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
export function askConfirm(title: string, msg: string, targetName?: string): Promise<boolean> {
|
|
424
|
-
const overlay = ensureConfirmOverlay();
|
|
425
|
-
(overlay.querySelector('.nb-confirm-title') as HTMLElement).textContent = title;
|
|
426
|
-
(overlay.querySelector('.nb-confirm-msg') as HTMLElement).innerHTML =
|
|
427
|
-
msg.replace('{target}', `<span class="nb-target">${targetName || ''}</span>`);
|
|
428
|
-
overlay.classList.add('open');
|
|
429
|
-
return new Promise<boolean>((resolve) => {
|
|
430
|
-
const cleanup = () => { overlay.classList.remove('open'); };
|
|
431
|
-
const onDanger = () => { cleanup(); resolve(true); off(); };
|
|
432
|
-
const onCancel = () => { cleanup(); resolve(false); off(); };
|
|
433
|
-
const onBackdrop = (e: Event) => { if (e.target === overlay) { cleanup(); resolve(false); off(); } };
|
|
434
|
-
const dangerBtn = overlay.querySelector('.nb-btn-danger') as HTMLElement;
|
|
435
|
-
const cancelBtn = overlay.querySelector('.nb-btn-cancel') as HTMLElement;
|
|
436
|
-
const off = () => {
|
|
437
|
-
dangerBtn.removeEventListener('click', onDanger);
|
|
438
|
-
cancelBtn.removeEventListener('click', onCancel);
|
|
439
|
-
overlay.removeEventListener('click', onBackdrop);
|
|
440
|
-
};
|
|
441
|
-
dangerBtn.addEventListener('click', onDanger);
|
|
442
|
-
cancelBtn.addEventListener('click', onCancel);
|
|
443
|
-
overlay.addEventListener('click', onBackdrop);
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
function ensureShareOverlay(): HTMLElement {
|
|
448
|
-
if (shareOverlay && document.body.contains(shareOverlay)) return shareOverlay;
|
|
449
|
-
shareOverlay = document.createElement('div');
|
|
450
|
-
shareOverlay.className = 'nb-share-overlay';
|
|
451
|
-
shareOverlay.innerHTML = `
|
|
452
|
-
<div class="nb-share-modal">
|
|
453
|
-
<div class="nb-share-title">Share notebook</div>
|
|
454
|
-
<div class="nb-share-sub">choose an export format</div>
|
|
455
|
-
<div class="nb-share-options">
|
|
456
|
-
<div class="nb-share-option" data-share="hyperskill">
|
|
457
|
-
<div class="nb-share-icon">HS</div>
|
|
458
|
-
<div class="nb-share-txt">
|
|
459
|
-
<div class="nb-share-name">Hyperskill link</div>
|
|
460
|
-
<div class="nb-share-desc">shareable url with the full state encoded</div>
|
|
461
|
-
</div>
|
|
462
|
-
<span class="nb-share-arrow">→</span>
|
|
463
|
-
</div>
|
|
464
|
-
<div class="nb-share-option" data-share="md">
|
|
465
|
-
<div class="nb-share-icon">MD</div>
|
|
466
|
-
<div class="nb-share-txt">
|
|
467
|
-
<div class="nb-share-name">Markdown</div>
|
|
468
|
-
<div class="nb-share-desc">portable .md with code blocks and prose</div>
|
|
469
|
-
</div>
|
|
470
|
-
<span class="nb-share-arrow">→</span>
|
|
471
|
-
</div>
|
|
472
|
-
<div class="nb-share-option" data-share="png">
|
|
473
|
-
<div class="nb-share-icon">PNG</div>
|
|
474
|
-
<div class="nb-share-txt">
|
|
475
|
-
<div class="nb-share-name">PNG snapshot</div>
|
|
476
|
-
<div class="nb-share-desc">static image of the rendered notebook</div>
|
|
477
|
-
</div>
|
|
478
|
-
<span class="nb-share-arrow">→</span>
|
|
479
|
-
</div>
|
|
480
|
-
<div class="nb-share-option" data-share="json">
|
|
481
|
-
<div class="nb-share-icon">JSON</div>
|
|
482
|
-
<div class="nb-share-txt">
|
|
483
|
-
<div class="nb-share-name">JSON export</div>
|
|
484
|
-
<div class="nb-share-desc">raw cell data for backup or re-import</div>
|
|
485
|
-
</div>
|
|
486
|
-
<span class="nb-share-arrow">→</span>
|
|
487
|
-
</div>
|
|
488
|
-
</div>
|
|
489
|
-
<button class="nb-btn nb-share-close">close</button>
|
|
490
|
-
</div>`;
|
|
491
|
-
document.body.appendChild(shareOverlay);
|
|
492
|
-
shareOverlay.addEventListener('click', (e) => {
|
|
493
|
-
if (e.target === shareOverlay) shareOverlay!.classList.remove('open');
|
|
494
|
-
});
|
|
495
|
-
(shareOverlay.querySelector('.nb-share-close') as HTMLElement).addEventListener('click', () => {
|
|
496
|
-
shareOverlay!.classList.remove('open');
|
|
497
|
-
});
|
|
498
|
-
return shareOverlay;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
export function openShareModal(state: NotebookState, onFormat: (fmt: string) => void): void {
|
|
502
|
-
const overlay = ensureShareOverlay();
|
|
503
|
-
// Rebind option clicks for the current callback
|
|
504
|
-
overlay.querySelectorAll<HTMLElement>('.nb-share-option').forEach((opt) => {
|
|
505
|
-
const clone = opt.cloneNode(true) as HTMLElement;
|
|
506
|
-
opt.parentNode!.replaceChild(clone, opt);
|
|
507
|
-
clone.addEventListener('click', () => {
|
|
508
|
-
const fmt = clone.dataset.share!;
|
|
509
|
-
onFormat(fmt);
|
|
510
|
-
overlay.classList.remove('open');
|
|
511
|
-
});
|
|
512
|
-
});
|
|
513
|
-
overlay.classList.add('open');
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// ---------------------------------------------------------------------------
|
|
517
|
-
// Styles — injected once per page
|
|
518
|
-
// ---------------------------------------------------------------------------
|
|
519
|
-
|
|
520
|
-
export function injectStyles(): void {
|
|
521
|
-
if (document.getElementById('nb-shared-styles')) return;
|
|
522
|
-
const style = document.createElement('style');
|
|
523
|
-
style.id = 'nb-shared-styles';
|
|
524
|
-
style.textContent = NOTEBOOK_STYLES;
|
|
525
|
-
document.head.appendChild(style);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
const NOTEBOOK_STYLES = `
|
|
529
|
-
.nb-root { font-family: var(--font-sans, 'Syne', system-ui, sans-serif); color: var(--color-text1); }
|
|
530
|
-
.nb-root * { box-sizing: border-box; }
|
|
531
|
-
|
|
532
|
-
.nb-btn {
|
|
533
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
534
|
-
font-size: 11px;
|
|
535
|
-
color: var(--color-text2);
|
|
536
|
-
background: transparent;
|
|
537
|
-
border: 1px solid var(--color-border);
|
|
538
|
-
padding: 5px 11px;
|
|
539
|
-
border-radius: 6px;
|
|
540
|
-
cursor: pointer;
|
|
541
|
-
transition: border-color 0.15s, color 0.15s;
|
|
542
|
-
letter-spacing: 0.04em;
|
|
543
|
-
}
|
|
544
|
-
.nb-btn:hover { border-color: var(--color-border2); color: var(--color-text1); }
|
|
545
|
-
.nb-btn-primary { background: var(--color-accent); color: #fff; border-color: var(--color-accent); }
|
|
546
|
-
.nb-btn-primary:hover { filter: brightness(1.1); color: #fff; }
|
|
547
|
-
.nb-btn-danger { background: var(--color-accent2); color: #fff; border-color: var(--color-accent2); }
|
|
548
|
-
.nb-btn-danger:hover { filter: brightness(1.1); color: #fff; }
|
|
549
|
-
|
|
550
|
-
.nb-icon-btn {
|
|
551
|
-
background: transparent; border: none;
|
|
552
|
-
color: var(--color-text2); cursor: pointer;
|
|
553
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
554
|
-
font-size: 11px; padding: 2px 6px; border-radius: 3px;
|
|
555
|
-
}
|
|
556
|
-
.nb-icon-btn:hover { color: var(--color-text1); background: var(--color-surface2); }
|
|
557
|
-
.nb-icon-btn.nb-danger:hover { color: var(--color-accent2); background: rgba(250,109,124,0.1); }
|
|
558
|
-
|
|
559
|
-
.nb-ctl-pill {
|
|
560
|
-
width: 22px; height: 22px; border-radius: 50%;
|
|
561
|
-
border: none; cursor: pointer; padding: 0;
|
|
562
|
-
display: inline-flex; align-items: center; justify-content: center;
|
|
563
|
-
color: #fff; transition: transform 0.1s, filter 0.15s;
|
|
564
|
-
flex-shrink: 0;
|
|
565
|
-
}
|
|
566
|
-
.nb-ctl-pill:hover { filter: brightness(1.15); }
|
|
567
|
-
.nb-ctl-pill:active { transform: scale(0.92); }
|
|
568
|
-
.nb-ctl-pill.nb-run { background: var(--color-teal); box-shadow: 0 0 0 1px rgba(62,207,178,0.35), 0 1px 2px rgba(0,0,0,0.25); }
|
|
569
|
-
.nb-ctl-pill.nb-stop { background: var(--color-accent2); box-shadow: 0 0 0 1px rgba(250,109,124,0.35), 0 1px 2px rgba(0,0,0,0.25); }
|
|
570
|
-
.nb-ctl-pill.nb-run::before {
|
|
571
|
-
content: ''; width: 0; height: 0;
|
|
572
|
-
border-left: 7px solid #fff;
|
|
573
|
-
border-top: 4px solid transparent;
|
|
574
|
-
border-bottom: 4px solid transparent;
|
|
575
|
-
margin-left: 2px;
|
|
576
|
-
}
|
|
577
|
-
.nb-ctl-pill.nb-stop::before {
|
|
578
|
-
content: ''; width: 8px; height: 8px; background: #fff; border-radius: 1px;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
.nb-timer {
|
|
582
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
583
|
-
font-size: 10.5px; color: var(--color-text2);
|
|
584
|
-
display: inline-flex; align-items: center; gap: 5px;
|
|
585
|
-
font-variant-numeric: tabular-nums;
|
|
586
|
-
}
|
|
587
|
-
.nb-timer.nb-running { color: var(--color-teal); }
|
|
588
|
-
.nb-timer .nb-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--color-text2); }
|
|
589
|
-
.nb-timer.nb-running .nb-dot { background: var(--color-teal); animation: nb-pulse 1s ease-in-out infinite; }
|
|
590
|
-
@keyframes nb-pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.7); } }
|
|
591
|
-
|
|
592
|
-
.nb-cell-wrapper.nb-running .nb-code-cell,
|
|
593
|
-
.nb-cell.nb-running {
|
|
594
|
-
position: relative;
|
|
595
|
-
}
|
|
596
|
-
.nb-cell-wrapper.nb-running .nb-code-cell::before,
|
|
597
|
-
.nb-cell.nb-running::before {
|
|
598
|
-
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
|
|
599
|
-
background: linear-gradient(90deg, transparent, var(--color-teal), transparent);
|
|
600
|
-
background-size: 200% 100%; animation: nb-sweep 1.4s linear infinite; z-index: 1;
|
|
601
|
-
}
|
|
602
|
-
@keyframes nb-sweep {
|
|
603
|
-
0% { background-position: 200% 0; }
|
|
604
|
-
100% { background-position: -200% 0; }
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
.nb-drag-handle {
|
|
608
|
-
cursor: grab; color: var(--color-text2); opacity: 0;
|
|
609
|
-
transition: opacity 0.15s; font-size: 14px; user-select: none; padding: 2px 4px;
|
|
610
|
-
}
|
|
611
|
-
.nb-cell-wrapper:hover .nb-drag-handle { opacity: 0.5; }
|
|
612
|
-
.nb-drag-handle:hover { opacity: 1 !important; color: var(--color-text1); }
|
|
613
|
-
.nb-drag-handle:active { cursor: grabbing; }
|
|
614
|
-
.nb-cell-wrapper.nb-dragging { opacity: 0.3; }
|
|
615
|
-
.nb-cell-wrapper.nb-drag-over-before { box-shadow: 0 -2px 0 var(--color-accent); }
|
|
616
|
-
.nb-cell-wrapper.nb-drag-over-after { box-shadow: 0 2px 0 var(--color-accent); }
|
|
617
|
-
|
|
618
|
-
.nb-root.nb-view-mode .nb-drag-handle,
|
|
619
|
-
.nb-root.nb-view-mode .nb-icon-btn.nb-danger,
|
|
620
|
-
.nb-root.nb-view-mode .nb-ctl-pill,
|
|
621
|
-
.nb-root.nb-view-mode .nb-toggle-src,
|
|
622
|
-
.nb-root.nb-view-mode .nb-toggle-res,
|
|
623
|
-
.nb-root.nb-view-mode .nb-add-cell { display: none !important; }
|
|
624
|
-
.nb-root.nb-view-mode textarea,
|
|
625
|
-
.nb-root.nb-view-mode [contenteditable] { pointer-events: none; }
|
|
626
|
-
.nb-root.nb-view-mode input.nb-title-edit,
|
|
627
|
-
.nb-root.nb-view-mode input.nb-doc-title,
|
|
628
|
-
.nb-root.nb-view-mode input.nb-ed-title { pointer-events: none; }
|
|
629
|
-
|
|
630
|
-
.nb-mode-switch {
|
|
631
|
-
display: inline-flex;
|
|
632
|
-
border: 1px solid var(--color-border);
|
|
633
|
-
border-radius: 999px; padding: 2px; background: var(--color-surface);
|
|
634
|
-
margin-right: 4px;
|
|
635
|
-
}
|
|
636
|
-
.nb-mode-switch button {
|
|
637
|
-
border: none; background: transparent; color: var(--color-text2);
|
|
638
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
639
|
-
font-size: 10px; padding: 4px 10px; cursor: pointer;
|
|
640
|
-
border-radius: 999px; text-transform: uppercase; letter-spacing: 0.08em;
|
|
641
|
-
}
|
|
642
|
-
.nb-mode-switch button.nb-on { background: var(--color-accent); color: #fff; }
|
|
643
|
-
|
|
644
|
-
/* History */
|
|
645
|
-
.nb-history-panel {
|
|
646
|
-
display: none;
|
|
647
|
-
margin-top: 12px; padding: 12px 14px;
|
|
648
|
-
background: var(--color-bg);
|
|
649
|
-
border: 1px solid var(--color-border);
|
|
650
|
-
border-radius: 8px;
|
|
651
|
-
max-height: 240px; overflow-y: auto;
|
|
652
|
-
}
|
|
653
|
-
.nb-history-panel.nb-open { display: block; }
|
|
654
|
-
.nb-history-panel .nb-hp-title {
|
|
655
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
656
|
-
font-size: 10px; color: var(--color-text2);
|
|
657
|
-
text-transform: uppercase; letter-spacing: 0.1em;
|
|
658
|
-
margin-bottom: 10px; display: flex; justify-content: space-between;
|
|
659
|
-
}
|
|
660
|
-
.nb-history-panel .nb-hp-entry {
|
|
661
|
-
display: flex; align-items: center; gap: 10px;
|
|
662
|
-
padding: 6px 4px; font-size: 12px; border-radius: 4px;
|
|
663
|
-
border-bottom: 1px solid var(--color-border);
|
|
664
|
-
}
|
|
665
|
-
.nb-history-panel .nb-hp-entry:last-child { border-bottom: none; }
|
|
666
|
-
.nb-history-panel .nb-hp-entry .nb-when {
|
|
667
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
668
|
-
font-size: 10px; color: var(--color-text2); min-width: 48px;
|
|
669
|
-
}
|
|
670
|
-
.nb-history-panel .nb-hp-entry .nb-action { flex: 1; color: var(--color-text1); }
|
|
671
|
-
.nb-history-panel .nb-hp-entry .nb-kind {
|
|
672
|
-
display: inline-block; padding: 1px 5px; border-radius: 3px;
|
|
673
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
674
|
-
font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; margin-right: 6px;
|
|
675
|
-
}
|
|
676
|
-
.nb-kind-add { background: rgba(62,207,178,0.15); color: var(--color-teal); }
|
|
677
|
-
.nb-kind-del { background: rgba(250,109,124,0.15); color: var(--color-accent2); }
|
|
678
|
-
.nb-kind-edit { background: rgba(124,109,250,0.15); color: var(--color-accent); }
|
|
679
|
-
.nb-kind-move { background: rgba(160,160,184,0.15); color: var(--color-text2); }
|
|
680
|
-
.nb-kind-run { background: rgba(240,160,80,0.15); color: var(--color-amber); }
|
|
681
|
-
.nb-hp-restore {
|
|
682
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
683
|
-
font-size: 10px; padding: 3px 8px;
|
|
684
|
-
color: var(--color-text2); background: transparent;
|
|
685
|
-
border: 1px solid var(--color-border); border-radius: 4px; cursor: pointer;
|
|
686
|
-
}
|
|
687
|
-
.nb-hp-restore:hover { color: var(--color-accent); border-color: var(--color-accent); }
|
|
688
|
-
.nb-hp-empty { font-size: 12px; color: var(--color-text2); text-align: center; padding: 12px 0; font-style: italic; }
|
|
689
|
-
|
|
690
|
-
/* Modals */
|
|
691
|
-
.nb-confirm-overlay, .nb-share-overlay {
|
|
692
|
-
position: fixed; inset: 0;
|
|
693
|
-
background: rgba(0, 0, 0, 0.55);
|
|
694
|
-
display: none; align-items: center; justify-content: center;
|
|
695
|
-
z-index: 1001; padding: 24px;
|
|
696
|
-
}
|
|
697
|
-
.nb-confirm-overlay.open, .nb-share-overlay.open { display: flex; }
|
|
698
|
-
.nb-confirm-modal, .nb-share-modal {
|
|
699
|
-
background: var(--color-surface);
|
|
700
|
-
border: 1px solid var(--color-border);
|
|
701
|
-
border-radius: 14px;
|
|
702
|
-
width: 100%; max-width: 440px;
|
|
703
|
-
padding: 22px;
|
|
704
|
-
font-family: var(--font-sans, 'Syne', sans-serif);
|
|
705
|
-
}
|
|
706
|
-
.nb-confirm-title, .nb-share-title {
|
|
707
|
-
font-size: 16px; font-weight: 600; margin: 0 0 6px;
|
|
708
|
-
}
|
|
709
|
-
.nb-confirm-msg {
|
|
710
|
-
font-size: 13px; color: var(--color-text2); line-height: 1.5; margin-bottom: 18px;
|
|
711
|
-
}
|
|
712
|
-
.nb-target {
|
|
713
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
714
|
-
font-size: 12px; background: var(--color-bg);
|
|
715
|
-
padding: 1px 6px; border-radius: 3px; color: var(--color-accent);
|
|
716
|
-
}
|
|
717
|
-
.nb-confirm-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
|
718
|
-
|
|
719
|
-
.nb-share-modal { max-width: 480px; }
|
|
720
|
-
.nb-share-sub {
|
|
721
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
722
|
-
font-size: 11px; color: var(--color-text2);
|
|
723
|
-
letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 20px;
|
|
724
|
-
}
|
|
725
|
-
.nb-share-options { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
|
|
726
|
-
.nb-share-option {
|
|
727
|
-
display: flex; align-items: center; gap: 14px;
|
|
728
|
-
padding: 12px 14px; background: var(--color-bg);
|
|
729
|
-
border: 1px solid var(--color-border); border-radius: 8px;
|
|
730
|
-
cursor: pointer; transition: border-color 0.15s, background 0.15s;
|
|
731
|
-
}
|
|
732
|
-
.nb-share-option:hover { border-color: var(--color-accent); background: var(--color-surface2); }
|
|
733
|
-
.nb-share-icon {
|
|
734
|
-
width: 30px; height: 30px; border-radius: 6px;
|
|
735
|
-
background: var(--color-surface2);
|
|
736
|
-
display: flex; align-items: center; justify-content: center;
|
|
737
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
738
|
-
font-size: 10px; font-weight: 600; color: var(--color-accent);
|
|
739
|
-
letter-spacing: 0.06em; flex-shrink: 0;
|
|
740
|
-
}
|
|
741
|
-
.nb-share-name { font-size: 13px; color: var(--color-text1); font-weight: 500; margin-bottom: 1px; }
|
|
742
|
-
.nb-share-desc { font-size: 11px; color: var(--color-text2); }
|
|
743
|
-
.nb-share-arrow {
|
|
744
|
-
margin-left: auto; color: var(--color-text2);
|
|
745
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace); font-size: 14px;
|
|
746
|
-
}
|
|
747
|
-
.nb-share-option:hover .nb-share-arrow { color: var(--color-accent); }
|
|
748
|
-
.nb-share-close {
|
|
749
|
-
width: 100%;
|
|
750
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
751
|
-
font-size: 11px; padding: 8px;
|
|
752
|
-
color: var(--color-text2); background: transparent;
|
|
753
|
-
border: 1px solid var(--color-border); border-radius: 6px; cursor: pointer;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
/* Edit surfaces */
|
|
757
|
-
textarea.nb-code-edit, textarea.nb-md-edit {
|
|
758
|
-
width: 100%; background: transparent; border: none; outline: none;
|
|
759
|
-
color: var(--color-text1);
|
|
760
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
761
|
-
font-size: 12.5px; line-height: 1.65;
|
|
762
|
-
resize: none; padding: 0; overflow: hidden;
|
|
763
|
-
}
|
|
764
|
-
textarea.nb-md-edit {
|
|
765
|
-
font-family: var(--font-sans, 'Syne', sans-serif) !important;
|
|
766
|
-
font-size: 14px !important; line-height: 1.6 !important;
|
|
767
|
-
}
|
|
768
|
-
.nb-kw { color: var(--color-accent); }
|
|
769
|
-
.nb-str { color: var(--color-teal); }
|
|
770
|
-
|
|
771
|
-
/* Data servers button + modal */
|
|
772
|
-
.nbs-trigger {
|
|
773
|
-
display: inline-flex; align-items: center; gap: 5px;
|
|
774
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
775
|
-
}
|
|
776
|
-
.nbs-trigger .nbs-badge {
|
|
777
|
-
display: inline-block;
|
|
778
|
-
font-size: 9.5px; padding: 1px 6px; border-radius: 999px;
|
|
779
|
-
background: var(--color-surface2); color: var(--color-text2);
|
|
780
|
-
font-variant-numeric: tabular-nums;
|
|
781
|
-
}
|
|
782
|
-
.nbs-trigger:not(:disabled):hover .nbs-badge {
|
|
783
|
-
background: var(--color-accent); color: #fff;
|
|
784
|
-
}
|
|
785
|
-
.nbs-trigger.nbs-empty-btn {
|
|
786
|
-
opacity: 0.45; cursor: not-allowed;
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
.nbs-overlay {
|
|
790
|
-
position: fixed; inset: 0;
|
|
791
|
-
background: rgba(0, 0, 0, 0.6);
|
|
792
|
-
display: none; align-items: center; justify-content: center;
|
|
793
|
-
z-index: 1002; padding: 24px;
|
|
794
|
-
}
|
|
795
|
-
.nbs-overlay.open { display: flex; }
|
|
796
|
-
.nbs-modal {
|
|
797
|
-
background: var(--color-surface);
|
|
798
|
-
border: 1px solid var(--color-border);
|
|
799
|
-
border-radius: 14px;
|
|
800
|
-
width: 100%; max-width: 720px; max-height: 80vh;
|
|
801
|
-
display: flex; flex-direction: column;
|
|
802
|
-
font-family: var(--font-sans, 'Syne', sans-serif);
|
|
803
|
-
overflow: hidden;
|
|
804
|
-
}
|
|
805
|
-
.nbs-header {
|
|
806
|
-
display: flex; align-items: center; justify-content: space-between;
|
|
807
|
-
padding: 16px 22px;
|
|
808
|
-
border-bottom: 1px solid var(--color-border);
|
|
809
|
-
}
|
|
810
|
-
.nbs-title { font-size: 16px; font-weight: 600; color: var(--color-text1); }
|
|
811
|
-
.nbs-close {
|
|
812
|
-
background: transparent; border: none; cursor: pointer;
|
|
813
|
-
color: var(--color-text2); font-size: 22px; line-height: 1;
|
|
814
|
-
width: 30px; height: 30px; border-radius: 50%;
|
|
815
|
-
}
|
|
816
|
-
.nbs-close:hover { color: var(--color-text1); background: var(--color-surface2); }
|
|
817
|
-
|
|
818
|
-
.nbs-body {
|
|
819
|
-
padding: 16px 22px; overflow-y: auto; flex: 1;
|
|
820
|
-
display: flex; flex-direction: column; gap: 18px;
|
|
821
|
-
}
|
|
822
|
-
.nbs-empty {
|
|
823
|
-
color: var(--color-text2); font-size: 13px;
|
|
824
|
-
padding: 30px 0; text-align: center; font-style: italic;
|
|
825
|
-
}
|
|
826
|
-
.nbs-empty-sub {
|
|
827
|
-
color: var(--color-text2); font-size: 11px;
|
|
828
|
-
padding: 6px 4px; font-style: italic;
|
|
829
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
830
|
-
}
|
|
831
|
-
.nbs-server {
|
|
832
|
-
border: 1px solid var(--color-border);
|
|
833
|
-
border-radius: 10px;
|
|
834
|
-
background: var(--color-bg);
|
|
835
|
-
overflow: hidden;
|
|
836
|
-
}
|
|
837
|
-
.nbs-server-head {
|
|
838
|
-
display: flex; align-items: center; gap: 10px;
|
|
839
|
-
padding: 10px 14px;
|
|
840
|
-
background: var(--color-surface2);
|
|
841
|
-
border-bottom: 1px solid var(--color-border);
|
|
842
|
-
}
|
|
843
|
-
.nbs-plug { font-size: 14px; }
|
|
844
|
-
.nbs-server-name {
|
|
845
|
-
font-size: 13px; color: var(--color-text1); font-weight: 500;
|
|
846
|
-
}
|
|
847
|
-
.nbs-server-url {
|
|
848
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
849
|
-
font-size: 10.5px; color: var(--color-text2);
|
|
850
|
-
margin-left: auto;
|
|
851
|
-
}
|
|
852
|
-
.nbs-section {
|
|
853
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
854
|
-
font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase;
|
|
855
|
-
color: var(--color-text2);
|
|
856
|
-
padding: 10px 14px 4px;
|
|
857
|
-
}
|
|
858
|
-
.nbs-list {
|
|
859
|
-
display: flex; flex-direction: column; gap: 2px;
|
|
860
|
-
padding: 0 8px 10px;
|
|
861
|
-
}
|
|
862
|
-
.nbs-item {
|
|
863
|
-
display: flex; align-items: center; gap: 10px;
|
|
864
|
-
padding: 7px 10px; border-radius: 6px;
|
|
865
|
-
cursor: pointer; font-size: 12.5px;
|
|
866
|
-
color: var(--color-text1);
|
|
867
|
-
transition: background 0.12s;
|
|
868
|
-
}
|
|
869
|
-
.nbs-item:hover { background: var(--color-surface2); }
|
|
870
|
-
.nbs-item-icon { font-size: 12px; width: 16px; text-align: center; flex-shrink: 0; }
|
|
871
|
-
.nbs-item-name {
|
|
872
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
873
|
-
font-size: 12px; color: var(--color-accent);
|
|
874
|
-
flex-shrink: 0;
|
|
875
|
-
}
|
|
876
|
-
.nbs-item-desc {
|
|
877
|
-
font-size: 11px; color: var(--color-text2);
|
|
878
|
-
margin-left: 4px;
|
|
879
|
-
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
880
|
-
}
|
|
881
|
-
`;
|
|
882
|
-
|
|
883
|
-
// ---------------------------------------------------------------------------
|
|
884
|
-
// Shared UI parts: Run/Stop control, history panel, drag-drop
|
|
885
|
-
// ---------------------------------------------------------------------------
|
|
886
|
-
|
|
887
|
-
export function mountRunControls(container: HTMLElement, cell: NotebookCell, cellWrap: HTMLElement, rerender: () => void): void {
|
|
888
|
-
container.innerHTML = '';
|
|
889
|
-
const state = cell.runState || 'idle';
|
|
890
|
-
|
|
891
|
-
if (state === 'running') {
|
|
892
|
-
const stop = document.createElement('button');
|
|
893
|
-
stop.className = 'nb-ctl-pill nb-stop';
|
|
894
|
-
stop.title = 'stop';
|
|
895
|
-
container.appendChild(stop);
|
|
896
|
-
|
|
897
|
-
const timer = document.createElement('span');
|
|
898
|
-
timer.className = 'nb-timer nb-running';
|
|
899
|
-
timer.style.marginLeft = '6px';
|
|
900
|
-
timer.innerHTML = '<span class="nb-dot"></span><span class="nb-elapsed">0ms</span>';
|
|
901
|
-
container.appendChild(timer);
|
|
902
|
-
|
|
903
|
-
cellWrap.classList.add('nb-running');
|
|
904
|
-
const wsCell = cellWrap.querySelector?.('.nb-cell');
|
|
905
|
-
if (wsCell) wsCell.classList.add('nb-running');
|
|
906
|
-
|
|
907
|
-
tickRunningCell(cell, timer.querySelector('.nb-elapsed') as HTMLElement, rerender);
|
|
908
|
-
|
|
909
|
-
stop.addEventListener('click', () => stopRun(cell, rerender));
|
|
910
|
-
return;
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
cellWrap.classList.remove('nb-running');
|
|
914
|
-
const wsCell = cellWrap.querySelector?.('.nb-cell');
|
|
915
|
-
if (wsCell) wsCell.classList.remove('nb-running');
|
|
916
|
-
|
|
917
|
-
// idle / done / stopped — single green Run button (replay = re-click Run)
|
|
918
|
-
const run = document.createElement('button');
|
|
919
|
-
run.className = 'nb-ctl-pill nb-run';
|
|
920
|
-
run.title = state === 'done' ? 'replay' : state === 'stopped' ? 'stopped · run again' : 'run';
|
|
921
|
-
run.addEventListener('click', () => startRun(cell, rerender));
|
|
922
|
-
container.appendChild(run);
|
|
923
|
-
|
|
924
|
-
if (cell.lastMs != null && state !== 'idle') {
|
|
925
|
-
const tag = document.createElement('span');
|
|
926
|
-
tag.className = 'nb-timer';
|
|
927
|
-
tag.style.marginLeft = '6px';
|
|
928
|
-
const dotColor = state === 'stopped' ? 'var(--color-accent2)' : 'var(--color-text2)';
|
|
929
|
-
const label = state === 'stopped' ? 'stopped' : 'last run';
|
|
930
|
-
tag.innerHTML = `<span class="nb-dot" style="background:${dotColor};"></span><span>${label} · ${formatDuration(cell.lastMs)}</span>`;
|
|
931
|
-
container.appendChild(tag);
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
export function mountHistoryPanel(
|
|
936
|
-
panelEl: HTMLElement,
|
|
937
|
-
state: NotebookState,
|
|
938
|
-
onRestore: (snap: { cell: NotebookCell; idx: number }) => void
|
|
939
|
-
): void {
|
|
940
|
-
const entries = state.history;
|
|
941
|
-
panelEl.innerHTML = `
|
|
942
|
-
<div class="nb-hp-title">
|
|
943
|
-
<span>history · ${entries.length} action${entries.length !== 1 ? 's' : ''}</span>
|
|
944
|
-
<span>↻ restores state</span>
|
|
945
|
-
</div>
|
|
946
|
-
${entries.length === 0
|
|
947
|
-
? '<div class="nb-hp-empty">no actions yet — edit, add, or delete cells</div>'
|
|
948
|
-
: entries.map((h, idx) => `
|
|
949
|
-
<div class="nb-hp-entry" data-idx="${idx}">
|
|
950
|
-
<span class="nb-when">${fmtRelTime(h.ts)}</span>
|
|
951
|
-
<span class="nb-action"><span class="nb-kind nb-kind-${h.kind}">${h.kind}</span>${escapeHtml(h.summary)}</span>
|
|
952
|
-
${h.snapshot ? '<button class="nb-hp-restore">restore</button>' : ''}
|
|
953
|
-
</div>`).join('')
|
|
954
|
-
}`;
|
|
955
|
-
panelEl.querySelectorAll<HTMLElement>('.nb-hp-restore').forEach((btn) => {
|
|
956
|
-
btn.addEventListener('click', () => {
|
|
957
|
-
const idx = parseInt(btn.closest('.nb-hp-entry')!.getAttribute('data-idx')!, 10);
|
|
958
|
-
const snap = entries[idx].snapshot;
|
|
959
|
-
if (snap) onRestore(snap);
|
|
960
|
-
});
|
|
961
|
-
});
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
export function setupDnD(container: HTMLElement, state: NotebookState, rerender: () => void): void {
|
|
965
|
-
let draggedId: string | null = null;
|
|
966
|
-
container.addEventListener('dragstart', (e) => {
|
|
967
|
-
const handle = (e.target as HTMLElement).closest('.nb-drag-handle');
|
|
968
|
-
if (!handle) { e.preventDefault(); return; }
|
|
969
|
-
const wrap = handle.closest('.nb-cell-wrapper') as HTMLElement;
|
|
970
|
-
draggedId = wrap.dataset.id!;
|
|
971
|
-
wrap.classList.add('nb-dragging');
|
|
972
|
-
e.dataTransfer!.effectAllowed = 'move';
|
|
973
|
-
});
|
|
974
|
-
container.addEventListener('dragend', () => {
|
|
975
|
-
container.querySelectorAll('.nb-cell-wrapper').forEach((w) => {
|
|
976
|
-
w.classList.remove('nb-dragging', 'nb-drag-over-before', 'nb-drag-over-after');
|
|
977
|
-
});
|
|
978
|
-
draggedId = null;
|
|
979
|
-
});
|
|
980
|
-
container.addEventListener('dragover', (e) => {
|
|
981
|
-
e.preventDefault();
|
|
982
|
-
const wrap = (e.target as HTMLElement).closest('.nb-cell-wrapper') as HTMLElement | null;
|
|
983
|
-
container.querySelectorAll('.nb-cell-wrapper').forEach((w) => {
|
|
984
|
-
w.classList.remove('nb-drag-over-before', 'nb-drag-over-after');
|
|
985
|
-
});
|
|
986
|
-
if (!wrap || wrap.dataset.id === draggedId) return;
|
|
987
|
-
const rect = wrap.getBoundingClientRect();
|
|
988
|
-
const before = (e.clientY - rect.top) < rect.height / 2;
|
|
989
|
-
wrap.classList.add(before ? 'nb-drag-over-before' : 'nb-drag-over-after');
|
|
990
|
-
});
|
|
991
|
-
container.addEventListener('drop', (e) => {
|
|
992
|
-
e.preventDefault();
|
|
993
|
-
const wrap = (e.target as HTMLElement).closest('.nb-cell-wrapper') as HTMLElement | null;
|
|
994
|
-
if (!wrap || !draggedId || wrap.dataset.id === draggedId) return;
|
|
995
|
-
const fromIdx = state.cells.findIndex((c) => c.id === draggedId);
|
|
996
|
-
let toIdx = state.cells.findIndex((c) => c.id === wrap.dataset.id);
|
|
997
|
-
const rect = wrap.getBoundingClientRect();
|
|
998
|
-
const before = (e.clientY - rect.top) < rect.height / 2;
|
|
999
|
-
if (!before) toIdx++;
|
|
1000
|
-
if (fromIdx < toIdx) toIdx--;
|
|
1001
|
-
moveCell(state, fromIdx, toIdx);
|
|
1002
|
-
rerender();
|
|
1003
|
-
});
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
// ---------------------------------------------------------------------------
|
|
1007
|
-
// Helpers for action handlers
|
|
1008
|
-
// ---------------------------------------------------------------------------
|
|
1009
|
-
|
|
1010
|
-
export async function deleteCellWithConfirm(
|
|
1011
|
-
state: NotebookState,
|
|
1012
|
-
cell: NotebookCell,
|
|
1013
|
-
describe: (c: NotebookCell) => string,
|
|
1014
|
-
rerender: () => void
|
|
1015
|
-
): Promise<void> {
|
|
1016
|
-
const targetName = describe(cell);
|
|
1017
|
-
const ok = await askConfirm(
|
|
1018
|
-
'Delete cell?',
|
|
1019
|
-
'Remove {target} from the notebook? You can restore it later from the history panel.',
|
|
1020
|
-
targetName
|
|
1021
|
-
);
|
|
1022
|
-
if (!ok) return;
|
|
1023
|
-
const idx = state.cells.findIndex((c) => c.id === cell.id);
|
|
1024
|
-
if (idx < 0) return;
|
|
1025
|
-
const removed = state.cells.splice(idx, 1)[0];
|
|
1026
|
-
logHistory(state, 'del', `removed ${targetName}`, { cell: removed, idx });
|
|
1027
|
-
rerender();
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
export function restoreCellFromSnapshot(state: NotebookState, snapshot: { cell: NotebookCell; idx: number }): void {
|
|
1031
|
-
const insertAt = Math.min(snapshot.idx, state.cells.length);
|
|
1032
|
-
state.cells.splice(insertAt, 0, snapshot.cell);
|
|
1033
|
-
logHistory(state, 'add', `restored ${snapshot.cell.type} cell`);
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
export function addCell(state: NotebookState, type: CellType, opts?: Partial<NotebookCell>): NotebookCell {
|
|
1037
|
-
const cell: NotebookCell = {
|
|
1038
|
-
id: uid(),
|
|
1039
|
-
type,
|
|
1040
|
-
content: opts?.content ?? defaultCellContent(type),
|
|
1041
|
-
hideSource: false,
|
|
1042
|
-
hideResult: false,
|
|
1043
|
-
status: 'stale',
|
|
1044
|
-
...(opts || {}),
|
|
1045
|
-
};
|
|
1046
|
-
state.cells.push(cell);
|
|
1047
|
-
logHistory(state, 'add', `added ${type} cell`);
|
|
1048
|
-
return cell;
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
function escapeHtml(s: string): string {
|
|
1052
|
-
return s.replace(/[&<>"']/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]!));
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
// ---------------------------------------------------------------------------
|
|
1056
|
-
// Keep history timestamps fresh across all notebooks
|
|
1057
|
-
// ---------------------------------------------------------------------------
|
|
1058
|
-
|
|
1059
|
-
const historyObservers = new Set<() => void>();
|
|
1060
|
-
export function registerHistoryObserver(fn: () => void): () => void {
|
|
1061
|
-
historyObservers.add(fn);
|
|
1062
|
-
return () => historyObservers.delete(fn);
|
|
1063
|
-
}
|
|
1064
|
-
setInterval(() => { historyObservers.forEach((fn) => fn()); }, 15000);
|