@webmcp-auto-ui/ui 2.5.28 → 2.5.30
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 +4 -3
- package/src/index.ts +6 -6
- package/src/theme/scale.ts +128 -0
- package/src/widgets/WidgetRenderer.svelte +33 -6
- package/src/widgets/notebook/import-modals.ts +9 -2
- package/src/widgets/notebook/left-pane.ts +9 -2
- package/src/widgets/notebook/{editorial.ts → notebook.ts} +14 -20
- package/src/widgets/notebook/prose.ts +342 -0
- package/src/widgets/notebook/recipes/{editorial.md → notebook.md} +9 -5
- package/src/widgets/notebook/shared.ts +42 -1
- package/src/widgets/rich/js-sandbox.ts +21 -5
- package/src/widgets/rich/sankey.ts +3 -8
- package/src/agent/DataServersPanel.svelte +0 -164
- package/src/widgets/notebook/compact.ts +0 -823
- package/src/widgets/notebook/document.ts +0 -1065
- package/src/widgets/notebook/recipes/compact.md +0 -124
- package/src/widgets/notebook/recipes/document.md +0 -139
- package/src/widgets/notebook/recipes/workspace.md +0 -119
- package/src/widgets/notebook/workspace.ts +0 -852
|
@@ -201,6 +201,348 @@ function walk(node: Node): void {
|
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Inline WYSIWYG editor — single contenteditable zone, no dual-view.
|
|
206
|
+
// Markdown remains the source of truth: rendered on mount/blur, converted back
|
|
207
|
+
// via turndown on input (debounced) and at blur.
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
// turndown is loaded lazily (browser-only). Top-level import breaks SSR because
|
|
211
|
+
// turndown's CJS internals use require() which throws in ESM scope.
|
|
212
|
+
let _td: any = null;
|
|
213
|
+
async function ensureTd(): Promise<any> {
|
|
214
|
+
if (_td) return _td;
|
|
215
|
+
if (typeof window === 'undefined') return null;
|
|
216
|
+
// @ts-ignore — turndown ships its own types but we stay ts-nocheck here
|
|
217
|
+
const mod = await import('turndown');
|
|
218
|
+
const TurndownService = mod.default || mod;
|
|
219
|
+
_td = new TurndownService({
|
|
220
|
+
headingStyle: 'atx',
|
|
221
|
+
hr: '---',
|
|
222
|
+
bulletListMarker: '-',
|
|
223
|
+
codeBlockStyle: 'fenced',
|
|
224
|
+
emDelimiter: '*',
|
|
225
|
+
strongDelimiter: '**',
|
|
226
|
+
linkStyle: 'inlined',
|
|
227
|
+
});
|
|
228
|
+
// Preserve <mark> as ==...== (matches our renderer)
|
|
229
|
+
_td.addRule('mark', {
|
|
230
|
+
filter: 'mark',
|
|
231
|
+
replacement: (content: string) => `==${content}==`,
|
|
232
|
+
});
|
|
233
|
+
return _td;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function htmlToMd(html: string): Promise<string> {
|
|
237
|
+
try {
|
|
238
|
+
const t = await ensureTd();
|
|
239
|
+
return t ? t.turndown(html || '') : '';
|
|
240
|
+
} catch { return ''; }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function ensureToolbarStyles(): void {
|
|
244
|
+
if (document.getElementById('nbe-wysiwyg-styles')) return;
|
|
245
|
+
const style = document.createElement('style');
|
|
246
|
+
style.id = 'nbe-wysiwyg-styles';
|
|
247
|
+
style.textContent = `
|
|
248
|
+
.nbe-prose-wysiwyg {
|
|
249
|
+
display: block;
|
|
250
|
+
min-height: 1.6em;
|
|
251
|
+
max-width: 620px;
|
|
252
|
+
padding: 4px 6px;
|
|
253
|
+
margin-bottom: 4px;
|
|
254
|
+
border: 1px dashed transparent;
|
|
255
|
+
border-radius: 4px;
|
|
256
|
+
outline: none;
|
|
257
|
+
cursor: text;
|
|
258
|
+
}
|
|
259
|
+
.nbe-prose-wysiwyg:hover { border-color: var(--color-border); }
|
|
260
|
+
.nbe-prose-wysiwyg:focus,
|
|
261
|
+
.nbe-prose-wysiwyg.nbe-focus {
|
|
262
|
+
border-color: var(--color-border2);
|
|
263
|
+
border-style: solid;
|
|
264
|
+
background: var(--color-bg);
|
|
265
|
+
}
|
|
266
|
+
.nbe-prose-wysiwyg[data-empty="true"]::before {
|
|
267
|
+
content: attr(data-placeholder);
|
|
268
|
+
color: var(--color-text2);
|
|
269
|
+
font-style: italic;
|
|
270
|
+
pointer-events: none;
|
|
271
|
+
}
|
|
272
|
+
.nbe-wysiwyg-toolbar {
|
|
273
|
+
position: fixed;
|
|
274
|
+
z-index: 1010;
|
|
275
|
+
display: inline-flex;
|
|
276
|
+
gap: 2px;
|
|
277
|
+
padding: 4px;
|
|
278
|
+
background: var(--color-surface2);
|
|
279
|
+
border: 1px solid var(--color-border);
|
|
280
|
+
border-radius: 6px;
|
|
281
|
+
box-shadow: 0 6px 20px rgba(0,0,0,0.18);
|
|
282
|
+
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
283
|
+
font-size: 11px;
|
|
284
|
+
opacity: 0;
|
|
285
|
+
transform: translateY(4px);
|
|
286
|
+
pointer-events: none;
|
|
287
|
+
transition: opacity 0.12s ease, transform 0.12s ease;
|
|
288
|
+
}
|
|
289
|
+
.nbe-wysiwyg-toolbar.nbe-visible {
|
|
290
|
+
opacity: 1;
|
|
291
|
+
transform: translateY(0);
|
|
292
|
+
pointer-events: auto;
|
|
293
|
+
}
|
|
294
|
+
.nbe-wysiwyg-toolbar button {
|
|
295
|
+
background: transparent;
|
|
296
|
+
color: var(--color-text1);
|
|
297
|
+
border: none;
|
|
298
|
+
border-radius: 3px;
|
|
299
|
+
padding: 4px 7px;
|
|
300
|
+
cursor: pointer;
|
|
301
|
+
font-family: inherit;
|
|
302
|
+
font-size: 11px;
|
|
303
|
+
min-width: 22px;
|
|
304
|
+
}
|
|
305
|
+
.nbe-wysiwyg-toolbar button:hover { background: var(--color-surface); }
|
|
306
|
+
.nbe-wysiwyg-toolbar button.nbe-wy-strong { font-weight: 700; }
|
|
307
|
+
.nbe-wysiwyg-toolbar button.nbe-wy-em { font-style: italic; }
|
|
308
|
+
`;
|
|
309
|
+
document.head.appendChild(style);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let _toolbarEl: HTMLElement | null = null;
|
|
313
|
+
let _activeEditor: HTMLElement | null = null;
|
|
314
|
+
let _toolbarCallback: ((cmd: string) => void) | null = null;
|
|
315
|
+
|
|
316
|
+
function ensureToolbar(): HTMLElement {
|
|
317
|
+
if (_toolbarEl) return _toolbarEl;
|
|
318
|
+
const bar = document.createElement('div');
|
|
319
|
+
bar.className = 'nbe-wysiwyg-toolbar';
|
|
320
|
+
bar.setAttribute('role', 'toolbar');
|
|
321
|
+
const btns: Array<[string, string, string]> = [
|
|
322
|
+
['bold', 'B', 'nbe-wy-strong'],
|
|
323
|
+
['italic', 'I', 'nbe-wy-em'],
|
|
324
|
+
['h2', 'H2', ''],
|
|
325
|
+
['h3', 'H3', ''],
|
|
326
|
+
['ul', '• list', ''],
|
|
327
|
+
['link', 'link', ''],
|
|
328
|
+
['code', '<>', ''],
|
|
329
|
+
];
|
|
330
|
+
for (const [cmd, label, cls] of btns) {
|
|
331
|
+
const b = document.createElement('button');
|
|
332
|
+
b.type = 'button';
|
|
333
|
+
b.textContent = label;
|
|
334
|
+
b.dataset.cmd = cmd;
|
|
335
|
+
if (cls) b.classList.add(cls);
|
|
336
|
+
// mousedown before focus is lost
|
|
337
|
+
b.addEventListener('mousedown', (e) => {
|
|
338
|
+
e.preventDefault();
|
|
339
|
+
_toolbarCallback?.(cmd);
|
|
340
|
+
});
|
|
341
|
+
bar.appendChild(b);
|
|
342
|
+
}
|
|
343
|
+
document.body.appendChild(bar);
|
|
344
|
+
_toolbarEl = bar;
|
|
345
|
+
return bar;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function positionToolbar(): void {
|
|
349
|
+
if (!_toolbarEl || !_activeEditor) return;
|
|
350
|
+
const sel = window.getSelection();
|
|
351
|
+
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
|
|
352
|
+
_toolbarEl.classList.remove('nbe-visible');
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// Only show if selection is within active editor
|
|
356
|
+
const range = sel.getRangeAt(0);
|
|
357
|
+
if (!_activeEditor.contains(range.commonAncestorContainer)) {
|
|
358
|
+
_toolbarEl.classList.remove('nbe-visible');
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const rect = range.getBoundingClientRect();
|
|
362
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
363
|
+
_toolbarEl.classList.remove('nbe-visible');
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const bar = _toolbarEl;
|
|
367
|
+
bar.classList.add('nbe-visible');
|
|
368
|
+
const barRect = bar.getBoundingClientRect();
|
|
369
|
+
let top = rect.bottom + 6;
|
|
370
|
+
if (top + barRect.height > window.innerHeight) top = rect.top - barRect.height - 6;
|
|
371
|
+
let left = rect.left + rect.width / 2 - barRect.width / 2;
|
|
372
|
+
left = Math.max(6, Math.min(window.innerWidth - barRect.width - 6, left));
|
|
373
|
+
bar.style.top = `${top}px`;
|
|
374
|
+
bar.style.left = `${left}px`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function hideToolbar(): void {
|
|
378
|
+
_toolbarEl?.classList.remove('nbe-visible');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function execCmd(cmd: string, editor: HTMLElement): void {
|
|
382
|
+
editor.focus();
|
|
383
|
+
switch (cmd) {
|
|
384
|
+
case 'bold': document.execCommand('bold'); break;
|
|
385
|
+
case 'italic': document.execCommand('italic'); break;
|
|
386
|
+
case 'h2': document.execCommand('formatBlock', false, 'H2'); break;
|
|
387
|
+
case 'h3': document.execCommand('formatBlock', false, 'H3'); break;
|
|
388
|
+
case 'ul': document.execCommand('insertUnorderedList'); break;
|
|
389
|
+
case 'code': {
|
|
390
|
+
const sel = window.getSelection();
|
|
391
|
+
if (!sel || sel.rangeCount === 0) return;
|
|
392
|
+
const range = sel.getRangeAt(0);
|
|
393
|
+
if (range.collapsed) return;
|
|
394
|
+
const text = range.toString();
|
|
395
|
+
const codeEl = document.createElement('code');
|
|
396
|
+
codeEl.textContent = text;
|
|
397
|
+
range.deleteContents();
|
|
398
|
+
range.insertNode(codeEl);
|
|
399
|
+
sel.removeAllRanges();
|
|
400
|
+
const r2 = document.createRange();
|
|
401
|
+
r2.setStartAfter(codeEl);
|
|
402
|
+
r2.collapse(true);
|
|
403
|
+
sel.addRange(r2);
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
case 'link': {
|
|
407
|
+
const url = window.prompt('Link URL:');
|
|
408
|
+
if (!url) return;
|
|
409
|
+
document.execCommand('createLink', false, url);
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// Fire an input event to trigger debounced MD conversion
|
|
414
|
+
editor.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
|
415
|
+
requestAnimationFrame(() => positionToolbar());
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Mount a WYSIWYG editor in place of the traditional textarea+preview split.
|
|
420
|
+
* - Single contenteditable zone rendering live markdown.
|
|
421
|
+
* - Floating toolbar (B/I/H2/H3/ul/link/code) shown on text selection.
|
|
422
|
+
* - Paste handler converts HTML → markdown (strips inline styles from Notion/GDocs).
|
|
423
|
+
* - On input (debounced) + on blur, HTML is converted to markdown into `get`/`set`.
|
|
424
|
+
*
|
|
425
|
+
* Returns the host element and a cleanup function.
|
|
426
|
+
*/
|
|
427
|
+
export function mountEditableProse(opts: {
|
|
428
|
+
getContent: () => string;
|
|
429
|
+
setContent: (md: string) => void;
|
|
430
|
+
onChange?: () => void;
|
|
431
|
+
placeholder?: string;
|
|
432
|
+
}): { el: HTMLElement; destroy: () => void } {
|
|
433
|
+
ensureToolbarStyles();
|
|
434
|
+
ensureToolbar();
|
|
435
|
+
|
|
436
|
+
const host = document.createElement('div');
|
|
437
|
+
host.className = 'nbe-prose nbe-prose-render nbe-prose-wysiwyg';
|
|
438
|
+
host.contentEditable = 'true';
|
|
439
|
+
host.spellcheck = true;
|
|
440
|
+
host.dataset.placeholder = opts.placeholder ?? 'write prose (markdown, WYSIWYG)…';
|
|
441
|
+
host.innerHTML = renderProse(opts.getContent() || '');
|
|
442
|
+
updateEmptyState(host);
|
|
443
|
+
|
|
444
|
+
let debounceId: any = null;
|
|
445
|
+
const scheduleSync = () => {
|
|
446
|
+
if (debounceId) clearTimeout(debounceId);
|
|
447
|
+
debounceId = setTimeout(() => {
|
|
448
|
+
flushToMd();
|
|
449
|
+
}, 400);
|
|
450
|
+
};
|
|
451
|
+
const flushToMd = async () => {
|
|
452
|
+
const html = host.innerHTML;
|
|
453
|
+
const md = await htmlToMd(html);
|
|
454
|
+
opts.setContent(md);
|
|
455
|
+
opts.onChange?.();
|
|
456
|
+
updateEmptyState(host);
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const onInput = () => {
|
|
460
|
+
updateEmptyState(host);
|
|
461
|
+
scheduleSync();
|
|
462
|
+
positionToolbar();
|
|
463
|
+
};
|
|
464
|
+
const onFocus = () => {
|
|
465
|
+
host.classList.add('nbe-focus');
|
|
466
|
+
_activeEditor = host;
|
|
467
|
+
_toolbarCallback = (cmd: string) => execCmd(cmd, host);
|
|
468
|
+
};
|
|
469
|
+
const onBlur = () => {
|
|
470
|
+
host.classList.remove('nbe-focus');
|
|
471
|
+
// If focus moves to the toolbar we skip — use a deferred check
|
|
472
|
+
setTimeout(() => {
|
|
473
|
+
if (document.activeElement === host) return;
|
|
474
|
+
if (_toolbarEl && _toolbarEl.contains(document.activeElement)) return;
|
|
475
|
+
if (_activeEditor === host) {
|
|
476
|
+
_activeEditor = null;
|
|
477
|
+
_toolbarCallback = null;
|
|
478
|
+
hideToolbar();
|
|
479
|
+
}
|
|
480
|
+
flushToMd();
|
|
481
|
+
}, 10);
|
|
482
|
+
};
|
|
483
|
+
const onSelectionChange = () => {
|
|
484
|
+
if (_activeEditor === host) positionToolbar();
|
|
485
|
+
};
|
|
486
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
487
|
+
const meta = e.metaKey || e.ctrlKey;
|
|
488
|
+
if (meta && e.key.toLowerCase() === 'b') { e.preventDefault(); execCmd('bold', host); }
|
|
489
|
+
else if (meta && e.key.toLowerCase() === 'i') { e.preventDefault(); execCmd('italic', host); }
|
|
490
|
+
else if (meta && e.key.toLowerCase() === 'k') { e.preventDefault(); execCmd('link', host); }
|
|
491
|
+
};
|
|
492
|
+
const onPaste = (e: ClipboardEvent) => {
|
|
493
|
+
const cd = e.clipboardData;
|
|
494
|
+
if (!cd) return;
|
|
495
|
+
const html = cd.getData('text/html');
|
|
496
|
+
const text = cd.getData('text/plain');
|
|
497
|
+
if (html) {
|
|
498
|
+
e.preventDefault();
|
|
499
|
+
// Strip inline styles by routing via turndown → re-render via our MD pipeline
|
|
500
|
+
htmlToMd(html).then((md) => {
|
|
501
|
+
const cleanHtml = renderProse(md);
|
|
502
|
+
document.execCommand('insertHTML', false, cleanHtml);
|
|
503
|
+
scheduleSync();
|
|
504
|
+
});
|
|
505
|
+
} else if (text) {
|
|
506
|
+
// Plain text paste — default behaviour fine, but still trigger sync
|
|
507
|
+
scheduleSync();
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
host.addEventListener('input', onInput);
|
|
512
|
+
host.addEventListener('focus', onFocus);
|
|
513
|
+
host.addEventListener('blur', onBlur);
|
|
514
|
+
host.addEventListener('keydown', onKeyDown);
|
|
515
|
+
host.addEventListener('paste', onPaste);
|
|
516
|
+
document.addEventListener('selectionchange', onSelectionChange);
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
el: host,
|
|
520
|
+
destroy: () => {
|
|
521
|
+
if (debounceId) clearTimeout(debounceId);
|
|
522
|
+
host.removeEventListener('input', onInput);
|
|
523
|
+
host.removeEventListener('focus', onFocus);
|
|
524
|
+
host.removeEventListener('blur', onBlur);
|
|
525
|
+
host.removeEventListener('keydown', onKeyDown);
|
|
526
|
+
host.removeEventListener('paste', onPaste);
|
|
527
|
+
document.removeEventListener('selectionchange', onSelectionChange);
|
|
528
|
+
if (_activeEditor === host) {
|
|
529
|
+
_activeEditor = null;
|
|
530
|
+
_toolbarCallback = null;
|
|
531
|
+
hideToolbar();
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function updateEmptyState(host: HTMLElement): void {
|
|
538
|
+
const txt = (host.textContent || '').trim();
|
|
539
|
+
if (!txt && host.children.length <= 1) {
|
|
540
|
+
host.dataset.empty = 'true';
|
|
541
|
+
} else {
|
|
542
|
+
host.dataset.empty = 'false';
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
204
546
|
// ---------------------------------------------------------------------------
|
|
205
547
|
// Renderer with inject buttons — used by recipe viewer modal.
|
|
206
548
|
// Each fenced code block gets an "↳ inject" button next to it.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
widget: notebook
|
|
2
|
+
widget: notebook
|
|
3
3
|
description: Publication-ready notebook with serif prose and inline cells, all drag-and-droppable in a single ordered flow. Inspired by Observable — cells can be prose paragraphs, sql queries, or js charts, mixed freely in any order to build an article-like narrative.
|
|
4
4
|
schema:
|
|
5
5
|
type: object
|
|
@@ -14,6 +14,10 @@ schema:
|
|
|
14
14
|
kicker:
|
|
15
15
|
type: string
|
|
16
16
|
description: Small uppercase label above the title (e.g. "analysis", "memo", "brief"). Editable inline. Defaults to "untitled".
|
|
17
|
+
hideLiveToggle:
|
|
18
|
+
type: boolean
|
|
19
|
+
default: false
|
|
20
|
+
description: When true, hides the "Live data" toggle in the header (useful for embedded/host-controlled contexts where the autoRun flag is managed externally).
|
|
17
21
|
cells:
|
|
18
22
|
type: array
|
|
19
23
|
description: Mixed flow of prose and code cells. All share the same ordering and can be reordered together.
|
|
@@ -35,7 +39,7 @@ schema:
|
|
|
35
39
|
|
|
36
40
|
## When to use
|
|
37
41
|
|
|
38
|
-
Use `notebook
|
|
42
|
+
Use `notebook` when the notebook is meant to be published or shared as a finished artifact:
|
|
39
43
|
- Research memos with code appendices visible on demand
|
|
40
44
|
- Blog-style writeups mixing narrative and runnable code
|
|
41
45
|
- Final deliverables where prose leads and code supports
|
|
@@ -46,7 +50,7 @@ The distinguishing feature: prose paragraphs and code cells share a single order
|
|
|
46
50
|
|
|
47
51
|
1. **Start with prose-first seed content** and intersperse code cells:
|
|
48
52
|
```
|
|
49
|
-
widget_display({name: "notebook
|
|
53
|
+
widget_display({name: "notebook", params: {
|
|
50
54
|
title: "Q3 observations",
|
|
51
55
|
kicker: "memo",
|
|
52
56
|
cells: [
|
|
@@ -74,7 +78,7 @@ The distinguishing feature: prose paragraphs and code cells share a single order
|
|
|
74
78
|
- Prose cells are rendered via an HTML-sanitizing markdown pipeline: markdown syntax is resolved, unsafe tags are stripped (XSS closed), `<mark>` and other editorial tags are preserved.
|
|
75
79
|
- The footer exposes a single `share` button.
|
|
76
80
|
- Run / Stop controls are at the left of each code cell's header, same as the other notebook layouts.
|
|
77
|
-
- Unlike the other widgets, `notebook
|
|
81
|
+
- Unlike the other widgets, `notebook` does not separate prose and code into different flows — they are the same flow in one list.
|
|
78
82
|
|
|
79
83
|
## Left pane — resources from connected servers
|
|
80
84
|
|
|
@@ -105,7 +109,7 @@ An editorial piece earns its weight when the prose is anchored to real material.
|
|
|
105
109
|
|
|
106
110
|
```ts
|
|
107
111
|
widget_display({
|
|
108
|
-
name: 'notebook
|
|
112
|
+
name: 'notebook',
|
|
109
113
|
params: {
|
|
110
114
|
title: '...',
|
|
111
115
|
kicker: 'memo',
|
|
@@ -104,6 +104,46 @@ export function autosize(ta: HTMLTextAreaElement): void {
|
|
|
104
104
|
ta.style.height = ta.scrollHeight + 'px';
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Scroll-preservation helper for rerender paths that wipe-and-rebuild a cells
|
|
109
|
+
* container. Call BEFORE the rebuild; invoke the returned fn AFTER.
|
|
110
|
+
*
|
|
111
|
+
* Walks up from `anchor` collecting scrollable ancestors, snapshots their
|
|
112
|
+
* scrollTop + window.scrollY + active-cell id, and restores them on the next
|
|
113
|
+
* animation frame. Without this, clicking a cell's run button scrolls the
|
|
114
|
+
* page back to the top because the cells container briefly collapses.
|
|
115
|
+
*/
|
|
116
|
+
export function preserveScrollAround(anchor: HTMLElement): () => void {
|
|
117
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
118
|
+
return () => { /* no-op in SSR */ };
|
|
119
|
+
}
|
|
120
|
+
const scrollParents: HTMLElement[] = [];
|
|
121
|
+
let node: HTMLElement | null = anchor;
|
|
122
|
+
while (node) {
|
|
123
|
+
const oy = getComputedStyle(node).overflowY;
|
|
124
|
+
if ((oy === 'auto' || oy === 'scroll') && node.scrollHeight > node.clientHeight) {
|
|
125
|
+
scrollParents.push(node);
|
|
126
|
+
}
|
|
127
|
+
node = node.parentElement;
|
|
128
|
+
}
|
|
129
|
+
const winY = window.scrollY;
|
|
130
|
+
const saved = scrollParents.map((el) => el.scrollTop);
|
|
131
|
+
const activeEl = document.activeElement as HTMLElement | null;
|
|
132
|
+
const activeCellId = activeEl?.closest<HTMLElement>('[data-cell-id]')?.dataset.cellId ?? null;
|
|
133
|
+
|
|
134
|
+
return () => {
|
|
135
|
+
requestAnimationFrame(() => {
|
|
136
|
+
scrollParents.forEach((el, i) => { el.scrollTop = saved[i]!; });
|
|
137
|
+
try { window.scrollTo({ top: winY, behavior: 'instant' as ScrollBehavior }); }
|
|
138
|
+
catch { window.scrollTo(0, winY); }
|
|
139
|
+
if (activeCellId) {
|
|
140
|
+
const host = anchor.querySelector<HTMLElement>(`[data-cell-id="${activeCellId}"] textarea`);
|
|
141
|
+
host?.focus?.();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
107
147
|
export function defaultCellContent(type: CellType): string {
|
|
108
148
|
if (type === 'md') return '### new section\n\nwrite here…';
|
|
109
149
|
if (type === 'sql') return 'select *\nfrom source\nlimit 10';
|
|
@@ -1062,10 +1102,11 @@ const NOTEBOOK_STYLES = `
|
|
|
1062
1102
|
|
|
1063
1103
|
.nb-root.nb-view-mode .nb-drag-handle,
|
|
1064
1104
|
.nb-root.nb-view-mode .nb-icon-btn.nb-danger,
|
|
1065
|
-
.nb-root.nb-view-mode .nb-ctl-pill,
|
|
1066
1105
|
.nb-root.nb-view-mode .nb-toggle-src,
|
|
1067
1106
|
.nb-root.nb-view-mode .nb-toggle-res,
|
|
1068
1107
|
.nb-root.nb-view-mode .nb-add-cell { display: none !important; }
|
|
1108
|
+
/* Run controls (.nb-ctl-pill) remain active in view mode — users can execute cells
|
|
1109
|
+
* even when the notebook is read-only; only editing the source is locked. */
|
|
1069
1110
|
.nb-root.nb-view-mode textarea,
|
|
1070
1111
|
.nb-root.nb-view-mode [contenteditable] { pointer-events: none; }
|
|
1071
1112
|
.nb-root.nb-view-mode input.nb-title-edit,
|
|
@@ -21,13 +21,18 @@ function buildSrcdoc(spec: JsSandboxSpec): string {
|
|
|
21
21
|
const css = spec.css ?? '';
|
|
22
22
|
const html = spec.html ?? '';
|
|
23
23
|
const code = spec.code ?? '';
|
|
24
|
+
// Neutral base: just reset box-sizing and remove body margin. NO default
|
|
25
|
+
// colours — sandboxed iframes can't inherit the host's CSS custom properties,
|
|
26
|
+
// so forcing a dark palette here would clash with user-provided `css` that
|
|
27
|
+
// assumes the browser default (white bg, black text).
|
|
24
28
|
return `<!DOCTYPE html>
|
|
25
29
|
<html>
|
|
26
30
|
<head>
|
|
27
31
|
<meta charset="utf-8">
|
|
28
32
|
<style>
|
|
29
33
|
*,*::before,*::after{box-sizing:border-box}
|
|
30
|
-
body{margin:0;padding:
|
|
34
|
+
html,body{margin:0;padding:0}
|
|
35
|
+
body{padding:8px;font-family:system-ui,sans-serif;font-size:13px}
|
|
31
36
|
${css}
|
|
32
37
|
</style>
|
|
33
38
|
</head>
|
|
@@ -38,7 +43,7 @@ ${css}
|
|
|
38
43
|
try{
|
|
39
44
|
${code}
|
|
40
45
|
}catch(e){
|
|
41
|
-
document.getElementById('root').innerHTML='<pre style="color:red;white-space:pre-wrap">'+e+'</pre>';
|
|
46
|
+
document.getElementById('root').innerHTML='<pre style="color:red;white-space:pre-wrap;padding:8px;margin:0">'+e+'</pre>';
|
|
42
47
|
}
|
|
43
48
|
})();
|
|
44
49
|
<\/script>
|
|
@@ -46,8 +51,15 @@ document.getElementById('root').innerHTML='<pre style="color:red;white-space:pre
|
|
|
46
51
|
</html>`;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
export function render(container: HTMLElement, data: JsSandboxData): () => void {
|
|
50
|
-
|
|
54
|
+
export function render(container: HTMLElement, data: JsSandboxData | JsSandboxSpec | undefined): () => void {
|
|
55
|
+
// Tolerate three shapes from callers:
|
|
56
|
+
// { spec: { code, html, css, ... } } — explicit spec wrapper
|
|
57
|
+
// { code, html, css, ... } — bare spec (widget_display params)
|
|
58
|
+
// undefined / {} — empty placeholder
|
|
59
|
+
const raw = data ?? {};
|
|
60
|
+
const spec: JsSandboxSpec = ('spec' in raw && raw.spec)
|
|
61
|
+
? (raw as JsSandboxData).spec
|
|
62
|
+
: (raw as JsSandboxSpec);
|
|
51
63
|
|
|
52
64
|
// Outer wrapper mirrors the Svelte markup classes.
|
|
53
65
|
const wrapper = document.createElement('div');
|
|
@@ -82,7 +94,11 @@ export function render(container: HTMLElement, data: JsSandboxData): () => void
|
|
|
82
94
|
if (spec && (spec.code || spec.html || spec.css)) {
|
|
83
95
|
iframe.srcdoc = buildSrcdoc(spec);
|
|
84
96
|
} else {
|
|
85
|
-
iframe.srcdoc = buildSrcdoc({
|
|
97
|
+
iframe.srcdoc = buildSrcdoc({
|
|
98
|
+
code: '',
|
|
99
|
+
html: '<div style="opacity:.7;font-family:system-ui;font-size:13px;padding:12px">⚠ js-sandbox: no code/html/css provided.</div>',
|
|
100
|
+
css: 'body{background:#f5f5f7;color:#333}',
|
|
101
|
+
});
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
wrapper.appendChild(iframe);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Emits on `container`:
|
|
8
8
|
* - CustomEvent 'widget:interact' { detail: { action: 'nodeclick', payload: node }, bubbles: true }
|
|
9
9
|
* - CustomEvent 'widget:interact' { detail: { action: 'linkclick', payload: link }, bubbles: true }
|
|
10
|
-
* - CustomEvent 'widget:
|
|
10
|
+
* - CustomEvent 'widget:interact' { detail: { action: 'node-dblclick', payload: node }, bubbles: true }
|
|
11
11
|
*
|
|
12
12
|
* Not a real d3-sankey layout — horizontal "bar" visualization sorted by value,
|
|
13
13
|
* matching the historical Svelte widget (Sankey.svelte).
|
|
@@ -37,7 +37,7 @@ type Cleanup = () => void;
|
|
|
37
37
|
|
|
38
38
|
function emitInteract(
|
|
39
39
|
container: HTMLElement,
|
|
40
|
-
action: 'nodeclick' | 'linkclick',
|
|
40
|
+
action: 'nodeclick' | 'linkclick' | 'node-dblclick',
|
|
41
41
|
payload: unknown,
|
|
42
42
|
): void {
|
|
43
43
|
container.dispatchEvent(
|
|
@@ -49,12 +49,7 @@ function emitInteract(
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function emitNodeDblclick(container: HTMLElement, node: SankeyNode): void {
|
|
52
|
-
container
|
|
53
|
-
new CustomEvent('widget:node-dblclick', {
|
|
54
|
-
detail: { nodeId: node.id, nodeData: node },
|
|
55
|
-
bubbles: true,
|
|
56
|
-
}),
|
|
57
|
-
);
|
|
52
|
+
emitInteract(container, 'node-dblclick', node);
|
|
58
53
|
}
|
|
59
54
|
|
|
60
55
|
export function render(container: HTMLElement, data: unknown): Cleanup {
|