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