quikdown 1.0.3 → 1.0.5

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.
@@ -0,0 +1,2319 @@
1
+ /**
2
+ * Quikdown Editor - Drop-in Markdown Parser
3
+ * @version 1.0.5
4
+ * @license BSD-2-Clause
5
+ * @copyright DeftIO 2025
6
+ */
7
+ 'use strict';
8
+
9
+ /**
10
+ * quikdown - A minimal markdown parser optimized for chat/LLM output
11
+ * Supports tables, code blocks, lists, and common formatting
12
+ * @param {string} markdown - The markdown source text
13
+ * @param {Object} options - Optional configuration object
14
+ * @param {Function} options.fence_plugin - Custom renderer for fenced code blocks
15
+ * (content, fence_string) => html string
16
+ * @param {boolean} options.inline_styles - If true, uses inline styles instead of classes
17
+ * @param {boolean} options.bidirectional - If true, adds data-qd attributes for source tracking
18
+ * @param {boolean} options.lazy_linefeeds - If true, single newlines become <br> tags
19
+ * @returns {string} - The rendered HTML
20
+ */
21
+
22
+ // Version will be injected at build time
23
+ const quikdownVersion = '1.0.5';
24
+
25
+ // Constants for reuse
26
+ const CLASS_PREFIX = 'quikdown-';
27
+ const PLACEHOLDER_CB = '§CB';
28
+ const PLACEHOLDER_IC = '§IC';
29
+
30
+ // Escape map at module level
31
+ const ESC_MAP = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
32
+
33
+ // Single source of truth for all style definitions - optimized
34
+ const QUIKDOWN_STYLES = {
35
+ h1: 'font-size:2em;font-weight:600;margin:.67em 0;text-align:left',
36
+ h2: 'font-size:1.5em;font-weight:600;margin:.83em 0',
37
+ h3: 'font-size:1.25em;font-weight:600;margin:1em 0',
38
+ h4: 'font-size:1em;font-weight:600;margin:1.33em 0',
39
+ h5: 'font-size:.875em;font-weight:600;margin:1.67em 0',
40
+ h6: 'font-size:.85em;font-weight:600;margin:2em 0',
41
+ pre: 'background:#f4f4f4;padding:10px;border-radius:4px;overflow-x:auto;margin:1em 0',
42
+ code: 'background:#f0f0f0;padding:2px 4px;border-radius:3px;font-family:monospace',
43
+ blockquote: 'border-left:4px solid #ddd;margin-left:0;padding-left:1em',
44
+ table: 'border-collapse:collapse;width:100%;margin:1em 0',
45
+ th: 'border:1px solid #ddd;padding:8px;background-color:#f2f2f2;font-weight:bold;text-align:left',
46
+ td: 'border:1px solid #ddd;padding:8px;text-align:left',
47
+ hr: 'border:none;border-top:1px solid #ddd;margin:1em 0',
48
+ img: 'max-width:100%;height:auto',
49
+ a: 'color:#06c;text-decoration:underline',
50
+ strong: 'font-weight:bold',
51
+ em: 'font-style:italic',
52
+ del: 'text-decoration:line-through',
53
+ ul: 'margin:.5em 0;padding-left:2em',
54
+ ol: 'margin:.5em 0;padding-left:2em',
55
+ li: 'margin:.25em 0',
56
+ // Task list specific styles
57
+ 'task-item': 'list-style:none',
58
+ 'task-checkbox': 'margin-right:.5em'
59
+ };
60
+
61
+ // Factory function to create getAttr for a given context
62
+ function createGetAttr(inline_styles, styles) {
63
+ return function(tag, additionalStyle = '') {
64
+ if (inline_styles) {
65
+ let style = styles[tag];
66
+ if (!style && !additionalStyle) return '';
67
+
68
+ // Remove default text-align if we're adding a different alignment
69
+ if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
70
+ style = style.replace(/text-align:[^;]+;?/, '').trim();
71
+ if (style && !style.endsWith(';')) style += ';';
72
+ }
73
+
74
+ /* istanbul ignore next - defensive: additionalStyle without style doesn't occur with current tags */
75
+ const fullStyle = additionalStyle ? (style ? `${style}${additionalStyle}` : additionalStyle) : style;
76
+ return ` style="${fullStyle}"`;
77
+ } else {
78
+ const classAttr = ` class="${CLASS_PREFIX}${tag}"`;
79
+ // Apply inline styles for alignment even when using CSS classes
80
+ if (additionalStyle) {
81
+ return `${classAttr} style="${additionalStyle}"`;
82
+ }
83
+ return classAttr;
84
+ }
85
+ };
86
+ }
87
+
88
+ function quikdown(markdown, options = {}) {
89
+ if (!markdown || typeof markdown !== 'string') {
90
+ return '';
91
+ }
92
+
93
+ const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false } = options;
94
+ const styles = QUIKDOWN_STYLES; // Use module-level styles
95
+ const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
96
+
97
+ // Escape HTML entities to prevent XSS
98
+ function escapeHtml(text) {
99
+ return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
100
+ }
101
+
102
+ // Helper to add data-qd attributes for bidirectional support
103
+ const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
104
+
105
+ // Sanitize URLs to prevent XSS attacks
106
+ function sanitizeUrl(url, allowUnsafe = false) {
107
+ /* istanbul ignore next - defensive programming, regex ensures url is never empty */
108
+ if (!url) return '';
109
+
110
+ // If unsafe URLs are explicitly allowed, return as-is
111
+ if (allowUnsafe) return url;
112
+
113
+ const trimmedUrl = url.trim();
114
+ const lowerUrl = trimmedUrl.toLowerCase();
115
+
116
+ // Block dangerous protocols
117
+ const dangerousProtocols = ['javascript:', 'vbscript:', 'data:'];
118
+
119
+ for (const protocol of dangerousProtocols) {
120
+ if (lowerUrl.startsWith(protocol)) {
121
+ // Exception: Allow data:image/* for images
122
+ if (protocol === 'data:' && lowerUrl.startsWith('data:image/')) {
123
+ return trimmedUrl;
124
+ }
125
+ // Return safe empty link for dangerous protocols
126
+ return '#';
127
+ }
128
+ }
129
+
130
+ return trimmedUrl;
131
+ }
132
+
133
+ // Process the markdown in phases
134
+ let html = markdown;
135
+
136
+ // Phase 1: Extract and protect code blocks and inline code
137
+ const codeBlocks = [];
138
+ const inlineCodes = [];
139
+
140
+ // Extract fenced code blocks first (supports both ``` and ~~~)
141
+ // Match paired fences - ``` with ``` and ~~~ with ~~~
142
+ // Fence must be at start of line
143
+ html = html.replace(/^(```|~~~)([^\n]*)\n([\s\S]*?)^\1$/gm, (match, fence, lang, code) => {
144
+ const placeholder = `${PLACEHOLDER_CB}${codeBlocks.length}§`;
145
+
146
+ // Trim the language specification
147
+ const langTrimmed = lang ? lang.trim() : '';
148
+
149
+ // If custom fence plugin is provided, use it
150
+ if (fence_plugin && typeof fence_plugin === 'function') {
151
+ codeBlocks.push({
152
+ lang: langTrimmed,
153
+ code: code.trimEnd(),
154
+ custom: true,
155
+ fence: fence
156
+ });
157
+ } else {
158
+ codeBlocks.push({
159
+ lang: langTrimmed,
160
+ code: escapeHtml(code.trimEnd()),
161
+ custom: false,
162
+ fence: fence
163
+ });
164
+ }
165
+ return placeholder;
166
+ });
167
+
168
+ // Extract inline code
169
+ html = html.replace(/`([^`]+)`/g, (match, code) => {
170
+ const placeholder = `${PLACEHOLDER_IC}${inlineCodes.length}§`;
171
+ inlineCodes.push(escapeHtml(code));
172
+ return placeholder;
173
+ });
174
+
175
+ // Now escape HTML in the rest of the content
176
+ html = escapeHtml(html);
177
+
178
+ // Phase 2: Process block elements
179
+
180
+ // Process tables
181
+ html = processTable(html, getAttr);
182
+
183
+ // Process headings (supports optional trailing #'s)
184
+ html = html.replace(/^(#{1,6})\s+(.+?)\s*#*$/gm, (match, hashes, content) => {
185
+ const level = hashes.length;
186
+ return `<h${level}${getAttr('h' + level)}${dataQd(hashes)}>${content}</h${level}>`;
187
+ });
188
+
189
+ // Process blockquotes (must handle escaped > since we already escaped HTML)
190
+ html = html.replace(/^&gt;\s+(.+)$/gm, `<blockquote${getAttr('blockquote')}>$1</blockquote>`);
191
+ // Merge consecutive blockquotes
192
+ html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
193
+
194
+ // Process horizontal rules
195
+ html = html.replace(/^---+$/gm, `<hr${getAttr('hr')}>`);
196
+
197
+ // Process lists
198
+ html = processLists(html, getAttr, inline_styles, bidirectional);
199
+
200
+ // Phase 3: Process inline elements
201
+
202
+ // Images (must come before links, with URL sanitization)
203
+ html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
204
+ const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
205
+ const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
206
+ const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
207
+ return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
208
+ });
209
+
210
+ // Links (with URL sanitization)
211
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
212
+ // Sanitize URL to prevent XSS
213
+ const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
214
+ const isExternal = /^https?:\/\//i.test(sanitizedHref);
215
+ const rel = isExternal ? ' rel="noopener noreferrer"' : '';
216
+ const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
217
+ return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
218
+ });
219
+
220
+ // Autolinks - convert bare URLs to clickable links
221
+ html = html.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (match, prefix, url) => {
222
+ const sanitizedUrl = sanitizeUrl(url, options.allow_unsafe_urls);
223
+ return `${prefix}<a${getAttr('a')} href="${sanitizedUrl}" rel="noopener noreferrer">${url}</a>`;
224
+ });
225
+
226
+ // Process inline formatting (bold, italic, strikethrough)
227
+ const inlinePatterns = [
228
+ [/\*\*(.+?)\*\*/g, 'strong', '**'],
229
+ [/__(.+?)__/g, 'strong', '__'],
230
+ [/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em', '*'],
231
+ [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
232
+ [/~~(.+?)~~/g, 'del', '~~']
233
+ ];
234
+
235
+ inlinePatterns.forEach(([pattern, tag, marker]) => {
236
+ html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
237
+ });
238
+
239
+ // Line breaks
240
+ if (lazy_linefeeds) {
241
+ // Lazy linefeeds: single newline becomes <br> (except between paragraphs and after/before block elements)
242
+ const blocks = [];
243
+ let bi = 0;
244
+
245
+ // Protect tables and lists
246
+ html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
247
+ blocks[bi] = m;
248
+ return `§B${bi++}§`;
249
+ });
250
+
251
+ // Handle paragraphs and block elements
252
+ html = html.replace(/\n\n+/g, '§P§')
253
+ // After block elements
254
+ .replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
255
+ .replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
256
+ // Before block elements
257
+ .replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
258
+ .replace(/\n(§B\d+§)/g, '§N§$1')
259
+ .replace(/(§B\d+§)\n/g, '$1§N§')
260
+ // Convert remaining newlines
261
+ .replace(/\n/g, `<br${getAttr('br')}>`)
262
+ // Restore
263
+ .replace(/§N§/g, '\n')
264
+ .replace(/§P§/g, '</p><p>');
265
+
266
+ // Restore protected blocks
267
+ blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
268
+
269
+ html = '<p>' + html + '</p>';
270
+ } else {
271
+ // Standard: two spaces at end of line for line breaks
272
+ html = html.replace(/ $/gm, `<br${getAttr('br')}>`);
273
+
274
+ // Paragraphs (double newlines)
275
+ html = html.replace(/\n\n+/g, '</p><p>');
276
+ html = '<p>' + html + '</p>';
277
+ }
278
+
279
+ // Clean up empty paragraphs and unwrap block elements
280
+ const cleanupPatterns = [
281
+ [/<p><\/p>/g, ''],
282
+ [/<p>(<h[1-6][^>]*>)/g, '$1'],
283
+ [/(<\/h[1-6]>)<\/p>/g, '$1'],
284
+ [/<p>(<blockquote[^>]*>)/g, '$1'],
285
+ [/(<\/blockquote>)<\/p>/g, '$1'],
286
+ [/<p>(<ul[^>]*>|<ol[^>]*>)/g, '$1'],
287
+ [/(<\/ul>|<\/ol>)<\/p>/g, '$1'],
288
+ [/<p>(<hr[^>]*>)<\/p>/g, '$1'],
289
+ [/<p>(<table[^>]*>)/g, '$1'],
290
+ [/(<\/table>)<\/p>/g, '$1'],
291
+ [/<p>(<pre[^>]*>)/g, '$1'],
292
+ [/(<\/pre>)<\/p>/g, '$1'],
293
+ [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)<\/p>`, 'g'), '$1']
294
+ ];
295
+
296
+ cleanupPatterns.forEach(([pattern, replacement]) => {
297
+ html = html.replace(pattern, replacement);
298
+ });
299
+
300
+ // Phase 4: Restore code blocks and inline code
301
+
302
+ // Restore code blocks
303
+ codeBlocks.forEach((block, i) => {
304
+ let replacement;
305
+
306
+ if (block.custom && fence_plugin) {
307
+ // Use custom fence plugin
308
+ replacement = fence_plugin(block.code, block.lang);
309
+ // If plugin returns undefined, fall back to default rendering
310
+ if (replacement === undefined) {
311
+ const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
312
+ const codeAttr = inline_styles ? getAttr('code') : langClass;
313
+ const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
314
+ const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
315
+ replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
316
+ }
317
+ } else {
318
+ // Default rendering
319
+ const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
320
+ const codeAttr = inline_styles ? getAttr('code') : langClass;
321
+ const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
322
+ const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
323
+ replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
324
+ }
325
+
326
+ const placeholder = `${PLACEHOLDER_CB}${i}§`;
327
+ html = html.replace(placeholder, replacement);
328
+ });
329
+
330
+ // Restore inline code
331
+ inlineCodes.forEach((code, i) => {
332
+ const placeholder = `${PLACEHOLDER_IC}${i}§`;
333
+ html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
334
+ });
335
+
336
+ return html.trim();
337
+ }
338
+
339
+ /**
340
+ * Process inline markdown formatting
341
+ */
342
+ function processInlineMarkdown(text, getAttr) {
343
+
344
+ // Process inline formatting patterns
345
+ const patterns = [
346
+ [/\*\*(.+?)\*\*/g, 'strong'],
347
+ [/__(.+?)__/g, 'strong'],
348
+ [/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em'],
349
+ [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em'],
350
+ [/~~(.+?)~~/g, 'del'],
351
+ [/`([^`]+)`/g, 'code']
352
+ ];
353
+
354
+ patterns.forEach(([pattern, tag]) => {
355
+ text = text.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
356
+ });
357
+
358
+ return text;
359
+ }
360
+
361
+ /**
362
+ * Process markdown tables
363
+ */
364
+ function processTable(text, getAttr) {
365
+ const lines = text.split('\n');
366
+ const result = [];
367
+ let inTable = false;
368
+ let tableLines = [];
369
+
370
+ for (let i = 0; i < lines.length; i++) {
371
+ const line = lines[i].trim();
372
+
373
+ // Check if this line looks like a table row (with or without trailing |)
374
+ if (line.includes('|') && (line.startsWith('|') || /[^\\|]/.test(line))) {
375
+ if (!inTable) {
376
+ inTable = true;
377
+ tableLines = [];
378
+ }
379
+ tableLines.push(line);
380
+ } else {
381
+ // Not a table line
382
+ if (inTable) {
383
+ // Process the accumulated table
384
+ const tableHtml = buildTable(tableLines, getAttr);
385
+ if (tableHtml) {
386
+ result.push(tableHtml);
387
+ } else {
388
+ // Not a valid table, restore original lines
389
+ result.push(...tableLines);
390
+ }
391
+ inTable = false;
392
+ tableLines = [];
393
+ }
394
+ result.push(lines[i]);
395
+ }
396
+ }
397
+
398
+ // Handle table at end of text
399
+ if (inTable && tableLines.length > 0) {
400
+ const tableHtml = buildTable(tableLines, getAttr);
401
+ if (tableHtml) {
402
+ result.push(tableHtml);
403
+ } else {
404
+ result.push(...tableLines);
405
+ }
406
+ }
407
+
408
+ return result.join('\n');
409
+ }
410
+
411
+ /**
412
+ * Build an HTML table from markdown table lines
413
+ */
414
+ function buildTable(lines, getAttr) {
415
+
416
+ if (lines.length < 2) return null;
417
+
418
+ // Check for separator line (second line should be the separator)
419
+ let separatorIndex = -1;
420
+ for (let i = 1; i < lines.length; i++) {
421
+ // Support separator with or without leading/trailing pipes
422
+ if (/^\|?[\s\-:|]+\|?$/.test(lines[i]) && lines[i].includes('-')) {
423
+ separatorIndex = i;
424
+ break;
425
+ }
426
+ }
427
+
428
+ if (separatorIndex === -1) return null;
429
+
430
+ const headerLines = lines.slice(0, separatorIndex);
431
+ const bodyLines = lines.slice(separatorIndex + 1);
432
+
433
+ // Parse alignment from separator
434
+ const separator = lines[separatorIndex];
435
+ // Handle pipes at start/end or not
436
+ const separatorCells = separator.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
437
+ const alignments = separatorCells.map(cell => {
438
+ const trimmed = cell.trim();
439
+ if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center';
440
+ if (trimmed.endsWith(':')) return 'right';
441
+ return 'left';
442
+ });
443
+
444
+ let html = `<table${getAttr('table')}>\n`;
445
+
446
+ // Build header
447
+ // Note: headerLines will always have length > 0 since separatorIndex starts from 1
448
+ html += `<thead${getAttr('thead')}>\n`;
449
+ headerLines.forEach(line => {
450
+ html += `<tr${getAttr('tr')}>\n`;
451
+ // Handle pipes at start/end or not
452
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
453
+ cells.forEach((cell, i) => {
454
+ const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
455
+ const processedCell = processInlineMarkdown(cell.trim(), getAttr);
456
+ html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
457
+ });
458
+ html += '</tr>\n';
459
+ });
460
+ html += '</thead>\n';
461
+
462
+ // Build body
463
+ if (bodyLines.length > 0) {
464
+ html += `<tbody${getAttr('tbody')}>\n`;
465
+ bodyLines.forEach(line => {
466
+ html += `<tr${getAttr('tr')}>\n`;
467
+ // Handle pipes at start/end or not
468
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
469
+ cells.forEach((cell, i) => {
470
+ const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
471
+ const processedCell = processInlineMarkdown(cell.trim(), getAttr);
472
+ html += `<td${getAttr('td', alignStyle)}>${processedCell}</td>\n`;
473
+ });
474
+ html += '</tr>\n';
475
+ });
476
+ html += '</tbody>\n';
477
+ }
478
+
479
+ html += '</table>';
480
+ return html;
481
+ }
482
+
483
+ /**
484
+ * Process markdown lists (ordered and unordered)
485
+ */
486
+ function processLists(text, getAttr, inline_styles, bidirectional) {
487
+
488
+ const lines = text.split('\n');
489
+ const result = [];
490
+ let listStack = []; // Track nested lists
491
+
492
+ // Helper to escape HTML for data-qd attributes
493
+ const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
494
+ const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
495
+
496
+ for (let i = 0; i < lines.length; i++) {
497
+ const line = lines[i];
498
+ const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
499
+
500
+ if (match) {
501
+ const [, indent, marker, content] = match;
502
+ const level = Math.floor(indent.length / 2);
503
+ const isOrdered = /^\d+\./.test(marker);
504
+ const listType = isOrdered ? 'ol' : 'ul';
505
+
506
+ // Check for task list items
507
+ let listItemContent = content;
508
+ let taskListClass = '';
509
+ const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
510
+ if (taskMatch && !isOrdered) {
511
+ const [, checked, taskContent] = taskMatch;
512
+ const isChecked = checked.toLowerCase() === 'x';
513
+ const checkboxAttr = inline_styles
514
+ ? ' style="margin-right:.5em"'
515
+ : ` class="${CLASS_PREFIX}task-checkbox"`;
516
+ listItemContent = `<input type="checkbox"${checkboxAttr}${isChecked ? ' checked' : ''} disabled> ${taskContent}`;
517
+ taskListClass = inline_styles ? ' style="list-style:none"' : ` class="${CLASS_PREFIX}task-item"`;
518
+ }
519
+
520
+ // Close deeper levels
521
+ while (listStack.length > level + 1) {
522
+ const list = listStack.pop();
523
+ result.push(`</${list.type}>`);
524
+ }
525
+
526
+ // Open new level if needed
527
+ if (listStack.length === level) {
528
+ // Need to open a new list
529
+ listStack.push({ type: listType, level });
530
+ result.push(`<${listType}${getAttr(listType)}>`);
531
+ } else if (listStack.length === level + 1) {
532
+ // Check if we need to switch list type
533
+ const currentList = listStack[listStack.length - 1];
534
+ if (currentList.type !== listType) {
535
+ result.push(`</${currentList.type}>`);
536
+ listStack.pop();
537
+ listStack.push({ type: listType, level });
538
+ result.push(`<${listType}${getAttr(listType)}>`);
539
+ }
540
+ }
541
+
542
+ const liAttr = taskListClass || getAttr('li');
543
+ result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
544
+ } else {
545
+ // Not a list item, close all lists
546
+ while (listStack.length > 0) {
547
+ const list = listStack.pop();
548
+ result.push(`</${list.type}>`);
549
+ }
550
+ result.push(line);
551
+ }
552
+ }
553
+
554
+ // Close any remaining lists
555
+ while (listStack.length > 0) {
556
+ const list = listStack.pop();
557
+ result.push(`</${list.type}>`);
558
+ }
559
+
560
+ return result.join('\n');
561
+ }
562
+
563
+ /**
564
+ * Emit CSS styles for quikdown elements
565
+ * @param {string} prefix - Optional class prefix (default: 'quikdown-')
566
+ * @param {string} theme - Optional theme: 'light' (default) or 'dark'
567
+ * @returns {string} CSS string with quikdown styles
568
+ */
569
+ quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
570
+ const styles = QUIKDOWN_STYLES;
571
+
572
+ // Define theme color overrides
573
+ const themeOverrides = {
574
+ dark: {
575
+ '#f4f4f4': '#2a2a2a', // pre background
576
+ '#f0f0f0': '#2a2a2a', // code background
577
+ '#f2f2f2': '#2a2a2a', // th background
578
+ '#ddd': '#3a3a3a', // borders
579
+ '#06c': '#6db3f2', // links
580
+ _textColor: '#e0e0e0'
581
+ },
582
+ light: {
583
+ _textColor: '#333' // Explicit text color for light theme
584
+ }
585
+ };
586
+
587
+ let css = '';
588
+ for (const [tag, style] of Object.entries(styles)) {
589
+ let themedStyle = style;
590
+
591
+ // Apply theme overrides if dark theme
592
+ if (theme === 'dark' && themeOverrides.dark) {
593
+ // Replace colors
594
+ for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
595
+ if (!oldColor.startsWith('_')) {
596
+ themedStyle = themedStyle.replace(new RegExp(oldColor, 'g'), newColor);
597
+ }
598
+ }
599
+
600
+ // Add text color for certain elements in dark theme
601
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
602
+ if (needsTextColor.includes(tag)) {
603
+ themedStyle += `;color:${themeOverrides.dark._textColor}`;
604
+ }
605
+ } else if (theme === 'light' && themeOverrides.light) {
606
+ // Add explicit text color for light theme elements too
607
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
608
+ if (needsTextColor.includes(tag)) {
609
+ themedStyle += `;color:${themeOverrides.light._textColor}`;
610
+ }
611
+ }
612
+
613
+ css += `.${prefix}${tag} { ${themedStyle} }\n`;
614
+ }
615
+
616
+ return css;
617
+ };
618
+
619
+ /**
620
+ * Configure quikdown with options and return a function
621
+ * @param {Object} options - Configuration options
622
+ * @returns {Function} Configured quikdown function
623
+ */
624
+ quikdown.configure = function(options) {
625
+ return function(markdown) {
626
+ return quikdown(markdown, options);
627
+ };
628
+ };
629
+
630
+ /**
631
+ * Version information
632
+ */
633
+ quikdown.version = quikdownVersion;
634
+
635
+ // Export for both CommonJS and ES6
636
+ /* istanbul ignore next */
637
+ if (typeof module !== 'undefined' && module.exports) {
638
+ module.exports = quikdown;
639
+ }
640
+
641
+ // For browser global
642
+ /* istanbul ignore next */
643
+ if (typeof window !== 'undefined') {
644
+ window.quikdown = quikdown;
645
+ }
646
+
647
+ /**
648
+ * quikdown_bd - Bidirectional markdown/HTML converter
649
+ * Extends core quikdown with HTML→Markdown conversion
650
+ *
651
+ * Uses data-qd attributes to preserve original markdown syntax
652
+ * Enables HTML→Markdown conversion for quikdown-generated HTML
653
+ */
654
+
655
+
656
+ /**
657
+ * Create bidirectional version by extending quikdown
658
+ * This wraps quikdown and adds the toMarkdown method
659
+ */
660
+ function quikdown_bd(markdown, options = {}) {
661
+ // Use core quikdown with bidirectional flag to add data-qd attributes
662
+ return quikdown(markdown, { ...options, bidirectional: true });
663
+ }
664
+
665
+ // Copy all properties and methods from quikdown (including version)
666
+ Object.keys(quikdown).forEach(key => {
667
+ quikdown_bd[key] = quikdown[key];
668
+ });
669
+
670
+ // Add the toMarkdown method for HTML→Markdown conversion
671
+ quikdown_bd.toMarkdown = function(htmlOrElement) {
672
+ // Accept either HTML string or DOM element
673
+ let container;
674
+ if (typeof htmlOrElement === 'string') {
675
+ container = document.createElement('div');
676
+ container.innerHTML = htmlOrElement;
677
+ } else if (htmlOrElement instanceof Element) {
678
+ /* istanbul ignore next - browser-only code path, not testable in jsdom */
679
+ container = htmlOrElement;
680
+ } else {
681
+ return '';
682
+ }
683
+
684
+ // Walk the DOM tree and reconstruct markdown
685
+ function walkNode(node, parentContext = {}) {
686
+ if (node.nodeType === Node.TEXT_NODE) {
687
+ // Return text content, preserving whitespace where needed
688
+ return node.textContent;
689
+ }
690
+
691
+ if (node.nodeType !== Node.ELEMENT_NODE) {
692
+ return '';
693
+ }
694
+
695
+ const tag = node.tagName.toLowerCase();
696
+ const dataQd = node.getAttribute('data-qd');
697
+
698
+ // Process children with context
699
+ let childContent = '';
700
+ for (let child of node.childNodes) {
701
+ childContent += walkNode(child, { parentTag: tag, ...parentContext });
702
+ }
703
+
704
+ // Determine markdown based on element and attributes
705
+ switch (tag) {
706
+ case 'h1':
707
+ case 'h2':
708
+ case 'h3':
709
+ case 'h4':
710
+ case 'h5':
711
+ case 'h6':
712
+ const level = parseInt(tag[1]);
713
+ const prefix = dataQd || '#'.repeat(level);
714
+ return `${prefix} ${childContent.trim()}\n\n`;
715
+
716
+ case 'strong':
717
+ case 'b':
718
+ if (!childContent) return ''; // Don't add markers for empty content
719
+ const boldMarker = dataQd || '**';
720
+ return `${boldMarker}${childContent}${boldMarker}`;
721
+
722
+ case 'em':
723
+ case 'i':
724
+ if (!childContent) return ''; // Don't add markers for empty content
725
+ const emMarker = dataQd || '*';
726
+ return `${emMarker}${childContent}${emMarker}`;
727
+
728
+ case 'del':
729
+ case 's':
730
+ case 'strike':
731
+ if (!childContent) return ''; // Don't add markers for empty content
732
+ const delMarker = dataQd || '~~';
733
+ return `${delMarker}${childContent}${delMarker}`;
734
+
735
+ case 'code':
736
+ // Note: code inside pre is handled directly by the pre case using querySelector
737
+ if (!childContent) return ''; // Don't add markers for empty content
738
+ const codeMarker = dataQd || '`';
739
+ return `${codeMarker}${childContent}${codeMarker}`;
740
+
741
+ case 'pre':
742
+ const fence = node.getAttribute('data-qd-fence') || dataQd || '```';
743
+ const lang = node.getAttribute('data-qd-lang') || '';
744
+ // Look for code element child
745
+ const codeEl = node.querySelector('code');
746
+ const codeContent = codeEl ? codeEl.textContent : childContent;
747
+ return `${fence}${lang}\n${codeContent.trimEnd()}\n${fence}\n\n`;
748
+
749
+ case 'blockquote':
750
+ const quoteMarker = dataQd || '>';
751
+ const lines = childContent.trim().split('\n');
752
+ return lines.map(line => `${quoteMarker} ${line}`).join('\n') + '\n\n';
753
+
754
+ case 'hr':
755
+ const hrMarker = dataQd || '---';
756
+ return `${hrMarker}\n\n`;
757
+
758
+ case 'br':
759
+ const brMarker = dataQd || ' ';
760
+ return `${brMarker}\n`;
761
+
762
+ case 'a':
763
+ const linkText = node.getAttribute('data-qd-text') || childContent.trim();
764
+ const href = node.getAttribute('href') || '';
765
+ // Check for autolinks
766
+ if (linkText === href && !dataQd) {
767
+ return `<${href}>`;
768
+ }
769
+ return `[${linkText}](${href})`;
770
+
771
+ case 'img':
772
+ const alt = node.getAttribute('data-qd-alt') || node.getAttribute('alt') || '';
773
+ const src = node.getAttribute('data-qd-src') || node.getAttribute('src') || '';
774
+ const imgMarker = dataQd || '!';
775
+ return `${imgMarker}[${alt}](${src})`;
776
+
777
+ case 'ul':
778
+ case 'ol':
779
+ return walkList(node, tag === 'ol') + '\n';
780
+
781
+ case 'li':
782
+ // Handled by list processor
783
+ return childContent;
784
+
785
+ case 'table':
786
+ return walkTable(node) + '\n\n';
787
+
788
+ case 'p':
789
+ // Check if it's actually a paragraph or just a wrapper
790
+ if (childContent.trim()) {
791
+ return childContent.trim() + '\n\n';
792
+ }
793
+ return '';
794
+
795
+ case 'div':
796
+ // Check if it's a mermaid container
797
+ if (node.classList && node.classList.contains('mermaid-container')) {
798
+ const fence = node.getAttribute('data-qd-fence') || '```';
799
+ const lang = node.getAttribute('data-qd-lang') || 'mermaid';
800
+
801
+ // First check for data-qd-source attribute on the container
802
+ const source = node.getAttribute('data-qd-source');
803
+ if (source) {
804
+ // Decode HTML entities from the attribute (mainly &quot;)
805
+ const temp = document.createElement('textarea');
806
+ temp.innerHTML = source;
807
+ const code = temp.value;
808
+ return `${fence}${lang}\n${code}\n${fence}\n\n`;
809
+ }
810
+
811
+ // Check for source on the pre.mermaid element
812
+ const mermaidPre = node.querySelector('pre.mermaid');
813
+ if (mermaidPre) {
814
+ const preSource = mermaidPre.getAttribute('data-qd-source');
815
+ if (preSource) {
816
+ const temp = document.createElement('textarea');
817
+ temp.innerHTML = preSource;
818
+ const code = temp.value;
819
+ return `${fence}${lang}\n${code}\n${fence}\n\n`;
820
+ }
821
+ }
822
+
823
+ // Fallback: Look for the legacy .mermaid-source element
824
+ const sourceElement = node.querySelector('.mermaid-source');
825
+ if (sourceElement) {
826
+ // Decode HTML entities
827
+ const temp = document.createElement('div');
828
+ temp.innerHTML = sourceElement.innerHTML;
829
+ const code = temp.textContent;
830
+ return `${fence}${lang}\n${code}\n${fence}\n\n`;
831
+ }
832
+
833
+ // Final fallback: try to extract from the mermaid element (unreliable after rendering)
834
+ const mermaidElement = node.querySelector('.mermaid');
835
+ if (mermaidElement && mermaidElement.textContent.includes('graph')) {
836
+ return `${fence}${lang}\n${mermaidElement.textContent.trim()}\n${fence}\n\n`;
837
+ }
838
+ }
839
+ // Check if it's a standalone mermaid diagram (legacy)
840
+ if (node.classList && node.classList.contains('mermaid')) {
841
+ const fence = node.getAttribute('data-qd-fence') || '```';
842
+ const lang = node.getAttribute('data-qd-lang') || 'mermaid';
843
+ const code = node.textContent.trim();
844
+ return `${fence}${lang}\n${code}\n${fence}\n\n`;
845
+ }
846
+ // Pass through other divs
847
+ return childContent;
848
+
849
+ case 'span':
850
+ // Pass through container elements
851
+ return childContent;
852
+
853
+ default:
854
+ return childContent;
855
+ }
856
+ }
857
+
858
+ // Walk list elements
859
+ function walkList(listNode, isOrdered, depth = 0) {
860
+ let result = '';
861
+ let index = 1;
862
+ const indent = ' '.repeat(depth);
863
+
864
+ for (let child of listNode.children) {
865
+ if (child.tagName !== 'LI') continue;
866
+
867
+ const dataQd = child.getAttribute('data-qd');
868
+ let marker = dataQd || (isOrdered ? `${index}.` : '-');
869
+
870
+ // Check for task list checkbox
871
+ const checkbox = child.querySelector('input[type="checkbox"]');
872
+ if (checkbox) {
873
+ const checked = checkbox.checked ? 'x' : ' ';
874
+ marker = '-';
875
+ // Get text without the checkbox
876
+ let text = '';
877
+ for (let node of child.childNodes) {
878
+ if (node.nodeType === Node.TEXT_NODE) {
879
+ text += node.textContent;
880
+ } else if (node.tagName && node.tagName !== 'INPUT') {
881
+ text += walkNode(node);
882
+ }
883
+ }
884
+ result += `${indent}${marker} [${checked}] ${text.trim()}\n`;
885
+ } else {
886
+ let itemContent = '';
887
+
888
+ for (let node of child.childNodes) {
889
+ if (node.tagName === 'UL' || node.tagName === 'OL') {
890
+ itemContent += walkList(node, node.tagName === 'OL', depth + 1);
891
+ } else {
892
+ itemContent += walkNode(node);
893
+ }
894
+ }
895
+
896
+ result += `${indent}${marker} ${itemContent.trim()}\n`;
897
+ }
898
+
899
+ index++;
900
+ }
901
+
902
+ return result;
903
+ }
904
+
905
+ // Walk table elements
906
+ function walkTable(table) {
907
+ let result = '';
908
+ const alignData = table.getAttribute('data-qd-align');
909
+ const alignments = alignData ? alignData.split(',') : [];
910
+
911
+ // Process header
912
+ const thead = table.querySelector('thead');
913
+ if (thead) {
914
+ const headerRow = thead.querySelector('tr');
915
+ if (headerRow) {
916
+ const headers = [];
917
+ for (let th of headerRow.querySelectorAll('th')) {
918
+ headers.push(th.textContent.trim());
919
+ }
920
+ result += '| ' + headers.join(' | ') + ' |\n';
921
+
922
+ // Add separator with alignment
923
+ const separators = headers.map((_, i) => {
924
+ const align = alignments[i] || 'left';
925
+ if (align === 'center') return ':---:';
926
+ if (align === 'right') return '---:';
927
+ return '---';
928
+ });
929
+ result += '| ' + separators.join(' | ') + ' |\n';
930
+ }
931
+ }
932
+
933
+ // Process body
934
+ const tbody = table.querySelector('tbody');
935
+ if (tbody) {
936
+ for (let row of tbody.querySelectorAll('tr')) {
937
+ const cells = [];
938
+ for (let td of row.querySelectorAll('td')) {
939
+ cells.push(td.textContent.trim());
940
+ }
941
+ if (cells.length > 0) {
942
+ result += '| ' + cells.join(' | ') + ' |\n';
943
+ }
944
+ }
945
+ }
946
+
947
+ return result.trim();
948
+ }
949
+
950
+ // Process the DOM tree
951
+ let markdown = walkNode(container);
952
+
953
+ // Clean up
954
+ markdown = markdown.replace(/\n{3,}/g, '\n\n'); // Remove excessive newlines
955
+ markdown = markdown.trim();
956
+
957
+ return markdown;
958
+ };
959
+
960
+ // Override the configure method to return a bidirectional version
961
+ quikdown_bd.configure = function(options) {
962
+ return function(markdown) {
963
+ return quikdown_bd(markdown, options);
964
+ };
965
+ };
966
+
967
+ // Set version
968
+ // Version is already copied from quikdown via Object.keys loop
969
+
970
+ // Export for both module and browser
971
+ /* istanbul ignore next */
972
+ if (typeof module !== 'undefined' && module.exports) {
973
+ module.exports = quikdown_bd;
974
+ }
975
+
976
+ /* istanbul ignore next */
977
+ if (typeof window !== 'undefined') {
978
+ window.quikdown_bd = quikdown_bd;
979
+ }
980
+
981
+ /**
982
+ * Quikdown Editor - A drop-in markdown editor control
983
+ * @version 1.0.5
984
+ * @license BSD-2-Clause
985
+ */
986
+
987
+
988
+ // Default options
989
+ const DEFAULT_OPTIONS = {
990
+ mode: 'split', // 'source' | 'preview' | 'split'
991
+ showToolbar: true,
992
+ theme: 'auto', // 'light' | 'dark' | 'auto'
993
+ lazy_linefeeds: false,
994
+ debounceDelay: 100,
995
+ placeholder: 'Start typing markdown...',
996
+ plugins: {
997
+ highlightjs: false,
998
+ mermaid: false
999
+ },
1000
+ customFences: {}, // { 'language': (code, lang) => html }
1001
+ enableComplexFences: true // Enable CSV tables, math rendering, SVG, etc.
1002
+ };
1003
+
1004
+ /**
1005
+ * Quikdown Editor - A complete markdown editing solution
1006
+ */
1007
+ class QuikdownEditor {
1008
+ constructor(container, options = {}) {
1009
+ // Resolve container
1010
+ this.container = typeof container === 'string'
1011
+ ? document.querySelector(container)
1012
+ : container;
1013
+
1014
+ if (!this.container) {
1015
+ throw new Error('QuikdownEditor: Invalid container');
1016
+ }
1017
+
1018
+ // Merge options
1019
+ this.options = { ...DEFAULT_OPTIONS, ...options };
1020
+
1021
+ // State
1022
+ this._markdown = '';
1023
+ this._html = '';
1024
+ this.currentMode = this.options.mode;
1025
+ this.updateTimer = null;
1026
+
1027
+ // Initialize
1028
+ this.initPromise = this.init();
1029
+ }
1030
+
1031
+ /**
1032
+ * Initialize the editor
1033
+ */
1034
+ async init() {
1035
+ // Load plugins if requested
1036
+ await this.loadPlugins();
1037
+
1038
+ // Build UI
1039
+ this.buildUI();
1040
+
1041
+ // Attach event listeners
1042
+ this.attachEvents();
1043
+
1044
+ // Apply initial theme
1045
+ this.applyTheme();
1046
+
1047
+ // Set initial mode
1048
+ this.setMode(this.currentMode);
1049
+
1050
+ // Set initial content if provided
1051
+ if (this.options.initialContent) {
1052
+ this.setMarkdown(this.options.initialContent);
1053
+ }
1054
+ }
1055
+
1056
+ /**
1057
+ * Build the editor UI
1058
+ */
1059
+ buildUI() {
1060
+ // Clear container
1061
+ this.container.innerHTML = '';
1062
+
1063
+ // Add editor class
1064
+ this.container.classList.add('qde-container');
1065
+
1066
+ // Create toolbar if enabled
1067
+ if (this.options.showToolbar) {
1068
+ this.toolbar = this.createToolbar();
1069
+ this.container.appendChild(this.toolbar);
1070
+ }
1071
+
1072
+ // Create editor area
1073
+ this.editorArea = document.createElement('div');
1074
+ this.editorArea.className = 'qde-editor';
1075
+
1076
+ // Create source panel
1077
+ this.sourcePanel = document.createElement('div');
1078
+ this.sourcePanel.className = 'qde-source';
1079
+
1080
+ this.sourceTextarea = document.createElement('textarea');
1081
+ this.sourceTextarea.className = 'qde-textarea';
1082
+ this.sourceTextarea.placeholder = this.options.placeholder;
1083
+ this.sourcePanel.appendChild(this.sourceTextarea);
1084
+
1085
+ // Create preview panel
1086
+ this.previewPanel = document.createElement('div');
1087
+ this.previewPanel.className = 'qde-preview';
1088
+ this.previewPanel.contentEditable = true;
1089
+
1090
+ // Add panels to editor
1091
+ this.editorArea.appendChild(this.sourcePanel);
1092
+ this.editorArea.appendChild(this.previewPanel);
1093
+ this.container.appendChild(this.editorArea);
1094
+
1095
+ // Add built-in styles if not already present
1096
+ this.injectStyles();
1097
+ }
1098
+
1099
+ /**
1100
+ * Create toolbar
1101
+ */
1102
+ createToolbar() {
1103
+ const toolbar = document.createElement('div');
1104
+ toolbar.className = 'qde-toolbar';
1105
+
1106
+ // Mode buttons
1107
+ const modes = ['source', 'split', 'preview'];
1108
+ const modeLabels = { source: 'Source', split: 'Split', preview: 'Rendered' };
1109
+ modes.forEach(mode => {
1110
+ const btn = document.createElement('button');
1111
+ btn.className = 'qde-btn';
1112
+ btn.dataset.mode = mode;
1113
+ btn.textContent = modeLabels[mode];
1114
+ btn.title = `Switch to ${modeLabels[mode]} view`;
1115
+ toolbar.appendChild(btn);
1116
+ });
1117
+
1118
+ // Spacer
1119
+ const spacer = document.createElement('span');
1120
+ spacer.className = 'qde-spacer';
1121
+ toolbar.appendChild(spacer);
1122
+
1123
+ // Copy buttons
1124
+ const copyButtons = [
1125
+ { action: 'copy-markdown', text: 'Copy MD', title: 'Copy markdown to clipboard' },
1126
+ { action: 'copy-html', text: 'Copy HTML', title: 'Copy HTML to clipboard' }
1127
+ ];
1128
+
1129
+ copyButtons.forEach(({ action, text, title }) => {
1130
+ const btn = document.createElement('button');
1131
+ btn.className = 'qde-btn';
1132
+ btn.dataset.action = action;
1133
+ btn.textContent = text;
1134
+ btn.title = title;
1135
+ toolbar.appendChild(btn);
1136
+ });
1137
+
1138
+ return toolbar;
1139
+ }
1140
+
1141
+ /**
1142
+ * Inject built-in styles
1143
+ */
1144
+ injectStyles() {
1145
+ if (document.getElementById('qde-styles')) return;
1146
+
1147
+ const style = document.createElement('style');
1148
+ style.id = 'qde-styles';
1149
+ style.textContent = `
1150
+ .qde-container {
1151
+ display: flex;
1152
+ flex-direction: column;
1153
+ height: 100%;
1154
+ border: 1px solid #ddd;
1155
+ border-radius: 4px;
1156
+ overflow: hidden;
1157
+ background: white;
1158
+ }
1159
+
1160
+ .qde-toolbar {
1161
+ display: flex;
1162
+ align-items: center;
1163
+ padding: 8px;
1164
+ background: #f5f5f5;
1165
+ border-bottom: 1px solid #ddd;
1166
+ gap: 4px;
1167
+ }
1168
+
1169
+ .qde-btn {
1170
+ padding: 6px 12px;
1171
+ border: 1px solid #ccc;
1172
+ background: white;
1173
+ border-radius: 3px;
1174
+ cursor: pointer;
1175
+ font-size: 14px;
1176
+ transition: all 0.2s;
1177
+ }
1178
+
1179
+ .qde-btn:hover {
1180
+ background: #e9e9e9;
1181
+ border-color: #999;
1182
+ }
1183
+
1184
+ .qde-btn.active {
1185
+ background: #007bff;
1186
+ color: white;
1187
+ border-color: #0056b3;
1188
+ }
1189
+
1190
+ .qde-spacer {
1191
+ flex: 1;
1192
+ }
1193
+
1194
+ .qde-editor {
1195
+ display: flex;
1196
+ flex: 1;
1197
+ overflow: hidden;
1198
+ }
1199
+
1200
+ .qde-source, .qde-preview {
1201
+ flex: 1;
1202
+ overflow: auto;
1203
+ padding: 16px;
1204
+ }
1205
+
1206
+ .qde-source {
1207
+ border-right: 1px solid #ddd;
1208
+ }
1209
+
1210
+ .qde-textarea {
1211
+ width: 100%;
1212
+ height: 100%;
1213
+ border: none;
1214
+ outline: none;
1215
+ resize: none;
1216
+ font-family: 'Monaco', 'Courier New', monospace;
1217
+ font-size: 14px;
1218
+ line-height: 1.5;
1219
+ }
1220
+
1221
+ .qde-preview {
1222
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1223
+ font-size: 16px;
1224
+ line-height: 1.6;
1225
+ outline: none;
1226
+ cursor: text; /* Standard text cursor */
1227
+ }
1228
+
1229
+ /* Fence-specific styles */
1230
+ .qde-svg-container {
1231
+ max-width: 100%;
1232
+ overflow: auto;
1233
+ }
1234
+
1235
+ .qde-svg-container svg {
1236
+ max-width: 100%;
1237
+ height: auto;
1238
+ }
1239
+
1240
+ .qde-html-container {
1241
+ /* HTML containers inherit background */
1242
+ margin: 12px 0;
1243
+ }
1244
+
1245
+ .qde-math-container {
1246
+ text-align: center;
1247
+ margin: 16px 0;
1248
+ overflow-x: auto;
1249
+ }
1250
+
1251
+ /* All tables in preview (both regular markdown and CSV) */
1252
+ .qde-preview table {
1253
+ width: 100%;
1254
+ border-collapse: collapse;
1255
+ margin: 12px 0;
1256
+ font-size: 14px;
1257
+ }
1258
+
1259
+ .qde-preview table th,
1260
+ .qde-preview table td {
1261
+ border: 1px solid #ddd;
1262
+ padding: 8px;
1263
+ }
1264
+
1265
+ /* Support for alignment classes from quikdown */
1266
+ .qde-preview .quikdown-left { text-align: left; }
1267
+ .qde-preview .quikdown-center { text-align: center; }
1268
+ .qde-preview .quikdown-right { text-align: right; }
1269
+
1270
+ .qde-preview table th {
1271
+ background: #f5f5f5;
1272
+ font-weight: bold;
1273
+ }
1274
+
1275
+ .qde-preview table tr:nth-child(even) {
1276
+ background: #f9f9f9;
1277
+ }
1278
+
1279
+ /* Specific to CSV-generated tables */
1280
+ .qde-data-table {
1281
+ /* Can add specific CSV table styles here if needed */
1282
+ }
1283
+
1284
+ .qde-json {
1285
+ /* Let highlight.js handle styling */
1286
+ overflow-x: auto;
1287
+ }
1288
+
1289
+ .qde-error {
1290
+ background: #fee;
1291
+ border: 1px solid #fcc;
1292
+ color: #c00;
1293
+ padding: 8px;
1294
+ border-radius: 4px;
1295
+ font-family: monospace;
1296
+ font-size: 12px;
1297
+ }
1298
+
1299
+ /* Read-only complex fence blocks in preview */
1300
+ .qde-preview [contenteditable="false"] {
1301
+ cursor: auto; /* Use automatic cursor (arrow for non-text) */
1302
+ user-select: text;
1303
+ position: relative;
1304
+ }
1305
+
1306
+ /* Ensure proper cursor for editable text elements */
1307
+ .qde-preview p,
1308
+ .qde-preview h1,
1309
+ .qde-preview h2,
1310
+ .qde-preview h3,
1311
+ .qde-preview h4,
1312
+ .qde-preview h5,
1313
+ .qde-preview h6,
1314
+ .qde-preview li,
1315
+ .qde-preview td,
1316
+ .qde-preview th,
1317
+ .qde-preview blockquote,
1318
+ .qde-preview pre[contenteditable="true"],
1319
+ .qde-preview code[contenteditable="true"] {
1320
+ cursor: text;
1321
+ }
1322
+
1323
+
1324
+ /* Non-editable complex renderers */
1325
+ .qde-preview .qde-svg-container[contenteditable="false"],
1326
+ .qde-preview .qde-html-container[contenteditable="false"],
1327
+ .qde-preview .qde-math-container[contenteditable="false"],
1328
+ .qde-preview .mermaid[contenteditable="false"] {
1329
+ opacity: 0.98;
1330
+ }
1331
+
1332
+ /* Subtle hover effect for read-only blocks */
1333
+ .qde-preview [contenteditable="false"]:hover::after {
1334
+ content: "Read-only";
1335
+ position: absolute;
1336
+ top: 2px;
1337
+ right: 2px;
1338
+ font-size: 10px;
1339
+ color: #999;
1340
+ background: rgba(255, 255, 255, 0.9);
1341
+ padding: 2px 4px;
1342
+ border-radius: 2px;
1343
+ pointer-events: none;
1344
+ }
1345
+
1346
+ /* Fix list padding in preview */
1347
+ .qde-preview ul,
1348
+ .qde-preview ol {
1349
+ padding-left: 2em;
1350
+ margin: 0.5em 0;
1351
+ }
1352
+
1353
+ .qde-preview li {
1354
+ margin: 0.25em 0;
1355
+ }
1356
+
1357
+ /* Mode-specific visibility */
1358
+ .qde-mode-source .qde-preview { display: none; }
1359
+ .qde-mode-source .qde-source { border-right: none; }
1360
+ .qde-mode-preview .qde-source { display: none; }
1361
+ .qde-mode-split .qde-source,
1362
+ .qde-mode-split .qde-preview { display: block; }
1363
+
1364
+ /* Dark theme */
1365
+ .qde-dark {
1366
+ background: #1e1e1e;
1367
+ color: #e0e0e0;
1368
+ }
1369
+
1370
+ .qde-dark .qde-toolbar {
1371
+ background: #2d2d2d;
1372
+ border-color: #444;
1373
+ }
1374
+
1375
+ .qde-dark .qde-btn {
1376
+ background: #3a3a3a;
1377
+ color: #e0e0e0;
1378
+ border-color: #555;
1379
+ }
1380
+
1381
+ .qde-dark .qde-btn:hover {
1382
+ background: #4a4a4a;
1383
+ }
1384
+
1385
+ .qde-dark .qde-source {
1386
+ border-color: #444;
1387
+ }
1388
+
1389
+ .qde-dark .qde-textarea {
1390
+ background: #1e1e1e;
1391
+ color: #e0e0e0;
1392
+ }
1393
+
1394
+ .qde-dark .qde-preview {
1395
+ background: #1e1e1e;
1396
+ color: #e0e0e0;
1397
+ }
1398
+
1399
+ /* Dark mode table styles */
1400
+ .qde-dark .qde-preview table th,
1401
+ .qde-dark .qde-preview table td {
1402
+ border-color: #3a3a3a;
1403
+ }
1404
+
1405
+ .qde-dark .qde-preview table th {
1406
+ background: #2d2d2d;
1407
+ }
1408
+
1409
+ .qde-dark .qde-preview table tr:nth-child(even) {
1410
+ background: #252525;
1411
+ }
1412
+
1413
+ /* Mobile responsive */
1414
+ @media (max-width: 768px) {
1415
+ .qde-mode-split .qde-editor {
1416
+ flex-direction: column;
1417
+ }
1418
+
1419
+ .qde-mode-split .qde-source {
1420
+ border-right: none;
1421
+ border-bottom: 1px solid #ddd;
1422
+ }
1423
+ }
1424
+ `;
1425
+
1426
+ document.head.appendChild(style);
1427
+ }
1428
+
1429
+ /**
1430
+ * Attach event listeners
1431
+ */
1432
+ attachEvents() {
1433
+ // Source textarea input
1434
+ this.sourceTextarea.addEventListener('input', () => {
1435
+ this.handleSourceInput();
1436
+ });
1437
+
1438
+ // Preview contenteditable input
1439
+ this.previewPanel.addEventListener('input', () => {
1440
+ this.handlePreviewInput();
1441
+ });
1442
+
1443
+ // Toolbar buttons
1444
+ if (this.toolbar) {
1445
+ this.toolbar.addEventListener('click', (e) => {
1446
+ const btn = e.target.closest('.qde-btn');
1447
+ if (!btn) return;
1448
+
1449
+ if (btn.dataset.mode) {
1450
+ this.setMode(btn.dataset.mode);
1451
+ } else if (btn.dataset.action) {
1452
+ this.handleAction(btn.dataset.action);
1453
+ }
1454
+ });
1455
+ }
1456
+
1457
+ // Keyboard shortcuts
1458
+ document.addEventListener('keydown', (e) => {
1459
+ if (e.ctrlKey || e.metaKey) {
1460
+ switch(e.key) {
1461
+ case '1':
1462
+ e.preventDefault();
1463
+ this.setMode('source');
1464
+ break;
1465
+ case '2':
1466
+ e.preventDefault();
1467
+ this.setMode('split');
1468
+ break;
1469
+ case '3':
1470
+ e.preventDefault();
1471
+ this.setMode('preview');
1472
+ break;
1473
+ }
1474
+ }
1475
+ });
1476
+ }
1477
+
1478
+ /**
1479
+ * Handle source textarea input
1480
+ */
1481
+ handleSourceInput() {
1482
+ clearTimeout(this.updateTimer);
1483
+ this.updateTimer = setTimeout(() => {
1484
+ this.updateFromMarkdown(this.sourceTextarea.value);
1485
+ }, this.options.debounceDelay);
1486
+ }
1487
+
1488
+ /**
1489
+ * Handle preview panel input
1490
+ */
1491
+ handlePreviewInput() {
1492
+ clearTimeout(this.updateTimer);
1493
+ this.updateTimer = setTimeout(() => {
1494
+ this.updateFromHTML();
1495
+ }, this.options.debounceDelay);
1496
+ }
1497
+
1498
+ /**
1499
+ * Update from markdown source
1500
+ */
1501
+ updateFromMarkdown(markdown) {
1502
+ this._markdown = markdown || '';
1503
+
1504
+ // Show placeholder if empty
1505
+ if (!this._markdown.trim()) {
1506
+ this._html = '';
1507
+ if (this.currentMode !== 'source') {
1508
+ this.previewPanel.innerHTML = '<div style="color: #999; font-style: italic; padding: 16px;">Start typing markdown in the source panel...</div>';
1509
+ }
1510
+ } else {
1511
+ this._html = quikdown_bd(markdown, {
1512
+ fence_plugin: this.createFencePlugin(),
1513
+ lazy_linefeeds: this.options.lazy_linefeeds
1514
+ });
1515
+
1516
+ // Update preview if visible
1517
+ if (this.currentMode !== 'source') {
1518
+ this.previewPanel.innerHTML = this._html;
1519
+ // Make all fence blocks non-editable
1520
+ this.makeFencesNonEditable();
1521
+ }
1522
+ }
1523
+
1524
+ // Trigger change event
1525
+ if (this.options.onChange) {
1526
+ this.options.onChange(this._markdown, this._html);
1527
+ }
1528
+ }
1529
+
1530
+ /**
1531
+ * Update from HTML preview
1532
+ */
1533
+ updateFromHTML() {
1534
+ // Clone the preview panel to avoid modifying the actual DOM
1535
+ const clonedPanel = this.previewPanel.cloneNode(true);
1536
+
1537
+ // Pre-process special elements on the clone
1538
+ this.preprocessSpecialElements(clonedPanel);
1539
+
1540
+ this._html = this.previewPanel.innerHTML;
1541
+ this._markdown = quikdown_bd.toMarkdown(clonedPanel);
1542
+
1543
+ // Update source if visible
1544
+ if (this.currentMode !== 'preview') {
1545
+ this.sourceTextarea.value = this._markdown;
1546
+ }
1547
+
1548
+ // Trigger change event
1549
+ if (this.options.onChange) {
1550
+ this.options.onChange(this._markdown, this._html);
1551
+ }
1552
+ }
1553
+
1554
+ /**
1555
+ * Pre-process special elements before markdown conversion
1556
+ */
1557
+ preprocessSpecialElements(panel) {
1558
+ if (!panel) return;
1559
+
1560
+
1561
+ // Restore non-editable complex fences from their data attributes
1562
+ const complexFences = panel.querySelectorAll('[contenteditable="false"][data-qd-source]');
1563
+ complexFences.forEach(element => {
1564
+ const source = element.getAttribute('data-qd-source');
1565
+ const fence = element.getAttribute('data-qd-fence') || '```';
1566
+ const lang = element.getAttribute('data-qd-lang') || '';
1567
+
1568
+ // Create a pre element with the original source
1569
+ const pre = document.createElement('pre');
1570
+ pre.setAttribute('data-qd-fence', fence);
1571
+ if (lang) pre.setAttribute('data-qd-lang', lang);
1572
+ const code = document.createElement('code');
1573
+ // The source is already the original unescaped content when using setAttribute
1574
+ // No need to unescape since browser handles it automatically
1575
+ code.textContent = source;
1576
+ pre.appendChild(code);
1577
+
1578
+ // Replace the complex element with pre
1579
+ element.parentNode.replaceChild(pre, element);
1580
+ });
1581
+
1582
+ // Convert CSV tables back to CSV fence blocks (these ARE editable)
1583
+ const csvTables = panel.querySelectorAll('table.qde-csv-table[data-qd-lang]');
1584
+ csvTables.forEach(table => {
1585
+ const lang = table.getAttribute('data-qd-lang');
1586
+ if (!lang || !['csv', 'psv', 'tsv'].includes(lang)) return;
1587
+
1588
+ const delimiter = lang === 'csv' ? ',' : lang === 'psv' ? '|' : '\t';
1589
+
1590
+ // Extract data from table
1591
+ let csv = '';
1592
+
1593
+ // Get headers
1594
+ const headers = [];
1595
+ const headerCells = table.querySelectorAll('thead th');
1596
+ headerCells.forEach(th => {
1597
+ const text = th.textContent.trim();
1598
+ // Quote if contains delimiter or quotes
1599
+ const needsQuoting = text.includes(delimiter) || text.includes('"') || text.includes('\n');
1600
+ headers.push(needsQuoting ? `"${text.replace(/"/g, '""')}"` : text);
1601
+ });
1602
+ csv += headers.join(delimiter) + '\n';
1603
+
1604
+ // Get rows
1605
+ const rows = table.querySelectorAll('tbody tr');
1606
+ rows.forEach(tr => {
1607
+ const cells = [];
1608
+ tr.querySelectorAll('td').forEach(td => {
1609
+ const text = td.textContent.trim();
1610
+ const needsQuoting = text.includes(delimiter) || text.includes('"') || text.includes('\n');
1611
+ cells.push(needsQuoting ? `"${text.replace(/"/g, '""')}"` : text);
1612
+ });
1613
+ csv += cells.join(delimiter) + '\n';
1614
+ });
1615
+
1616
+ // Create a pre element with the CSV data
1617
+ const pre = document.createElement('pre');
1618
+ pre.setAttribute('data-qd-fence', '```');
1619
+ pre.setAttribute('data-qd-lang', lang);
1620
+ const code = document.createElement('code');
1621
+ code.textContent = csv.trim();
1622
+ pre.appendChild(code);
1623
+
1624
+ // Replace table with pre
1625
+ table.parentNode.replaceChild(pre, table);
1626
+ });
1627
+ }
1628
+
1629
+ /**
1630
+ * Create fence plugin for syntax highlighting
1631
+ */
1632
+ createFencePlugin() {
1633
+ return (code, lang) => {
1634
+ // Check custom fences first (they take precedence)
1635
+ if (this.options.customFences && this.options.customFences[lang]) {
1636
+ try {
1637
+ return this.options.customFences[lang](code, lang);
1638
+ } catch (err) {
1639
+ console.error(`Custom fence plugin error for ${lang}:`, err);
1640
+ return `<pre><code class="language-${lang}">${this.escapeHtml(code)}</code></pre>`;
1641
+ }
1642
+ }
1643
+
1644
+ // For bidirectional editing, only apply syntax highlighting
1645
+ // Skip complex transformations that break round-trip conversion
1646
+ const skipComplexRendering = !this.options.enableComplexFences;
1647
+
1648
+ if (!skipComplexRendering) {
1649
+ // Built-in lazy loading fence handlers (disabled for now)
1650
+ switch(lang) {
1651
+ case 'svg':
1652
+ return this.renderSVG(code);
1653
+
1654
+ case 'html':
1655
+ return this.renderHTML(code);
1656
+
1657
+ case 'math':
1658
+ case 'katex':
1659
+ case 'tex':
1660
+ case 'latex':
1661
+ return this.renderMath(code, lang);
1662
+
1663
+ case 'csv':
1664
+ case 'psv':
1665
+ case 'tsv':
1666
+ return this.renderTable(code, lang);
1667
+
1668
+ case 'json':
1669
+ case 'json5':
1670
+ return this.renderJSON(code, lang);
1671
+
1672
+ case 'mermaid':
1673
+ if (window.mermaid) {
1674
+ return this.renderMermaid(code);
1675
+ }
1676
+ break;
1677
+ }
1678
+ }
1679
+
1680
+ // Syntax highlighting support - keep editable for bidirectional
1681
+ if (window.hljs && lang && hljs.getLanguage(lang)) {
1682
+ const highlighted = hljs.highlight(code, { language: lang }).value;
1683
+ // Don't add contenteditable="false" - the bidirectional system can extract text from the highlighted code
1684
+ return `<pre data-qd-fence="\`\`\`" data-qd-lang="${lang}"><code class="hljs language-${lang}">${highlighted}</code></pre>`;
1685
+ }
1686
+
1687
+ // Default: let quikdown handle it
1688
+ return undefined;
1689
+ };
1690
+ }
1691
+
1692
+ /**
1693
+ * Render SVG content
1694
+ */
1695
+ renderSVG(code) {
1696
+ try {
1697
+ // Basic SVG validation
1698
+ const parser = new DOMParser();
1699
+ const doc = parser.parseFromString(code, 'image/svg+xml');
1700
+ const parseError = doc.querySelector('parsererror');
1701
+
1702
+ if (parseError) {
1703
+ throw new Error('Invalid SVG');
1704
+ }
1705
+
1706
+ // Sanitize SVG by removing script tags and event handlers
1707
+ const svg = doc.documentElement;
1708
+ svg.querySelectorAll('script').forEach(el => el.remove());
1709
+
1710
+ // Remove event handlers
1711
+ const walker = document.createTreeWalker(svg, NodeFilter.SHOW_ELEMENT);
1712
+ let node;
1713
+ while (node = walker.nextNode()) {
1714
+ for (let i = node.attributes.length - 1; i >= 0; i--) {
1715
+ const attr = node.attributes[i];
1716
+ if (attr.name.startsWith('on') || attr.value.includes('javascript:')) {
1717
+ node.removeAttribute(attr.name);
1718
+ }
1719
+ }
1720
+ }
1721
+
1722
+ // Create container element programmatically to avoid attribute escaping issues
1723
+ const container = document.createElement('div');
1724
+ container.className = 'qde-svg-container';
1725
+ container.contentEditable = 'false';
1726
+ container.setAttribute('data-qd-fence', '```');
1727
+ container.setAttribute('data-qd-lang', 'svg');
1728
+ container.setAttribute('data-qd-source', code); // No escaping needed when using setAttribute!
1729
+ container.innerHTML = new XMLSerializer().serializeToString(svg);
1730
+
1731
+ // Return the HTML string
1732
+ return container.outerHTML;
1733
+ } catch (err) {
1734
+ const errorContainer = document.createElement('pre');
1735
+ errorContainer.className = 'qde-error';
1736
+ errorContainer.contentEditable = 'false';
1737
+ errorContainer.setAttribute('data-qd-fence', '```');
1738
+ errorContainer.setAttribute('data-qd-lang', 'svg');
1739
+ errorContainer.textContent = `Invalid SVG: ${err.message}`;
1740
+ return errorContainer.outerHTML;
1741
+ }
1742
+ }
1743
+
1744
+ /**
1745
+ * Render HTML content with DOMPurify if available
1746
+ */
1747
+ renderHTML(code) {
1748
+ const id = `html-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1749
+
1750
+ // If DOMPurify is loaded, use it
1751
+ if (window.DOMPurify) {
1752
+ const clean = DOMPurify.sanitize(code);
1753
+
1754
+ // Create container programmatically
1755
+ const container = document.createElement('div');
1756
+ container.className = 'qde-html-container';
1757
+ container.contentEditable = 'false';
1758
+ container.setAttribute('data-qd-fence', '```');
1759
+ container.setAttribute('data-qd-lang', 'html');
1760
+ container.setAttribute('data-qd-source', code);
1761
+ container.innerHTML = clean;
1762
+
1763
+ return container.outerHTML;
1764
+ }
1765
+
1766
+ // Try to lazy load DOMPurify
1767
+ this.lazyLoadLibrary(
1768
+ 'DOMPurify',
1769
+ () => window.DOMPurify,
1770
+ 'https://unpkg.com/dompurify/dist/purify.min.js'
1771
+ ).then(loaded => {
1772
+ if (loaded) {
1773
+ const element = document.getElementById(id);
1774
+ if (element) {
1775
+ const clean = DOMPurify.sanitize(code);
1776
+ element.innerHTML = clean;
1777
+ // Update attributes after loading
1778
+ element.setAttribute('data-qd-source', code);
1779
+ element.setAttribute('data-qd-fence', '```');
1780
+ element.setAttribute('data-qd-lang', 'html');
1781
+ }
1782
+ }
1783
+ });
1784
+
1785
+ // Return placeholder with bidirectional attributes - non-editable
1786
+ const placeholder = document.createElement('div');
1787
+ placeholder.id = id;
1788
+ placeholder.className = 'qde-html-container';
1789
+ placeholder.contentEditable = 'false';
1790
+ placeholder.setAttribute('data-qd-fence', '```');
1791
+ placeholder.setAttribute('data-qd-lang', 'html');
1792
+ placeholder.setAttribute('data-qd-source', code);
1793
+ const pre = document.createElement('pre');
1794
+ pre.textContent = code;
1795
+ placeholder.appendChild(pre);
1796
+
1797
+ return placeholder.outerHTML;
1798
+ }
1799
+
1800
+ /**
1801
+ * Render math with KaTeX if available
1802
+ */
1803
+ renderMath(code, lang) {
1804
+ const id = `math-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1805
+
1806
+ // If KaTeX is loaded, use it
1807
+ if (window.katex) {
1808
+ try {
1809
+ const rendered = katex.renderToString(code, {
1810
+ displayMode: true,
1811
+ throwOnError: false
1812
+ });
1813
+
1814
+ // Create container programmatically
1815
+ const container = document.createElement('div');
1816
+ container.className = 'qde-math-container';
1817
+ container.contentEditable = 'false';
1818
+ container.setAttribute('data-qd-fence', '```');
1819
+ container.setAttribute('data-qd-lang', lang);
1820
+ container.setAttribute('data-qd-source', code);
1821
+ container.innerHTML = rendered;
1822
+
1823
+ return container.outerHTML;
1824
+ } catch (err) {
1825
+ const errorContainer = document.createElement('pre');
1826
+ errorContainer.className = 'qde-error';
1827
+ errorContainer.contentEditable = 'false';
1828
+ errorContainer.setAttribute('data-qd-fence', '```');
1829
+ errorContainer.setAttribute('data-qd-lang', lang);
1830
+ errorContainer.setAttribute('data-qd-source', code);
1831
+ errorContainer.textContent = `Math error: ${err.message}`;
1832
+ return errorContainer.outerHTML;
1833
+ }
1834
+ }
1835
+
1836
+ // Try to lazy load KaTeX
1837
+ this.lazyLoadLibrary(
1838
+ 'KaTeX',
1839
+ () => window.katex,
1840
+ 'https://unpkg.com/katex/dist/katex.min.js',
1841
+ 'https://unpkg.com/katex/dist/katex.min.css'
1842
+ ).then(loaded => {
1843
+ if (loaded) {
1844
+ const element = document.getElementById(id);
1845
+ if (element) {
1846
+ try {
1847
+ katex.render(code, element, {
1848
+ displayMode: true,
1849
+ throwOnError: false
1850
+ });
1851
+ // Update attributes after rendering
1852
+ element.setAttribute('data-qd-source', code);
1853
+ element.setAttribute('data-qd-fence', '```');
1854
+ element.setAttribute('data-qd-lang', lang);
1855
+ } catch (err) {
1856
+ element.innerHTML = `<pre class="qde-error">Math error: ${this.escapeHtml(err.message)}</pre>`;
1857
+ }
1858
+ }
1859
+ }
1860
+ });
1861
+
1862
+ // Return placeholder with bidirectional attributes - non-editable
1863
+ const placeholder = document.createElement('div');
1864
+ placeholder.id = id;
1865
+ placeholder.className = 'qde-math-container';
1866
+ placeholder.contentEditable = 'false';
1867
+ placeholder.setAttribute('data-qd-fence', '```');
1868
+ placeholder.setAttribute('data-qd-lang', lang);
1869
+ placeholder.setAttribute('data-qd-source', code);
1870
+ const pre = document.createElement('pre');
1871
+ pre.textContent = code;
1872
+ placeholder.appendChild(pre);
1873
+
1874
+ return placeholder.outerHTML;
1875
+ }
1876
+
1877
+ /**
1878
+ * Render CSV/PSV/TSV as HTML table
1879
+ */
1880
+ renderTable(code, lang) {
1881
+ const escapedCode = this.escapeHtml(code);
1882
+ try {
1883
+ const delimiter = lang === 'csv' ? ',' : lang === 'psv' ? '|' : '\t';
1884
+ const lines = code.trim().split('\n');
1885
+
1886
+ if (lines.length === 0) {
1887
+ return `<pre data-qd-fence="\`\`\`" data-qd-lang="${lang}" data-qd-source="${escapedCode}">${escapedCode}</pre>`;
1888
+ }
1889
+
1890
+ // CSV tables CAN be editable - we'll convert HTML table back to CSV
1891
+ // Don't need data-qd-source since we convert the table structure back to CSV
1892
+ let html = `<table class="qde-data-table qde-csv-table" data-qd-fence="\`\`\`" data-qd-lang="${lang}">`;
1893
+
1894
+ // Parse header
1895
+ const header = this.parseCSVLine(lines[0], delimiter);
1896
+ html += '<thead><tr>';
1897
+ header.forEach(cell => {
1898
+ html += `<th>${this.escapeHtml(cell.trim())}</th>`;
1899
+ });
1900
+ html += '</tr></thead>';
1901
+
1902
+ // Parse body
1903
+ if (lines.length > 1) {
1904
+ html += '<tbody>';
1905
+ for (let i = 1; i < lines.length; i++) {
1906
+ const row = this.parseCSVLine(lines[i], delimiter);
1907
+ html += '<tr>';
1908
+ row.forEach(cell => {
1909
+ html += `<td>${this.escapeHtml(cell.trim())}</td>`;
1910
+ });
1911
+ html += '</tr>';
1912
+ }
1913
+ html += '</tbody>';
1914
+ }
1915
+
1916
+ html += '</table>';
1917
+ return html;
1918
+ } catch (err) {
1919
+ return `<pre data-qd-fence="\`\`\`" data-qd-lang="${lang}" data-qd-source="${escapedCode}">${escapedCode}</pre>`;
1920
+ }
1921
+ }
1922
+
1923
+ /**
1924
+ * Parse CSV line handling quoted values
1925
+ */
1926
+ parseCSVLine(line, delimiter) {
1927
+ const result = [];
1928
+ let current = '';
1929
+ let inQuotes = false;
1930
+
1931
+ for (let i = 0; i < line.length; i++) {
1932
+ const char = line[i];
1933
+ const nextChar = line[i + 1];
1934
+
1935
+ if (char === '"') {
1936
+ if (inQuotes && nextChar === '"') {
1937
+ current += '"';
1938
+ i++; // Skip next quote
1939
+ } else {
1940
+ inQuotes = !inQuotes;
1941
+ }
1942
+ } else if (char === delimiter && !inQuotes) {
1943
+ result.push(current);
1944
+ current = '';
1945
+ } else {
1946
+ current += char;
1947
+ }
1948
+ }
1949
+
1950
+ result.push(current);
1951
+ return result;
1952
+ }
1953
+
1954
+ /**
1955
+ * Render JSON with syntax highlighting
1956
+ */
1957
+ renderJSON(code, lang) {
1958
+ // If highlight.js is available, use it for all JSON
1959
+ if (window.hljs && hljs.getLanguage('json')) {
1960
+ try {
1961
+ // Try to format if valid JSON
1962
+ let toHighlight = code;
1963
+ try {
1964
+ const data = JSON.parse(code);
1965
+ toHighlight = JSON.stringify(data, null, 2);
1966
+ } catch (e) {
1967
+ // Use original if not valid JSON
1968
+ }
1969
+
1970
+ const highlighted = hljs.highlight(toHighlight, { language: 'json' }).value;
1971
+ return `<pre class="qde-json" data-qd-fence="\`\`\`" data-qd-lang="${lang}"><code class="hljs language-json">${highlighted}</code></pre>`;
1972
+ } catch (e) {
1973
+ // Fall through if highlighting fails
1974
+ }
1975
+ }
1976
+
1977
+ // No highlighting available - return plain
1978
+ return `<pre class="qde-json" data-qd-fence="\`\`\`" data-qd-lang="${lang}">${this.escapeHtml(code)}</pre>`;
1979
+ }
1980
+
1981
+ /**
1982
+ * Render Mermaid diagram
1983
+ */
1984
+ renderMermaid(code) {
1985
+ const id = `mermaid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1986
+ setTimeout(() => {
1987
+ const element = document.getElementById(id);
1988
+ if (element && window.mermaid) {
1989
+ mermaid.render(id + '-svg', code).then(result => {
1990
+ element.innerHTML = result.svg;
1991
+ }).catch(err => {
1992
+ element.innerHTML = `<pre>Error rendering diagram: ${err.message}</pre>`;
1993
+ });
1994
+ }
1995
+ }, 0);
1996
+
1997
+ // Create container programmatically
1998
+ const container = document.createElement('div');
1999
+ container.id = id;
2000
+ container.className = 'mermaid';
2001
+ container.contentEditable = 'false';
2002
+ container.setAttribute('data-qd-source', code);
2003
+ container.setAttribute('data-qd-fence', '```');
2004
+ container.setAttribute('data-qd-lang', 'mermaid');
2005
+ container.textContent = 'Loading diagram...';
2006
+
2007
+ return container.outerHTML;
2008
+ }
2009
+
2010
+ /**
2011
+ * Escape HTML for attributes
2012
+ */
2013
+ escapeHtml(text) {
2014
+ return (text ?? "").replace(/[&"'<>]/g, m =>
2015
+ ({'&':'&amp;','"':'&quot;',"'":'&#39;','<':'&lt;','>':'&gt;'}[m]));
2016
+ }
2017
+
2018
+ /**
2019
+ * Make complex fence blocks non-editable
2020
+ */
2021
+ makeFencesNonEditable() {
2022
+ if (!this.previewPanel) return;
2023
+
2024
+ // Only make specific complex fence types non-editable
2025
+ // SVG, HTML, Math, Mermaid already have contenteditable="false" set
2026
+ // Syntax-highlighted code also has it set
2027
+
2028
+ // Don't make regular code blocks or tables non-editable
2029
+ // They can be edited and properly round-trip
2030
+ }
2031
+
2032
+ /**
2033
+ * Load plugins dynamically
2034
+ */
2035
+ async loadPlugins() {
2036
+ const promises = [];
2037
+
2038
+ // Load highlight.js (check if already loaded)
2039
+ if (this.options.plugins.highlightjs && !window.hljs) {
2040
+ promises.push(
2041
+ this.loadScript('https://unpkg.com/@highlightjs/cdn-assets/highlight.min.js'),
2042
+ this.loadCSS('https://unpkg.com/@highlightjs/cdn-assets/styles/github.min.css')
2043
+ );
2044
+ }
2045
+
2046
+ // Load mermaid (check if already loaded)
2047
+ if (this.options.plugins.mermaid && !window.mermaid) {
2048
+ promises.push(
2049
+ this.loadScript('https://unpkg.com/mermaid/dist/mermaid.min.js').then(() => {
2050
+ if (window.mermaid) {
2051
+ mermaid.initialize({ startOnLoad: false });
2052
+ }
2053
+ })
2054
+ );
2055
+ }
2056
+
2057
+ await Promise.all(promises);
2058
+ }
2059
+
2060
+ /**
2061
+ * Lazy load library if not already loaded
2062
+ */
2063
+ async lazyLoadLibrary(name, check, scriptUrl, cssUrl = null) {
2064
+ // Check if library is already loaded
2065
+ if (check()) {
2066
+ return true;
2067
+ }
2068
+
2069
+ try {
2070
+ const promises = [];
2071
+
2072
+ // Load script
2073
+ if (scriptUrl) {
2074
+ promises.push(this.loadScript(scriptUrl));
2075
+ }
2076
+
2077
+ // Load CSS if provided
2078
+ if (cssUrl) {
2079
+ promises.push(this.loadCSS(cssUrl));
2080
+ }
2081
+
2082
+ await Promise.all(promises);
2083
+
2084
+ // Verify library loaded
2085
+ return check();
2086
+ } catch (err) {
2087
+ console.error(`Failed to load ${name}:`, err);
2088
+ return false;
2089
+ }
2090
+ }
2091
+
2092
+ /**
2093
+ * Load external script
2094
+ */
2095
+ loadScript(src) {
2096
+ return new Promise((resolve, reject) => {
2097
+ const script = document.createElement('script');
2098
+ script.src = src;
2099
+ script.onload = resolve;
2100
+ script.onerror = reject;
2101
+ document.head.appendChild(script);
2102
+ });
2103
+ }
2104
+
2105
+ /**
2106
+ * Load external CSS
2107
+ */
2108
+ loadCSS(href) {
2109
+ return new Promise((resolve) => {
2110
+ const link = document.createElement('link');
2111
+ link.rel = 'stylesheet';
2112
+ link.href = href;
2113
+ link.onload = resolve;
2114
+ document.head.appendChild(link);
2115
+ // Resolve anyway after timeout (CSS doesn't always fire onload)
2116
+ setTimeout(resolve, 1000);
2117
+ });
2118
+ }
2119
+
2120
+ /**
2121
+ * Apply theme
2122
+ */
2123
+ applyTheme() {
2124
+ const theme = this.options.theme;
2125
+
2126
+ if (theme === 'auto') {
2127
+ // Check system preference
2128
+ const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
2129
+ this.container.classList.toggle('qde-dark', isDark);
2130
+
2131
+ // Listen for changes
2132
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
2133
+ this.container.classList.toggle('qde-dark', e.matches);
2134
+ });
2135
+ } else {
2136
+ this.container.classList.toggle('qde-dark', theme === 'dark');
2137
+ }
2138
+ }
2139
+
2140
+ /**
2141
+ * Set lazy linefeeds option
2142
+ * @param {boolean} enabled - Whether to enable lazy linefeeds
2143
+ */
2144
+ setLazyLinefeeds(enabled) {
2145
+ this.options.lazy_linefeeds = enabled;
2146
+ // Re-render if we have content
2147
+ if (this._markdown) {
2148
+ this.updateFromSource();
2149
+ }
2150
+ }
2151
+
2152
+ /**
2153
+ * Get lazy linefeeds option
2154
+ * @returns {boolean}
2155
+ */
2156
+ getLazyLinefeeds() {
2157
+ return this.options.lazy_linefeeds;
2158
+ }
2159
+
2160
+ /**
2161
+ * Set editor mode
2162
+ */
2163
+ setMode(mode) {
2164
+ if (!['source', 'preview', 'split'].includes(mode)) return;
2165
+
2166
+ this.currentMode = mode;
2167
+ this.container.className = `qde-container qde-mode-${mode}`;
2168
+
2169
+ // Update toolbar buttons
2170
+ if (this.toolbar) {
2171
+ this.toolbar.querySelectorAll('.qde-btn[data-mode]').forEach(btn => {
2172
+ btn.classList.toggle('active', btn.dataset.mode === mode);
2173
+ });
2174
+ }
2175
+
2176
+ // Apply theme class
2177
+ if (this.container.classList.contains('qde-dark')) {
2178
+ this.container.classList.add('qde-dark');
2179
+ }
2180
+
2181
+ // Make fence blocks non-editable when showing preview
2182
+ if (mode !== 'source') {
2183
+ setTimeout(() => this.makeFencesNonEditable(), 0);
2184
+ }
2185
+
2186
+ // Trigger mode change event
2187
+ if (this.options.onModeChange) {
2188
+ this.options.onModeChange(mode);
2189
+ }
2190
+ }
2191
+
2192
+ /**
2193
+ * Handle toolbar actions
2194
+ */
2195
+ handleAction(action) {
2196
+ switch(action) {
2197
+ case 'copy-markdown':
2198
+ this.copy('markdown');
2199
+ break;
2200
+ case 'copy-html':
2201
+ this.copy('html');
2202
+ break;
2203
+ }
2204
+ }
2205
+
2206
+ /**
2207
+ * Copy content to clipboard
2208
+ */
2209
+ async copy(type) {
2210
+ const content = type === 'markdown' ? this._markdown : this._html;
2211
+
2212
+ try {
2213
+ await navigator.clipboard.writeText(content);
2214
+
2215
+ // Visual feedback
2216
+ const btn = this.toolbar.querySelector(`[data-action="copy-${type}"]`);
2217
+ if (btn) {
2218
+ const originalText = btn.textContent;
2219
+ btn.textContent = 'Copied!';
2220
+ setTimeout(() => {
2221
+ btn.textContent = originalText;
2222
+ }, 1500);
2223
+ }
2224
+ } catch (err) {
2225
+ console.error('Failed to copy:', err);
2226
+ }
2227
+ }
2228
+
2229
+ // Public API
2230
+
2231
+ /**
2232
+ * Get current markdown
2233
+ */
2234
+ get markdown() {
2235
+ return this._markdown;
2236
+ }
2237
+
2238
+ /**
2239
+ * Set markdown content
2240
+ */
2241
+ set markdown(value) {
2242
+ this.setMarkdown(value);
2243
+ }
2244
+
2245
+ /**
2246
+ * Get current HTML
2247
+ */
2248
+ get html() {
2249
+ return this._html;
2250
+ }
2251
+
2252
+ /**
2253
+ * Get current mode
2254
+ */
2255
+ get mode() {
2256
+ return this.currentMode;
2257
+ }
2258
+
2259
+ /**
2260
+ * Set markdown content
2261
+ */
2262
+ async setMarkdown(markdown) {
2263
+ // Wait for initialization if needed
2264
+ if (this.initPromise) {
2265
+ await this.initPromise;
2266
+ }
2267
+
2268
+ this._markdown = markdown;
2269
+ if (this.sourceTextarea) {
2270
+ this.sourceTextarea.value = markdown;
2271
+ }
2272
+ this.updateFromMarkdown(markdown);
2273
+ }
2274
+
2275
+ /**
2276
+ * Get markdown content
2277
+ */
2278
+ getMarkdown() {
2279
+ return this._markdown;
2280
+ }
2281
+
2282
+ /**
2283
+ * Get HTML content
2284
+ */
2285
+ getHTML() {
2286
+ return this._html;
2287
+ }
2288
+
2289
+ /**
2290
+ * Destroy the editor
2291
+ */
2292
+ destroy() {
2293
+ // Clear timers
2294
+ clearTimeout(this.updateTimer);
2295
+
2296
+ // Clear container
2297
+ this.container.innerHTML = '';
2298
+ this.container.classList.remove('qde-container', 'qde-dark');
2299
+
2300
+ // Remove injected styles (only if no other editors exist)
2301
+ const otherEditors = document.querySelectorAll('.qde-container');
2302
+ if (otherEditors.length === 0) {
2303
+ const style = document.getElementById('qde-styles');
2304
+ if (style) style.remove();
2305
+ }
2306
+ }
2307
+ }
2308
+
2309
+ // Export for CommonJS (needed for bundled ESM to work with Jest)
2310
+ if (typeof module !== 'undefined' && module.exports) {
2311
+ module.exports = QuikdownEditor;
2312
+ }
2313
+
2314
+ // Also export for UMD builds
2315
+ if (typeof window !== 'undefined') {
2316
+ window.QuikdownEditor = QuikdownEditor;
2317
+ }
2318
+
2319
+ module.exports = QuikdownEditor;