@webmcp-auto-ui/ui 2.5.27 → 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.
Files changed (76) hide show
  1. package/package.json +18 -5
  2. package/src/agent/LLMSelector.svelte +11 -3
  3. package/src/agent/ModelCacheManager.svelte +359 -0
  4. package/src/index.ts +42 -30
  5. package/src/theme/scale.ts +128 -0
  6. package/src/widgets/WidgetRenderer.svelte +144 -107
  7. package/src/widgets/export-widget.ts +28 -1
  8. package/src/widgets/helpers/safe-image.ts +78 -0
  9. package/src/widgets/notebook/.gitkeep +0 -0
  10. package/src/widgets/notebook/chart-renderer.ts +63 -0
  11. package/src/widgets/notebook/executors/.gitkeep +1 -0
  12. package/src/widgets/notebook/executors/index.ts +4 -0
  13. package/src/widgets/notebook/executors/js-worker.ts +269 -0
  14. package/src/widgets/notebook/executors/sql.ts +206 -0
  15. package/src/widgets/notebook/import-modals.ts +560 -0
  16. package/src/widgets/notebook/left-pane.ts +256 -0
  17. package/src/widgets/notebook/notebook.ts +930 -0
  18. package/src/widgets/notebook/prose.ts +615 -0
  19. package/src/widgets/notebook/recipe-browser.ts +350 -0
  20. package/src/widgets/notebook/recipes/notebook.md +124 -0
  21. package/src/widgets/notebook/resource-extractor.ts +162 -0
  22. package/src/widgets/notebook/share-handlers.ts +222 -0
  23. package/src/widgets/notebook/shared.ts +1633 -0
  24. package/src/widgets/rich/cards.ts +181 -0
  25. package/src/widgets/rich/carousel.ts +319 -0
  26. package/src/widgets/rich/chart-rich.ts +386 -0
  27. package/src/widgets/rich/d3.ts +503 -0
  28. package/src/widgets/rich/data-table.ts +342 -0
  29. package/src/widgets/rich/gallery.ts +350 -0
  30. package/src/widgets/rich/grid-data.ts +173 -0
  31. package/src/widgets/rich/hemicycle.ts +313 -0
  32. package/src/widgets/rich/js-sandbox.ts +122 -0
  33. package/src/widgets/rich/json-viewer.ts +202 -0
  34. package/src/widgets/rich/log.ts +143 -0
  35. package/src/widgets/rich/map.ts +218 -0
  36. package/src/widgets/rich/profile.ts +256 -0
  37. package/src/widgets/rich/sankey.ts +257 -0
  38. package/src/widgets/rich/stat-card.ts +125 -0
  39. package/src/widgets/rich/timeline.ts +179 -0
  40. package/src/widgets/rich/trombinoscope.ts +246 -0
  41. package/src/widgets/simple/actions.ts +89 -0
  42. package/src/widgets/simple/alert.ts +100 -0
  43. package/src/widgets/simple/chart.ts +189 -0
  44. package/src/widgets/simple/code.ts +79 -0
  45. package/src/widgets/simple/kv.ts +68 -0
  46. package/src/widgets/simple/list.ts +89 -0
  47. package/src/widgets/simple/stat.ts +58 -0
  48. package/src/widgets/simple/tags.ts +125 -0
  49. package/src/widgets/simple/text.ts +198 -0
  50. package/src/widgets/SafeImage.svelte +0 -76
  51. package/src/widgets/rich/Cards.svelte +0 -39
  52. package/src/widgets/rich/Carousel.svelte +0 -88
  53. package/src/widgets/rich/Chart.svelte +0 -142
  54. package/src/widgets/rich/D3Widget.svelte +0 -378
  55. package/src/widgets/rich/DataTable.svelte +0 -62
  56. package/src/widgets/rich/Gallery.svelte +0 -94
  57. package/src/widgets/rich/GridData.svelte +0 -44
  58. package/src/widgets/rich/Hemicycle.svelte +0 -78
  59. package/src/widgets/rich/JsSandbox.svelte +0 -51
  60. package/src/widgets/rich/JsonViewer.svelte +0 -42
  61. package/src/widgets/rich/LogViewer.svelte +0 -24
  62. package/src/widgets/rich/MapView.svelte +0 -140
  63. package/src/widgets/rich/ProfileCard.svelte +0 -59
  64. package/src/widgets/rich/Sankey.svelte +0 -56
  65. package/src/widgets/rich/StatCard.svelte +0 -35
  66. package/src/widgets/rich/Timeline.svelte +0 -43
  67. package/src/widgets/rich/Trombinoscope.svelte +0 -48
  68. package/src/widgets/simple/ActionsBlock.svelte +0 -15
  69. package/src/widgets/simple/AlertBlock.svelte +0 -11
  70. package/src/widgets/simple/ChartBlock.svelte +0 -21
  71. package/src/widgets/simple/CodeBlock.svelte +0 -11
  72. package/src/widgets/simple/KVBlock.svelte +0 -16
  73. package/src/widgets/simple/ListBlock.svelte +0 -17
  74. package/src/widgets/simple/StatBlock.svelte +0 -14
  75. package/src/widgets/simple/TagsBlock.svelte +0 -15
  76. package/src/widgets/simple/TextBlock.svelte +0 -122
@@ -0,0 +1,615 @@
1
+ // @ts-nocheck
2
+ // ---------------------------------------------------------------------------
3
+ // Lightweight markdown renderer + allowlist sanitizer for notebook prose cells.
4
+ // Also: renderMarkdownWithInjectButtons — used by recipe viewer modal.
5
+ // No external dependencies.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ const BLOCK_TAGS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'hr', 'br']);
9
+ const INLINE_TAGS = new Set(['strong', 'em', 'code', 'a', 'mark', 's', 'u']);
10
+ const ALLOWED_TAGS = new Set([...BLOCK_TAGS, ...INLINE_TAGS]);
11
+ const ALLOWED_ATTRS_BY_TAG: Record<string, Set<string>> = {
12
+ a: new Set(['href', 'title', 'target', 'rel']),
13
+ };
14
+
15
+ function escapeHtml(s: string): string {
16
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
17
+ }
18
+
19
+ /**
20
+ * Scan and wrap `*italic*` segments. Avoids the lookbehind regex (Safari < 16.4)
21
+ * and avoids consuming the preceding char (the previous regex did, which broke
22
+ * `*a* *b*` since the space between them was eaten by the first match).
23
+ * A `*` is treated as an italic delimiter only when it is NOT part of a `**`
24
+ * sequence (bold has already been replaced, so any remaining `**` is leftover).
25
+ */
26
+ function scanItalic(s: string): string {
27
+ let out = '';
28
+ let i = 0;
29
+ while (i < s.length) {
30
+ const ch = s[i];
31
+ if (ch === '*' && s[i + 1] !== '*' && (i === 0 || s[i - 1] !== '*')) {
32
+ // Look for closing `*` on the same line, with non-empty content
33
+ let j = i + 1;
34
+ while (j < s.length && s[j] !== '*' && s[j] !== '\n') j++;
35
+ if (j < s.length && s[j] === '*' && j > i + 1 && s[j + 1] !== '*') {
36
+ out += `<em>${s.slice(i + 1, j)}</em>`;
37
+ i = j + 1;
38
+ continue;
39
+ }
40
+ }
41
+ out += ch;
42
+ i++;
43
+ }
44
+ return out;
45
+ }
46
+
47
+ function renderInline(s: string): string {
48
+ // Escape first, then re-apply allowed inline constructs
49
+ let out = escapeHtml(s);
50
+ // inline code `...`
51
+ out = out.replace(/`([^`\n]+)`/g, (_m, g1) => `<code>${g1}</code>`);
52
+ // bold **...**
53
+ out = out.replace(/\*\*([^\*\n]+)\*\*/g, (_m, g1) => `<strong>${g1}</strong>`);
54
+ // italic *...* — manual scan avoids consuming the leading char (which broke
55
+ // patterns like `*a* *b*` when the previous regex used [^\*] as a guard).
56
+ out = scanItalic(out);
57
+ // links [text](href)
58
+ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, href) => {
59
+ const safeHref = /^https?:\/\//i.test(href) ? href : (href.startsWith('/') || href.startsWith('#') ? href : '#');
60
+ return `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${text}</a>`;
61
+ });
62
+ // marks ==...==
63
+ out = out.replace(/==([^=\n]+)==/g, (_m, g1) => `<mark>${g1}</mark>`);
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Render markdown to a sanitized HTML string. Handles headings, paragraphs,
69
+ * fenced code blocks, unordered lists, ordered lists, blockquotes, hr.
70
+ * Not a full MD spec — covers the ~90% used in notebook prose cells.
71
+ */
72
+ export function renderProse(content: string): string {
73
+ if (!content) return '';
74
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
75
+ const out: string[] = [];
76
+ let i = 0;
77
+
78
+ while (i < lines.length) {
79
+ const line = lines[i];
80
+
81
+ // Fenced code block
82
+ if (/^```/.test(line)) {
83
+ const lang = line.replace(/^```/, '').trim();
84
+ const code: string[] = [];
85
+ i++;
86
+ while (i < lines.length && !/^```/.test(lines[i])) {
87
+ code.push(lines[i]);
88
+ i++;
89
+ }
90
+ i++; // skip closing fence
91
+ const cls = lang ? ` class="language-${escapeHtml(lang)}"` : '';
92
+ out.push(`<pre><code${cls}>${escapeHtml(code.join('\n'))}</code></pre>`);
93
+ continue;
94
+ }
95
+
96
+ // Heading
97
+ const mH = /^(#{1,6})\s+(.*)$/.exec(line);
98
+ if (mH) {
99
+ const level = mH[1].length;
100
+ out.push(`<h${level}>${renderInline(mH[2])}</h${level}>`);
101
+ i++;
102
+ continue;
103
+ }
104
+
105
+ // Horizontal rule
106
+ if (/^---+\s*$/.test(line) || /^\*\*\*+\s*$/.test(line)) {
107
+ out.push('<hr/>');
108
+ i++;
109
+ continue;
110
+ }
111
+
112
+ // Unordered list
113
+ if (/^[-*+]\s+/.test(line)) {
114
+ const items: string[] = [];
115
+ while (i < lines.length && /^[-*+]\s+/.test(lines[i])) {
116
+ items.push(lines[i].replace(/^[-*+]\s+/, ''));
117
+ i++;
118
+ }
119
+ out.push('<ul>' + items.map((t) => `<li>${renderInline(t)}</li>`).join('') + '</ul>');
120
+ continue;
121
+ }
122
+
123
+ // Ordered list
124
+ if (/^\d+\.\s+/.test(line)) {
125
+ const items: string[] = [];
126
+ while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
127
+ items.push(lines[i].replace(/^\d+\.\s+/, ''));
128
+ i++;
129
+ }
130
+ out.push('<ol>' + items.map((t) => `<li>${renderInline(t)}</li>`).join('') + '</ol>');
131
+ continue;
132
+ }
133
+
134
+ // Blockquote
135
+ if (/^>\s?/.test(line)) {
136
+ const quote: string[] = [];
137
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
138
+ quote.push(lines[i].replace(/^>\s?/, ''));
139
+ i++;
140
+ }
141
+ out.push(`<blockquote>${renderInline(quote.join(' '))}</blockquote>`);
142
+ continue;
143
+ }
144
+
145
+ // Blank → paragraph break
146
+ if (/^\s*$/.test(line)) {
147
+ i++;
148
+ continue;
149
+ }
150
+
151
+ // Paragraph: gather contiguous non-empty lines
152
+ const para: string[] = [];
153
+ while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,6}\s|```|[-*+]\s|\d+\.\s|>\s?|---+\s*$)/.test(lines[i])) {
154
+ para.push(lines[i]);
155
+ i++;
156
+ }
157
+ out.push(`<p>${renderInline(para.join(' '))}</p>`);
158
+ }
159
+
160
+ return sanitize(out.join('\n'));
161
+ }
162
+
163
+ /**
164
+ * Final sanitization pass: allowlist tags + attrs, strip javascript: hrefs and on* handlers.
165
+ */
166
+ function sanitize(html: string): string {
167
+ if (typeof document === 'undefined') return html;
168
+ const tpl = document.createElement('template');
169
+ tpl.innerHTML = html;
170
+ walk(tpl.content);
171
+ return tpl.innerHTML;
172
+ }
173
+
174
+ function walk(node: Node): void {
175
+ const children = Array.from(node.childNodes);
176
+ for (const child of children) {
177
+ if (child.nodeType === 1) {
178
+ const el = child as Element;
179
+ const tag = el.tagName.toLowerCase();
180
+ if (!ALLOWED_TAGS.has(tag)) {
181
+ // Unwrap disallowed tags (keep text)
182
+ while (el.firstChild) node.insertBefore(el.firstChild, el);
183
+ node.removeChild(el);
184
+ continue;
185
+ }
186
+ const allowedAttrs = ALLOWED_ATTRS_BY_TAG[tag] ?? new Set<string>();
187
+ for (const attr of Array.from(el.attributes)) {
188
+ if (!allowedAttrs.has(attr.name)) {
189
+ el.removeAttribute(attr.name);
190
+ continue;
191
+ }
192
+ if (attr.name === 'href' && /^\s*javascript:/i.test(attr.value)) {
193
+ el.removeAttribute(attr.name);
194
+ }
195
+ }
196
+ walk(el);
197
+ } else if (child.nodeType !== 3 && child.nodeType !== 8) {
198
+ // Remove anything that's not element / text / comment
199
+ node.removeChild(child);
200
+ }
201
+ }
202
+ }
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
+
539
+ // ---------------------------------------------------------------------------
540
+ // Renderer with inject buttons — used by recipe viewer modal.
541
+ // Each fenced code block gets an "↳ inject" button next to it.
542
+ // ---------------------------------------------------------------------------
543
+
544
+ export interface InjectFenceEvent {
545
+ lang: string;
546
+ content: string;
547
+ }
548
+
549
+ /**
550
+ * Render markdown into a container. Each fenced code block is rendered with
551
+ * an "↳ inject" button; clicking it calls onInject({lang, content}).
552
+ * Returns a cleanup function.
553
+ */
554
+ export function renderMarkdownWithInjectButtons(
555
+ body: string,
556
+ onInject: (e: InjectFenceEvent) => void,
557
+ ): { root: HTMLElement; destroy: () => void } {
558
+ const root = document.createElement('div');
559
+ root.className = 'nb-md-render';
560
+
561
+ const lines = (body || '').replace(/\r\n/g, '\n').split('\n');
562
+ const buf: string[] = [];
563
+ let i = 0;
564
+ const cleanups: Array<() => void> = [];
565
+
566
+ const flushProse = () => {
567
+ if (buf.length) {
568
+ const chunk = buf.join('\n');
569
+ const p = document.createElement('div');
570
+ p.innerHTML = renderProse(chunk);
571
+ root.appendChild(p);
572
+ buf.length = 0;
573
+ }
574
+ };
575
+
576
+ while (i < lines.length) {
577
+ const line = lines[i];
578
+ if (/^```/.test(line)) {
579
+ flushProse();
580
+ const lang = line.replace(/^```/, '').trim().toLowerCase() || 'text';
581
+ const code: string[] = [];
582
+ i++;
583
+ while (i < lines.length && !/^```/.test(lines[i])) {
584
+ code.push(lines[i]);
585
+ i++;
586
+ }
587
+ i++; // closing fence
588
+ const content = code.join('\n');
589
+
590
+ const block = document.createElement('div');
591
+ block.className = 'nb-md-fence';
592
+ block.innerHTML = `
593
+ <div class="nb-md-fence-head">
594
+ <span class="nb-md-fence-lang">${escapeHtml(lang)}</span>
595
+ <button type="button" class="nb-md-fence-inject">↳ inject</button>
596
+ </div>
597
+ <pre><code class="language-${escapeHtml(lang)}">${escapeHtml(content)}</code></pre>
598
+ `;
599
+ const btn = block.querySelector('.nb-md-fence-inject') as HTMLButtonElement;
600
+ const handler = () => onInject({ lang, content });
601
+ btn.addEventListener('click', handler);
602
+ cleanups.push(() => btn.removeEventListener('click', handler));
603
+ root.appendChild(block);
604
+ continue;
605
+ }
606
+ buf.push(line);
607
+ i++;
608
+ }
609
+ flushProse();
610
+
611
+ return {
612
+ root,
613
+ destroy: () => cleanups.forEach((f) => f()),
614
+ };
615
+ }