quikdown 1.1.1 → 1.2.3
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 +39 -7
- package/dist/quikdown.cjs +23 -10
- package/dist/quikdown.dark.css +1 -1
- package/dist/quikdown.esm.js +23 -10
- package/dist/quikdown.esm.min.js +2 -2
- package/dist/quikdown.esm.min.js.gz +0 -0
- package/dist/quikdown.esm.min.js.map +1 -1
- package/dist/quikdown.light.css +1 -1
- package/dist/quikdown.umd.js +23 -10
- package/dist/quikdown.umd.min.js +2 -2
- package/dist/quikdown.umd.min.js.gz +0 -0
- package/dist/quikdown.umd.min.js.map +1 -1
- package/dist/quikdown_ast.cjs +513 -0
- package/dist/quikdown_ast.d.ts +227 -0
- package/dist/quikdown_ast.esm.js +511 -0
- package/dist/quikdown_ast.esm.min.js +8 -0
- package/dist/quikdown_ast.esm.min.js.gz +0 -0
- package/dist/quikdown_ast.esm.min.js.map +1 -0
- package/dist/quikdown_ast.umd.js +519 -0
- package/dist/quikdown_ast.umd.min.js +8 -0
- package/dist/quikdown_ast.umd.min.js.gz +0 -0
- package/dist/quikdown_ast.umd.min.js.map +1 -0
- package/dist/quikdown_ast_html.cjs +1058 -0
- package/dist/quikdown_ast_html.d.ts +68 -0
- package/dist/quikdown_ast_html.esm.js +1056 -0
- package/dist/quikdown_ast_html.esm.min.js +8 -0
- package/dist/quikdown_ast_html.esm.min.js.gz +0 -0
- package/dist/quikdown_ast_html.esm.min.js.map +1 -0
- package/dist/quikdown_ast_html.umd.js +1064 -0
- package/dist/quikdown_ast_html.umd.min.js +8 -0
- package/dist/quikdown_ast_html.umd.min.js.gz +0 -0
- package/dist/quikdown_ast_html.umd.min.js.map +1 -0
- package/dist/quikdown_bd.cjs +38 -19
- package/dist/quikdown_bd.esm.js +38 -19
- package/dist/quikdown_bd.esm.min.js +2 -2
- package/dist/quikdown_bd.esm.min.js.gz +0 -0
- package/dist/quikdown_bd.esm.min.js.map +1 -1
- package/dist/quikdown_bd.umd.js +38 -19
- package/dist/quikdown_bd.umd.min.js +2 -2
- package/dist/quikdown_bd.umd.min.js.gz +0 -0
- package/dist/quikdown_bd.umd.min.js.map +1 -1
- package/dist/quikdown_edit.cjs +836 -117
- package/dist/quikdown_edit.d.ts +123 -131
- package/dist/quikdown_edit.esm.js +836 -117
- package/dist/quikdown_edit.esm.min.js +3 -3
- package/dist/quikdown_edit.esm.min.js.gz +0 -0
- package/dist/quikdown_edit.esm.min.js.map +1 -1
- package/dist/quikdown_edit.umd.js +836 -117
- package/dist/quikdown_edit.umd.min.js +3 -3
- package/dist/quikdown_edit.umd.min.js.gz +0 -0
- package/dist/quikdown_edit.umd.min.js.map +1 -1
- package/dist/quikdown_json.cjs +556 -0
- package/dist/quikdown_json.d.ts +48 -0
- package/dist/quikdown_json.esm.js +554 -0
- package/dist/quikdown_json.esm.min.js +8 -0
- package/dist/quikdown_json.esm.min.js.gz +0 -0
- package/dist/quikdown_json.esm.min.js.map +1 -0
- package/dist/quikdown_json.umd.js +562 -0
- package/dist/quikdown_json.umd.min.js +8 -0
- package/dist/quikdown_json.umd.min.js.gz +0 -0
- package/dist/quikdown_json.umd.min.js.map +1 -0
- package/dist/quikdown_yaml.cjs +717 -0
- package/dist/quikdown_yaml.d.ts +51 -0
- package/dist/quikdown_yaml.esm.js +715 -0
- package/dist/quikdown_yaml.esm.min.js +8 -0
- package/dist/quikdown_yaml.esm.min.js.gz +0 -0
- package/dist/quikdown_yaml.esm.min.js.map +1 -0
- package/dist/quikdown_yaml.umd.js +723 -0
- package/dist/quikdown_yaml.umd.min.js +8 -0
- package/dist/quikdown_yaml.umd.min.js.gz +0 -0
- package/dist/quikdown_yaml.umd.min.js.map +1 -0
- package/package.json +100 -45
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Quikdown Editor - Drop-in Markdown Parser
|
|
3
|
-
* @version 1.
|
|
3
|
+
* @version 1.2.3
|
|
4
4
|
* @license BSD-2-Clause
|
|
5
5
|
* @copyright DeftIO 2025
|
|
6
6
|
*/
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
// Version will be injected at build time
|
|
27
|
-
const quikdownVersion = '1.
|
|
27
|
+
const quikdownVersion = '1.2.3';
|
|
28
28
|
|
|
29
29
|
// Constants for reuse
|
|
30
30
|
const CLASS_PREFIX = 'quikdown-';
|
|
@@ -72,6 +72,11 @@
|
|
|
72
72
|
// Remove default text-align if we're adding a different alignment
|
|
73
73
|
if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
|
|
74
74
|
style = style.replace(/text-align:[^;]+;?/, '').trim();
|
|
75
|
+
// Ensure trailing semicolon before concatenating additionalStyle.
|
|
76
|
+
// Both short-circuit paths of this guard (empty `style` or
|
|
77
|
+
// already-has-`;`) are defensive and unreachable with the
|
|
78
|
+
// current QUIKDOWN_STYLES values — istanbul ignore next.
|
|
79
|
+
/* istanbul ignore next */
|
|
75
80
|
if (style && !style.endsWith(';')) style += ';';
|
|
76
81
|
}
|
|
77
82
|
|
|
@@ -103,9 +108,12 @@
|
|
|
103
108
|
return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
|
|
104
109
|
}
|
|
105
110
|
|
|
106
|
-
// Helper to add data-qd attributes for bidirectional support
|
|
111
|
+
// Helper to add data-qd attributes for bidirectional support.
|
|
112
|
+
// The non-bidirectional branch is a trivial no-op arrow; it's exercised in
|
|
113
|
+
// the core bundle but never in quikdown_bd (which always sets bidirectional=true).
|
|
114
|
+
/* istanbul ignore next - trivial no-op fallback */
|
|
107
115
|
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
108
|
-
|
|
116
|
+
|
|
109
117
|
// Sanitize URLs to prevent XSS attacks
|
|
110
118
|
function sanitizeUrl(url, allowUnsafe = false) {
|
|
111
119
|
/* istanbul ignore next - defensive programming, regex ensures url is never empty */
|
|
@@ -274,7 +282,7 @@
|
|
|
274
282
|
html = '<p>' + html + '</p>';
|
|
275
283
|
} else {
|
|
276
284
|
// Standard: two spaces at end of line for line breaks
|
|
277
|
-
html = html.replace(/
|
|
285
|
+
html = html.replace(/ {2}$/gm, `<br${getAttr('br')}>`);
|
|
278
286
|
|
|
279
287
|
// Paragraphs (double newlines)
|
|
280
288
|
// Don't add </p> after block elements (they're not in paragraphs)
|
|
@@ -303,7 +311,7 @@
|
|
|
303
311
|
[/(<\/table>)<\/p>/g, '$1'],
|
|
304
312
|
[/<p>(<pre[^>]*>)/g, '$1'],
|
|
305
313
|
[/(<\/pre>)<\/p>/g, '$1'],
|
|
306
|
-
[new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)
|
|
314
|
+
[new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)</p>`, 'g'), '$1']
|
|
307
315
|
];
|
|
308
316
|
|
|
309
317
|
cleanupPatterns.forEach(([pattern, replacement]) => {
|
|
@@ -509,10 +517,15 @@
|
|
|
509
517
|
|
|
510
518
|
const lines = text.split('\n');
|
|
511
519
|
const result = [];
|
|
512
|
-
|
|
520
|
+
const listStack = []; // Track nested lists
|
|
513
521
|
|
|
514
|
-
// Helper to escape HTML for data-qd attributes
|
|
515
|
-
|
|
522
|
+
// Helper to escape HTML for data-qd attributes. List markers (`-`, `*`,
|
|
523
|
+
// `+`, `1.`, etc.) never contain HTML-special chars, so the replace
|
|
524
|
+
// callback is defensive-only and never actually fires in practice.
|
|
525
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g,
|
|
526
|
+
/* istanbul ignore next - defensive: list markers never contain HTML specials */
|
|
527
|
+
m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
|
|
528
|
+
/* istanbul ignore next - trivial no-op fallback; not exercised via bd bundle */
|
|
516
529
|
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
517
530
|
|
|
518
531
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -684,8 +697,11 @@
|
|
|
684
697
|
return quikdown(markdown, { ...options, bidirectional: true });
|
|
685
698
|
}
|
|
686
699
|
|
|
687
|
-
// Copy all properties and methods from quikdown (including version)
|
|
700
|
+
// Copy all properties and methods from quikdown (including version).
|
|
701
|
+
// Skip `configure` — quikdown_bd provides its own override below, so the
|
|
702
|
+
// inner quikdown.configure is dead code in this bundle.
|
|
688
703
|
Object.keys(quikdown).forEach(key => {
|
|
704
|
+
if (key === 'configure') return;
|
|
689
705
|
quikdown_bd[key] = quikdown[key];
|
|
690
706
|
});
|
|
691
707
|
|
|
@@ -719,7 +735,7 @@
|
|
|
719
735
|
|
|
720
736
|
// Process children with context
|
|
721
737
|
let childContent = '';
|
|
722
|
-
for (
|
|
738
|
+
for (const child of node.childNodes) {
|
|
723
739
|
childContent += walkNode(child, { parentTag: tag, ...parentContext });
|
|
724
740
|
}
|
|
725
741
|
|
|
@@ -953,7 +969,7 @@
|
|
|
953
969
|
let index = 1;
|
|
954
970
|
const indent = ' '.repeat(depth);
|
|
955
971
|
|
|
956
|
-
for (
|
|
972
|
+
for (const child of listNode.children) {
|
|
957
973
|
if (child.tagName !== 'LI') continue;
|
|
958
974
|
|
|
959
975
|
const dataQd = child.getAttribute('data-qd');
|
|
@@ -966,7 +982,7 @@
|
|
|
966
982
|
marker = '-';
|
|
967
983
|
// Get text without the checkbox
|
|
968
984
|
let text = '';
|
|
969
|
-
for (
|
|
985
|
+
for (const node of child.childNodes) {
|
|
970
986
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
971
987
|
text += node.textContent;
|
|
972
988
|
} else if (node.tagName && node.tagName !== 'INPUT') {
|
|
@@ -977,7 +993,7 @@
|
|
|
977
993
|
} else {
|
|
978
994
|
let itemContent = '';
|
|
979
995
|
|
|
980
|
-
for (
|
|
996
|
+
for (const node of child.childNodes) {
|
|
981
997
|
if (node.tagName === 'UL' || node.tagName === 'OL') {
|
|
982
998
|
itemContent += walkList(node, node.tagName === 'OL', depth + 1);
|
|
983
999
|
} else {
|
|
@@ -1006,7 +1022,7 @@
|
|
|
1006
1022
|
const headerRow = thead.querySelector('tr');
|
|
1007
1023
|
if (headerRow) {
|
|
1008
1024
|
const headers = [];
|
|
1009
|
-
for (
|
|
1025
|
+
for (const th of headerRow.querySelectorAll('th')) {
|
|
1010
1026
|
headers.push(th.textContent.trim());
|
|
1011
1027
|
}
|
|
1012
1028
|
result += '| ' + headers.join(' | ') + ' |\n';
|
|
@@ -1025,9 +1041,9 @@
|
|
|
1025
1041
|
// Process body
|
|
1026
1042
|
const tbody = table.querySelector('tbody');
|
|
1027
1043
|
if (tbody) {
|
|
1028
|
-
for (
|
|
1044
|
+
for (const row of tbody.querySelectorAll('tr')) {
|
|
1029
1045
|
const cells = [];
|
|
1030
|
-
for (
|
|
1046
|
+
for (const td of row.querySelectorAll('td')) {
|
|
1031
1047
|
cells.push(td.textContent.trim());
|
|
1032
1048
|
}
|
|
1033
1049
|
if (cells.length > 0) {
|
|
@@ -1049,10 +1065,13 @@
|
|
|
1049
1065
|
return markdown;
|
|
1050
1066
|
};
|
|
1051
1067
|
|
|
1052
|
-
// Override the configure method to return a bidirectional version
|
|
1068
|
+
// Override the configure method to return a bidirectional version.
|
|
1069
|
+
// We delegate to the inner quikdown.configure so the shared closure
|
|
1070
|
+
// machinery is exercised in both bundles (no dead code).
|
|
1053
1071
|
quikdown_bd.configure = function(options) {
|
|
1072
|
+
const innerParser = quikdown.configure({ ...options, bidirectional: true });
|
|
1054
1073
|
return function(markdown) {
|
|
1055
|
-
return
|
|
1074
|
+
return innerParser(markdown);
|
|
1056
1075
|
};
|
|
1057
1076
|
};
|
|
1058
1077
|
|
|
@@ -1931,7 +1950,7 @@
|
|
|
1931
1950
|
// First try baseVal.value (works for absolute units)
|
|
1932
1951
|
width = svg.width.baseVal.value;
|
|
1933
1952
|
height = svg.height.baseVal.value;
|
|
1934
|
-
} catch (
|
|
1953
|
+
} catch (_e) {
|
|
1935
1954
|
// Fallback for relative units - use viewBox or rendered size
|
|
1936
1955
|
if (svg.viewBox && svg.viewBox.baseVal) {
|
|
1937
1956
|
width = svg.viewBox.baseVal.width;
|
|
@@ -1950,8 +1969,8 @@
|
|
|
1950
1969
|
// Apply aggressive downsizing for MathJax SVGs
|
|
1951
1970
|
let scaleFactor = 0.04; // Further reduced for smaller output
|
|
1952
1971
|
|
|
1953
|
-
|
|
1954
|
-
|
|
1972
|
+
const scaledWidth = width * scaleFactor;
|
|
1973
|
+
const scaledHeight = height * scaleFactor;
|
|
1955
1974
|
|
|
1956
1975
|
// If still too large after base scaling, scale down further
|
|
1957
1976
|
if (scaledWidth > targetMaxWidth || scaledHeight > targetMaxHeight) {
|
|
@@ -2184,7 +2203,7 @@
|
|
|
2184
2203
|
let mapDataUrl = '';
|
|
2185
2204
|
try {
|
|
2186
2205
|
mapDataUrl = canvas.toDataURL('image/png', 1.0);
|
|
2187
|
-
} catch (
|
|
2206
|
+
} catch (_e) {
|
|
2188
2207
|
console.warn('Map canvas tainted; falling back to placeholder');
|
|
2189
2208
|
}
|
|
2190
2209
|
|
|
@@ -2547,7 +2566,8 @@
|
|
|
2547
2566
|
const DEFAULT_OPTIONS = {
|
|
2548
2567
|
mode: 'split', // 'source' | 'preview' | 'split'
|
|
2549
2568
|
showToolbar: true,
|
|
2550
|
-
showRemoveHR: false, // Show button to remove horizontal rules (---)
|
|
2569
|
+
showRemoveHR: false, // Show button to remove horizontal rules (---)
|
|
2570
|
+
showLazyLinefeeds: false, // Show button to convert lazy linefeeds
|
|
2551
2571
|
theme: 'auto', // 'light' | 'dark' | 'auto'
|
|
2552
2572
|
lazy_linefeeds: false,
|
|
2553
2573
|
inline_styles: false, // Use CSS classes (false) or inline styles (true)
|
|
@@ -2557,8 +2577,73 @@
|
|
|
2557
2577
|
highlightjs: false,
|
|
2558
2578
|
mermaid: false
|
|
2559
2579
|
},
|
|
2580
|
+
/**
|
|
2581
|
+
* Preload fence-rendering libraries at construction time so the FIRST
|
|
2582
|
+
* encounter with a fence type renders instantly (no lazy load delay).
|
|
2583
|
+
*
|
|
2584
|
+
* Accepts:
|
|
2585
|
+
* - 'all' — preload every known library
|
|
2586
|
+
* - ['highlightjs','mermaid','math',
|
|
2587
|
+
* 'geojson','stl'] — preload specific libraries
|
|
2588
|
+
* - [{ name: 'mylib', script: 'https://...', css: '...' }]
|
|
2589
|
+
* — preload an arbitrary library
|
|
2590
|
+
*
|
|
2591
|
+
* Without this, fence libraries are loaded on demand the first time their
|
|
2592
|
+
* fence type is encountered. That keeps the editor lightweight, but the
|
|
2593
|
+
* first SVG/Mermaid/Math/GeoJSON/STL fence will show "loading..." for a
|
|
2594
|
+
* moment. Set `preloadFences` if you want zero-delay rendering — at the
|
|
2595
|
+
* cost of a few hundred KB of upfront network.
|
|
2596
|
+
*
|
|
2597
|
+
* Developer's choice. The editor itself is still ~70 KB minified;
|
|
2598
|
+
* `preloadFences` only affects the OPTIONAL fence renderers.
|
|
2599
|
+
*/
|
|
2600
|
+
preloadFences: null,
|
|
2560
2601
|
customFences: {}, // { 'language': (code, lang) => html }
|
|
2561
|
-
enableComplexFences: true // Enable CSV tables, math rendering, SVG, etc.
|
|
2602
|
+
enableComplexFences: true, // Enable CSV tables, math rendering, SVG, etc.
|
|
2603
|
+
showUndoRedo: false, // Show undo/redo toolbar buttons
|
|
2604
|
+
undoStackSize: 100 // Maximum number of undo states to keep
|
|
2605
|
+
};
|
|
2606
|
+
|
|
2607
|
+
// Library catalog used by preloadFences. Each entry knows how to:
|
|
2608
|
+
// - check if the library is already on the page (so we don't double-load)
|
|
2609
|
+
// - load it via script (and optional CSS)
|
|
2610
|
+
const FENCE_LIBRARIES = {
|
|
2611
|
+
highlightjs: {
|
|
2612
|
+
check: () => typeof window.hljs !== 'undefined',
|
|
2613
|
+
script: 'https://unpkg.com/@highlightjs/cdn-assets/highlight.min.js',
|
|
2614
|
+
css: 'https://unpkg.com/@highlightjs/cdn-assets/styles/github.min.css',
|
|
2615
|
+
cssDark: 'https://unpkg.com/@highlightjs/cdn-assets/styles/github-dark.min.css'
|
|
2616
|
+
},
|
|
2617
|
+
mermaid: {
|
|
2618
|
+
check: () => typeof window.mermaid !== 'undefined',
|
|
2619
|
+
script: 'https://unpkg.com/mermaid/dist/mermaid.min.js',
|
|
2620
|
+
afterLoad: () => {
|
|
2621
|
+
if (window.mermaid) window.mermaid.initialize({ startOnLoad: false });
|
|
2622
|
+
}
|
|
2623
|
+
},
|
|
2624
|
+
math: {
|
|
2625
|
+
check: () => typeof window.MathJax !== 'undefined',
|
|
2626
|
+
script: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js',
|
|
2627
|
+
beforeLoad: () => {
|
|
2628
|
+
// Configure MathJax before loading (must be set on window before script runs)
|
|
2629
|
+
if (!window.MathJax) {
|
|
2630
|
+
window.MathJax = {
|
|
2631
|
+
tex: { inlineMath: [['$', '$'], ['\\(', '\\)']], displayMath: [['$$', '$$'], ['\\[', '\\]']] },
|
|
2632
|
+
svg: { fontCache: 'global' },
|
|
2633
|
+
startup: { typeset: false }
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
},
|
|
2638
|
+
geojson: {
|
|
2639
|
+
check: () => typeof window.L !== 'undefined',
|
|
2640
|
+
script: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
|
|
2641
|
+
css: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
|
|
2642
|
+
},
|
|
2643
|
+
stl: {
|
|
2644
|
+
check: () => typeof window.THREE !== 'undefined',
|
|
2645
|
+
script: 'https://unpkg.com/three@0.147.0/build/three.min.js'
|
|
2646
|
+
}
|
|
2562
2647
|
};
|
|
2563
2648
|
|
|
2564
2649
|
/**
|
|
@@ -2583,6 +2668,11 @@
|
|
|
2583
2668
|
this._html = '';
|
|
2584
2669
|
this.currentMode = this.options.mode;
|
|
2585
2670
|
this.updateTimer = null;
|
|
2671
|
+
|
|
2672
|
+
// Undo/redo state
|
|
2673
|
+
this._undoStack = [];
|
|
2674
|
+
this._redoStack = [];
|
|
2675
|
+
this._isUndoRedo = false;
|
|
2586
2676
|
|
|
2587
2677
|
// Initialize
|
|
2588
2678
|
this.initPromise = this.init();
|
|
@@ -2639,6 +2729,7 @@
|
|
|
2639
2729
|
|
|
2640
2730
|
this.sourceTextarea = document.createElement('textarea');
|
|
2641
2731
|
this.sourceTextarea.className = 'qde-textarea';
|
|
2732
|
+
this.sourceTextarea.spellcheck = false;
|
|
2642
2733
|
this.sourceTextarea.placeholder = this.options.placeholder;
|
|
2643
2734
|
this.sourcePanel.appendChild(this.sourceTextarea);
|
|
2644
2735
|
|
|
@@ -2646,6 +2737,7 @@
|
|
|
2646
2737
|
this.previewPanel = document.createElement('div');
|
|
2647
2738
|
this.previewPanel.className = 'qde-preview';
|
|
2648
2739
|
this.previewPanel.contentEditable = true;
|
|
2740
|
+
this.previewPanel.spellcheck = false;
|
|
2649
2741
|
|
|
2650
2742
|
// Add panels to editor
|
|
2651
2743
|
this.editorArea.appendChild(this.sourcePanel);
|
|
@@ -2675,6 +2767,23 @@
|
|
|
2675
2767
|
toolbar.appendChild(btn);
|
|
2676
2768
|
});
|
|
2677
2769
|
|
|
2770
|
+
// Undo/Redo buttons (if enabled)
|
|
2771
|
+
if (this.options.showUndoRedo) {
|
|
2772
|
+
const undoBtn = document.createElement('button');
|
|
2773
|
+
undoBtn.className = 'qde-btn disabled';
|
|
2774
|
+
undoBtn.dataset.action = 'undo';
|
|
2775
|
+
undoBtn.textContent = 'Undo';
|
|
2776
|
+
undoBtn.title = 'Undo (Ctrl+Z)';
|
|
2777
|
+
toolbar.appendChild(undoBtn);
|
|
2778
|
+
|
|
2779
|
+
const redoBtn = document.createElement('button');
|
|
2780
|
+
redoBtn.className = 'qde-btn disabled';
|
|
2781
|
+
redoBtn.dataset.action = 'redo';
|
|
2782
|
+
redoBtn.textContent = 'Redo';
|
|
2783
|
+
redoBtn.title = 'Redo (Ctrl+Shift+Z / Ctrl+Y)';
|
|
2784
|
+
toolbar.appendChild(redoBtn);
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2678
2787
|
// Spacer
|
|
2679
2788
|
const spacer = document.createElement('span');
|
|
2680
2789
|
spacer.className = 'qde-spacer';
|
|
@@ -2705,6 +2814,16 @@
|
|
|
2705
2814
|
removeHRBtn.title = 'Remove all horizontal rules (---) from markdown';
|
|
2706
2815
|
toolbar.appendChild(removeHRBtn);
|
|
2707
2816
|
}
|
|
2817
|
+
|
|
2818
|
+
// Lazy linefeeds button (if enabled)
|
|
2819
|
+
if (this.options.showLazyLinefeeds) {
|
|
2820
|
+
const lazyLFBtn = document.createElement('button');
|
|
2821
|
+
lazyLFBtn.className = 'qde-btn';
|
|
2822
|
+
lazyLFBtn.dataset.action = 'lazy-linefeeds';
|
|
2823
|
+
lazyLFBtn.textContent = 'Fix Linefeeds';
|
|
2824
|
+
lazyLFBtn.title = 'Convert single newlines to paragraph breaks (one-time transform)';
|
|
2825
|
+
toolbar.appendChild(lazyLFBtn);
|
|
2826
|
+
}
|
|
2708
2827
|
|
|
2709
2828
|
return toolbar;
|
|
2710
2829
|
}
|
|
@@ -2757,6 +2876,11 @@
|
|
|
2757
2876
|
color: white;
|
|
2758
2877
|
border-color: #0056b3;
|
|
2759
2878
|
}
|
|
2879
|
+
|
|
2880
|
+
.qde-btn.disabled {
|
|
2881
|
+
opacity: 0.4;
|
|
2882
|
+
pointer-events: none;
|
|
2883
|
+
}
|
|
2760
2884
|
|
|
2761
2885
|
.qde-spacer {
|
|
2762
2886
|
flex: 1;
|
|
@@ -2769,24 +2893,45 @@
|
|
|
2769
2893
|
}
|
|
2770
2894
|
|
|
2771
2895
|
.qde-source, .qde-preview {
|
|
2772
|
-
flex: 1;
|
|
2896
|
+
flex: 1 1 0;
|
|
2897
|
+
min-width: 0; /* allow flex shrinking below content size */
|
|
2898
|
+
min-height: 0;
|
|
2773
2899
|
overflow: auto;
|
|
2774
2900
|
padding: 16px;
|
|
2901
|
+
box-sizing: border-box;
|
|
2775
2902
|
}
|
|
2776
|
-
|
|
2903
|
+
|
|
2777
2904
|
.qde-source {
|
|
2778
2905
|
border-right: 1px solid #ddd;
|
|
2906
|
+
/* Source pane is just a container for the textarea — make it
|
|
2907
|
+
a positioning context so the textarea can fill it absolutely */
|
|
2908
|
+
position: relative;
|
|
2909
|
+
padding: 0; /* textarea brings its own padding */
|
|
2779
2910
|
}
|
|
2780
|
-
|
|
2911
|
+
|
|
2781
2912
|
.qde-textarea {
|
|
2913
|
+
display: block;
|
|
2914
|
+
position: absolute;
|
|
2915
|
+
inset: 0;
|
|
2782
2916
|
width: 100%;
|
|
2783
2917
|
height: 100%;
|
|
2784
2918
|
border: none;
|
|
2785
2919
|
outline: none;
|
|
2786
2920
|
resize: none;
|
|
2921
|
+
padding: 16px;
|
|
2922
|
+
box-sizing: border-box;
|
|
2787
2923
|
font-family: 'Monaco', 'Courier New', monospace;
|
|
2788
2924
|
font-size: 14px;
|
|
2789
2925
|
line-height: 1.5;
|
|
2926
|
+
background: transparent;
|
|
2927
|
+
color: inherit;
|
|
2928
|
+
/* Wrap long lines so the textarea only scrolls VERTICALLY.
|
|
2929
|
+
pre-wrap preserves intentional line breaks/whitespace
|
|
2930
|
+
while soft-wrapping at the right edge. */
|
|
2931
|
+
white-space: pre-wrap;
|
|
2932
|
+
word-wrap: break-word;
|
|
2933
|
+
overflow-x: hidden;
|
|
2934
|
+
overflow-y: auto;
|
|
2790
2935
|
}
|
|
2791
2936
|
|
|
2792
2937
|
.qde-preview {
|
|
@@ -2795,14 +2940,69 @@
|
|
|
2795
2940
|
line-height: 1.6;
|
|
2796
2941
|
outline: none;
|
|
2797
2942
|
cursor: text; /* Standard text cursor */
|
|
2943
|
+
overflow-x: hidden; /* never scroll horizontally; clip wide content */
|
|
2798
2944
|
}
|
|
2799
|
-
|
|
2945
|
+
|
|
2946
|
+
/* Code blocks and inline code — self-contained so the editor
|
|
2947
|
+
does not depend on any external stylesheet for these. */
|
|
2948
|
+
.qde-preview pre {
|
|
2949
|
+
background: #f4f4f4;
|
|
2950
|
+
color: #1f2937;
|
|
2951
|
+
padding: 10px;
|
|
2952
|
+
border-radius: 4px;
|
|
2953
|
+
overflow-x: auto;
|
|
2954
|
+
margin: 0.6em 0;
|
|
2955
|
+
font-size: 0.9em;
|
|
2956
|
+
line-height: 1.5;
|
|
2957
|
+
font-family: ui-monospace, "SF Mono", Monaco, "Cascadia Code",
|
|
2958
|
+
"Roboto Mono", Consolas, "Courier New", monospace;
|
|
2959
|
+
}
|
|
2960
|
+
.qde-preview code {
|
|
2961
|
+
padding: 2px 4px;
|
|
2962
|
+
font-size: 0.9em;
|
|
2963
|
+
border-radius: 3px;
|
|
2964
|
+
background: #f0f0f0;
|
|
2965
|
+
color: #1f2937;
|
|
2966
|
+
font-family: ui-monospace, "SF Mono", Monaco, "Cascadia Code",
|
|
2967
|
+
"Roboto Mono", Consolas, "Courier New", monospace;
|
|
2968
|
+
}
|
|
2969
|
+
.qde-preview pre code {
|
|
2970
|
+
padding: 0;
|
|
2971
|
+
font-size: inherit;
|
|
2972
|
+
border-radius: 0;
|
|
2973
|
+
background: transparent;
|
|
2974
|
+
color: inherit;
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
/* Wide fence content (Leaflet maps, large SVGs, STL canvases,
|
|
2978
|
+
iframes, raw <img>) must never overflow the preview pane */
|
|
2979
|
+
.qde-preview .geojson-container,
|
|
2980
|
+
.qde-preview .qde-stl-container,
|
|
2981
|
+
.qde-preview .qde-svg-container,
|
|
2982
|
+
.qde-preview .leaflet-container,
|
|
2983
|
+
.qde-preview iframe,
|
|
2984
|
+
.qde-preview img,
|
|
2985
|
+
.qde-preview > svg {
|
|
2986
|
+
max-width: 100%;
|
|
2987
|
+
}
|
|
2988
|
+
.qde-preview .leaflet-container { box-sizing: border-box; }
|
|
2989
|
+
|
|
2990
|
+
/* Standard markdown tables (the .quikdown-table class) need to
|
|
2991
|
+
scroll horizontally inside their own wrapper rather than
|
|
2992
|
+
making the whole preview pane scroll */
|
|
2993
|
+
.qde-preview table.quikdown-table,
|
|
2994
|
+
.qde-preview table.qde-csv-table {
|
|
2995
|
+
display: block;
|
|
2996
|
+
max-width: 100%;
|
|
2997
|
+
overflow-x: auto;
|
|
2998
|
+
}
|
|
2999
|
+
|
|
2800
3000
|
/* Fence-specific styles */
|
|
2801
3001
|
.qde-svg-container {
|
|
2802
3002
|
max-width: 100%;
|
|
2803
3003
|
overflow: auto;
|
|
2804
3004
|
}
|
|
2805
|
-
|
|
3005
|
+
|
|
2806
3006
|
.qde-svg-container svg {
|
|
2807
3007
|
max-width: 100%;
|
|
2808
3008
|
height: auto;
|
|
@@ -2874,6 +3074,45 @@
|
|
|
2874
3074
|
position: relative;
|
|
2875
3075
|
}
|
|
2876
3076
|
|
|
3077
|
+
/* Reset headings inside the preview to plain browser defaults so
|
|
3078
|
+
parent-page styles (site navs, marketing pages, design systems)
|
|
3079
|
+
cannot bleed in. Business-casual: black text, decreasing sizes,
|
|
3080
|
+
no decorative borders. See docs/quikdown-editor.md for how
|
|
3081
|
+
embedders can override these with their own stylesheet. */
|
|
3082
|
+
.qde-preview h1 { font-size: 2em; }
|
|
3083
|
+
.qde-preview h2 { font-size: 1.5em; }
|
|
3084
|
+
.qde-preview h3 { font-size: 1.25em; }
|
|
3085
|
+
.qde-preview h4 { font-size: 1em; }
|
|
3086
|
+
.qde-preview h5 { font-size: 0.875em; }
|
|
3087
|
+
.qde-preview h6 { font-size: 0.85em; }
|
|
3088
|
+
.qde-preview h1,
|
|
3089
|
+
.qde-preview h2,
|
|
3090
|
+
.qde-preview h3,
|
|
3091
|
+
.qde-preview h4,
|
|
3092
|
+
.qde-preview h5,
|
|
3093
|
+
.qde-preview h6 {
|
|
3094
|
+
font-weight: bold;
|
|
3095
|
+
color: inherit;
|
|
3096
|
+
border: none;
|
|
3097
|
+
margin: 0.6em 0 0.3em 0;
|
|
3098
|
+
line-height: 1.25;
|
|
3099
|
+
}
|
|
3100
|
+
.qde-preview p {
|
|
3101
|
+
margin: 0.35em 0;
|
|
3102
|
+
}
|
|
3103
|
+
.qde-preview ul,
|
|
3104
|
+
.qde-preview ol {
|
|
3105
|
+
padding-left: 1.8em;
|
|
3106
|
+
margin: 0.4em 0;
|
|
3107
|
+
}
|
|
3108
|
+
.qde-preview li {
|
|
3109
|
+
margin: 0.15em 0;
|
|
3110
|
+
}
|
|
3111
|
+
.qde-preview blockquote {
|
|
3112
|
+
margin: 0.5em 0;
|
|
3113
|
+
padding-left: 1em;
|
|
3114
|
+
}
|
|
3115
|
+
|
|
2877
3116
|
/* Ensure proper cursor for editable text elements */
|
|
2878
3117
|
.qde-preview p,
|
|
2879
3118
|
.qde-preview h1,
|
|
@@ -2936,6 +3175,7 @@
|
|
|
2936
3175
|
.qde-dark {
|
|
2937
3176
|
background: #1e1e1e;
|
|
2938
3177
|
color: #e0e0e0;
|
|
3178
|
+
border-color: #444;
|
|
2939
3179
|
}
|
|
2940
3180
|
|
|
2941
3181
|
.qde-dark .qde-toolbar {
|
|
@@ -2967,6 +3207,20 @@
|
|
|
2967
3207
|
color: #e0e0e0;
|
|
2968
3208
|
}
|
|
2969
3209
|
|
|
3210
|
+
/* Dark mode code blocks */
|
|
3211
|
+
.qde-dark .qde-preview pre {
|
|
3212
|
+
background: #2d2d3a;
|
|
3213
|
+
color: #e6e6f0;
|
|
3214
|
+
}
|
|
3215
|
+
.qde-dark .qde-preview code {
|
|
3216
|
+
background: #2a2a3a;
|
|
3217
|
+
color: #e6e6f0;
|
|
3218
|
+
}
|
|
3219
|
+
.qde-dark .qde-preview pre code {
|
|
3220
|
+
background: transparent;
|
|
3221
|
+
color: inherit;
|
|
3222
|
+
}
|
|
3223
|
+
|
|
2970
3224
|
/* Dark mode table styles */
|
|
2971
3225
|
.qde-dark .qde-preview table th,
|
|
2972
3226
|
.qde-dark .qde-preview table td {
|
|
@@ -2986,11 +3240,14 @@
|
|
|
2986
3240
|
.qde-mode-split .qde-editor {
|
|
2987
3241
|
flex-direction: column;
|
|
2988
3242
|
}
|
|
2989
|
-
|
|
3243
|
+
|
|
2990
3244
|
.qde-mode-split .qde-source {
|
|
2991
3245
|
border-right: none;
|
|
2992
3246
|
border-bottom: 1px solid #ddd;
|
|
2993
3247
|
}
|
|
3248
|
+
.qde-dark.qde-mode-split .qde-source {
|
|
3249
|
+
border-bottom-color: #444;
|
|
3250
|
+
}
|
|
2994
3251
|
}
|
|
2995
3252
|
`;
|
|
2996
3253
|
|
|
@@ -3041,6 +3298,21 @@
|
|
|
3041
3298
|
e.preventDefault();
|
|
3042
3299
|
this.setMode('preview');
|
|
3043
3300
|
break;
|
|
3301
|
+
case 'z':
|
|
3302
|
+
case 'Z':
|
|
3303
|
+
if (e.shiftKey) {
|
|
3304
|
+
e.preventDefault();
|
|
3305
|
+
this.redo();
|
|
3306
|
+
} else {
|
|
3307
|
+
e.preventDefault();
|
|
3308
|
+
this.undo();
|
|
3309
|
+
}
|
|
3310
|
+
break;
|
|
3311
|
+
case 'y':
|
|
3312
|
+
case 'Y':
|
|
3313
|
+
e.preventDefault();
|
|
3314
|
+
this.redo();
|
|
3315
|
+
break;
|
|
3044
3316
|
}
|
|
3045
3317
|
}
|
|
3046
3318
|
});
|
|
@@ -3070,6 +3342,12 @@
|
|
|
3070
3342
|
* Update from markdown source
|
|
3071
3343
|
*/
|
|
3072
3344
|
updateFromMarkdown(markdown) {
|
|
3345
|
+
// Push current state to undo stack before changing (unless this is an undo/redo operation)
|
|
3346
|
+
if (!this._isUndoRedo) {
|
|
3347
|
+
this._pushUndoState(markdown || '');
|
|
3348
|
+
}
|
|
3349
|
+
this._isUndoRedo = false;
|
|
3350
|
+
|
|
3073
3351
|
this._markdown = markdown || '';
|
|
3074
3352
|
|
|
3075
3353
|
// Show placeholder if empty
|
|
@@ -3095,16 +3373,9 @@
|
|
|
3095
3373
|
if (window.MathJax && window.MathJax.typesetPromise) {
|
|
3096
3374
|
const mathElements = this.previewPanel.querySelectorAll('.math-display');
|
|
3097
3375
|
if (mathElements.length > 0) {
|
|
3098
|
-
mathElements.forEach(el => {
|
|
3099
|
-
});
|
|
3100
3376
|
window.MathJax.typesetPromise(Array.from(mathElements))
|
|
3101
|
-
.
|
|
3102
|
-
|
|
3103
|
-
el.querySelector('mjx-container');
|
|
3104
|
-
});
|
|
3105
|
-
})
|
|
3106
|
-
.catch(err => {
|
|
3107
|
-
console.warn('MathJax batch processing failed:', err);
|
|
3377
|
+
.catch(_err => {
|
|
3378
|
+
console.warn('MathJax batch processing failed:', _err);
|
|
3108
3379
|
});
|
|
3109
3380
|
}
|
|
3110
3381
|
}
|
|
@@ -3123,24 +3394,34 @@
|
|
|
3123
3394
|
updateFromHTML() {
|
|
3124
3395
|
// Clone the preview panel to avoid modifying the actual DOM
|
|
3125
3396
|
const clonedPanel = this.previewPanel.cloneNode(true);
|
|
3126
|
-
|
|
3397
|
+
|
|
3127
3398
|
// Pre-process special elements on the clone
|
|
3128
3399
|
this.preprocessSpecialElements(clonedPanel);
|
|
3129
|
-
|
|
3400
|
+
|
|
3130
3401
|
this._html = this.previewPanel.innerHTML;
|
|
3131
|
-
|
|
3402
|
+
const newMarkdown = quikdown_bd.toMarkdown(clonedPanel, {
|
|
3132
3403
|
fence_plugin: this.createFencePlugin()
|
|
3133
3404
|
});
|
|
3134
|
-
|
|
3405
|
+
|
|
3406
|
+
// Push previous state to undo stack (now that we know the new markdown)
|
|
3407
|
+
if (!this._isUndoRedo) {
|
|
3408
|
+
this._pushUndoState(newMarkdown);
|
|
3409
|
+
}
|
|
3410
|
+
this._isUndoRedo = false;
|
|
3411
|
+
|
|
3412
|
+
this._markdown = newMarkdown;
|
|
3413
|
+
|
|
3135
3414
|
// Update source if visible
|
|
3136
3415
|
if (this.currentMode !== 'preview') {
|
|
3137
3416
|
this.sourceTextarea.value = this._markdown;
|
|
3138
3417
|
}
|
|
3139
|
-
|
|
3418
|
+
|
|
3140
3419
|
// Trigger change event
|
|
3141
3420
|
if (this.options.onChange) {
|
|
3142
3421
|
this.options.onChange(this._markdown, this._html);
|
|
3143
3422
|
}
|
|
3423
|
+
|
|
3424
|
+
this._updateUndoButtons();
|
|
3144
3425
|
}
|
|
3145
3426
|
|
|
3146
3427
|
/**
|
|
@@ -3341,7 +3622,7 @@
|
|
|
3341
3622
|
// Remove event handlers
|
|
3342
3623
|
const walker = document.createTreeWalker(svg, NodeFilter.SHOW_ELEMENT);
|
|
3343
3624
|
let node;
|
|
3344
|
-
while (node = walker.nextNode()) {
|
|
3625
|
+
while ((node = walker.nextNode())) {
|
|
3345
3626
|
for (let i = node.attributes.length - 1; i >= 0; i--) {
|
|
3346
3627
|
const attr = node.attributes[i];
|
|
3347
3628
|
if (attr.name.startsWith('on') || attr.value.includes('javascript:')) {
|
|
@@ -3431,7 +3712,7 @@
|
|
|
3431
3712
|
/**
|
|
3432
3713
|
* Render math with MathJax (SVG output for better copy support)
|
|
3433
3714
|
*/
|
|
3434
|
-
renderMath(code,
|
|
3715
|
+
renderMath(code, _lang) {
|
|
3435
3716
|
const id = `math-${Math.random().toString(36).substring(2, 15)}`;
|
|
3436
3717
|
|
|
3437
3718
|
// Create container exactly like squibview
|
|
@@ -3554,11 +3835,11 @@
|
|
|
3554
3835
|
|
|
3555
3836
|
html += '</table>';
|
|
3556
3837
|
return html;
|
|
3557
|
-
} catch (
|
|
3838
|
+
} catch (_err) {
|
|
3558
3839
|
return `<pre data-qd-fence="\`\`\`" data-qd-lang="${lang}" data-qd-source="${escapedCode}">${escapedCode}</pre>`;
|
|
3559
3840
|
}
|
|
3560
3841
|
}
|
|
3561
|
-
|
|
3842
|
+
|
|
3562
3843
|
/**
|
|
3563
3844
|
* Parse CSV line handling quoted values
|
|
3564
3845
|
*/
|
|
@@ -3602,13 +3883,13 @@
|
|
|
3602
3883
|
try {
|
|
3603
3884
|
const data = JSON.parse(code);
|
|
3604
3885
|
toHighlight = JSON.stringify(data, null, 2);
|
|
3605
|
-
} catch (
|
|
3886
|
+
} catch (_e) {
|
|
3606
3887
|
// Use original if not valid JSON
|
|
3607
3888
|
}
|
|
3608
3889
|
|
|
3609
3890
|
const highlighted = hljs.highlight(toHighlight, { language: 'json' }).value;
|
|
3610
3891
|
return `<pre class="qde-json" data-qd-fence="\`\`\`" data-qd-lang="${lang}"><code class="hljs language-json">${highlighted}</code></pre>`;
|
|
3611
|
-
} catch (
|
|
3892
|
+
} catch (_e) {
|
|
3612
3893
|
// Fall through if highlighting fails
|
|
3613
3894
|
}
|
|
3614
3895
|
}
|
|
@@ -3701,7 +3982,7 @@
|
|
|
3701
3982
|
if (loaded) {
|
|
3702
3983
|
renderMap();
|
|
3703
3984
|
} else {
|
|
3704
|
-
const element = document.getElementById(
|
|
3985
|
+
const element = document.getElementById(mapId + '-container');
|
|
3705
3986
|
if (element) {
|
|
3706
3987
|
element.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">Failed to load map library</div>';
|
|
3707
3988
|
}
|
|
@@ -3737,18 +4018,12 @@
|
|
|
3737
4018
|
*/
|
|
3738
4019
|
renderSTL(code) {
|
|
3739
4020
|
const id = `qde-stl-viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
3740
|
-
|
|
3741
|
-
// Function to render the 3D model
|
|
4021
|
+
|
|
4022
|
+
// Function to render the 3D model (assumes window.THREE is loaded)
|
|
3742
4023
|
const render3D = () => {
|
|
3743
4024
|
const element = document.getElementById(id);
|
|
3744
4025
|
if (!element) return;
|
|
3745
|
-
|
|
3746
|
-
// Check if Three.js is available
|
|
3747
|
-
if (typeof window.THREE === 'undefined') {
|
|
3748
|
-
element.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">Three.js library not loaded. Add <script src="https://unpkg.com/three@0.147.0/build/three.min.js"></script> to your HTML.</div>';
|
|
3749
|
-
return;
|
|
3750
|
-
}
|
|
3751
|
-
|
|
4026
|
+
|
|
3752
4027
|
try {
|
|
3753
4028
|
const THREE = window.THREE;
|
|
3754
4029
|
|
|
@@ -3806,9 +4081,34 @@
|
|
|
3806
4081
|
}
|
|
3807
4082
|
};
|
|
3808
4083
|
|
|
3809
|
-
//
|
|
3810
|
-
|
|
3811
|
-
|
|
4084
|
+
// If Three.js is already loaded, render immediately. Otherwise lazy-load
|
|
4085
|
+
// it from a CDN (matches the GeoJSON/Leaflet pattern).
|
|
4086
|
+
if (window.THREE) {
|
|
4087
|
+
setTimeout(render3D, 0);
|
|
4088
|
+
} else {
|
|
4089
|
+
if (!window._qde_three_loading) {
|
|
4090
|
+
window._qde_three_loading = this.lazyLoadLibrary(
|
|
4091
|
+
'Three.js',
|
|
4092
|
+
() => window.THREE,
|
|
4093
|
+
'https://unpkg.com/three@0.147.0/build/three.min.js'
|
|
4094
|
+
).catch(_err => {
|
|
4095
|
+
console.warn('Failed to load Three.js for STL rendering');
|
|
4096
|
+
window._qde_three_loading = null;
|
|
4097
|
+
return false;
|
|
4098
|
+
});
|
|
4099
|
+
}
|
|
4100
|
+
window._qde_three_loading.then(loaded => {
|
|
4101
|
+
if (loaded) {
|
|
4102
|
+
render3D();
|
|
4103
|
+
} else {
|
|
4104
|
+
const element = document.getElementById(id);
|
|
4105
|
+
if (element) {
|
|
4106
|
+
element.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">Failed to load Three.js for STL rendering</div>';
|
|
4107
|
+
}
|
|
4108
|
+
}
|
|
4109
|
+
});
|
|
4110
|
+
}
|
|
4111
|
+
|
|
3812
4112
|
// Return placeholder with data-stl-id for copy functionality
|
|
3813
4113
|
return `<div id="${id}" class="qde-stl-container" data-stl-id="${id}" data-qd-fence="\`\`\`" data-qd-lang="stl" data-qd-source="${this.escapeHtml(code)}" contenteditable="false" style="height: 400px; background: #f0f0f0; display: flex; align-items: center; justify-content: center;">Loading 3D model...</div>`;
|
|
3814
4114
|
}
|
|
@@ -3900,30 +4200,64 @@
|
|
|
3900
4200
|
}
|
|
3901
4201
|
|
|
3902
4202
|
/**
|
|
3903
|
-
* Load plugins dynamically
|
|
4203
|
+
* Load plugins dynamically — honors both `plugins: { highlightjs, mermaid }`
|
|
4204
|
+
* (legacy) and the newer `preloadFences` option which can preload any
|
|
4205
|
+
* combination of fence libraries (or 'all') at construction time.
|
|
3904
4206
|
*/
|
|
3905
4207
|
async loadPlugins() {
|
|
3906
|
-
const
|
|
3907
|
-
|
|
3908
|
-
//
|
|
3909
|
-
if (this.options.plugins
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
this.loadCSS('https://unpkg.com/@highlightjs/cdn-assets/styles/github.min.css')
|
|
3913
|
-
);
|
|
4208
|
+
const namesToLoad = new Set();
|
|
4209
|
+
|
|
4210
|
+
// Legacy plugins option
|
|
4211
|
+
if (this.options.plugins) {
|
|
4212
|
+
if (this.options.plugins.highlightjs) namesToLoad.add('highlightjs');
|
|
4213
|
+
if (this.options.plugins.mermaid) namesToLoad.add('mermaid');
|
|
3914
4214
|
}
|
|
3915
|
-
|
|
3916
|
-
//
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
4215
|
+
|
|
4216
|
+
// New preloadFences option
|
|
4217
|
+
const pf = this.options.preloadFences;
|
|
4218
|
+
if (pf === 'all') {
|
|
4219
|
+
Object.keys(FENCE_LIBRARIES).forEach(n => namesToLoad.add(n));
|
|
4220
|
+
} else if (Array.isArray(pf)) {
|
|
4221
|
+
for (const entry of pf) {
|
|
4222
|
+
if (typeof entry === 'string') {
|
|
4223
|
+
if (FENCE_LIBRARIES[entry]) namesToLoad.add(entry);
|
|
4224
|
+
else console.warn(`QuikdownEditor: unknown preloadFences entry "${entry}"`);
|
|
4225
|
+
} else if (entry && typeof entry === 'object' && entry.script) {
|
|
4226
|
+
// Custom library: { name, script, css? }
|
|
4227
|
+
namesToLoad.add('__custom__:' + (entry.name || entry.script));
|
|
4228
|
+
FENCE_LIBRARIES['__custom__:' + (entry.name || entry.script)] = {
|
|
4229
|
+
check: () => false,
|
|
4230
|
+
script: entry.script,
|
|
4231
|
+
css: entry.css
|
|
4232
|
+
};
|
|
4233
|
+
}
|
|
4234
|
+
}
|
|
4235
|
+
} else if (pf) {
|
|
4236
|
+
console.warn('QuikdownEditor: preloadFences should be "all", an array, or null');
|
|
3925
4237
|
}
|
|
3926
|
-
|
|
4238
|
+
|
|
4239
|
+
// Load each in parallel; respect already-loaded state
|
|
4240
|
+
const promises = [];
|
|
4241
|
+
for (const name of namesToLoad) {
|
|
4242
|
+
const lib = FENCE_LIBRARIES[name];
|
|
4243
|
+
if (!lib || lib.check()) continue;
|
|
4244
|
+
if (lib.beforeLoad) lib.beforeLoad();
|
|
4245
|
+
const p = (async () => {
|
|
4246
|
+
try {
|
|
4247
|
+
const tasks = [];
|
|
4248
|
+
if (lib.script) tasks.push(this.loadScript(lib.script));
|
|
4249
|
+
if (lib.css) tasks.push(this.loadCSS(lib.css, 'qde-hljs-light'));
|
|
4250
|
+
if (lib.cssDark) tasks.push(this.loadCSS(lib.cssDark, 'qde-hljs-dark'));
|
|
4251
|
+
await Promise.all(tasks);
|
|
4252
|
+
if (lib.css && lib.cssDark) this._syncHljsTheme();
|
|
4253
|
+
if (lib.afterLoad) lib.afterLoad();
|
|
4254
|
+
} catch (err) {
|
|
4255
|
+
console.warn(`QuikdownEditor: failed to preload ${name}:`, err);
|
|
4256
|
+
}
|
|
4257
|
+
})();
|
|
4258
|
+
promises.push(p);
|
|
4259
|
+
}
|
|
4260
|
+
|
|
3927
4261
|
await Promise.all(promises);
|
|
3928
4262
|
}
|
|
3929
4263
|
|
|
@@ -3975,36 +4309,73 @@
|
|
|
3975
4309
|
/**
|
|
3976
4310
|
* Load external CSS
|
|
3977
4311
|
*/
|
|
3978
|
-
loadCSS(href) {
|
|
4312
|
+
loadCSS(href, id) {
|
|
3979
4313
|
return new Promise((resolve) => {
|
|
3980
4314
|
const link = document.createElement('link');
|
|
3981
4315
|
link.rel = 'stylesheet';
|
|
3982
4316
|
link.href = href;
|
|
4317
|
+
if (id) link.id = id;
|
|
3983
4318
|
link.onload = resolve;
|
|
3984
4319
|
document.head.appendChild(link);
|
|
3985
4320
|
// Resolve anyway after timeout (CSS doesn't always fire onload)
|
|
3986
4321
|
setTimeout(resolve, 1000);
|
|
3987
4322
|
});
|
|
3988
4323
|
}
|
|
3989
|
-
|
|
4324
|
+
|
|
3990
4325
|
/**
|
|
3991
|
-
*
|
|
4326
|
+
* Enable the hljs stylesheet matching the current theme and disable
|
|
4327
|
+
* the other one. Called from applyTheme and after hljs CSS loads.
|
|
4328
|
+
*/
|
|
4329
|
+
_syncHljsTheme() {
|
|
4330
|
+
const isDark = this.container.classList.contains('qde-dark');
|
|
4331
|
+
const light = document.getElementById('qde-hljs-light');
|
|
4332
|
+
const dark = document.getElementById('qde-hljs-dark');
|
|
4333
|
+
if (light) light.disabled = isDark;
|
|
4334
|
+
if (dark) dark.disabled = !isDark;
|
|
4335
|
+
}
|
|
4336
|
+
|
|
4337
|
+
/**
|
|
4338
|
+
* Apply the current theme (based on this.options.theme)
|
|
3992
4339
|
*/
|
|
3993
4340
|
applyTheme() {
|
|
3994
4341
|
const theme = this.options.theme;
|
|
3995
|
-
|
|
4342
|
+
|
|
4343
|
+
// Tear down any previous auto-mode listener so we don't stack them
|
|
4344
|
+
if (this._autoThemeListener) {
|
|
4345
|
+
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this._autoThemeListener);
|
|
4346
|
+
this._autoThemeListener = null;
|
|
4347
|
+
}
|
|
4348
|
+
|
|
3996
4349
|
if (theme === 'auto') {
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
this.
|
|
4000
|
-
|
|
4001
|
-
// Listen for changes
|
|
4002
|
-
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
4350
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
4351
|
+
this.container.classList.toggle('qde-dark', mq.matches);
|
|
4352
|
+
this._autoThemeListener = (e) => {
|
|
4003
4353
|
this.container.classList.toggle('qde-dark', e.matches);
|
|
4004
|
-
|
|
4354
|
+
this._syncHljsTheme();
|
|
4355
|
+
};
|
|
4356
|
+
mq.addEventListener('change', this._autoThemeListener);
|
|
4005
4357
|
} else {
|
|
4006
4358
|
this.container.classList.toggle('qde-dark', theme === 'dark');
|
|
4007
4359
|
}
|
|
4360
|
+
this._syncHljsTheme();
|
|
4361
|
+
}
|
|
4362
|
+
|
|
4363
|
+
/**
|
|
4364
|
+
* Set theme at runtime. Accepts 'light', 'dark', or 'auto'.
|
|
4365
|
+
* @param {'light'|'dark'|'auto'} theme
|
|
4366
|
+
*/
|
|
4367
|
+
setTheme(theme) {
|
|
4368
|
+
if (!['light', 'dark', 'auto'].includes(theme)) return;
|
|
4369
|
+
this.options.theme = theme;
|
|
4370
|
+
this.applyTheme();
|
|
4371
|
+
}
|
|
4372
|
+
|
|
4373
|
+
/**
|
|
4374
|
+
* Get the current theme option (as configured, not resolved).
|
|
4375
|
+
* @returns {'light'|'dark'|'auto'}
|
|
4376
|
+
*/
|
|
4377
|
+
getTheme() {
|
|
4378
|
+
return this.options.theme;
|
|
4008
4379
|
}
|
|
4009
4380
|
|
|
4010
4381
|
/**
|
|
@@ -4048,10 +4419,18 @@
|
|
|
4048
4419
|
*/
|
|
4049
4420
|
setMode(mode) {
|
|
4050
4421
|
if (!['source', 'preview', 'split'].includes(mode)) return;
|
|
4051
|
-
|
|
4422
|
+
|
|
4423
|
+
// Preserve theme class across mode swap (the assignment to className
|
|
4424
|
+
// below would otherwise wipe it out — this used to be a no-op bug
|
|
4425
|
+
// where dark mode was lost on every setMode call).
|
|
4426
|
+
const wasDark = this.container.classList.contains('qde-dark');
|
|
4427
|
+
|
|
4052
4428
|
this.currentMode = mode;
|
|
4053
4429
|
this.container.className = `qde-container qde-mode-${mode}`;
|
|
4054
|
-
|
|
4430
|
+
if (wasDark) {
|
|
4431
|
+
this.container.classList.add('qde-dark');
|
|
4432
|
+
}
|
|
4433
|
+
|
|
4055
4434
|
// Update toolbar buttons
|
|
4056
4435
|
if (this.toolbar) {
|
|
4057
4436
|
this.toolbar.querySelectorAll('.qde-btn[data-mode]').forEach(btn => {
|
|
@@ -4059,11 +4438,6 @@
|
|
|
4059
4438
|
});
|
|
4060
4439
|
}
|
|
4061
4440
|
|
|
4062
|
-
// Apply theme class
|
|
4063
|
-
if (this.container.classList.contains('qde-dark')) {
|
|
4064
|
-
this.container.classList.add('qde-dark');
|
|
4065
|
-
}
|
|
4066
|
-
|
|
4067
4441
|
// Make fence blocks non-editable when showing preview
|
|
4068
4442
|
if (mode !== 'source') {
|
|
4069
4443
|
setTimeout(() => this.makeFencesNonEditable(), 0);
|
|
@@ -4075,6 +4449,105 @@
|
|
|
4075
4449
|
}
|
|
4076
4450
|
}
|
|
4077
4451
|
|
|
4452
|
+
// --- Undo / Redo ---
|
|
4453
|
+
|
|
4454
|
+
/**
|
|
4455
|
+
* Push current markdown state onto the undo stack (called before a change).
|
|
4456
|
+
* Only pushes if the new state differs from the current state.
|
|
4457
|
+
* @param {string} newMarkdown - the incoming markdown (used to detect no-op)
|
|
4458
|
+
* @private
|
|
4459
|
+
*/
|
|
4460
|
+
_pushUndoState(newMarkdown) {
|
|
4461
|
+
// Don't push if the content hasn't actually changed
|
|
4462
|
+
if (newMarkdown === this._markdown) return;
|
|
4463
|
+
|
|
4464
|
+
this._undoStack.push(this._markdown);
|
|
4465
|
+
|
|
4466
|
+
// Enforce max stack size
|
|
4467
|
+
const max = this.options.undoStackSize || 100;
|
|
4468
|
+
if (this._undoStack.length > max) {
|
|
4469
|
+
this._undoStack.splice(0, this._undoStack.length - max);
|
|
4470
|
+
}
|
|
4471
|
+
|
|
4472
|
+
// Any new edit clears the redo stack
|
|
4473
|
+
this._redoStack = [];
|
|
4474
|
+
this._updateUndoButtons();
|
|
4475
|
+
}
|
|
4476
|
+
|
|
4477
|
+
/**
|
|
4478
|
+
* Undo the last change. Restores the previous markdown state.
|
|
4479
|
+
*/
|
|
4480
|
+
undo() {
|
|
4481
|
+
if (!this.canUndo()) return;
|
|
4482
|
+
// Save current state to redo stack
|
|
4483
|
+
this._redoStack.push(this._markdown);
|
|
4484
|
+
const previous = this._undoStack.pop();
|
|
4485
|
+
this._isUndoRedo = true;
|
|
4486
|
+
// Update state directly (setMarkdown is async; keep it synchronous here)
|
|
4487
|
+
this._markdown = previous;
|
|
4488
|
+
if (this.sourceTextarea) {
|
|
4489
|
+
this.sourceTextarea.value = previous;
|
|
4490
|
+
}
|
|
4491
|
+
this.updateFromMarkdown(previous);
|
|
4492
|
+
this._updateUndoButtons();
|
|
4493
|
+
}
|
|
4494
|
+
|
|
4495
|
+
/**
|
|
4496
|
+
* Redo the last undone change.
|
|
4497
|
+
*/
|
|
4498
|
+
redo() {
|
|
4499
|
+
if (!this.canRedo()) return;
|
|
4500
|
+
// Save current state to undo stack
|
|
4501
|
+
this._undoStack.push(this._markdown);
|
|
4502
|
+
const next = this._redoStack.pop();
|
|
4503
|
+
this._isUndoRedo = true;
|
|
4504
|
+
this._markdown = next;
|
|
4505
|
+
if (this.sourceTextarea) {
|
|
4506
|
+
this.sourceTextarea.value = next;
|
|
4507
|
+
}
|
|
4508
|
+
this.updateFromMarkdown(next);
|
|
4509
|
+
this._updateUndoButtons();
|
|
4510
|
+
}
|
|
4511
|
+
|
|
4512
|
+
/**
|
|
4513
|
+
* @returns {boolean} true if undo is possible
|
|
4514
|
+
*/
|
|
4515
|
+
canUndo() {
|
|
4516
|
+
return this._undoStack.length > 0;
|
|
4517
|
+
}
|
|
4518
|
+
|
|
4519
|
+
/**
|
|
4520
|
+
* @returns {boolean} true if redo is possible
|
|
4521
|
+
*/
|
|
4522
|
+
canRedo() {
|
|
4523
|
+
return this._redoStack.length > 0;
|
|
4524
|
+
}
|
|
4525
|
+
|
|
4526
|
+
/**
|
|
4527
|
+
* Clear the undo and redo history.
|
|
4528
|
+
*/
|
|
4529
|
+
clearHistory() {
|
|
4530
|
+
this._undoStack = [];
|
|
4531
|
+
this._redoStack = [];
|
|
4532
|
+
this._updateUndoButtons();
|
|
4533
|
+
}
|
|
4534
|
+
|
|
4535
|
+
/**
|
|
4536
|
+
* Update the disabled state of the undo/redo toolbar buttons.
|
|
4537
|
+
* @private
|
|
4538
|
+
*/
|
|
4539
|
+
_updateUndoButtons() {
|
|
4540
|
+
if (!this.toolbar) return;
|
|
4541
|
+
const undoBtn = this.toolbar.querySelector('[data-action="undo"]');
|
|
4542
|
+
const redoBtn = this.toolbar.querySelector('[data-action="redo"]');
|
|
4543
|
+
if (undoBtn) {
|
|
4544
|
+
undoBtn.classList.toggle('disabled', !this.canUndo());
|
|
4545
|
+
}
|
|
4546
|
+
if (redoBtn) {
|
|
4547
|
+
redoBtn.classList.toggle('disabled', !this.canRedo());
|
|
4548
|
+
}
|
|
4549
|
+
}
|
|
4550
|
+
|
|
4078
4551
|
/**
|
|
4079
4552
|
* Handle toolbar actions
|
|
4080
4553
|
*/
|
|
@@ -4092,6 +4565,15 @@
|
|
|
4092
4565
|
case 'remove-hr':
|
|
4093
4566
|
this.removeHR();
|
|
4094
4567
|
break;
|
|
4568
|
+
case 'lazy-linefeeds':
|
|
4569
|
+
this.convertLazyLinefeeds();
|
|
4570
|
+
break;
|
|
4571
|
+
case 'undo':
|
|
4572
|
+
this.undo();
|
|
4573
|
+
break;
|
|
4574
|
+
case 'redo':
|
|
4575
|
+
this.redo();
|
|
4576
|
+
break;
|
|
4095
4577
|
}
|
|
4096
4578
|
}
|
|
4097
4579
|
|
|
@@ -4179,24 +4661,13 @@
|
|
|
4179
4661
|
}
|
|
4180
4662
|
|
|
4181
4663
|
/**
|
|
4182
|
-
* Remove all horizontal rules (---) from markdown
|
|
4664
|
+
* Remove all horizontal rules (---) from markdown source.
|
|
4665
|
+
* Preserves content inside fences (``` or ~~~) and table separator rows.
|
|
4183
4666
|
*/
|
|
4184
4667
|
async removeHR() {
|
|
4185
|
-
|
|
4186
|
-
// Matches: ---, ___, ***, ----, etc. with optional spaces
|
|
4187
|
-
const cleaned = this._markdown
|
|
4188
|
-
.split('\n')
|
|
4189
|
-
.filter(line => {
|
|
4190
|
-
// Keep lines that aren't just HR patterns
|
|
4191
|
-
const trimmed = line.trim();
|
|
4192
|
-
// Match HR patterns: 3+ of -, _, or * with optional spaces between
|
|
4193
|
-
return !(/^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed));
|
|
4194
|
-
})
|
|
4195
|
-
.join('\n');
|
|
4196
|
-
|
|
4197
|
-
// Update the markdown
|
|
4668
|
+
const cleaned = QuikdownEditor.removeHRFromMarkdown(this._markdown);
|
|
4198
4669
|
await this.setMarkdown(cleaned);
|
|
4199
|
-
|
|
4670
|
+
|
|
4200
4671
|
// Visual feedback if toolbar button exists
|
|
4201
4672
|
const btn = this.toolbar?.querySelector('[data-action="remove-hr"]');
|
|
4202
4673
|
if (btn) {
|
|
@@ -4207,6 +4678,247 @@
|
|
|
4207
4678
|
}, 1500);
|
|
4208
4679
|
}
|
|
4209
4680
|
}
|
|
4681
|
+
|
|
4682
|
+
/**
|
|
4683
|
+
* Static: remove horizontal rules from markdown string.
|
|
4684
|
+
* Safe for fences, tables, and all markdown constructs.
|
|
4685
|
+
* Can be used headless without an editor instance.
|
|
4686
|
+
* @param {string} markdown - source markdown
|
|
4687
|
+
* @returns {string} markdown with standalone HRs removed
|
|
4688
|
+
*/
|
|
4689
|
+
static removeHRFromMarkdown(markdown) {
|
|
4690
|
+
const lines = (markdown || '').split('\n');
|
|
4691
|
+
const result = [];
|
|
4692
|
+
let inFence = false;
|
|
4693
|
+
let fenceChar = null; // '`' or '~'
|
|
4694
|
+
let fenceLen = 0; // length of opening fence marker
|
|
4695
|
+
|
|
4696
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4697
|
+
const line = lines[i];
|
|
4698
|
+
const trimmed = line.trim();
|
|
4699
|
+
|
|
4700
|
+
// Track fence open/close (``` or ~~~, 3+ chars)
|
|
4701
|
+
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
|
|
4702
|
+
if (fenceMatch) {
|
|
4703
|
+
const matchChar = fenceMatch[1][0];
|
|
4704
|
+
const matchLen = fenceMatch[1].length;
|
|
4705
|
+
if (!inFence) {
|
|
4706
|
+
inFence = true;
|
|
4707
|
+
fenceChar = matchChar;
|
|
4708
|
+
fenceLen = matchLen;
|
|
4709
|
+
result.push(line);
|
|
4710
|
+
continue;
|
|
4711
|
+
} else if (matchChar === fenceChar && matchLen >= fenceLen && /^(`{3,}|~{3,})\s*$/.test(trimmed)) {
|
|
4712
|
+
// Closing fence: same char, at least as many chars, no trailing content
|
|
4713
|
+
inFence = false;
|
|
4714
|
+
fenceChar = null;
|
|
4715
|
+
fenceLen = 0;
|
|
4716
|
+
result.push(line);
|
|
4717
|
+
continue;
|
|
4718
|
+
}
|
|
4719
|
+
}
|
|
4720
|
+
|
|
4721
|
+
// Inside a fence — keep everything
|
|
4722
|
+
if (inFence) {
|
|
4723
|
+
result.push(line);
|
|
4724
|
+
continue;
|
|
4725
|
+
}
|
|
4726
|
+
|
|
4727
|
+
// Detect table row/separator with pipes — always keep
|
|
4728
|
+
if (/^\|.*\|$/.test(trimmed) || (/^[-| :]+$/.test(trimmed) && trimmed.includes('|'))) {
|
|
4729
|
+
result.push(line);
|
|
4730
|
+
continue;
|
|
4731
|
+
}
|
|
4732
|
+
|
|
4733
|
+
// Check if this line is a standalone HR
|
|
4734
|
+
const isHR = /^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed);
|
|
4735
|
+
if (isHR) {
|
|
4736
|
+
// Table separator heuristic: immediately adjacent lines (no blank
|
|
4737
|
+
// lines between) that look like table rows protect this HR-like line
|
|
4738
|
+
const prevLine = i > 0 ? lines[i - 1].trim() : '';
|
|
4739
|
+
const nextLine = i < lines.length - 1 ? lines[i + 1].trim() : '';
|
|
4740
|
+
if (_looksLikeTableRow(prevLine) || _looksLikeTableRow(nextLine)) {
|
|
4741
|
+
result.push(line);
|
|
4742
|
+
continue;
|
|
4743
|
+
}
|
|
4744
|
+
// It's a real HR — skip it
|
|
4745
|
+
continue;
|
|
4746
|
+
}
|
|
4747
|
+
|
|
4748
|
+
result.push(line);
|
|
4749
|
+
}
|
|
4750
|
+
|
|
4751
|
+
return result.join('\n');
|
|
4752
|
+
}
|
|
4753
|
+
|
|
4754
|
+
/**
|
|
4755
|
+
* Convert lazy linefeeds in markdown source.
|
|
4756
|
+
* Replaces single newlines with double newlines (adds real line breaks)
|
|
4757
|
+
* except inside fences, tables, and other block-level constructs.
|
|
4758
|
+
* Idempotent: calling multiple times produces the same result.
|
|
4759
|
+
* Can be used as a toolbar action or headless via the static method.
|
|
4760
|
+
*/
|
|
4761
|
+
async convertLazyLinefeeds() {
|
|
4762
|
+
const converted = QuikdownEditor.convertLazyLinefeeds(this._markdown);
|
|
4763
|
+
await this.setMarkdown(converted);
|
|
4764
|
+
|
|
4765
|
+
// Visual feedback if toolbar button exists
|
|
4766
|
+
const btn = this.toolbar?.querySelector('[data-action="lazy-linefeeds"]');
|
|
4767
|
+
if (btn) {
|
|
4768
|
+
const originalText = btn.textContent;
|
|
4769
|
+
btn.textContent = 'Converted!';
|
|
4770
|
+
setTimeout(() => {
|
|
4771
|
+
btn.textContent = originalText;
|
|
4772
|
+
}, 1500);
|
|
4773
|
+
}
|
|
4774
|
+
}
|
|
4775
|
+
|
|
4776
|
+
/**
|
|
4777
|
+
* Static: convert lazy linefeeds in markdown source.
|
|
4778
|
+
* Turns single \n between non-blank lines into \n\n so each line becomes
|
|
4779
|
+
* its own paragraph / hard break. Idempotent — already-doubled newlines
|
|
4780
|
+
* are not doubled again. Fences, tables, lists, blockquotes, headings,
|
|
4781
|
+
* and HTML blocks are left untouched.
|
|
4782
|
+
* @param {string} markdown - source markdown
|
|
4783
|
+
* @returns {string} markdown with lazy linefeeds resolved
|
|
4784
|
+
*/
|
|
4785
|
+
static convertLazyLinefeeds(markdown) {
|
|
4786
|
+
// Two-phase approach (much cleaner than the old single pass):
|
|
4787
|
+
//
|
|
4788
|
+
// Phase A: walk lines, classify each as { content, blank, fence }.
|
|
4789
|
+
// Inside fences, lines are passed through verbatim.
|
|
4790
|
+
// Phase B: emit lines with the rule:
|
|
4791
|
+
// "between two adjacent CONTENT lines, ensure exactly one
|
|
4792
|
+
// blank line — never zero, never more than one."
|
|
4793
|
+
//
|
|
4794
|
+
// The rule applies regardless of whether the content lines are
|
|
4795
|
+
// headings, lists, blockquotes, table rows, paragraphs, or HR — any
|
|
4796
|
+
// adjacent pair of non-fence non-blank lines gets exactly one blank
|
|
4797
|
+
// between them. This produces the cleanest possible output for any
|
|
4798
|
+
// input and is fully idempotent.
|
|
4799
|
+
//
|
|
4800
|
+
// Lines that are whitespace-only (e.g. " ") are normalized to
|
|
4801
|
+
// empty strings, eliminating "phantom" blank lines.
|
|
4802
|
+
//
|
|
4803
|
+
// Lists are a special case: adjacent list items (same marker type)
|
|
4804
|
+
// should NOT get a blank line between them, otherwise we'd break
|
|
4805
|
+
// tight lists.
|
|
4806
|
+
//
|
|
4807
|
+
// Same applies to blockquote lines and table rows — adjacent rows
|
|
4808
|
+
// belong to the same block.
|
|
4809
|
+
|
|
4810
|
+
const inputLines = (markdown || '').split('\n');
|
|
4811
|
+
|
|
4812
|
+
// -------- Phase A: classify lines, normalize whitespace-only --------
|
|
4813
|
+
// Each entry: { line, kind } where kind is one of:
|
|
4814
|
+
// 'fence-open', 'fence-close', 'fence-body', 'blank', 'content'
|
|
4815
|
+
// Plus a 'category' for content lines: 'list-ul', 'list-ol',
|
|
4816
|
+
// 'blockquote', 'table', 'heading', 'hr', 'paragraph'
|
|
4817
|
+
const items = [];
|
|
4818
|
+
let inFence = false;
|
|
4819
|
+
let fenceChar = null;
|
|
4820
|
+
let fenceLen = 0;
|
|
4821
|
+
|
|
4822
|
+
for (const rawLine of inputLines) {
|
|
4823
|
+
const line = rawLine;
|
|
4824
|
+
const trimmed = line.trim();
|
|
4825
|
+
|
|
4826
|
+
// Fence tracking
|
|
4827
|
+
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
|
|
4828
|
+
if (fenceMatch && !inFence) {
|
|
4829
|
+
inFence = true;
|
|
4830
|
+
fenceChar = fenceMatch[1][0];
|
|
4831
|
+
fenceLen = fenceMatch[1].length;
|
|
4832
|
+
items.push({ line, kind: 'fence-open' });
|
|
4833
|
+
continue;
|
|
4834
|
+
}
|
|
4835
|
+
if (inFence) {
|
|
4836
|
+
if (fenceMatch &&
|
|
4837
|
+
fenceMatch[1][0] === fenceChar &&
|
|
4838
|
+
fenceMatch[1].length >= fenceLen &&
|
|
4839
|
+
/^(`{3,}|~{3,})\s*$/.test(trimmed)) {
|
|
4840
|
+
inFence = false;
|
|
4841
|
+
fenceChar = null;
|
|
4842
|
+
fenceLen = 0;
|
|
4843
|
+
items.push({ line, kind: 'fence-close' });
|
|
4844
|
+
} else {
|
|
4845
|
+
items.push({ line, kind: 'fence-body' });
|
|
4846
|
+
}
|
|
4847
|
+
continue;
|
|
4848
|
+
}
|
|
4849
|
+
|
|
4850
|
+
// Outside fence: whitespace-only lines become canonical blanks
|
|
4851
|
+
if (trimmed === '') {
|
|
4852
|
+
items.push({ line: '', kind: 'blank' });
|
|
4853
|
+
continue;
|
|
4854
|
+
}
|
|
4855
|
+
|
|
4856
|
+
// Categorize content lines so we can recognize adjacent same-kind blocks
|
|
4857
|
+
let category = 'paragraph';
|
|
4858
|
+
if (/^#{1,6}\s/.test(trimmed)) category = 'heading';
|
|
4859
|
+
else if (/^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed)) category = 'hr';
|
|
4860
|
+
else if (/^(\d+\.)\s/.test(trimmed)) category = 'list-ol';
|
|
4861
|
+
else if (/^[-*+]\s/.test(trimmed)) category = 'list-ul';
|
|
4862
|
+
else if (/^>/.test(trimmed)) category = 'blockquote';
|
|
4863
|
+
else if (/^\|/.test(trimmed)) category = 'table';
|
|
4864
|
+
// Indented continuation of a list (2+ leading spaces or tab)
|
|
4865
|
+
else if (/^(?: {4}|\t| {2,}[-*+]| {2,}\d+\.)/.test(line)) category = 'list-cont';
|
|
4866
|
+
|
|
4867
|
+
items.push({ line, kind: 'content', category });
|
|
4868
|
+
}
|
|
4869
|
+
|
|
4870
|
+
// -------- Phase B: emit with exactly-one-blank-line normalization --------
|
|
4871
|
+
// Same-block adjacent lines (lists, blockquotes, tables) stay
|
|
4872
|
+
// touching; any other adjacent content pair gets exactly one blank.
|
|
4873
|
+
const result = [];
|
|
4874
|
+
let prev = null; // last emitted non-blank content item
|
|
4875
|
+
|
|
4876
|
+
function inSameBlock(a, b) {
|
|
4877
|
+
if (!a || !b) return false;
|
|
4878
|
+
// Lists: same marker family OR list-content continuation
|
|
4879
|
+
if ((a.category === 'list-ul' || a.category === 'list-ol' || a.category === 'list-cont') &&
|
|
4880
|
+
(b.category === 'list-ul' || b.category === 'list-ol' || b.category === 'list-cont')) {
|
|
4881
|
+
return true;
|
|
4882
|
+
}
|
|
4883
|
+
// Blockquotes
|
|
4884
|
+
if (a.category === 'blockquote' && b.category === 'blockquote') return true;
|
|
4885
|
+
// Table rows
|
|
4886
|
+
if (a.category === 'table' && b.category === 'table') return true;
|
|
4887
|
+
return false;
|
|
4888
|
+
}
|
|
4889
|
+
|
|
4890
|
+
for (const item of items) {
|
|
4891
|
+
if (item.kind === 'fence-open' || item.kind === 'fence-body' || item.kind === 'fence-close') {
|
|
4892
|
+
// Fences: ensure exactly one blank line before the fence-open
|
|
4893
|
+
if (item.kind === 'fence-open' && prev && result.length > 0 && result[result.length - 1] !== '') {
|
|
4894
|
+
result.push('');
|
|
4895
|
+
}
|
|
4896
|
+
result.push(item.line);
|
|
4897
|
+
if (item.kind === 'fence-close') prev = { kind: 'content', category: 'fence' };
|
|
4898
|
+
continue;
|
|
4899
|
+
}
|
|
4900
|
+
|
|
4901
|
+
if (item.kind === 'blank') {
|
|
4902
|
+
// Skip — Phase B inserts its own blank lines as needed
|
|
4903
|
+
continue;
|
|
4904
|
+
}
|
|
4905
|
+
|
|
4906
|
+
// item.kind === 'content'
|
|
4907
|
+
if (prev) {
|
|
4908
|
+
if (inSameBlock(prev, item)) ; else {
|
|
4909
|
+
// Different blocks (or paragraphs): exactly one blank
|
|
4910
|
+
if (result[result.length - 1] !== '') result.push('');
|
|
4911
|
+
}
|
|
4912
|
+
}
|
|
4913
|
+
result.push(item.line);
|
|
4914
|
+
prev = item;
|
|
4915
|
+
}
|
|
4916
|
+
|
|
4917
|
+
// Trim trailing blank lines so output has exactly one terminal newline
|
|
4918
|
+
while (result.length > 0 && result[result.length - 1] === '') result.pop();
|
|
4919
|
+
|
|
4920
|
+
return result.join('\n');
|
|
4921
|
+
}
|
|
4210
4922
|
|
|
4211
4923
|
/**
|
|
4212
4924
|
* Copy rendered content as rich text
|
|
@@ -4250,6 +4962,13 @@
|
|
|
4250
4962
|
}
|
|
4251
4963
|
}
|
|
4252
4964
|
|
|
4965
|
+
// --- Internal helpers for removeHR fence/table awareness ---
|
|
4966
|
+
|
|
4967
|
+
/** Heuristic: does this line look like a markdown table row? */
|
|
4968
|
+
function _looksLikeTableRow(line) {
|
|
4969
|
+
return line.includes('|');
|
|
4970
|
+
}
|
|
4971
|
+
|
|
4253
4972
|
// Export for CommonJS (needed for bundled ESM to work with Jest)
|
|
4254
4973
|
if (typeof module !== 'undefined' && module.exports) {
|
|
4255
4974
|
module.exports = QuikdownEditor;
|