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.
Files changed (72) hide show
  1. package/README.md +39 -7
  2. package/dist/quikdown.cjs +23 -10
  3. package/dist/quikdown.dark.css +1 -1
  4. package/dist/quikdown.esm.js +23 -10
  5. package/dist/quikdown.esm.min.js +2 -2
  6. package/dist/quikdown.esm.min.js.gz +0 -0
  7. package/dist/quikdown.esm.min.js.map +1 -1
  8. package/dist/quikdown.light.css +1 -1
  9. package/dist/quikdown.umd.js +23 -10
  10. package/dist/quikdown.umd.min.js +2 -2
  11. package/dist/quikdown.umd.min.js.gz +0 -0
  12. package/dist/quikdown.umd.min.js.map +1 -1
  13. package/dist/quikdown_ast.cjs +513 -0
  14. package/dist/quikdown_ast.d.ts +227 -0
  15. package/dist/quikdown_ast.esm.js +511 -0
  16. package/dist/quikdown_ast.esm.min.js +8 -0
  17. package/dist/quikdown_ast.esm.min.js.gz +0 -0
  18. package/dist/quikdown_ast.esm.min.js.map +1 -0
  19. package/dist/quikdown_ast.umd.js +519 -0
  20. package/dist/quikdown_ast.umd.min.js +8 -0
  21. package/dist/quikdown_ast.umd.min.js.gz +0 -0
  22. package/dist/quikdown_ast.umd.min.js.map +1 -0
  23. package/dist/quikdown_ast_html.cjs +1058 -0
  24. package/dist/quikdown_ast_html.d.ts +68 -0
  25. package/dist/quikdown_ast_html.esm.js +1056 -0
  26. package/dist/quikdown_ast_html.esm.min.js +8 -0
  27. package/dist/quikdown_ast_html.esm.min.js.gz +0 -0
  28. package/dist/quikdown_ast_html.esm.min.js.map +1 -0
  29. package/dist/quikdown_ast_html.umd.js +1064 -0
  30. package/dist/quikdown_ast_html.umd.min.js +8 -0
  31. package/dist/quikdown_ast_html.umd.min.js.gz +0 -0
  32. package/dist/quikdown_ast_html.umd.min.js.map +1 -0
  33. package/dist/quikdown_bd.cjs +38 -19
  34. package/dist/quikdown_bd.esm.js +38 -19
  35. package/dist/quikdown_bd.esm.min.js +2 -2
  36. package/dist/quikdown_bd.esm.min.js.gz +0 -0
  37. package/dist/quikdown_bd.esm.min.js.map +1 -1
  38. package/dist/quikdown_bd.umd.js +38 -19
  39. package/dist/quikdown_bd.umd.min.js +2 -2
  40. package/dist/quikdown_bd.umd.min.js.gz +0 -0
  41. package/dist/quikdown_bd.umd.min.js.map +1 -1
  42. package/dist/quikdown_edit.cjs +836 -117
  43. package/dist/quikdown_edit.d.ts +123 -131
  44. package/dist/quikdown_edit.esm.js +836 -117
  45. package/dist/quikdown_edit.esm.min.js +3 -3
  46. package/dist/quikdown_edit.esm.min.js.gz +0 -0
  47. package/dist/quikdown_edit.esm.min.js.map +1 -1
  48. package/dist/quikdown_edit.umd.js +836 -117
  49. package/dist/quikdown_edit.umd.min.js +3 -3
  50. package/dist/quikdown_edit.umd.min.js.gz +0 -0
  51. package/dist/quikdown_edit.umd.min.js.map +1 -1
  52. package/dist/quikdown_json.cjs +556 -0
  53. package/dist/quikdown_json.d.ts +48 -0
  54. package/dist/quikdown_json.esm.js +554 -0
  55. package/dist/quikdown_json.esm.min.js +8 -0
  56. package/dist/quikdown_json.esm.min.js.gz +0 -0
  57. package/dist/quikdown_json.esm.min.js.map +1 -0
  58. package/dist/quikdown_json.umd.js +562 -0
  59. package/dist/quikdown_json.umd.min.js +8 -0
  60. package/dist/quikdown_json.umd.min.js.gz +0 -0
  61. package/dist/quikdown_json.umd.min.js.map +1 -0
  62. package/dist/quikdown_yaml.cjs +717 -0
  63. package/dist/quikdown_yaml.d.ts +51 -0
  64. package/dist/quikdown_yaml.esm.js +715 -0
  65. package/dist/quikdown_yaml.esm.min.js +8 -0
  66. package/dist/quikdown_yaml.esm.min.js.gz +0 -0
  67. package/dist/quikdown_yaml.esm.min.js.map +1 -0
  68. package/dist/quikdown_yaml.umd.js +723 -0
  69. package/dist/quikdown_yaml.umd.min.js +8 -0
  70. package/dist/quikdown_yaml.umd.min.js.gz +0 -0
  71. package/dist/quikdown_yaml.umd.min.js.map +1 -0
  72. package/package.json +100 -45
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Quikdown Editor - Drop-in Markdown Parser
3
- * @version 1.1.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.1.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(/ $/gm, `<br${getAttr('br')}>`);
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+§)<\/p>`, 'g'), '$1']
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
- let listStack = []; // Track nested lists
514
+ const listStack = []; // Track nested lists
507
515
 
508
- // Helper to escape HTML for data-qd attributes
509
- const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[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 (let child of node.childNodes) {
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 (let child of listNode.children) {
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 (let node of child.childNodes) {
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 (let node of child.childNodes) {
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 (let th of headerRow.querySelectorAll('th')) {
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 (let row of tbody.querySelectorAll('tr')) {
1038
+ for (const row of tbody.querySelectorAll('tr')) {
1023
1039
  const cells = [];
1024
- for (let td of row.querySelectorAll('td')) {
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 quikdown_bd(markdown, options);
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 (e) {
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
- let scaledWidth = width * scaleFactor;
1948
- let scaledHeight = height * scaleFactor;
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 (e) {
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
- .then(() => {
3096
- mathElements.forEach(el => {
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
- this._markdown = quikdown_bd.toMarkdown(clonedPanel, {
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, lang) {
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 (err) {
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 (e) {
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 (e) {
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(id);
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 &lt;script src="https://unpkg.com/three@0.147.0/build/three.min.js"&gt;&lt;/script&gt; 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
- // Render after DOM update
3804
- setTimeout(render3D, 0);
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 promises = [];
3901
-
3902
- // Load highlight.js (check if already loaded)
3903
- if (this.options.plugins.highlightjs && !window.hljs) {
3904
- promises.push(
3905
- this.loadScript('https://unpkg.com/@highlightjs/cdn-assets/highlight.min.js'),
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
- // Load mermaid (check if already loaded)
3911
- if (this.options.plugins.mermaid && !window.mermaid) {
3912
- promises.push(
3913
- this.loadScript('https://unpkg.com/mermaid/dist/mermaid.min.js').then(() => {
3914
- if (window.mermaid) {
3915
- mermaid.initialize({ startOnLoad: false });
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
- * Apply theme
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
- // Check system preference
3992
- const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
3993
- this.container.classList.toggle('qde-dark', isDark);
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
- // Remove standalone HR lines (3 or more dashes/underscores/asterisks)
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;