@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.
- package/package.json +18 -5
- package/src/agent/LLMSelector.svelte +11 -3
- package/src/agent/ModelCacheManager.svelte +359 -0
- package/src/index.ts +42 -30
- package/src/theme/scale.ts +128 -0
- package/src/widgets/WidgetRenderer.svelte +144 -107
- package/src/widgets/export-widget.ts +28 -1
- package/src/widgets/helpers/safe-image.ts +78 -0
- package/src/widgets/notebook/.gitkeep +0 -0
- package/src/widgets/notebook/chart-renderer.ts +63 -0
- package/src/widgets/notebook/executors/.gitkeep +1 -0
- package/src/widgets/notebook/executors/index.ts +4 -0
- package/src/widgets/notebook/executors/js-worker.ts +269 -0
- package/src/widgets/notebook/executors/sql.ts +206 -0
- package/src/widgets/notebook/import-modals.ts +560 -0
- package/src/widgets/notebook/left-pane.ts +256 -0
- package/src/widgets/notebook/notebook.ts +930 -0
- package/src/widgets/notebook/prose.ts +615 -0
- package/src/widgets/notebook/recipe-browser.ts +350 -0
- package/src/widgets/notebook/recipes/notebook.md +124 -0
- package/src/widgets/notebook/resource-extractor.ts +162 -0
- package/src/widgets/notebook/share-handlers.ts +222 -0
- package/src/widgets/notebook/shared.ts +1633 -0
- package/src/widgets/rich/cards.ts +181 -0
- package/src/widgets/rich/carousel.ts +319 -0
- package/src/widgets/rich/chart-rich.ts +386 -0
- package/src/widgets/rich/d3.ts +503 -0
- package/src/widgets/rich/data-table.ts +342 -0
- package/src/widgets/rich/gallery.ts +350 -0
- package/src/widgets/rich/grid-data.ts +173 -0
- package/src/widgets/rich/hemicycle.ts +313 -0
- package/src/widgets/rich/js-sandbox.ts +122 -0
- package/src/widgets/rich/json-viewer.ts +202 -0
- package/src/widgets/rich/log.ts +143 -0
- package/src/widgets/rich/map.ts +218 -0
- package/src/widgets/rich/profile.ts +256 -0
- package/src/widgets/rich/sankey.ts +257 -0
- package/src/widgets/rich/stat-card.ts +125 -0
- package/src/widgets/rich/timeline.ts +179 -0
- package/src/widgets/rich/trombinoscope.ts +246 -0
- package/src/widgets/simple/actions.ts +89 -0
- package/src/widgets/simple/alert.ts +100 -0
- package/src/widgets/simple/chart.ts +189 -0
- package/src/widgets/simple/code.ts +79 -0
- package/src/widgets/simple/kv.ts +68 -0
- package/src/widgets/simple/list.ts +89 -0
- package/src/widgets/simple/stat.ts +58 -0
- package/src/widgets/simple/tags.ts +125 -0
- package/src/widgets/simple/text.ts +198 -0
- package/src/widgets/SafeImage.svelte +0 -76
- package/src/widgets/rich/Cards.svelte +0 -39
- package/src/widgets/rich/Carousel.svelte +0 -88
- package/src/widgets/rich/Chart.svelte +0 -142
- package/src/widgets/rich/D3Widget.svelte +0 -378
- package/src/widgets/rich/DataTable.svelte +0 -62
- package/src/widgets/rich/Gallery.svelte +0 -94
- package/src/widgets/rich/GridData.svelte +0 -44
- package/src/widgets/rich/Hemicycle.svelte +0 -78
- package/src/widgets/rich/JsSandbox.svelte +0 -51
- package/src/widgets/rich/JsonViewer.svelte +0 -42
- package/src/widgets/rich/LogViewer.svelte +0 -24
- package/src/widgets/rich/MapView.svelte +0 -140
- package/src/widgets/rich/ProfileCard.svelte +0 -59
- package/src/widgets/rich/Sankey.svelte +0 -56
- package/src/widgets/rich/StatCard.svelte +0 -35
- package/src/widgets/rich/Timeline.svelte +0 -43
- package/src/widgets/rich/Trombinoscope.svelte +0 -48
- package/src/widgets/simple/ActionsBlock.svelte +0 -15
- package/src/widgets/simple/AlertBlock.svelte +0 -11
- package/src/widgets/simple/ChartBlock.svelte +0 -21
- package/src/widgets/simple/CodeBlock.svelte +0 -11
- package/src/widgets/simple/KVBlock.svelte +0 -16
- package/src/widgets/simple/ListBlock.svelte +0 -17
- package/src/widgets/simple/StatBlock.svelte +0 -14
- package/src/widgets/simple/TagsBlock.svelte +0 -15
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
+
}
|