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
|
*/
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
// Version will be injected at build time
|
|
21
|
-
const quikdownVersion = '1.
|
|
21
|
+
const quikdownVersion = '1.2.3';
|
|
22
22
|
|
|
23
23
|
// Constants for reuse
|
|
24
24
|
const CLASS_PREFIX = 'quikdown-';
|
|
@@ -66,6 +66,11 @@ function createGetAttr(inline_styles, styles) {
|
|
|
66
66
|
// Remove default text-align if we're adding a different alignment
|
|
67
67
|
if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
|
|
68
68
|
style = style.replace(/text-align:[^;]+;?/, '').trim();
|
|
69
|
+
// Ensure trailing semicolon before concatenating additionalStyle.
|
|
70
|
+
// Both short-circuit paths of this guard (empty `style` or
|
|
71
|
+
// already-has-`;`) are defensive and unreachable with the
|
|
72
|
+
// current QUIKDOWN_STYLES values — istanbul ignore next.
|
|
73
|
+
/* istanbul ignore next */
|
|
69
74
|
if (style && !style.endsWith(';')) style += ';';
|
|
70
75
|
}
|
|
71
76
|
|
|
@@ -97,9 +102,12 @@ function quikdown(markdown, options = {}) {
|
|
|
97
102
|
return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
// Helper to add data-qd attributes for bidirectional support
|
|
105
|
+
// Helper to add data-qd attributes for bidirectional support.
|
|
106
|
+
// The non-bidirectional branch is a trivial no-op arrow; it's exercised in
|
|
107
|
+
// the core bundle but never in quikdown_bd (which always sets bidirectional=true).
|
|
108
|
+
/* istanbul ignore next - trivial no-op fallback */
|
|
101
109
|
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
102
|
-
|
|
110
|
+
|
|
103
111
|
// Sanitize URLs to prevent XSS attacks
|
|
104
112
|
function sanitizeUrl(url, allowUnsafe = false) {
|
|
105
113
|
/* istanbul ignore next - defensive programming, regex ensures url is never empty */
|
|
@@ -268,7 +276,7 @@ function quikdown(markdown, options = {}) {
|
|
|
268
276
|
html = '<p>' + html + '</p>';
|
|
269
277
|
} else {
|
|
270
278
|
// Standard: two spaces at end of line for line breaks
|
|
271
|
-
html = html.replace(/
|
|
279
|
+
html = html.replace(/ {2}$/gm, `<br${getAttr('br')}>`);
|
|
272
280
|
|
|
273
281
|
// Paragraphs (double newlines)
|
|
274
282
|
// Don't add </p> after block elements (they're not in paragraphs)
|
|
@@ -297,7 +305,7 @@ function quikdown(markdown, options = {}) {
|
|
|
297
305
|
[/(<\/table>)<\/p>/g, '$1'],
|
|
298
306
|
[/<p>(<pre[^>]*>)/g, '$1'],
|
|
299
307
|
[/(<\/pre>)<\/p>/g, '$1'],
|
|
300
|
-
[new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)
|
|
308
|
+
[new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)</p>`, 'g'), '$1']
|
|
301
309
|
];
|
|
302
310
|
|
|
303
311
|
cleanupPatterns.forEach(([pattern, replacement]) => {
|
|
@@ -503,10 +511,15 @@ function processLists(text, getAttr, inline_styles, bidirectional) {
|
|
|
503
511
|
|
|
504
512
|
const lines = text.split('\n');
|
|
505
513
|
const result = [];
|
|
506
|
-
|
|
514
|
+
const listStack = []; // Track nested lists
|
|
507
515
|
|
|
508
|
-
// Helper to escape HTML for data-qd attributes
|
|
509
|
-
|
|
516
|
+
// Helper to escape HTML for data-qd attributes. List markers (`-`, `*`,
|
|
517
|
+
// `+`, `1.`, etc.) never contain HTML-special chars, so the replace
|
|
518
|
+
// callback is defensive-only and never actually fires in practice.
|
|
519
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g,
|
|
520
|
+
/* istanbul ignore next - defensive: list markers never contain HTML specials */
|
|
521
|
+
m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
|
|
522
|
+
/* istanbul ignore next - trivial no-op fallback; not exercised via bd bundle */
|
|
510
523
|
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
511
524
|
|
|
512
525
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -678,8 +691,11 @@ function quikdown_bd(markdown, options = {}) {
|
|
|
678
691
|
return quikdown(markdown, { ...options, bidirectional: true });
|
|
679
692
|
}
|
|
680
693
|
|
|
681
|
-
// Copy all properties and methods from quikdown (including version)
|
|
694
|
+
// Copy all properties and methods from quikdown (including version).
|
|
695
|
+
// Skip `configure` — quikdown_bd provides its own override below, so the
|
|
696
|
+
// inner quikdown.configure is dead code in this bundle.
|
|
682
697
|
Object.keys(quikdown).forEach(key => {
|
|
698
|
+
if (key === 'configure') return;
|
|
683
699
|
quikdown_bd[key] = quikdown[key];
|
|
684
700
|
});
|
|
685
701
|
|
|
@@ -713,7 +729,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
|
|
|
713
729
|
|
|
714
730
|
// Process children with context
|
|
715
731
|
let childContent = '';
|
|
716
|
-
for (
|
|
732
|
+
for (const child of node.childNodes) {
|
|
717
733
|
childContent += walkNode(child, { parentTag: tag, ...parentContext });
|
|
718
734
|
}
|
|
719
735
|
|
|
@@ -947,7 +963,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
|
|
|
947
963
|
let index = 1;
|
|
948
964
|
const indent = ' '.repeat(depth);
|
|
949
965
|
|
|
950
|
-
for (
|
|
966
|
+
for (const child of listNode.children) {
|
|
951
967
|
if (child.tagName !== 'LI') continue;
|
|
952
968
|
|
|
953
969
|
const dataQd = child.getAttribute('data-qd');
|
|
@@ -960,7 +976,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
|
|
|
960
976
|
marker = '-';
|
|
961
977
|
// Get text without the checkbox
|
|
962
978
|
let text = '';
|
|
963
|
-
for (
|
|
979
|
+
for (const node of child.childNodes) {
|
|
964
980
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
965
981
|
text += node.textContent;
|
|
966
982
|
} else if (node.tagName && node.tagName !== 'INPUT') {
|
|
@@ -971,7 +987,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
|
|
|
971
987
|
} else {
|
|
972
988
|
let itemContent = '';
|
|
973
989
|
|
|
974
|
-
for (
|
|
990
|
+
for (const node of child.childNodes) {
|
|
975
991
|
if (node.tagName === 'UL' || node.tagName === 'OL') {
|
|
976
992
|
itemContent += walkList(node, node.tagName === 'OL', depth + 1);
|
|
977
993
|
} else {
|
|
@@ -1000,7 +1016,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
|
|
|
1000
1016
|
const headerRow = thead.querySelector('tr');
|
|
1001
1017
|
if (headerRow) {
|
|
1002
1018
|
const headers = [];
|
|
1003
|
-
for (
|
|
1019
|
+
for (const th of headerRow.querySelectorAll('th')) {
|
|
1004
1020
|
headers.push(th.textContent.trim());
|
|
1005
1021
|
}
|
|
1006
1022
|
result += '| ' + headers.join(' | ') + ' |\n';
|
|
@@ -1019,9 +1035,9 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
|
|
|
1019
1035
|
// Process body
|
|
1020
1036
|
const tbody = table.querySelector('tbody');
|
|
1021
1037
|
if (tbody) {
|
|
1022
|
-
for (
|
|
1038
|
+
for (const row of tbody.querySelectorAll('tr')) {
|
|
1023
1039
|
const cells = [];
|
|
1024
|
-
for (
|
|
1040
|
+
for (const td of row.querySelectorAll('td')) {
|
|
1025
1041
|
cells.push(td.textContent.trim());
|
|
1026
1042
|
}
|
|
1027
1043
|
if (cells.length > 0) {
|
|
@@ -1043,10 +1059,13 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
|
|
|
1043
1059
|
return markdown;
|
|
1044
1060
|
};
|
|
1045
1061
|
|
|
1046
|
-
// Override the configure method to return a bidirectional version
|
|
1062
|
+
// Override the configure method to return a bidirectional version.
|
|
1063
|
+
// We delegate to the inner quikdown.configure so the shared closure
|
|
1064
|
+
// machinery is exercised in both bundles (no dead code).
|
|
1047
1065
|
quikdown_bd.configure = function(options) {
|
|
1066
|
+
const innerParser = quikdown.configure({ ...options, bidirectional: true });
|
|
1048
1067
|
return function(markdown) {
|
|
1049
|
-
return
|
|
1068
|
+
return innerParser(markdown);
|
|
1050
1069
|
};
|
|
1051
1070
|
};
|
|
1052
1071
|
|
|
@@ -1925,7 +1944,7 @@ async function getRenderedContent(previewPanel) {
|
|
|
1925
1944
|
// First try baseVal.value (works for absolute units)
|
|
1926
1945
|
width = svg.width.baseVal.value;
|
|
1927
1946
|
height = svg.height.baseVal.value;
|
|
1928
|
-
} catch (
|
|
1947
|
+
} catch (_e) {
|
|
1929
1948
|
// Fallback for relative units - use viewBox or rendered size
|
|
1930
1949
|
if (svg.viewBox && svg.viewBox.baseVal) {
|
|
1931
1950
|
width = svg.viewBox.baseVal.width;
|
|
@@ -1944,8 +1963,8 @@ async function getRenderedContent(previewPanel) {
|
|
|
1944
1963
|
// Apply aggressive downsizing for MathJax SVGs
|
|
1945
1964
|
let scaleFactor = 0.04; // Further reduced for smaller output
|
|
1946
1965
|
|
|
1947
|
-
|
|
1948
|
-
|
|
1966
|
+
const scaledWidth = width * scaleFactor;
|
|
1967
|
+
const scaledHeight = height * scaleFactor;
|
|
1949
1968
|
|
|
1950
1969
|
// If still too large after base scaling, scale down further
|
|
1951
1970
|
if (scaledWidth > targetMaxWidth || scaledHeight > targetMaxHeight) {
|
|
@@ -2178,7 +2197,7 @@ async function getRenderedContent(previewPanel) {
|
|
|
2178
2197
|
let mapDataUrl = '';
|
|
2179
2198
|
try {
|
|
2180
2199
|
mapDataUrl = canvas.toDataURL('image/png', 1.0);
|
|
2181
|
-
} catch (
|
|
2200
|
+
} catch (_e) {
|
|
2182
2201
|
console.warn('Map canvas tainted; falling back to placeholder');
|
|
2183
2202
|
}
|
|
2184
2203
|
|
|
@@ -2541,7 +2560,8 @@ async function getRenderedContent(previewPanel) {
|
|
|
2541
2560
|
const DEFAULT_OPTIONS = {
|
|
2542
2561
|
mode: 'split', // 'source' | 'preview' | 'split'
|
|
2543
2562
|
showToolbar: true,
|
|
2544
|
-
showRemoveHR: false, // Show button to remove horizontal rules (---)
|
|
2563
|
+
showRemoveHR: false, // Show button to remove horizontal rules (---)
|
|
2564
|
+
showLazyLinefeeds: false, // Show button to convert lazy linefeeds
|
|
2545
2565
|
theme: 'auto', // 'light' | 'dark' | 'auto'
|
|
2546
2566
|
lazy_linefeeds: false,
|
|
2547
2567
|
inline_styles: false, // Use CSS classes (false) or inline styles (true)
|
|
@@ -2551,8 +2571,73 @@ const DEFAULT_OPTIONS = {
|
|
|
2551
2571
|
highlightjs: false,
|
|
2552
2572
|
mermaid: false
|
|
2553
2573
|
},
|
|
2574
|
+
/**
|
|
2575
|
+
* Preload fence-rendering libraries at construction time so the FIRST
|
|
2576
|
+
* encounter with a fence type renders instantly (no lazy load delay).
|
|
2577
|
+
*
|
|
2578
|
+
* Accepts:
|
|
2579
|
+
* - 'all' — preload every known library
|
|
2580
|
+
* - ['highlightjs','mermaid','math',
|
|
2581
|
+
* 'geojson','stl'] — preload specific libraries
|
|
2582
|
+
* - [{ name: 'mylib', script: 'https://...', css: '...' }]
|
|
2583
|
+
* — preload an arbitrary library
|
|
2584
|
+
*
|
|
2585
|
+
* Without this, fence libraries are loaded on demand the first time their
|
|
2586
|
+
* fence type is encountered. That keeps the editor lightweight, but the
|
|
2587
|
+
* first SVG/Mermaid/Math/GeoJSON/STL fence will show "loading..." for a
|
|
2588
|
+
* moment. Set `preloadFences` if you want zero-delay rendering — at the
|
|
2589
|
+
* cost of a few hundred KB of upfront network.
|
|
2590
|
+
*
|
|
2591
|
+
* Developer's choice. The editor itself is still ~70 KB minified;
|
|
2592
|
+
* `preloadFences` only affects the OPTIONAL fence renderers.
|
|
2593
|
+
*/
|
|
2594
|
+
preloadFences: null,
|
|
2554
2595
|
customFences: {}, // { 'language': (code, lang) => html }
|
|
2555
|
-
enableComplexFences: true // Enable CSV tables, math rendering, SVG, etc.
|
|
2596
|
+
enableComplexFences: true, // Enable CSV tables, math rendering, SVG, etc.
|
|
2597
|
+
showUndoRedo: false, // Show undo/redo toolbar buttons
|
|
2598
|
+
undoStackSize: 100 // Maximum number of undo states to keep
|
|
2599
|
+
};
|
|
2600
|
+
|
|
2601
|
+
// Library catalog used by preloadFences. Each entry knows how to:
|
|
2602
|
+
// - check if the library is already on the page (so we don't double-load)
|
|
2603
|
+
// - load it via script (and optional CSS)
|
|
2604
|
+
const FENCE_LIBRARIES = {
|
|
2605
|
+
highlightjs: {
|
|
2606
|
+
check: () => typeof window.hljs !== 'undefined',
|
|
2607
|
+
script: 'https://unpkg.com/@highlightjs/cdn-assets/highlight.min.js',
|
|
2608
|
+
css: 'https://unpkg.com/@highlightjs/cdn-assets/styles/github.min.css',
|
|
2609
|
+
cssDark: 'https://unpkg.com/@highlightjs/cdn-assets/styles/github-dark.min.css'
|
|
2610
|
+
},
|
|
2611
|
+
mermaid: {
|
|
2612
|
+
check: () => typeof window.mermaid !== 'undefined',
|
|
2613
|
+
script: 'https://unpkg.com/mermaid/dist/mermaid.min.js',
|
|
2614
|
+
afterLoad: () => {
|
|
2615
|
+
if (window.mermaid) window.mermaid.initialize({ startOnLoad: false });
|
|
2616
|
+
}
|
|
2617
|
+
},
|
|
2618
|
+
math: {
|
|
2619
|
+
check: () => typeof window.MathJax !== 'undefined',
|
|
2620
|
+
script: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js',
|
|
2621
|
+
beforeLoad: () => {
|
|
2622
|
+
// Configure MathJax before loading (must be set on window before script runs)
|
|
2623
|
+
if (!window.MathJax) {
|
|
2624
|
+
window.MathJax = {
|
|
2625
|
+
tex: { inlineMath: [['$', '$'], ['\\(', '\\)']], displayMath: [['$$', '$$'], ['\\[', '\\]']] },
|
|
2626
|
+
svg: { fontCache: 'global' },
|
|
2627
|
+
startup: { typeset: false }
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
},
|
|
2632
|
+
geojson: {
|
|
2633
|
+
check: () => typeof window.L !== 'undefined',
|
|
2634
|
+
script: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
|
|
2635
|
+
css: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
|
|
2636
|
+
},
|
|
2637
|
+
stl: {
|
|
2638
|
+
check: () => typeof window.THREE !== 'undefined',
|
|
2639
|
+
script: 'https://unpkg.com/three@0.147.0/build/three.min.js'
|
|
2640
|
+
}
|
|
2556
2641
|
};
|
|
2557
2642
|
|
|
2558
2643
|
/**
|
|
@@ -2577,6 +2662,11 @@ class QuikdownEditor {
|
|
|
2577
2662
|
this._html = '';
|
|
2578
2663
|
this.currentMode = this.options.mode;
|
|
2579
2664
|
this.updateTimer = null;
|
|
2665
|
+
|
|
2666
|
+
// Undo/redo state
|
|
2667
|
+
this._undoStack = [];
|
|
2668
|
+
this._redoStack = [];
|
|
2669
|
+
this._isUndoRedo = false;
|
|
2580
2670
|
|
|
2581
2671
|
// Initialize
|
|
2582
2672
|
this.initPromise = this.init();
|
|
@@ -2633,6 +2723,7 @@ class QuikdownEditor {
|
|
|
2633
2723
|
|
|
2634
2724
|
this.sourceTextarea = document.createElement('textarea');
|
|
2635
2725
|
this.sourceTextarea.className = 'qde-textarea';
|
|
2726
|
+
this.sourceTextarea.spellcheck = false;
|
|
2636
2727
|
this.sourceTextarea.placeholder = this.options.placeholder;
|
|
2637
2728
|
this.sourcePanel.appendChild(this.sourceTextarea);
|
|
2638
2729
|
|
|
@@ -2640,6 +2731,7 @@ class QuikdownEditor {
|
|
|
2640
2731
|
this.previewPanel = document.createElement('div');
|
|
2641
2732
|
this.previewPanel.className = 'qde-preview';
|
|
2642
2733
|
this.previewPanel.contentEditable = true;
|
|
2734
|
+
this.previewPanel.spellcheck = false;
|
|
2643
2735
|
|
|
2644
2736
|
// Add panels to editor
|
|
2645
2737
|
this.editorArea.appendChild(this.sourcePanel);
|
|
@@ -2669,6 +2761,23 @@ class QuikdownEditor {
|
|
|
2669
2761
|
toolbar.appendChild(btn);
|
|
2670
2762
|
});
|
|
2671
2763
|
|
|
2764
|
+
// Undo/Redo buttons (if enabled)
|
|
2765
|
+
if (this.options.showUndoRedo) {
|
|
2766
|
+
const undoBtn = document.createElement('button');
|
|
2767
|
+
undoBtn.className = 'qde-btn disabled';
|
|
2768
|
+
undoBtn.dataset.action = 'undo';
|
|
2769
|
+
undoBtn.textContent = 'Undo';
|
|
2770
|
+
undoBtn.title = 'Undo (Ctrl+Z)';
|
|
2771
|
+
toolbar.appendChild(undoBtn);
|
|
2772
|
+
|
|
2773
|
+
const redoBtn = document.createElement('button');
|
|
2774
|
+
redoBtn.className = 'qde-btn disabled';
|
|
2775
|
+
redoBtn.dataset.action = 'redo';
|
|
2776
|
+
redoBtn.textContent = 'Redo';
|
|
2777
|
+
redoBtn.title = 'Redo (Ctrl+Shift+Z / Ctrl+Y)';
|
|
2778
|
+
toolbar.appendChild(redoBtn);
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2672
2781
|
// Spacer
|
|
2673
2782
|
const spacer = document.createElement('span');
|
|
2674
2783
|
spacer.className = 'qde-spacer';
|
|
@@ -2699,6 +2808,16 @@ class QuikdownEditor {
|
|
|
2699
2808
|
removeHRBtn.title = 'Remove all horizontal rules (---) from markdown';
|
|
2700
2809
|
toolbar.appendChild(removeHRBtn);
|
|
2701
2810
|
}
|
|
2811
|
+
|
|
2812
|
+
// Lazy linefeeds button (if enabled)
|
|
2813
|
+
if (this.options.showLazyLinefeeds) {
|
|
2814
|
+
const lazyLFBtn = document.createElement('button');
|
|
2815
|
+
lazyLFBtn.className = 'qde-btn';
|
|
2816
|
+
lazyLFBtn.dataset.action = 'lazy-linefeeds';
|
|
2817
|
+
lazyLFBtn.textContent = 'Fix Linefeeds';
|
|
2818
|
+
lazyLFBtn.title = 'Convert single newlines to paragraph breaks (one-time transform)';
|
|
2819
|
+
toolbar.appendChild(lazyLFBtn);
|
|
2820
|
+
}
|
|
2702
2821
|
|
|
2703
2822
|
return toolbar;
|
|
2704
2823
|
}
|
|
@@ -2751,6 +2870,11 @@ class QuikdownEditor {
|
|
|
2751
2870
|
color: white;
|
|
2752
2871
|
border-color: #0056b3;
|
|
2753
2872
|
}
|
|
2873
|
+
|
|
2874
|
+
.qde-btn.disabled {
|
|
2875
|
+
opacity: 0.4;
|
|
2876
|
+
pointer-events: none;
|
|
2877
|
+
}
|
|
2754
2878
|
|
|
2755
2879
|
.qde-spacer {
|
|
2756
2880
|
flex: 1;
|
|
@@ -2763,24 +2887,45 @@ class QuikdownEditor {
|
|
|
2763
2887
|
}
|
|
2764
2888
|
|
|
2765
2889
|
.qde-source, .qde-preview {
|
|
2766
|
-
flex: 1;
|
|
2890
|
+
flex: 1 1 0;
|
|
2891
|
+
min-width: 0; /* allow flex shrinking below content size */
|
|
2892
|
+
min-height: 0;
|
|
2767
2893
|
overflow: auto;
|
|
2768
2894
|
padding: 16px;
|
|
2895
|
+
box-sizing: border-box;
|
|
2769
2896
|
}
|
|
2770
|
-
|
|
2897
|
+
|
|
2771
2898
|
.qde-source {
|
|
2772
2899
|
border-right: 1px solid #ddd;
|
|
2900
|
+
/* Source pane is just a container for the textarea — make it
|
|
2901
|
+
a positioning context so the textarea can fill it absolutely */
|
|
2902
|
+
position: relative;
|
|
2903
|
+
padding: 0; /* textarea brings its own padding */
|
|
2773
2904
|
}
|
|
2774
|
-
|
|
2905
|
+
|
|
2775
2906
|
.qde-textarea {
|
|
2907
|
+
display: block;
|
|
2908
|
+
position: absolute;
|
|
2909
|
+
inset: 0;
|
|
2776
2910
|
width: 100%;
|
|
2777
2911
|
height: 100%;
|
|
2778
2912
|
border: none;
|
|
2779
2913
|
outline: none;
|
|
2780
2914
|
resize: none;
|
|
2915
|
+
padding: 16px;
|
|
2916
|
+
box-sizing: border-box;
|
|
2781
2917
|
font-family: 'Monaco', 'Courier New', monospace;
|
|
2782
2918
|
font-size: 14px;
|
|
2783
2919
|
line-height: 1.5;
|
|
2920
|
+
background: transparent;
|
|
2921
|
+
color: inherit;
|
|
2922
|
+
/* Wrap long lines so the textarea only scrolls VERTICALLY.
|
|
2923
|
+
pre-wrap preserves intentional line breaks/whitespace
|
|
2924
|
+
while soft-wrapping at the right edge. */
|
|
2925
|
+
white-space: pre-wrap;
|
|
2926
|
+
word-wrap: break-word;
|
|
2927
|
+
overflow-x: hidden;
|
|
2928
|
+
overflow-y: auto;
|
|
2784
2929
|
}
|
|
2785
2930
|
|
|
2786
2931
|
.qde-preview {
|
|
@@ -2789,14 +2934,69 @@ class QuikdownEditor {
|
|
|
2789
2934
|
line-height: 1.6;
|
|
2790
2935
|
outline: none;
|
|
2791
2936
|
cursor: text; /* Standard text cursor */
|
|
2937
|
+
overflow-x: hidden; /* never scroll horizontally; clip wide content */
|
|
2792
2938
|
}
|
|
2793
|
-
|
|
2939
|
+
|
|
2940
|
+
/* Code blocks and inline code — self-contained so the editor
|
|
2941
|
+
does not depend on any external stylesheet for these. */
|
|
2942
|
+
.qde-preview pre {
|
|
2943
|
+
background: #f4f4f4;
|
|
2944
|
+
color: #1f2937;
|
|
2945
|
+
padding: 10px;
|
|
2946
|
+
border-radius: 4px;
|
|
2947
|
+
overflow-x: auto;
|
|
2948
|
+
margin: 0.6em 0;
|
|
2949
|
+
font-size: 0.9em;
|
|
2950
|
+
line-height: 1.5;
|
|
2951
|
+
font-family: ui-monospace, "SF Mono", Monaco, "Cascadia Code",
|
|
2952
|
+
"Roboto Mono", Consolas, "Courier New", monospace;
|
|
2953
|
+
}
|
|
2954
|
+
.qde-preview code {
|
|
2955
|
+
padding: 2px 4px;
|
|
2956
|
+
font-size: 0.9em;
|
|
2957
|
+
border-radius: 3px;
|
|
2958
|
+
background: #f0f0f0;
|
|
2959
|
+
color: #1f2937;
|
|
2960
|
+
font-family: ui-monospace, "SF Mono", Monaco, "Cascadia Code",
|
|
2961
|
+
"Roboto Mono", Consolas, "Courier New", monospace;
|
|
2962
|
+
}
|
|
2963
|
+
.qde-preview pre code {
|
|
2964
|
+
padding: 0;
|
|
2965
|
+
font-size: inherit;
|
|
2966
|
+
border-radius: 0;
|
|
2967
|
+
background: transparent;
|
|
2968
|
+
color: inherit;
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
/* Wide fence content (Leaflet maps, large SVGs, STL canvases,
|
|
2972
|
+
iframes, raw <img>) must never overflow the preview pane */
|
|
2973
|
+
.qde-preview .geojson-container,
|
|
2974
|
+
.qde-preview .qde-stl-container,
|
|
2975
|
+
.qde-preview .qde-svg-container,
|
|
2976
|
+
.qde-preview .leaflet-container,
|
|
2977
|
+
.qde-preview iframe,
|
|
2978
|
+
.qde-preview img,
|
|
2979
|
+
.qde-preview > svg {
|
|
2980
|
+
max-width: 100%;
|
|
2981
|
+
}
|
|
2982
|
+
.qde-preview .leaflet-container { box-sizing: border-box; }
|
|
2983
|
+
|
|
2984
|
+
/* Standard markdown tables (the .quikdown-table class) need to
|
|
2985
|
+
scroll horizontally inside their own wrapper rather than
|
|
2986
|
+
making the whole preview pane scroll */
|
|
2987
|
+
.qde-preview table.quikdown-table,
|
|
2988
|
+
.qde-preview table.qde-csv-table {
|
|
2989
|
+
display: block;
|
|
2990
|
+
max-width: 100%;
|
|
2991
|
+
overflow-x: auto;
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2794
2994
|
/* Fence-specific styles */
|
|
2795
2995
|
.qde-svg-container {
|
|
2796
2996
|
max-width: 100%;
|
|
2797
2997
|
overflow: auto;
|
|
2798
2998
|
}
|
|
2799
|
-
|
|
2999
|
+
|
|
2800
3000
|
.qde-svg-container svg {
|
|
2801
3001
|
max-width: 100%;
|
|
2802
3002
|
height: auto;
|
|
@@ -2868,6 +3068,45 @@ class QuikdownEditor {
|
|
|
2868
3068
|
position: relative;
|
|
2869
3069
|
}
|
|
2870
3070
|
|
|
3071
|
+
/* Reset headings inside the preview to plain browser defaults so
|
|
3072
|
+
parent-page styles (site navs, marketing pages, design systems)
|
|
3073
|
+
cannot bleed in. Business-casual: black text, decreasing sizes,
|
|
3074
|
+
no decorative borders. See docs/quikdown-editor.md for how
|
|
3075
|
+
embedders can override these with their own stylesheet. */
|
|
3076
|
+
.qde-preview h1 { font-size: 2em; }
|
|
3077
|
+
.qde-preview h2 { font-size: 1.5em; }
|
|
3078
|
+
.qde-preview h3 { font-size: 1.25em; }
|
|
3079
|
+
.qde-preview h4 { font-size: 1em; }
|
|
3080
|
+
.qde-preview h5 { font-size: 0.875em; }
|
|
3081
|
+
.qde-preview h6 { font-size: 0.85em; }
|
|
3082
|
+
.qde-preview h1,
|
|
3083
|
+
.qde-preview h2,
|
|
3084
|
+
.qde-preview h3,
|
|
3085
|
+
.qde-preview h4,
|
|
3086
|
+
.qde-preview h5,
|
|
3087
|
+
.qde-preview h6 {
|
|
3088
|
+
font-weight: bold;
|
|
3089
|
+
color: inherit;
|
|
3090
|
+
border: none;
|
|
3091
|
+
margin: 0.6em 0 0.3em 0;
|
|
3092
|
+
line-height: 1.25;
|
|
3093
|
+
}
|
|
3094
|
+
.qde-preview p {
|
|
3095
|
+
margin: 0.35em 0;
|
|
3096
|
+
}
|
|
3097
|
+
.qde-preview ul,
|
|
3098
|
+
.qde-preview ol {
|
|
3099
|
+
padding-left: 1.8em;
|
|
3100
|
+
margin: 0.4em 0;
|
|
3101
|
+
}
|
|
3102
|
+
.qde-preview li {
|
|
3103
|
+
margin: 0.15em 0;
|
|
3104
|
+
}
|
|
3105
|
+
.qde-preview blockquote {
|
|
3106
|
+
margin: 0.5em 0;
|
|
3107
|
+
padding-left: 1em;
|
|
3108
|
+
}
|
|
3109
|
+
|
|
2871
3110
|
/* Ensure proper cursor for editable text elements */
|
|
2872
3111
|
.qde-preview p,
|
|
2873
3112
|
.qde-preview h1,
|
|
@@ -2930,6 +3169,7 @@ class QuikdownEditor {
|
|
|
2930
3169
|
.qde-dark {
|
|
2931
3170
|
background: #1e1e1e;
|
|
2932
3171
|
color: #e0e0e0;
|
|
3172
|
+
border-color: #444;
|
|
2933
3173
|
}
|
|
2934
3174
|
|
|
2935
3175
|
.qde-dark .qde-toolbar {
|
|
@@ -2961,6 +3201,20 @@ class QuikdownEditor {
|
|
|
2961
3201
|
color: #e0e0e0;
|
|
2962
3202
|
}
|
|
2963
3203
|
|
|
3204
|
+
/* Dark mode code blocks */
|
|
3205
|
+
.qde-dark .qde-preview pre {
|
|
3206
|
+
background: #2d2d3a;
|
|
3207
|
+
color: #e6e6f0;
|
|
3208
|
+
}
|
|
3209
|
+
.qde-dark .qde-preview code {
|
|
3210
|
+
background: #2a2a3a;
|
|
3211
|
+
color: #e6e6f0;
|
|
3212
|
+
}
|
|
3213
|
+
.qde-dark .qde-preview pre code {
|
|
3214
|
+
background: transparent;
|
|
3215
|
+
color: inherit;
|
|
3216
|
+
}
|
|
3217
|
+
|
|
2964
3218
|
/* Dark mode table styles */
|
|
2965
3219
|
.qde-dark .qde-preview table th,
|
|
2966
3220
|
.qde-dark .qde-preview table td {
|
|
@@ -2980,11 +3234,14 @@ class QuikdownEditor {
|
|
|
2980
3234
|
.qde-mode-split .qde-editor {
|
|
2981
3235
|
flex-direction: column;
|
|
2982
3236
|
}
|
|
2983
|
-
|
|
3237
|
+
|
|
2984
3238
|
.qde-mode-split .qde-source {
|
|
2985
3239
|
border-right: none;
|
|
2986
3240
|
border-bottom: 1px solid #ddd;
|
|
2987
3241
|
}
|
|
3242
|
+
.qde-dark.qde-mode-split .qde-source {
|
|
3243
|
+
border-bottom-color: #444;
|
|
3244
|
+
}
|
|
2988
3245
|
}
|
|
2989
3246
|
`;
|
|
2990
3247
|
|
|
@@ -3035,6 +3292,21 @@ class QuikdownEditor {
|
|
|
3035
3292
|
e.preventDefault();
|
|
3036
3293
|
this.setMode('preview');
|
|
3037
3294
|
break;
|
|
3295
|
+
case 'z':
|
|
3296
|
+
case 'Z':
|
|
3297
|
+
if (e.shiftKey) {
|
|
3298
|
+
e.preventDefault();
|
|
3299
|
+
this.redo();
|
|
3300
|
+
} else {
|
|
3301
|
+
e.preventDefault();
|
|
3302
|
+
this.undo();
|
|
3303
|
+
}
|
|
3304
|
+
break;
|
|
3305
|
+
case 'y':
|
|
3306
|
+
case 'Y':
|
|
3307
|
+
e.preventDefault();
|
|
3308
|
+
this.redo();
|
|
3309
|
+
break;
|
|
3038
3310
|
}
|
|
3039
3311
|
}
|
|
3040
3312
|
});
|
|
@@ -3064,6 +3336,12 @@ class QuikdownEditor {
|
|
|
3064
3336
|
* Update from markdown source
|
|
3065
3337
|
*/
|
|
3066
3338
|
updateFromMarkdown(markdown) {
|
|
3339
|
+
// Push current state to undo stack before changing (unless this is an undo/redo operation)
|
|
3340
|
+
if (!this._isUndoRedo) {
|
|
3341
|
+
this._pushUndoState(markdown || '');
|
|
3342
|
+
}
|
|
3343
|
+
this._isUndoRedo = false;
|
|
3344
|
+
|
|
3067
3345
|
this._markdown = markdown || '';
|
|
3068
3346
|
|
|
3069
3347
|
// Show placeholder if empty
|
|
@@ -3089,16 +3367,9 @@ class QuikdownEditor {
|
|
|
3089
3367
|
if (window.MathJax && window.MathJax.typesetPromise) {
|
|
3090
3368
|
const mathElements = this.previewPanel.querySelectorAll('.math-display');
|
|
3091
3369
|
if (mathElements.length > 0) {
|
|
3092
|
-
mathElements.forEach(el => {
|
|
3093
|
-
});
|
|
3094
3370
|
window.MathJax.typesetPromise(Array.from(mathElements))
|
|
3095
|
-
.
|
|
3096
|
-
|
|
3097
|
-
el.querySelector('mjx-container');
|
|
3098
|
-
});
|
|
3099
|
-
})
|
|
3100
|
-
.catch(err => {
|
|
3101
|
-
console.warn('MathJax batch processing failed:', err);
|
|
3371
|
+
.catch(_err => {
|
|
3372
|
+
console.warn('MathJax batch processing failed:', _err);
|
|
3102
3373
|
});
|
|
3103
3374
|
}
|
|
3104
3375
|
}
|
|
@@ -3117,24 +3388,34 @@ class QuikdownEditor {
|
|
|
3117
3388
|
updateFromHTML() {
|
|
3118
3389
|
// Clone the preview panel to avoid modifying the actual DOM
|
|
3119
3390
|
const clonedPanel = this.previewPanel.cloneNode(true);
|
|
3120
|
-
|
|
3391
|
+
|
|
3121
3392
|
// Pre-process special elements on the clone
|
|
3122
3393
|
this.preprocessSpecialElements(clonedPanel);
|
|
3123
|
-
|
|
3394
|
+
|
|
3124
3395
|
this._html = this.previewPanel.innerHTML;
|
|
3125
|
-
|
|
3396
|
+
const newMarkdown = quikdown_bd.toMarkdown(clonedPanel, {
|
|
3126
3397
|
fence_plugin: this.createFencePlugin()
|
|
3127
3398
|
});
|
|
3128
|
-
|
|
3399
|
+
|
|
3400
|
+
// Push previous state to undo stack (now that we know the new markdown)
|
|
3401
|
+
if (!this._isUndoRedo) {
|
|
3402
|
+
this._pushUndoState(newMarkdown);
|
|
3403
|
+
}
|
|
3404
|
+
this._isUndoRedo = false;
|
|
3405
|
+
|
|
3406
|
+
this._markdown = newMarkdown;
|
|
3407
|
+
|
|
3129
3408
|
// Update source if visible
|
|
3130
3409
|
if (this.currentMode !== 'preview') {
|
|
3131
3410
|
this.sourceTextarea.value = this._markdown;
|
|
3132
3411
|
}
|
|
3133
|
-
|
|
3412
|
+
|
|
3134
3413
|
// Trigger change event
|
|
3135
3414
|
if (this.options.onChange) {
|
|
3136
3415
|
this.options.onChange(this._markdown, this._html);
|
|
3137
3416
|
}
|
|
3417
|
+
|
|
3418
|
+
this._updateUndoButtons();
|
|
3138
3419
|
}
|
|
3139
3420
|
|
|
3140
3421
|
/**
|
|
@@ -3335,7 +3616,7 @@ class QuikdownEditor {
|
|
|
3335
3616
|
// Remove event handlers
|
|
3336
3617
|
const walker = document.createTreeWalker(svg, NodeFilter.SHOW_ELEMENT);
|
|
3337
3618
|
let node;
|
|
3338
|
-
while (node = walker.nextNode()) {
|
|
3619
|
+
while ((node = walker.nextNode())) {
|
|
3339
3620
|
for (let i = node.attributes.length - 1; i >= 0; i--) {
|
|
3340
3621
|
const attr = node.attributes[i];
|
|
3341
3622
|
if (attr.name.startsWith('on') || attr.value.includes('javascript:')) {
|
|
@@ -3425,7 +3706,7 @@ class QuikdownEditor {
|
|
|
3425
3706
|
/**
|
|
3426
3707
|
* Render math with MathJax (SVG output for better copy support)
|
|
3427
3708
|
*/
|
|
3428
|
-
renderMath(code,
|
|
3709
|
+
renderMath(code, _lang) {
|
|
3429
3710
|
const id = `math-${Math.random().toString(36).substring(2, 15)}`;
|
|
3430
3711
|
|
|
3431
3712
|
// Create container exactly like squibview
|
|
@@ -3548,11 +3829,11 @@ class QuikdownEditor {
|
|
|
3548
3829
|
|
|
3549
3830
|
html += '</table>';
|
|
3550
3831
|
return html;
|
|
3551
|
-
} catch (
|
|
3832
|
+
} catch (_err) {
|
|
3552
3833
|
return `<pre data-qd-fence="\`\`\`" data-qd-lang="${lang}" data-qd-source="${escapedCode}">${escapedCode}</pre>`;
|
|
3553
3834
|
}
|
|
3554
3835
|
}
|
|
3555
|
-
|
|
3836
|
+
|
|
3556
3837
|
/**
|
|
3557
3838
|
* Parse CSV line handling quoted values
|
|
3558
3839
|
*/
|
|
@@ -3596,13 +3877,13 @@ class QuikdownEditor {
|
|
|
3596
3877
|
try {
|
|
3597
3878
|
const data = JSON.parse(code);
|
|
3598
3879
|
toHighlight = JSON.stringify(data, null, 2);
|
|
3599
|
-
} catch (
|
|
3880
|
+
} catch (_e) {
|
|
3600
3881
|
// Use original if not valid JSON
|
|
3601
3882
|
}
|
|
3602
3883
|
|
|
3603
3884
|
const highlighted = hljs.highlight(toHighlight, { language: 'json' }).value;
|
|
3604
3885
|
return `<pre class="qde-json" data-qd-fence="\`\`\`" data-qd-lang="${lang}"><code class="hljs language-json">${highlighted}</code></pre>`;
|
|
3605
|
-
} catch (
|
|
3886
|
+
} catch (_e) {
|
|
3606
3887
|
// Fall through if highlighting fails
|
|
3607
3888
|
}
|
|
3608
3889
|
}
|
|
@@ -3695,7 +3976,7 @@ class QuikdownEditor {
|
|
|
3695
3976
|
if (loaded) {
|
|
3696
3977
|
renderMap();
|
|
3697
3978
|
} else {
|
|
3698
|
-
const element = document.getElementById(
|
|
3979
|
+
const element = document.getElementById(mapId + '-container');
|
|
3699
3980
|
if (element) {
|
|
3700
3981
|
element.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">Failed to load map library</div>';
|
|
3701
3982
|
}
|
|
@@ -3731,18 +4012,12 @@ class QuikdownEditor {
|
|
|
3731
4012
|
*/
|
|
3732
4013
|
renderSTL(code) {
|
|
3733
4014
|
const id = `qde-stl-viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
3734
|
-
|
|
3735
|
-
// Function to render the 3D model
|
|
4015
|
+
|
|
4016
|
+
// Function to render the 3D model (assumes window.THREE is loaded)
|
|
3736
4017
|
const render3D = () => {
|
|
3737
4018
|
const element = document.getElementById(id);
|
|
3738
4019
|
if (!element) return;
|
|
3739
|
-
|
|
3740
|
-
// Check if Three.js is available
|
|
3741
|
-
if (typeof window.THREE === 'undefined') {
|
|
3742
|
-
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>';
|
|
3743
|
-
return;
|
|
3744
|
-
}
|
|
3745
|
-
|
|
4020
|
+
|
|
3746
4021
|
try {
|
|
3747
4022
|
const THREE = window.THREE;
|
|
3748
4023
|
|
|
@@ -3800,9 +4075,34 @@ class QuikdownEditor {
|
|
|
3800
4075
|
}
|
|
3801
4076
|
};
|
|
3802
4077
|
|
|
3803
|
-
//
|
|
3804
|
-
|
|
3805
|
-
|
|
4078
|
+
// If Three.js is already loaded, render immediately. Otherwise lazy-load
|
|
4079
|
+
// it from a CDN (matches the GeoJSON/Leaflet pattern).
|
|
4080
|
+
if (window.THREE) {
|
|
4081
|
+
setTimeout(render3D, 0);
|
|
4082
|
+
} else {
|
|
4083
|
+
if (!window._qde_three_loading) {
|
|
4084
|
+
window._qde_three_loading = this.lazyLoadLibrary(
|
|
4085
|
+
'Three.js',
|
|
4086
|
+
() => window.THREE,
|
|
4087
|
+
'https://unpkg.com/three@0.147.0/build/three.min.js'
|
|
4088
|
+
).catch(_err => {
|
|
4089
|
+
console.warn('Failed to load Three.js for STL rendering');
|
|
4090
|
+
window._qde_three_loading = null;
|
|
4091
|
+
return false;
|
|
4092
|
+
});
|
|
4093
|
+
}
|
|
4094
|
+
window._qde_three_loading.then(loaded => {
|
|
4095
|
+
if (loaded) {
|
|
4096
|
+
render3D();
|
|
4097
|
+
} else {
|
|
4098
|
+
const element = document.getElementById(id);
|
|
4099
|
+
if (element) {
|
|
4100
|
+
element.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">Failed to load Three.js for STL rendering</div>';
|
|
4101
|
+
}
|
|
4102
|
+
}
|
|
4103
|
+
});
|
|
4104
|
+
}
|
|
4105
|
+
|
|
3806
4106
|
// Return placeholder with data-stl-id for copy functionality
|
|
3807
4107
|
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>`;
|
|
3808
4108
|
}
|
|
@@ -3894,30 +4194,64 @@ class QuikdownEditor {
|
|
|
3894
4194
|
}
|
|
3895
4195
|
|
|
3896
4196
|
/**
|
|
3897
|
-
* Load plugins dynamically
|
|
4197
|
+
* Load plugins dynamically — honors both `plugins: { highlightjs, mermaid }`
|
|
4198
|
+
* (legacy) and the newer `preloadFences` option which can preload any
|
|
4199
|
+
* combination of fence libraries (or 'all') at construction time.
|
|
3898
4200
|
*/
|
|
3899
4201
|
async loadPlugins() {
|
|
3900
|
-
const
|
|
3901
|
-
|
|
3902
|
-
//
|
|
3903
|
-
if (this.options.plugins
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
this.loadCSS('https://unpkg.com/@highlightjs/cdn-assets/styles/github.min.css')
|
|
3907
|
-
);
|
|
4202
|
+
const namesToLoad = new Set();
|
|
4203
|
+
|
|
4204
|
+
// Legacy plugins option
|
|
4205
|
+
if (this.options.plugins) {
|
|
4206
|
+
if (this.options.plugins.highlightjs) namesToLoad.add('highlightjs');
|
|
4207
|
+
if (this.options.plugins.mermaid) namesToLoad.add('mermaid');
|
|
3908
4208
|
}
|
|
3909
|
-
|
|
3910
|
-
//
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
4209
|
+
|
|
4210
|
+
// New preloadFences option
|
|
4211
|
+
const pf = this.options.preloadFences;
|
|
4212
|
+
if (pf === 'all') {
|
|
4213
|
+
Object.keys(FENCE_LIBRARIES).forEach(n => namesToLoad.add(n));
|
|
4214
|
+
} else if (Array.isArray(pf)) {
|
|
4215
|
+
for (const entry of pf) {
|
|
4216
|
+
if (typeof entry === 'string') {
|
|
4217
|
+
if (FENCE_LIBRARIES[entry]) namesToLoad.add(entry);
|
|
4218
|
+
else console.warn(`QuikdownEditor: unknown preloadFences entry "${entry}"`);
|
|
4219
|
+
} else if (entry && typeof entry === 'object' && entry.script) {
|
|
4220
|
+
// Custom library: { name, script, css? }
|
|
4221
|
+
namesToLoad.add('__custom__:' + (entry.name || entry.script));
|
|
4222
|
+
FENCE_LIBRARIES['__custom__:' + (entry.name || entry.script)] = {
|
|
4223
|
+
check: () => false,
|
|
4224
|
+
script: entry.script,
|
|
4225
|
+
css: entry.css
|
|
4226
|
+
};
|
|
4227
|
+
}
|
|
4228
|
+
}
|
|
4229
|
+
} else if (pf) {
|
|
4230
|
+
console.warn('QuikdownEditor: preloadFences should be "all", an array, or null');
|
|
3919
4231
|
}
|
|
3920
|
-
|
|
4232
|
+
|
|
4233
|
+
// Load each in parallel; respect already-loaded state
|
|
4234
|
+
const promises = [];
|
|
4235
|
+
for (const name of namesToLoad) {
|
|
4236
|
+
const lib = FENCE_LIBRARIES[name];
|
|
4237
|
+
if (!lib || lib.check()) continue;
|
|
4238
|
+
if (lib.beforeLoad) lib.beforeLoad();
|
|
4239
|
+
const p = (async () => {
|
|
4240
|
+
try {
|
|
4241
|
+
const tasks = [];
|
|
4242
|
+
if (lib.script) tasks.push(this.loadScript(lib.script));
|
|
4243
|
+
if (lib.css) tasks.push(this.loadCSS(lib.css, 'qde-hljs-light'));
|
|
4244
|
+
if (lib.cssDark) tasks.push(this.loadCSS(lib.cssDark, 'qde-hljs-dark'));
|
|
4245
|
+
await Promise.all(tasks);
|
|
4246
|
+
if (lib.css && lib.cssDark) this._syncHljsTheme();
|
|
4247
|
+
if (lib.afterLoad) lib.afterLoad();
|
|
4248
|
+
} catch (err) {
|
|
4249
|
+
console.warn(`QuikdownEditor: failed to preload ${name}:`, err);
|
|
4250
|
+
}
|
|
4251
|
+
})();
|
|
4252
|
+
promises.push(p);
|
|
4253
|
+
}
|
|
4254
|
+
|
|
3921
4255
|
await Promise.all(promises);
|
|
3922
4256
|
}
|
|
3923
4257
|
|
|
@@ -3969,36 +4303,73 @@ class QuikdownEditor {
|
|
|
3969
4303
|
/**
|
|
3970
4304
|
* Load external CSS
|
|
3971
4305
|
*/
|
|
3972
|
-
loadCSS(href) {
|
|
4306
|
+
loadCSS(href, id) {
|
|
3973
4307
|
return new Promise((resolve) => {
|
|
3974
4308
|
const link = document.createElement('link');
|
|
3975
4309
|
link.rel = 'stylesheet';
|
|
3976
4310
|
link.href = href;
|
|
4311
|
+
if (id) link.id = id;
|
|
3977
4312
|
link.onload = resolve;
|
|
3978
4313
|
document.head.appendChild(link);
|
|
3979
4314
|
// Resolve anyway after timeout (CSS doesn't always fire onload)
|
|
3980
4315
|
setTimeout(resolve, 1000);
|
|
3981
4316
|
});
|
|
3982
4317
|
}
|
|
3983
|
-
|
|
4318
|
+
|
|
3984
4319
|
/**
|
|
3985
|
-
*
|
|
4320
|
+
* Enable the hljs stylesheet matching the current theme and disable
|
|
4321
|
+
* the other one. Called from applyTheme and after hljs CSS loads.
|
|
4322
|
+
*/
|
|
4323
|
+
_syncHljsTheme() {
|
|
4324
|
+
const isDark = this.container.classList.contains('qde-dark');
|
|
4325
|
+
const light = document.getElementById('qde-hljs-light');
|
|
4326
|
+
const dark = document.getElementById('qde-hljs-dark');
|
|
4327
|
+
if (light) light.disabled = isDark;
|
|
4328
|
+
if (dark) dark.disabled = !isDark;
|
|
4329
|
+
}
|
|
4330
|
+
|
|
4331
|
+
/**
|
|
4332
|
+
* Apply the current theme (based on this.options.theme)
|
|
3986
4333
|
*/
|
|
3987
4334
|
applyTheme() {
|
|
3988
4335
|
const theme = this.options.theme;
|
|
3989
|
-
|
|
4336
|
+
|
|
4337
|
+
// Tear down any previous auto-mode listener so we don't stack them
|
|
4338
|
+
if (this._autoThemeListener) {
|
|
4339
|
+
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this._autoThemeListener);
|
|
4340
|
+
this._autoThemeListener = null;
|
|
4341
|
+
}
|
|
4342
|
+
|
|
3990
4343
|
if (theme === 'auto') {
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
this.
|
|
3994
|
-
|
|
3995
|
-
// Listen for changes
|
|
3996
|
-
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
4344
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
4345
|
+
this.container.classList.toggle('qde-dark', mq.matches);
|
|
4346
|
+
this._autoThemeListener = (e) => {
|
|
3997
4347
|
this.container.classList.toggle('qde-dark', e.matches);
|
|
3998
|
-
|
|
4348
|
+
this._syncHljsTheme();
|
|
4349
|
+
};
|
|
4350
|
+
mq.addEventListener('change', this._autoThemeListener);
|
|
3999
4351
|
} else {
|
|
4000
4352
|
this.container.classList.toggle('qde-dark', theme === 'dark');
|
|
4001
4353
|
}
|
|
4354
|
+
this._syncHljsTheme();
|
|
4355
|
+
}
|
|
4356
|
+
|
|
4357
|
+
/**
|
|
4358
|
+
* Set theme at runtime. Accepts 'light', 'dark', or 'auto'.
|
|
4359
|
+
* @param {'light'|'dark'|'auto'} theme
|
|
4360
|
+
*/
|
|
4361
|
+
setTheme(theme) {
|
|
4362
|
+
if (!['light', 'dark', 'auto'].includes(theme)) return;
|
|
4363
|
+
this.options.theme = theme;
|
|
4364
|
+
this.applyTheme();
|
|
4365
|
+
}
|
|
4366
|
+
|
|
4367
|
+
/**
|
|
4368
|
+
* Get the current theme option (as configured, not resolved).
|
|
4369
|
+
* @returns {'light'|'dark'|'auto'}
|
|
4370
|
+
*/
|
|
4371
|
+
getTheme() {
|
|
4372
|
+
return this.options.theme;
|
|
4002
4373
|
}
|
|
4003
4374
|
|
|
4004
4375
|
/**
|
|
@@ -4042,10 +4413,18 @@ class QuikdownEditor {
|
|
|
4042
4413
|
*/
|
|
4043
4414
|
setMode(mode) {
|
|
4044
4415
|
if (!['source', 'preview', 'split'].includes(mode)) return;
|
|
4045
|
-
|
|
4416
|
+
|
|
4417
|
+
// Preserve theme class across mode swap (the assignment to className
|
|
4418
|
+
// below would otherwise wipe it out — this used to be a no-op bug
|
|
4419
|
+
// where dark mode was lost on every setMode call).
|
|
4420
|
+
const wasDark = this.container.classList.contains('qde-dark');
|
|
4421
|
+
|
|
4046
4422
|
this.currentMode = mode;
|
|
4047
4423
|
this.container.className = `qde-container qde-mode-${mode}`;
|
|
4048
|
-
|
|
4424
|
+
if (wasDark) {
|
|
4425
|
+
this.container.classList.add('qde-dark');
|
|
4426
|
+
}
|
|
4427
|
+
|
|
4049
4428
|
// Update toolbar buttons
|
|
4050
4429
|
if (this.toolbar) {
|
|
4051
4430
|
this.toolbar.querySelectorAll('.qde-btn[data-mode]').forEach(btn => {
|
|
@@ -4053,11 +4432,6 @@ class QuikdownEditor {
|
|
|
4053
4432
|
});
|
|
4054
4433
|
}
|
|
4055
4434
|
|
|
4056
|
-
// Apply theme class
|
|
4057
|
-
if (this.container.classList.contains('qde-dark')) {
|
|
4058
|
-
this.container.classList.add('qde-dark');
|
|
4059
|
-
}
|
|
4060
|
-
|
|
4061
4435
|
// Make fence blocks non-editable when showing preview
|
|
4062
4436
|
if (mode !== 'source') {
|
|
4063
4437
|
setTimeout(() => this.makeFencesNonEditable(), 0);
|
|
@@ -4069,6 +4443,105 @@ class QuikdownEditor {
|
|
|
4069
4443
|
}
|
|
4070
4444
|
}
|
|
4071
4445
|
|
|
4446
|
+
// --- Undo / Redo ---
|
|
4447
|
+
|
|
4448
|
+
/**
|
|
4449
|
+
* Push current markdown state onto the undo stack (called before a change).
|
|
4450
|
+
* Only pushes if the new state differs from the current state.
|
|
4451
|
+
* @param {string} newMarkdown - the incoming markdown (used to detect no-op)
|
|
4452
|
+
* @private
|
|
4453
|
+
*/
|
|
4454
|
+
_pushUndoState(newMarkdown) {
|
|
4455
|
+
// Don't push if the content hasn't actually changed
|
|
4456
|
+
if (newMarkdown === this._markdown) return;
|
|
4457
|
+
|
|
4458
|
+
this._undoStack.push(this._markdown);
|
|
4459
|
+
|
|
4460
|
+
// Enforce max stack size
|
|
4461
|
+
const max = this.options.undoStackSize || 100;
|
|
4462
|
+
if (this._undoStack.length > max) {
|
|
4463
|
+
this._undoStack.splice(0, this._undoStack.length - max);
|
|
4464
|
+
}
|
|
4465
|
+
|
|
4466
|
+
// Any new edit clears the redo stack
|
|
4467
|
+
this._redoStack = [];
|
|
4468
|
+
this._updateUndoButtons();
|
|
4469
|
+
}
|
|
4470
|
+
|
|
4471
|
+
/**
|
|
4472
|
+
* Undo the last change. Restores the previous markdown state.
|
|
4473
|
+
*/
|
|
4474
|
+
undo() {
|
|
4475
|
+
if (!this.canUndo()) return;
|
|
4476
|
+
// Save current state to redo stack
|
|
4477
|
+
this._redoStack.push(this._markdown);
|
|
4478
|
+
const previous = this._undoStack.pop();
|
|
4479
|
+
this._isUndoRedo = true;
|
|
4480
|
+
// Update state directly (setMarkdown is async; keep it synchronous here)
|
|
4481
|
+
this._markdown = previous;
|
|
4482
|
+
if (this.sourceTextarea) {
|
|
4483
|
+
this.sourceTextarea.value = previous;
|
|
4484
|
+
}
|
|
4485
|
+
this.updateFromMarkdown(previous);
|
|
4486
|
+
this._updateUndoButtons();
|
|
4487
|
+
}
|
|
4488
|
+
|
|
4489
|
+
/**
|
|
4490
|
+
* Redo the last undone change.
|
|
4491
|
+
*/
|
|
4492
|
+
redo() {
|
|
4493
|
+
if (!this.canRedo()) return;
|
|
4494
|
+
// Save current state to undo stack
|
|
4495
|
+
this._undoStack.push(this._markdown);
|
|
4496
|
+
const next = this._redoStack.pop();
|
|
4497
|
+
this._isUndoRedo = true;
|
|
4498
|
+
this._markdown = next;
|
|
4499
|
+
if (this.sourceTextarea) {
|
|
4500
|
+
this.sourceTextarea.value = next;
|
|
4501
|
+
}
|
|
4502
|
+
this.updateFromMarkdown(next);
|
|
4503
|
+
this._updateUndoButtons();
|
|
4504
|
+
}
|
|
4505
|
+
|
|
4506
|
+
/**
|
|
4507
|
+
* @returns {boolean} true if undo is possible
|
|
4508
|
+
*/
|
|
4509
|
+
canUndo() {
|
|
4510
|
+
return this._undoStack.length > 0;
|
|
4511
|
+
}
|
|
4512
|
+
|
|
4513
|
+
/**
|
|
4514
|
+
* @returns {boolean} true if redo is possible
|
|
4515
|
+
*/
|
|
4516
|
+
canRedo() {
|
|
4517
|
+
return this._redoStack.length > 0;
|
|
4518
|
+
}
|
|
4519
|
+
|
|
4520
|
+
/**
|
|
4521
|
+
* Clear the undo and redo history.
|
|
4522
|
+
*/
|
|
4523
|
+
clearHistory() {
|
|
4524
|
+
this._undoStack = [];
|
|
4525
|
+
this._redoStack = [];
|
|
4526
|
+
this._updateUndoButtons();
|
|
4527
|
+
}
|
|
4528
|
+
|
|
4529
|
+
/**
|
|
4530
|
+
* Update the disabled state of the undo/redo toolbar buttons.
|
|
4531
|
+
* @private
|
|
4532
|
+
*/
|
|
4533
|
+
_updateUndoButtons() {
|
|
4534
|
+
if (!this.toolbar) return;
|
|
4535
|
+
const undoBtn = this.toolbar.querySelector('[data-action="undo"]');
|
|
4536
|
+
const redoBtn = this.toolbar.querySelector('[data-action="redo"]');
|
|
4537
|
+
if (undoBtn) {
|
|
4538
|
+
undoBtn.classList.toggle('disabled', !this.canUndo());
|
|
4539
|
+
}
|
|
4540
|
+
if (redoBtn) {
|
|
4541
|
+
redoBtn.classList.toggle('disabled', !this.canRedo());
|
|
4542
|
+
}
|
|
4543
|
+
}
|
|
4544
|
+
|
|
4072
4545
|
/**
|
|
4073
4546
|
* Handle toolbar actions
|
|
4074
4547
|
*/
|
|
@@ -4086,6 +4559,15 @@ class QuikdownEditor {
|
|
|
4086
4559
|
case 'remove-hr':
|
|
4087
4560
|
this.removeHR();
|
|
4088
4561
|
break;
|
|
4562
|
+
case 'lazy-linefeeds':
|
|
4563
|
+
this.convertLazyLinefeeds();
|
|
4564
|
+
break;
|
|
4565
|
+
case 'undo':
|
|
4566
|
+
this.undo();
|
|
4567
|
+
break;
|
|
4568
|
+
case 'redo':
|
|
4569
|
+
this.redo();
|
|
4570
|
+
break;
|
|
4089
4571
|
}
|
|
4090
4572
|
}
|
|
4091
4573
|
|
|
@@ -4173,24 +4655,13 @@ class QuikdownEditor {
|
|
|
4173
4655
|
}
|
|
4174
4656
|
|
|
4175
4657
|
/**
|
|
4176
|
-
* Remove all horizontal rules (---) from markdown
|
|
4658
|
+
* Remove all horizontal rules (---) from markdown source.
|
|
4659
|
+
* Preserves content inside fences (``` or ~~~) and table separator rows.
|
|
4177
4660
|
*/
|
|
4178
4661
|
async removeHR() {
|
|
4179
|
-
|
|
4180
|
-
// Matches: ---, ___, ***, ----, etc. with optional spaces
|
|
4181
|
-
const cleaned = this._markdown
|
|
4182
|
-
.split('\n')
|
|
4183
|
-
.filter(line => {
|
|
4184
|
-
// Keep lines that aren't just HR patterns
|
|
4185
|
-
const trimmed = line.trim();
|
|
4186
|
-
// Match HR patterns: 3+ of -, _, or * with optional spaces between
|
|
4187
|
-
return !(/^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed));
|
|
4188
|
-
})
|
|
4189
|
-
.join('\n');
|
|
4190
|
-
|
|
4191
|
-
// Update the markdown
|
|
4662
|
+
const cleaned = QuikdownEditor.removeHRFromMarkdown(this._markdown);
|
|
4192
4663
|
await this.setMarkdown(cleaned);
|
|
4193
|
-
|
|
4664
|
+
|
|
4194
4665
|
// Visual feedback if toolbar button exists
|
|
4195
4666
|
const btn = this.toolbar?.querySelector('[data-action="remove-hr"]');
|
|
4196
4667
|
if (btn) {
|
|
@@ -4201,6 +4672,247 @@ class QuikdownEditor {
|
|
|
4201
4672
|
}, 1500);
|
|
4202
4673
|
}
|
|
4203
4674
|
}
|
|
4675
|
+
|
|
4676
|
+
/**
|
|
4677
|
+
* Static: remove horizontal rules from markdown string.
|
|
4678
|
+
* Safe for fences, tables, and all markdown constructs.
|
|
4679
|
+
* Can be used headless without an editor instance.
|
|
4680
|
+
* @param {string} markdown - source markdown
|
|
4681
|
+
* @returns {string} markdown with standalone HRs removed
|
|
4682
|
+
*/
|
|
4683
|
+
static removeHRFromMarkdown(markdown) {
|
|
4684
|
+
const lines = (markdown || '').split('\n');
|
|
4685
|
+
const result = [];
|
|
4686
|
+
let inFence = false;
|
|
4687
|
+
let fenceChar = null; // '`' or '~'
|
|
4688
|
+
let fenceLen = 0; // length of opening fence marker
|
|
4689
|
+
|
|
4690
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4691
|
+
const line = lines[i];
|
|
4692
|
+
const trimmed = line.trim();
|
|
4693
|
+
|
|
4694
|
+
// Track fence open/close (``` or ~~~, 3+ chars)
|
|
4695
|
+
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
|
|
4696
|
+
if (fenceMatch) {
|
|
4697
|
+
const matchChar = fenceMatch[1][0];
|
|
4698
|
+
const matchLen = fenceMatch[1].length;
|
|
4699
|
+
if (!inFence) {
|
|
4700
|
+
inFence = true;
|
|
4701
|
+
fenceChar = matchChar;
|
|
4702
|
+
fenceLen = matchLen;
|
|
4703
|
+
result.push(line);
|
|
4704
|
+
continue;
|
|
4705
|
+
} else if (matchChar === fenceChar && matchLen >= fenceLen && /^(`{3,}|~{3,})\s*$/.test(trimmed)) {
|
|
4706
|
+
// Closing fence: same char, at least as many chars, no trailing content
|
|
4707
|
+
inFence = false;
|
|
4708
|
+
fenceChar = null;
|
|
4709
|
+
fenceLen = 0;
|
|
4710
|
+
result.push(line);
|
|
4711
|
+
continue;
|
|
4712
|
+
}
|
|
4713
|
+
}
|
|
4714
|
+
|
|
4715
|
+
// Inside a fence — keep everything
|
|
4716
|
+
if (inFence) {
|
|
4717
|
+
result.push(line);
|
|
4718
|
+
continue;
|
|
4719
|
+
}
|
|
4720
|
+
|
|
4721
|
+
// Detect table row/separator with pipes — always keep
|
|
4722
|
+
if (/^\|.*\|$/.test(trimmed) || (/^[-| :]+$/.test(trimmed) && trimmed.includes('|'))) {
|
|
4723
|
+
result.push(line);
|
|
4724
|
+
continue;
|
|
4725
|
+
}
|
|
4726
|
+
|
|
4727
|
+
// Check if this line is a standalone HR
|
|
4728
|
+
const isHR = /^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed);
|
|
4729
|
+
if (isHR) {
|
|
4730
|
+
// Table separator heuristic: immediately adjacent lines (no blank
|
|
4731
|
+
// lines between) that look like table rows protect this HR-like line
|
|
4732
|
+
const prevLine = i > 0 ? lines[i - 1].trim() : '';
|
|
4733
|
+
const nextLine = i < lines.length - 1 ? lines[i + 1].trim() : '';
|
|
4734
|
+
if (_looksLikeTableRow(prevLine) || _looksLikeTableRow(nextLine)) {
|
|
4735
|
+
result.push(line);
|
|
4736
|
+
continue;
|
|
4737
|
+
}
|
|
4738
|
+
// It's a real HR — skip it
|
|
4739
|
+
continue;
|
|
4740
|
+
}
|
|
4741
|
+
|
|
4742
|
+
result.push(line);
|
|
4743
|
+
}
|
|
4744
|
+
|
|
4745
|
+
return result.join('\n');
|
|
4746
|
+
}
|
|
4747
|
+
|
|
4748
|
+
/**
|
|
4749
|
+
* Convert lazy linefeeds in markdown source.
|
|
4750
|
+
* Replaces single newlines with double newlines (adds real line breaks)
|
|
4751
|
+
* except inside fences, tables, and other block-level constructs.
|
|
4752
|
+
* Idempotent: calling multiple times produces the same result.
|
|
4753
|
+
* Can be used as a toolbar action or headless via the static method.
|
|
4754
|
+
*/
|
|
4755
|
+
async convertLazyLinefeeds() {
|
|
4756
|
+
const converted = QuikdownEditor.convertLazyLinefeeds(this._markdown);
|
|
4757
|
+
await this.setMarkdown(converted);
|
|
4758
|
+
|
|
4759
|
+
// Visual feedback if toolbar button exists
|
|
4760
|
+
const btn = this.toolbar?.querySelector('[data-action="lazy-linefeeds"]');
|
|
4761
|
+
if (btn) {
|
|
4762
|
+
const originalText = btn.textContent;
|
|
4763
|
+
btn.textContent = 'Converted!';
|
|
4764
|
+
setTimeout(() => {
|
|
4765
|
+
btn.textContent = originalText;
|
|
4766
|
+
}, 1500);
|
|
4767
|
+
}
|
|
4768
|
+
}
|
|
4769
|
+
|
|
4770
|
+
/**
|
|
4771
|
+
* Static: convert lazy linefeeds in markdown source.
|
|
4772
|
+
* Turns single \n between non-blank lines into \n\n so each line becomes
|
|
4773
|
+
* its own paragraph / hard break. Idempotent — already-doubled newlines
|
|
4774
|
+
* are not doubled again. Fences, tables, lists, blockquotes, headings,
|
|
4775
|
+
* and HTML blocks are left untouched.
|
|
4776
|
+
* @param {string} markdown - source markdown
|
|
4777
|
+
* @returns {string} markdown with lazy linefeeds resolved
|
|
4778
|
+
*/
|
|
4779
|
+
static convertLazyLinefeeds(markdown) {
|
|
4780
|
+
// Two-phase approach (much cleaner than the old single pass):
|
|
4781
|
+
//
|
|
4782
|
+
// Phase A: walk lines, classify each as { content, blank, fence }.
|
|
4783
|
+
// Inside fences, lines are passed through verbatim.
|
|
4784
|
+
// Phase B: emit lines with the rule:
|
|
4785
|
+
// "between two adjacent CONTENT lines, ensure exactly one
|
|
4786
|
+
// blank line — never zero, never more than one."
|
|
4787
|
+
//
|
|
4788
|
+
// The rule applies regardless of whether the content lines are
|
|
4789
|
+
// headings, lists, blockquotes, table rows, paragraphs, or HR — any
|
|
4790
|
+
// adjacent pair of non-fence non-blank lines gets exactly one blank
|
|
4791
|
+
// between them. This produces the cleanest possible output for any
|
|
4792
|
+
// input and is fully idempotent.
|
|
4793
|
+
//
|
|
4794
|
+
// Lines that are whitespace-only (e.g. " ") are normalized to
|
|
4795
|
+
// empty strings, eliminating "phantom" blank lines.
|
|
4796
|
+
//
|
|
4797
|
+
// Lists are a special case: adjacent list items (same marker type)
|
|
4798
|
+
// should NOT get a blank line between them, otherwise we'd break
|
|
4799
|
+
// tight lists.
|
|
4800
|
+
//
|
|
4801
|
+
// Same applies to blockquote lines and table rows — adjacent rows
|
|
4802
|
+
// belong to the same block.
|
|
4803
|
+
|
|
4804
|
+
const inputLines = (markdown || '').split('\n');
|
|
4805
|
+
|
|
4806
|
+
// -------- Phase A: classify lines, normalize whitespace-only --------
|
|
4807
|
+
// Each entry: { line, kind } where kind is one of:
|
|
4808
|
+
// 'fence-open', 'fence-close', 'fence-body', 'blank', 'content'
|
|
4809
|
+
// Plus a 'category' for content lines: 'list-ul', 'list-ol',
|
|
4810
|
+
// 'blockquote', 'table', 'heading', 'hr', 'paragraph'
|
|
4811
|
+
const items = [];
|
|
4812
|
+
let inFence = false;
|
|
4813
|
+
let fenceChar = null;
|
|
4814
|
+
let fenceLen = 0;
|
|
4815
|
+
|
|
4816
|
+
for (const rawLine of inputLines) {
|
|
4817
|
+
const line = rawLine;
|
|
4818
|
+
const trimmed = line.trim();
|
|
4819
|
+
|
|
4820
|
+
// Fence tracking
|
|
4821
|
+
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
|
|
4822
|
+
if (fenceMatch && !inFence) {
|
|
4823
|
+
inFence = true;
|
|
4824
|
+
fenceChar = fenceMatch[1][0];
|
|
4825
|
+
fenceLen = fenceMatch[1].length;
|
|
4826
|
+
items.push({ line, kind: 'fence-open' });
|
|
4827
|
+
continue;
|
|
4828
|
+
}
|
|
4829
|
+
if (inFence) {
|
|
4830
|
+
if (fenceMatch &&
|
|
4831
|
+
fenceMatch[1][0] === fenceChar &&
|
|
4832
|
+
fenceMatch[1].length >= fenceLen &&
|
|
4833
|
+
/^(`{3,}|~{3,})\s*$/.test(trimmed)) {
|
|
4834
|
+
inFence = false;
|
|
4835
|
+
fenceChar = null;
|
|
4836
|
+
fenceLen = 0;
|
|
4837
|
+
items.push({ line, kind: 'fence-close' });
|
|
4838
|
+
} else {
|
|
4839
|
+
items.push({ line, kind: 'fence-body' });
|
|
4840
|
+
}
|
|
4841
|
+
continue;
|
|
4842
|
+
}
|
|
4843
|
+
|
|
4844
|
+
// Outside fence: whitespace-only lines become canonical blanks
|
|
4845
|
+
if (trimmed === '') {
|
|
4846
|
+
items.push({ line: '', kind: 'blank' });
|
|
4847
|
+
continue;
|
|
4848
|
+
}
|
|
4849
|
+
|
|
4850
|
+
// Categorize content lines so we can recognize adjacent same-kind blocks
|
|
4851
|
+
let category = 'paragraph';
|
|
4852
|
+
if (/^#{1,6}\s/.test(trimmed)) category = 'heading';
|
|
4853
|
+
else if (/^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed)) category = 'hr';
|
|
4854
|
+
else if (/^(\d+\.)\s/.test(trimmed)) category = 'list-ol';
|
|
4855
|
+
else if (/^[-*+]\s/.test(trimmed)) category = 'list-ul';
|
|
4856
|
+
else if (/^>/.test(trimmed)) category = 'blockquote';
|
|
4857
|
+
else if (/^\|/.test(trimmed)) category = 'table';
|
|
4858
|
+
// Indented continuation of a list (2+ leading spaces or tab)
|
|
4859
|
+
else if (/^(?: {4}|\t| {2,}[-*+]| {2,}\d+\.)/.test(line)) category = 'list-cont';
|
|
4860
|
+
|
|
4861
|
+
items.push({ line, kind: 'content', category });
|
|
4862
|
+
}
|
|
4863
|
+
|
|
4864
|
+
// -------- Phase B: emit with exactly-one-blank-line normalization --------
|
|
4865
|
+
// Same-block adjacent lines (lists, blockquotes, tables) stay
|
|
4866
|
+
// touching; any other adjacent content pair gets exactly one blank.
|
|
4867
|
+
const result = [];
|
|
4868
|
+
let prev = null; // last emitted non-blank content item
|
|
4869
|
+
|
|
4870
|
+
function inSameBlock(a, b) {
|
|
4871
|
+
if (!a || !b) return false;
|
|
4872
|
+
// Lists: same marker family OR list-content continuation
|
|
4873
|
+
if ((a.category === 'list-ul' || a.category === 'list-ol' || a.category === 'list-cont') &&
|
|
4874
|
+
(b.category === 'list-ul' || b.category === 'list-ol' || b.category === 'list-cont')) {
|
|
4875
|
+
return true;
|
|
4876
|
+
}
|
|
4877
|
+
// Blockquotes
|
|
4878
|
+
if (a.category === 'blockquote' && b.category === 'blockquote') return true;
|
|
4879
|
+
// Table rows
|
|
4880
|
+
if (a.category === 'table' && b.category === 'table') return true;
|
|
4881
|
+
return false;
|
|
4882
|
+
}
|
|
4883
|
+
|
|
4884
|
+
for (const item of items) {
|
|
4885
|
+
if (item.kind === 'fence-open' || item.kind === 'fence-body' || item.kind === 'fence-close') {
|
|
4886
|
+
// Fences: ensure exactly one blank line before the fence-open
|
|
4887
|
+
if (item.kind === 'fence-open' && prev && result.length > 0 && result[result.length - 1] !== '') {
|
|
4888
|
+
result.push('');
|
|
4889
|
+
}
|
|
4890
|
+
result.push(item.line);
|
|
4891
|
+
if (item.kind === 'fence-close') prev = { kind: 'content', category: 'fence' };
|
|
4892
|
+
continue;
|
|
4893
|
+
}
|
|
4894
|
+
|
|
4895
|
+
if (item.kind === 'blank') {
|
|
4896
|
+
// Skip — Phase B inserts its own blank lines as needed
|
|
4897
|
+
continue;
|
|
4898
|
+
}
|
|
4899
|
+
|
|
4900
|
+
// item.kind === 'content'
|
|
4901
|
+
if (prev) {
|
|
4902
|
+
if (inSameBlock(prev, item)) ; else {
|
|
4903
|
+
// Different blocks (or paragraphs): exactly one blank
|
|
4904
|
+
if (result[result.length - 1] !== '') result.push('');
|
|
4905
|
+
}
|
|
4906
|
+
}
|
|
4907
|
+
result.push(item.line);
|
|
4908
|
+
prev = item;
|
|
4909
|
+
}
|
|
4910
|
+
|
|
4911
|
+
// Trim trailing blank lines so output has exactly one terminal newline
|
|
4912
|
+
while (result.length > 0 && result[result.length - 1] === '') result.pop();
|
|
4913
|
+
|
|
4914
|
+
return result.join('\n');
|
|
4915
|
+
}
|
|
4204
4916
|
|
|
4205
4917
|
/**
|
|
4206
4918
|
* Copy rendered content as rich text
|
|
@@ -4244,6 +4956,13 @@ class QuikdownEditor {
|
|
|
4244
4956
|
}
|
|
4245
4957
|
}
|
|
4246
4958
|
|
|
4959
|
+
// --- Internal helpers for removeHR fence/table awareness ---
|
|
4960
|
+
|
|
4961
|
+
/** Heuristic: does this line look like a markdown table row? */
|
|
4962
|
+
function _looksLikeTableRow(line) {
|
|
4963
|
+
return line.includes('|');
|
|
4964
|
+
}
|
|
4965
|
+
|
|
4247
4966
|
// Export for CommonJS (needed for bundled ESM to work with Jest)
|
|
4248
4967
|
if (typeof module !== 'undefined' && module.exports) {
|
|
4249
4968
|
module.exports = QuikdownEditor;
|