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