@webmcp-auto-ui/ui 2.5.28 → 2.5.29

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.
@@ -201,6 +201,341 @@ 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
+ // @ts-ignore — turndown ships its own types but we stay ts-nocheck here
211
+ import TurndownService from 'turndown';
212
+
213
+ let _td: any = null;
214
+ function td(): any {
215
+ if (_td) return _td;
216
+ _td = new TurndownService({
217
+ headingStyle: 'atx',
218
+ hr: '---',
219
+ bulletListMarker: '-',
220
+ codeBlockStyle: 'fenced',
221
+ emDelimiter: '*',
222
+ strongDelimiter: '**',
223
+ linkStyle: 'inlined',
224
+ });
225
+ // Preserve <mark> as ==...== (matches our renderer)
226
+ _td.addRule('mark', {
227
+ filter: 'mark',
228
+ replacement: (content: string) => `==${content}==`,
229
+ });
230
+ return _td;
231
+ }
232
+
233
+ function htmlToMd(html: string): string {
234
+ try { return td().turndown(html || ''); } catch { return ''; }
235
+ }
236
+
237
+ function ensureToolbarStyles(): void {
238
+ if (document.getElementById('nbe-wysiwyg-styles')) return;
239
+ const style = document.createElement('style');
240
+ style.id = 'nbe-wysiwyg-styles';
241
+ style.textContent = `
242
+ .nbe-prose-wysiwyg {
243
+ display: block;
244
+ min-height: 1.6em;
245
+ max-width: 620px;
246
+ padding: 4px 6px;
247
+ margin-bottom: 4px;
248
+ border: 1px dashed transparent;
249
+ border-radius: 4px;
250
+ outline: none;
251
+ cursor: text;
252
+ }
253
+ .nbe-prose-wysiwyg:hover { border-color: var(--color-border); }
254
+ .nbe-prose-wysiwyg:focus,
255
+ .nbe-prose-wysiwyg.nbe-focus {
256
+ border-color: var(--color-border2);
257
+ border-style: solid;
258
+ background: var(--color-bg);
259
+ }
260
+ .nbe-prose-wysiwyg[data-empty="true"]::before {
261
+ content: attr(data-placeholder);
262
+ color: var(--color-text2);
263
+ font-style: italic;
264
+ pointer-events: none;
265
+ }
266
+ .nbe-wysiwyg-toolbar {
267
+ position: fixed;
268
+ z-index: 1010;
269
+ display: inline-flex;
270
+ gap: 2px;
271
+ padding: 4px;
272
+ background: var(--color-surface2);
273
+ border: 1px solid var(--color-border);
274
+ border-radius: 6px;
275
+ box-shadow: 0 6px 20px rgba(0,0,0,0.18);
276
+ font-family: var(--font-mono, 'IBM Plex Mono', monospace);
277
+ font-size: 11px;
278
+ opacity: 0;
279
+ transform: translateY(4px);
280
+ pointer-events: none;
281
+ transition: opacity 0.12s ease, transform 0.12s ease;
282
+ }
283
+ .nbe-wysiwyg-toolbar.nbe-visible {
284
+ opacity: 1;
285
+ transform: translateY(0);
286
+ pointer-events: auto;
287
+ }
288
+ .nbe-wysiwyg-toolbar button {
289
+ background: transparent;
290
+ color: var(--color-text1);
291
+ border: none;
292
+ border-radius: 3px;
293
+ padding: 4px 7px;
294
+ cursor: pointer;
295
+ font-family: inherit;
296
+ font-size: 11px;
297
+ min-width: 22px;
298
+ }
299
+ .nbe-wysiwyg-toolbar button:hover { background: var(--color-surface); }
300
+ .nbe-wysiwyg-toolbar button.nbe-wy-strong { font-weight: 700; }
301
+ .nbe-wysiwyg-toolbar button.nbe-wy-em { font-style: italic; }
302
+ `;
303
+ document.head.appendChild(style);
304
+ }
305
+
306
+ let _toolbarEl: HTMLElement | null = null;
307
+ let _activeEditor: HTMLElement | null = null;
308
+ let _toolbarCallback: ((cmd: string) => void) | null = null;
309
+
310
+ function ensureToolbar(): HTMLElement {
311
+ if (_toolbarEl) return _toolbarEl;
312
+ const bar = document.createElement('div');
313
+ bar.className = 'nbe-wysiwyg-toolbar';
314
+ bar.setAttribute('role', 'toolbar');
315
+ const btns: Array<[string, string, string]> = [
316
+ ['bold', 'B', 'nbe-wy-strong'],
317
+ ['italic', 'I', 'nbe-wy-em'],
318
+ ['h2', 'H2', ''],
319
+ ['h3', 'H3', ''],
320
+ ['ul', '• list', ''],
321
+ ['link', 'link', ''],
322
+ ['code', '<>', ''],
323
+ ];
324
+ for (const [cmd, label, cls] of btns) {
325
+ const b = document.createElement('button');
326
+ b.type = 'button';
327
+ b.textContent = label;
328
+ b.dataset.cmd = cmd;
329
+ if (cls) b.classList.add(cls);
330
+ // mousedown before focus is lost
331
+ b.addEventListener('mousedown', (e) => {
332
+ e.preventDefault();
333
+ _toolbarCallback?.(cmd);
334
+ });
335
+ bar.appendChild(b);
336
+ }
337
+ document.body.appendChild(bar);
338
+ _toolbarEl = bar;
339
+ return bar;
340
+ }
341
+
342
+ function positionToolbar(): void {
343
+ if (!_toolbarEl || !_activeEditor) return;
344
+ const sel = window.getSelection();
345
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
346
+ _toolbarEl.classList.remove('nbe-visible');
347
+ return;
348
+ }
349
+ // Only show if selection is within active editor
350
+ const range = sel.getRangeAt(0);
351
+ if (!_activeEditor.contains(range.commonAncestorContainer)) {
352
+ _toolbarEl.classList.remove('nbe-visible');
353
+ return;
354
+ }
355
+ const rect = range.getBoundingClientRect();
356
+ if (rect.width === 0 && rect.height === 0) {
357
+ _toolbarEl.classList.remove('nbe-visible');
358
+ return;
359
+ }
360
+ const bar = _toolbarEl;
361
+ bar.classList.add('nbe-visible');
362
+ const barRect = bar.getBoundingClientRect();
363
+ let top = rect.bottom + 6;
364
+ if (top + barRect.height > window.innerHeight) top = rect.top - barRect.height - 6;
365
+ let left = rect.left + rect.width / 2 - barRect.width / 2;
366
+ left = Math.max(6, Math.min(window.innerWidth - barRect.width - 6, left));
367
+ bar.style.top = `${top}px`;
368
+ bar.style.left = `${left}px`;
369
+ }
370
+
371
+ function hideToolbar(): void {
372
+ _toolbarEl?.classList.remove('nbe-visible');
373
+ }
374
+
375
+ function execCmd(cmd: string, editor: HTMLElement): void {
376
+ editor.focus();
377
+ switch (cmd) {
378
+ case 'bold': document.execCommand('bold'); break;
379
+ case 'italic': document.execCommand('italic'); break;
380
+ case 'h2': document.execCommand('formatBlock', false, 'H2'); break;
381
+ case 'h3': document.execCommand('formatBlock', false, 'H3'); break;
382
+ case 'ul': document.execCommand('insertUnorderedList'); break;
383
+ case 'code': {
384
+ const sel = window.getSelection();
385
+ if (!sel || sel.rangeCount === 0) return;
386
+ const range = sel.getRangeAt(0);
387
+ if (range.collapsed) return;
388
+ const text = range.toString();
389
+ const codeEl = document.createElement('code');
390
+ codeEl.textContent = text;
391
+ range.deleteContents();
392
+ range.insertNode(codeEl);
393
+ sel.removeAllRanges();
394
+ const r2 = document.createRange();
395
+ r2.setStartAfter(codeEl);
396
+ r2.collapse(true);
397
+ sel.addRange(r2);
398
+ break;
399
+ }
400
+ case 'link': {
401
+ const url = window.prompt('Link URL:');
402
+ if (!url) return;
403
+ document.execCommand('createLink', false, url);
404
+ break;
405
+ }
406
+ }
407
+ // Fire an input event to trigger debounced MD conversion
408
+ editor.dispatchEvent(new InputEvent('input', { bubbles: true }));
409
+ requestAnimationFrame(() => positionToolbar());
410
+ }
411
+
412
+ /**
413
+ * Mount a WYSIWYG editor in place of the traditional textarea+preview split.
414
+ * - Single contenteditable zone rendering live markdown.
415
+ * - Floating toolbar (B/I/H2/H3/ul/link/code) shown on text selection.
416
+ * - Paste handler converts HTML → markdown (strips inline styles from Notion/GDocs).
417
+ * - On input (debounced) + on blur, HTML is converted to markdown into `get`/`set`.
418
+ *
419
+ * Returns the host element and a cleanup function.
420
+ */
421
+ export function mountEditableProse(opts: {
422
+ getContent: () => string;
423
+ setContent: (md: string) => void;
424
+ onChange?: () => void;
425
+ placeholder?: string;
426
+ }): { el: HTMLElement; destroy: () => void } {
427
+ ensureToolbarStyles();
428
+ ensureToolbar();
429
+
430
+ const host = document.createElement('div');
431
+ host.className = 'nbe-prose nbe-prose-render nbe-prose-wysiwyg';
432
+ host.contentEditable = 'true';
433
+ host.spellcheck = true;
434
+ host.dataset.placeholder = opts.placeholder ?? 'write prose (markdown, WYSIWYG)…';
435
+ host.innerHTML = renderProse(opts.getContent() || '');
436
+ updateEmptyState(host);
437
+
438
+ let debounceId: any = null;
439
+ const scheduleSync = () => {
440
+ if (debounceId) clearTimeout(debounceId);
441
+ debounceId = setTimeout(() => {
442
+ flushToMd();
443
+ }, 400);
444
+ };
445
+ const flushToMd = () => {
446
+ const html = host.innerHTML;
447
+ const md = htmlToMd(html);
448
+ opts.setContent(md);
449
+ opts.onChange?.();
450
+ updateEmptyState(host);
451
+ };
452
+
453
+ const onInput = () => {
454
+ updateEmptyState(host);
455
+ scheduleSync();
456
+ positionToolbar();
457
+ };
458
+ const onFocus = () => {
459
+ host.classList.add('nbe-focus');
460
+ _activeEditor = host;
461
+ _toolbarCallback = (cmd: string) => execCmd(cmd, host);
462
+ };
463
+ const onBlur = () => {
464
+ host.classList.remove('nbe-focus');
465
+ // If focus moves to the toolbar we skip — use a deferred check
466
+ setTimeout(() => {
467
+ if (document.activeElement === host) return;
468
+ if (_toolbarEl && _toolbarEl.contains(document.activeElement)) return;
469
+ if (_activeEditor === host) {
470
+ _activeEditor = null;
471
+ _toolbarCallback = null;
472
+ hideToolbar();
473
+ }
474
+ flushToMd();
475
+ }, 10);
476
+ };
477
+ const onSelectionChange = () => {
478
+ if (_activeEditor === host) positionToolbar();
479
+ };
480
+ const onKeyDown = (e: KeyboardEvent) => {
481
+ const meta = e.metaKey || e.ctrlKey;
482
+ if (meta && e.key.toLowerCase() === 'b') { e.preventDefault(); execCmd('bold', host); }
483
+ else if (meta && e.key.toLowerCase() === 'i') { e.preventDefault(); execCmd('italic', host); }
484
+ else if (meta && e.key.toLowerCase() === 'k') { e.preventDefault(); execCmd('link', host); }
485
+ };
486
+ const onPaste = (e: ClipboardEvent) => {
487
+ const cd = e.clipboardData;
488
+ if (!cd) return;
489
+ const html = cd.getData('text/html');
490
+ const text = cd.getData('text/plain');
491
+ if (html) {
492
+ e.preventDefault();
493
+ // Strip inline styles by routing via turndown → re-render via our MD pipeline
494
+ const md = htmlToMd(html);
495
+ const cleanHtml = renderProse(md);
496
+ document.execCommand('insertHTML', false, cleanHtml);
497
+ scheduleSync();
498
+ } else if (text) {
499
+ // Plain text paste — default behaviour fine, but still trigger sync
500
+ scheduleSync();
501
+ }
502
+ };
503
+
504
+ host.addEventListener('input', onInput);
505
+ host.addEventListener('focus', onFocus);
506
+ host.addEventListener('blur', onBlur);
507
+ host.addEventListener('keydown', onKeyDown);
508
+ host.addEventListener('paste', onPaste);
509
+ document.addEventListener('selectionchange', onSelectionChange);
510
+
511
+ return {
512
+ el: host,
513
+ destroy: () => {
514
+ if (debounceId) clearTimeout(debounceId);
515
+ host.removeEventListener('input', onInput);
516
+ host.removeEventListener('focus', onFocus);
517
+ host.removeEventListener('blur', onBlur);
518
+ host.removeEventListener('keydown', onKeyDown);
519
+ host.removeEventListener('paste', onPaste);
520
+ document.removeEventListener('selectionchange', onSelectionChange);
521
+ if (_activeEditor === host) {
522
+ _activeEditor = null;
523
+ _toolbarCallback = null;
524
+ hideToolbar();
525
+ }
526
+ },
527
+ };
528
+ }
529
+
530
+ function updateEmptyState(host: HTMLElement): void {
531
+ const txt = (host.textContent || '').trim();
532
+ if (!txt && host.children.length <= 1) {
533
+ host.dataset.empty = 'true';
534
+ } else {
535
+ host.dataset.empty = 'false';
536
+ }
537
+ }
538
+
204
539
  // ---------------------------------------------------------------------------
205
540
  // Renderer with inject buttons — used by recipe viewer modal.
206
541
  // Each fenced code block gets an "↳ inject" button next to it.
@@ -1,5 +1,5 @@
1
1
  ---
2
- widget: notebook-editorial
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-editorial` when the notebook is meant to be published or shared as a finished artifact:
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-editorial", params: {
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-editorial` does not separate prose and code into different flows — they are the same flow in one list.
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-editorial',
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:8px;font-family:system-ui,sans-serif;font-size:13px;background:var(--bg,#1a1a2e);color:var(--fg,#e2e2e8)}
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
- const spec: JsSandboxSpec = (data && data.spec) || ({ code: '' } as JsSandboxSpec);
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({ code: '', html: '', css: '' });
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:node-dblclick' { detail: { nodeId, nodeData }, bubbles: true }
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.dispatchEvent(
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 {