quikdown 1.1.1 → 1.2.2

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 (58) hide show
  1. package/README.md +35 -3
  2. package/dist/quikdown.cjs +5 -5
  3. package/dist/quikdown.dark.css +1 -1
  4. package/dist/quikdown.esm.js +5 -5
  5. package/dist/quikdown.esm.min.js +2 -2
  6. package/dist/quikdown.esm.min.js.map +1 -1
  7. package/dist/quikdown.light.css +1 -1
  8. package/dist/quikdown.umd.js +5 -5
  9. package/dist/quikdown.umd.min.js +2 -2
  10. package/dist/quikdown.umd.min.js.map +1 -1
  11. package/dist/quikdown_ast.cjs +513 -0
  12. package/dist/quikdown_ast.d.ts +227 -0
  13. package/dist/quikdown_ast.esm.js +511 -0
  14. package/dist/quikdown_ast.esm.min.js +8 -0
  15. package/dist/quikdown_ast.esm.min.js.map +1 -0
  16. package/dist/quikdown_ast.umd.js +519 -0
  17. package/dist/quikdown_ast.umd.min.js +8 -0
  18. package/dist/quikdown_ast.umd.min.js.map +1 -0
  19. package/dist/quikdown_ast_html.cjs +1058 -0
  20. package/dist/quikdown_ast_html.d.ts +68 -0
  21. package/dist/quikdown_ast_html.esm.js +1056 -0
  22. package/dist/quikdown_ast_html.esm.min.js +8 -0
  23. package/dist/quikdown_ast_html.esm.min.js.map +1 -0
  24. package/dist/quikdown_ast_html.umd.js +1064 -0
  25. package/dist/quikdown_ast_html.umd.min.js +8 -0
  26. package/dist/quikdown_ast_html.umd.min.js.map +1 -0
  27. package/dist/quikdown_bd.cjs +12 -12
  28. package/dist/quikdown_bd.esm.js +12 -12
  29. package/dist/quikdown_bd.esm.min.js +2 -2
  30. package/dist/quikdown_bd.esm.min.js.map +1 -1
  31. package/dist/quikdown_bd.umd.js +12 -12
  32. package/dist/quikdown_bd.umd.min.js +2 -2
  33. package/dist/quikdown_bd.umd.min.js.map +1 -1
  34. package/dist/quikdown_edit.cjs +434 -58
  35. package/dist/quikdown_edit.d.ts +110 -132
  36. package/dist/quikdown_edit.esm.js +434 -58
  37. package/dist/quikdown_edit.esm.min.js +3 -3
  38. package/dist/quikdown_edit.esm.min.js.map +1 -1
  39. package/dist/quikdown_edit.umd.js +434 -58
  40. package/dist/quikdown_edit.umd.min.js +3 -3
  41. package/dist/quikdown_edit.umd.min.js.map +1 -1
  42. package/dist/quikdown_json.cjs +556 -0
  43. package/dist/quikdown_json.d.ts +48 -0
  44. package/dist/quikdown_json.esm.js +554 -0
  45. package/dist/quikdown_json.esm.min.js +8 -0
  46. package/dist/quikdown_json.esm.min.js.map +1 -0
  47. package/dist/quikdown_json.umd.js +562 -0
  48. package/dist/quikdown_json.umd.min.js +8 -0
  49. package/dist/quikdown_json.umd.min.js.map +1 -0
  50. package/dist/quikdown_yaml.cjs +717 -0
  51. package/dist/quikdown_yaml.d.ts +51 -0
  52. package/dist/quikdown_yaml.esm.js +715 -0
  53. package/dist/quikdown_yaml.esm.min.js +8 -0
  54. package/dist/quikdown_yaml.esm.min.js.map +1 -0
  55. package/dist/quikdown_yaml.umd.js +723 -0
  56. package/dist/quikdown_yaml.umd.min.js +8 -0
  57. package/dist/quikdown_yaml.umd.min.js.map +1 -0
  58. package/package.json +91 -38
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Quikdown Editor - Drop-in Markdown Parser
3
- * @version 1.1.1
3
+ * @version 1.2.2
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.2';
22
22
 
23
23
  // Constants for reuse
24
24
  const CLASS_PREFIX = 'quikdown-';
@@ -268,7 +268,7 @@ function quikdown(markdown, options = {}) {
268
268
  html = '<p>' + html + '</p>';
269
269
  } else {
270
270
  // Standard: two spaces at end of line for line breaks
271
- html = html.replace(/ $/gm, `<br${getAttr('br')}>`);
271
+ html = html.replace(/ {2}$/gm, `<br${getAttr('br')}>`);
272
272
 
273
273
  // Paragraphs (double newlines)
274
274
  // Don't add </p> after block elements (they're not in paragraphs)
@@ -297,7 +297,7 @@ function quikdown(markdown, options = {}) {
297
297
  [/(<\/table>)<\/p>/g, '$1'],
298
298
  [/<p>(<pre[^>]*>)/g, '$1'],
299
299
  [/(<\/pre>)<\/p>/g, '$1'],
300
- [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)<\/p>`, 'g'), '$1']
300
+ [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)</p>`, 'g'), '$1']
301
301
  ];
302
302
 
303
303
  cleanupPatterns.forEach(([pattern, replacement]) => {
@@ -503,7 +503,7 @@ function processLists(text, getAttr, inline_styles, bidirectional) {
503
503
 
504
504
  const lines = text.split('\n');
505
505
  const result = [];
506
- let listStack = []; // Track nested lists
506
+ const listStack = []; // Track nested lists
507
507
 
508
508
  // Helper to escape HTML for data-qd attributes
509
509
  const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
@@ -713,7 +713,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
713
713
 
714
714
  // Process children with context
715
715
  let childContent = '';
716
- for (let child of node.childNodes) {
716
+ for (const child of node.childNodes) {
717
717
  childContent += walkNode(child, { parentTag: tag, ...parentContext });
718
718
  }
719
719
 
@@ -947,7 +947,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
947
947
  let index = 1;
948
948
  const indent = ' '.repeat(depth);
949
949
 
950
- for (let child of listNode.children) {
950
+ for (const child of listNode.children) {
951
951
  if (child.tagName !== 'LI') continue;
952
952
 
953
953
  const dataQd = child.getAttribute('data-qd');
@@ -960,7 +960,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
960
960
  marker = '-';
961
961
  // Get text without the checkbox
962
962
  let text = '';
963
- for (let node of child.childNodes) {
963
+ for (const node of child.childNodes) {
964
964
  if (node.nodeType === Node.TEXT_NODE) {
965
965
  text += node.textContent;
966
966
  } else if (node.tagName && node.tagName !== 'INPUT') {
@@ -971,7 +971,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
971
971
  } else {
972
972
  let itemContent = '';
973
973
 
974
- for (let node of child.childNodes) {
974
+ for (const node of child.childNodes) {
975
975
  if (node.tagName === 'UL' || node.tagName === 'OL') {
976
976
  itemContent += walkList(node, node.tagName === 'OL', depth + 1);
977
977
  } else {
@@ -1000,7 +1000,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
1000
1000
  const headerRow = thead.querySelector('tr');
1001
1001
  if (headerRow) {
1002
1002
  const headers = [];
1003
- for (let th of headerRow.querySelectorAll('th')) {
1003
+ for (const th of headerRow.querySelectorAll('th')) {
1004
1004
  headers.push(th.textContent.trim());
1005
1005
  }
1006
1006
  result += '| ' + headers.join(' | ') + ' |\n';
@@ -1019,9 +1019,9 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
1019
1019
  // Process body
1020
1020
  const tbody = table.querySelector('tbody');
1021
1021
  if (tbody) {
1022
- for (let row of tbody.querySelectorAll('tr')) {
1022
+ for (const row of tbody.querySelectorAll('tr')) {
1023
1023
  const cells = [];
1024
- for (let td of row.querySelectorAll('td')) {
1024
+ for (const td of row.querySelectorAll('td')) {
1025
1025
  cells.push(td.textContent.trim());
1026
1026
  }
1027
1027
  if (cells.length > 0) {
@@ -1925,7 +1925,7 @@ async function getRenderedContent(previewPanel) {
1925
1925
  // First try baseVal.value (works for absolute units)
1926
1926
  width = svg.width.baseVal.value;
1927
1927
  height = svg.height.baseVal.value;
1928
- } catch (e) {
1928
+ } catch (_e) {
1929
1929
  // Fallback for relative units - use viewBox or rendered size
1930
1930
  if (svg.viewBox && svg.viewBox.baseVal) {
1931
1931
  width = svg.viewBox.baseVal.width;
@@ -1944,8 +1944,8 @@ async function getRenderedContent(previewPanel) {
1944
1944
  // Apply aggressive downsizing for MathJax SVGs
1945
1945
  let scaleFactor = 0.04; // Further reduced for smaller output
1946
1946
 
1947
- let scaledWidth = width * scaleFactor;
1948
- let scaledHeight = height * scaleFactor;
1947
+ const scaledWidth = width * scaleFactor;
1948
+ const scaledHeight = height * scaleFactor;
1949
1949
 
1950
1950
  // If still too large after base scaling, scale down further
1951
1951
  if (scaledWidth > targetMaxWidth || scaledHeight > targetMaxHeight) {
@@ -2178,7 +2178,7 @@ async function getRenderedContent(previewPanel) {
2178
2178
  let mapDataUrl = '';
2179
2179
  try {
2180
2180
  mapDataUrl = canvas.toDataURL('image/png', 1.0);
2181
- } catch (e) {
2181
+ } catch (_e) {
2182
2182
  console.warn('Map canvas tainted; falling back to placeholder');
2183
2183
  }
2184
2184
 
@@ -2541,7 +2541,8 @@ async function getRenderedContent(previewPanel) {
2541
2541
  const DEFAULT_OPTIONS = {
2542
2542
  mode: 'split', // 'source' | 'preview' | 'split'
2543
2543
  showToolbar: true,
2544
- showRemoveHR: false, // Show button to remove horizontal rules (---)
2544
+ showRemoveHR: false, // Show button to remove horizontal rules (---)
2545
+ showLazyLinefeeds: false, // Show button to convert lazy linefeeds
2545
2546
  theme: 'auto', // 'light' | 'dark' | 'auto'
2546
2547
  lazy_linefeeds: false,
2547
2548
  inline_styles: false, // Use CSS classes (false) or inline styles (true)
@@ -2552,7 +2553,9 @@ const DEFAULT_OPTIONS = {
2552
2553
  mermaid: false
2553
2554
  },
2554
2555
  customFences: {}, // { 'language': (code, lang) => html }
2555
- enableComplexFences: true // Enable CSV tables, math rendering, SVG, etc.
2556
+ enableComplexFences: true, // Enable CSV tables, math rendering, SVG, etc.
2557
+ showUndoRedo: false, // Show undo/redo toolbar buttons
2558
+ undoStackSize: 100 // Maximum number of undo states to keep
2556
2559
  };
2557
2560
 
2558
2561
  /**
@@ -2577,6 +2580,11 @@ class QuikdownEditor {
2577
2580
  this._html = '';
2578
2581
  this.currentMode = this.options.mode;
2579
2582
  this.updateTimer = null;
2583
+
2584
+ // Undo/redo state
2585
+ this._undoStack = [];
2586
+ this._redoStack = [];
2587
+ this._isUndoRedo = false;
2580
2588
 
2581
2589
  // Initialize
2582
2590
  this.initPromise = this.init();
@@ -2669,6 +2677,23 @@ class QuikdownEditor {
2669
2677
  toolbar.appendChild(btn);
2670
2678
  });
2671
2679
 
2680
+ // Undo/Redo buttons (if enabled)
2681
+ if (this.options.showUndoRedo) {
2682
+ const undoBtn = document.createElement('button');
2683
+ undoBtn.className = 'qde-btn disabled';
2684
+ undoBtn.dataset.action = 'undo';
2685
+ undoBtn.textContent = 'Undo';
2686
+ undoBtn.title = 'Undo (Ctrl+Z)';
2687
+ toolbar.appendChild(undoBtn);
2688
+
2689
+ const redoBtn = document.createElement('button');
2690
+ redoBtn.className = 'qde-btn disabled';
2691
+ redoBtn.dataset.action = 'redo';
2692
+ redoBtn.textContent = 'Redo';
2693
+ redoBtn.title = 'Redo (Ctrl+Shift+Z / Ctrl+Y)';
2694
+ toolbar.appendChild(redoBtn);
2695
+ }
2696
+
2672
2697
  // Spacer
2673
2698
  const spacer = document.createElement('span');
2674
2699
  spacer.className = 'qde-spacer';
@@ -2699,6 +2724,16 @@ class QuikdownEditor {
2699
2724
  removeHRBtn.title = 'Remove all horizontal rules (---) from markdown';
2700
2725
  toolbar.appendChild(removeHRBtn);
2701
2726
  }
2727
+
2728
+ // Lazy linefeeds button (if enabled)
2729
+ if (this.options.showLazyLinefeeds) {
2730
+ const lazyLFBtn = document.createElement('button');
2731
+ lazyLFBtn.className = 'qde-btn';
2732
+ lazyLFBtn.dataset.action = 'lazy-linefeeds';
2733
+ lazyLFBtn.textContent = 'Fix Linefeeds';
2734
+ lazyLFBtn.title = 'Convert single newlines to paragraph breaks (one-time transform)';
2735
+ toolbar.appendChild(lazyLFBtn);
2736
+ }
2702
2737
 
2703
2738
  return toolbar;
2704
2739
  }
@@ -2751,6 +2786,11 @@ class QuikdownEditor {
2751
2786
  color: white;
2752
2787
  border-color: #0056b3;
2753
2788
  }
2789
+
2790
+ .qde-btn.disabled {
2791
+ opacity: 0.4;
2792
+ pointer-events: none;
2793
+ }
2754
2794
 
2755
2795
  .qde-spacer {
2756
2796
  flex: 1;
@@ -3035,6 +3075,21 @@ class QuikdownEditor {
3035
3075
  e.preventDefault();
3036
3076
  this.setMode('preview');
3037
3077
  break;
3078
+ case 'z':
3079
+ case 'Z':
3080
+ if (e.shiftKey) {
3081
+ e.preventDefault();
3082
+ this.redo();
3083
+ } else {
3084
+ e.preventDefault();
3085
+ this.undo();
3086
+ }
3087
+ break;
3088
+ case 'y':
3089
+ case 'Y':
3090
+ e.preventDefault();
3091
+ this.redo();
3092
+ break;
3038
3093
  }
3039
3094
  }
3040
3095
  });
@@ -3064,6 +3119,12 @@ class QuikdownEditor {
3064
3119
  * Update from markdown source
3065
3120
  */
3066
3121
  updateFromMarkdown(markdown) {
3122
+ // Push current state to undo stack before changing (unless this is an undo/redo operation)
3123
+ if (!this._isUndoRedo) {
3124
+ this._pushUndoState(markdown || '');
3125
+ }
3126
+ this._isUndoRedo = false;
3127
+
3067
3128
  this._markdown = markdown || '';
3068
3129
 
3069
3130
  // Show placeholder if empty
@@ -3089,16 +3150,9 @@ class QuikdownEditor {
3089
3150
  if (window.MathJax && window.MathJax.typesetPromise) {
3090
3151
  const mathElements = this.previewPanel.querySelectorAll('.math-display');
3091
3152
  if (mathElements.length > 0) {
3092
- mathElements.forEach(el => {
3093
- });
3094
3153
  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);
3154
+ .catch(_err => {
3155
+ console.warn('MathJax batch processing failed:', _err);
3102
3156
  });
3103
3157
  }
3104
3158
  }
@@ -3335,7 +3389,7 @@ class QuikdownEditor {
3335
3389
  // Remove event handlers
3336
3390
  const walker = document.createTreeWalker(svg, NodeFilter.SHOW_ELEMENT);
3337
3391
  let node;
3338
- while (node = walker.nextNode()) {
3392
+ while ((node = walker.nextNode())) {
3339
3393
  for (let i = node.attributes.length - 1; i >= 0; i--) {
3340
3394
  const attr = node.attributes[i];
3341
3395
  if (attr.name.startsWith('on') || attr.value.includes('javascript:')) {
@@ -3425,7 +3479,7 @@ class QuikdownEditor {
3425
3479
  /**
3426
3480
  * Render math with MathJax (SVG output for better copy support)
3427
3481
  */
3428
- renderMath(code, lang) {
3482
+ renderMath(code, _lang) {
3429
3483
  const id = `math-${Math.random().toString(36).substring(2, 15)}`;
3430
3484
 
3431
3485
  // Create container exactly like squibview
@@ -3548,11 +3602,11 @@ class QuikdownEditor {
3548
3602
 
3549
3603
  html += '</table>';
3550
3604
  return html;
3551
- } catch (err) {
3605
+ } catch (_err) {
3552
3606
  return `<pre data-qd-fence="\`\`\`" data-qd-lang="${lang}" data-qd-source="${escapedCode}">${escapedCode}</pre>`;
3553
3607
  }
3554
3608
  }
3555
-
3609
+
3556
3610
  /**
3557
3611
  * Parse CSV line handling quoted values
3558
3612
  */
@@ -3596,13 +3650,13 @@ class QuikdownEditor {
3596
3650
  try {
3597
3651
  const data = JSON.parse(code);
3598
3652
  toHighlight = JSON.stringify(data, null, 2);
3599
- } catch (e) {
3653
+ } catch (_e) {
3600
3654
  // Use original if not valid JSON
3601
3655
  }
3602
3656
 
3603
3657
  const highlighted = hljs.highlight(toHighlight, { language: 'json' }).value;
3604
3658
  return `<pre class="qde-json" data-qd-fence="\`\`\`" data-qd-lang="${lang}"><code class="hljs language-json">${highlighted}</code></pre>`;
3605
- } catch (e) {
3659
+ } catch (_e) {
3606
3660
  // Fall through if highlighting fails
3607
3661
  }
3608
3662
  }
@@ -3695,7 +3749,7 @@ class QuikdownEditor {
3695
3749
  if (loaded) {
3696
3750
  renderMap();
3697
3751
  } else {
3698
- const element = document.getElementById(id);
3752
+ const element = document.getElementById(mapId + '-container');
3699
3753
  if (element) {
3700
3754
  element.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">Failed to load map library</div>';
3701
3755
  }
@@ -3982,24 +4036,46 @@ class QuikdownEditor {
3982
4036
  }
3983
4037
 
3984
4038
  /**
3985
- * Apply theme
4039
+ * Apply the current theme (based on this.options.theme)
3986
4040
  */
3987
4041
  applyTheme() {
3988
4042
  const theme = this.options.theme;
3989
-
4043
+
4044
+ // Tear down any previous auto-mode listener so we don't stack them
4045
+ if (this._autoThemeListener) {
4046
+ window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this._autoThemeListener);
4047
+ this._autoThemeListener = null;
4048
+ }
4049
+
3990
4050
  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) => {
4051
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
4052
+ this.container.classList.toggle('qde-dark', mq.matches);
4053
+ this._autoThemeListener = (e) => {
3997
4054
  this.container.classList.toggle('qde-dark', e.matches);
3998
- });
4055
+ };
4056
+ mq.addEventListener('change', this._autoThemeListener);
3999
4057
  } else {
4000
4058
  this.container.classList.toggle('qde-dark', theme === 'dark');
4001
4059
  }
4002
4060
  }
4061
+
4062
+ /**
4063
+ * Set theme at runtime. Accepts 'light', 'dark', or 'auto'.
4064
+ * @param {'light'|'dark'|'auto'} theme
4065
+ */
4066
+ setTheme(theme) {
4067
+ if (!['light', 'dark', 'auto'].includes(theme)) return;
4068
+ this.options.theme = theme;
4069
+ this.applyTheme();
4070
+ }
4071
+
4072
+ /**
4073
+ * Get the current theme option (as configured, not resolved).
4074
+ * @returns {'light'|'dark'|'auto'}
4075
+ */
4076
+ getTheme() {
4077
+ return this.options.theme;
4078
+ }
4003
4079
 
4004
4080
  /**
4005
4081
  * Set lazy linefeeds option
@@ -4069,6 +4145,105 @@ class QuikdownEditor {
4069
4145
  }
4070
4146
  }
4071
4147
 
4148
+ // --- Undo / Redo ---
4149
+
4150
+ /**
4151
+ * Push current markdown state onto the undo stack (called before a change).
4152
+ * Only pushes if the new state differs from the current state.
4153
+ * @param {string} newMarkdown - the incoming markdown (used to detect no-op)
4154
+ * @private
4155
+ */
4156
+ _pushUndoState(newMarkdown) {
4157
+ // Don't push if the content hasn't actually changed
4158
+ if (newMarkdown === this._markdown) return;
4159
+
4160
+ this._undoStack.push(this._markdown);
4161
+
4162
+ // Enforce max stack size
4163
+ const max = this.options.undoStackSize || 100;
4164
+ if (this._undoStack.length > max) {
4165
+ this._undoStack.splice(0, this._undoStack.length - max);
4166
+ }
4167
+
4168
+ // Any new edit clears the redo stack
4169
+ this._redoStack = [];
4170
+ this._updateUndoButtons();
4171
+ }
4172
+
4173
+ /**
4174
+ * Undo the last change. Restores the previous markdown state.
4175
+ */
4176
+ undo() {
4177
+ if (!this.canUndo()) return;
4178
+ // Save current state to redo stack
4179
+ this._redoStack.push(this._markdown);
4180
+ const previous = this._undoStack.pop();
4181
+ this._isUndoRedo = true;
4182
+ // Update state directly (setMarkdown is async; keep it synchronous here)
4183
+ this._markdown = previous;
4184
+ if (this.sourceTextarea) {
4185
+ this.sourceTextarea.value = previous;
4186
+ }
4187
+ this.updateFromMarkdown(previous);
4188
+ this._updateUndoButtons();
4189
+ }
4190
+
4191
+ /**
4192
+ * Redo the last undone change.
4193
+ */
4194
+ redo() {
4195
+ if (!this.canRedo()) return;
4196
+ // Save current state to undo stack
4197
+ this._undoStack.push(this._markdown);
4198
+ const next = this._redoStack.pop();
4199
+ this._isUndoRedo = true;
4200
+ this._markdown = next;
4201
+ if (this.sourceTextarea) {
4202
+ this.sourceTextarea.value = next;
4203
+ }
4204
+ this.updateFromMarkdown(next);
4205
+ this._updateUndoButtons();
4206
+ }
4207
+
4208
+ /**
4209
+ * @returns {boolean} true if undo is possible
4210
+ */
4211
+ canUndo() {
4212
+ return this._undoStack.length > 0;
4213
+ }
4214
+
4215
+ /**
4216
+ * @returns {boolean} true if redo is possible
4217
+ */
4218
+ canRedo() {
4219
+ return this._redoStack.length > 0;
4220
+ }
4221
+
4222
+ /**
4223
+ * Clear the undo and redo history.
4224
+ */
4225
+ clearHistory() {
4226
+ this._undoStack = [];
4227
+ this._redoStack = [];
4228
+ this._updateUndoButtons();
4229
+ }
4230
+
4231
+ /**
4232
+ * Update the disabled state of the undo/redo toolbar buttons.
4233
+ * @private
4234
+ */
4235
+ _updateUndoButtons() {
4236
+ if (!this.toolbar) return;
4237
+ const undoBtn = this.toolbar.querySelector('[data-action="undo"]');
4238
+ const redoBtn = this.toolbar.querySelector('[data-action="redo"]');
4239
+ if (undoBtn) {
4240
+ undoBtn.classList.toggle('disabled', !this.canUndo());
4241
+ }
4242
+ if (redoBtn) {
4243
+ redoBtn.classList.toggle('disabled', !this.canRedo());
4244
+ }
4245
+ }
4246
+
4072
4247
  /**
4073
4248
  * Handle toolbar actions
4074
4249
  */
@@ -4086,6 +4261,15 @@ class QuikdownEditor {
4086
4261
  case 'remove-hr':
4087
4262
  this.removeHR();
4088
4263
  break;
4264
+ case 'lazy-linefeeds':
4265
+ this.convertLazyLinefeeds();
4266
+ break;
4267
+ case 'undo':
4268
+ this.undo();
4269
+ break;
4270
+ case 'redo':
4271
+ this.redo();
4272
+ break;
4089
4273
  }
4090
4274
  }
4091
4275
 
@@ -4173,24 +4357,13 @@ class QuikdownEditor {
4173
4357
  }
4174
4358
 
4175
4359
  /**
4176
- * Remove all horizontal rules (---) from markdown
4360
+ * Remove all horizontal rules (---) from markdown source.
4361
+ * Preserves content inside fences (``` or ~~~) and table separator rows.
4177
4362
  */
4178
4363
  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
4364
+ const cleaned = QuikdownEditor.removeHRFromMarkdown(this._markdown);
4192
4365
  await this.setMarkdown(cleaned);
4193
-
4366
+
4194
4367
  // Visual feedback if toolbar button exists
4195
4368
  const btn = this.toolbar?.querySelector('[data-action="remove-hr"]');
4196
4369
  if (btn) {
@@ -4201,6 +4374,202 @@ class QuikdownEditor {
4201
4374
  }, 1500);
4202
4375
  }
4203
4376
  }
4377
+
4378
+ /**
4379
+ * Static: remove horizontal rules from markdown string.
4380
+ * Safe for fences, tables, and all markdown constructs.
4381
+ * Can be used headless without an editor instance.
4382
+ * @param {string} markdown - source markdown
4383
+ * @returns {string} markdown with standalone HRs removed
4384
+ */
4385
+ static removeHRFromMarkdown(markdown) {
4386
+ const lines = (markdown || '').split('\n');
4387
+ const result = [];
4388
+ let inFence = false;
4389
+ let fenceChar = null; // '`' or '~'
4390
+ let fenceLen = 0; // length of opening fence marker
4391
+
4392
+ for (let i = 0; i < lines.length; i++) {
4393
+ const line = lines[i];
4394
+ const trimmed = line.trim();
4395
+
4396
+ // Track fence open/close (``` or ~~~, 3+ chars)
4397
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
4398
+ if (fenceMatch) {
4399
+ const matchChar = fenceMatch[1][0];
4400
+ const matchLen = fenceMatch[1].length;
4401
+ if (!inFence) {
4402
+ inFence = true;
4403
+ fenceChar = matchChar;
4404
+ fenceLen = matchLen;
4405
+ result.push(line);
4406
+ continue;
4407
+ } else if (matchChar === fenceChar && matchLen >= fenceLen && /^(`{3,}|~{3,})\s*$/.test(trimmed)) {
4408
+ // Closing fence: same char, at least as many chars, no trailing content
4409
+ inFence = false;
4410
+ fenceChar = null;
4411
+ fenceLen = 0;
4412
+ result.push(line);
4413
+ continue;
4414
+ }
4415
+ }
4416
+
4417
+ // Inside a fence — keep everything
4418
+ if (inFence) {
4419
+ result.push(line);
4420
+ continue;
4421
+ }
4422
+
4423
+ // Detect table row/separator with pipes — always keep
4424
+ if (/^\|.*\|$/.test(trimmed) || (/^[-| :]+$/.test(trimmed) && trimmed.includes('|'))) {
4425
+ result.push(line);
4426
+ continue;
4427
+ }
4428
+
4429
+ // Check if this line is a standalone HR
4430
+ const isHR = /^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed);
4431
+ if (isHR) {
4432
+ // Table separator heuristic: immediately adjacent lines (no blank
4433
+ // lines between) that look like table rows protect this HR-like line
4434
+ const prevLine = i > 0 ? lines[i - 1].trim() : '';
4435
+ const nextLine = i < lines.length - 1 ? lines[i + 1].trim() : '';
4436
+ if (_looksLikeTableRow(prevLine) || _looksLikeTableRow(nextLine)) {
4437
+ result.push(line);
4438
+ continue;
4439
+ }
4440
+ // It's a real HR — skip it
4441
+ continue;
4442
+ }
4443
+
4444
+ result.push(line);
4445
+ }
4446
+
4447
+ return result.join('\n');
4448
+ }
4449
+
4450
+ /**
4451
+ * Convert lazy linefeeds in markdown source.
4452
+ * Replaces single newlines with double newlines (adds real line breaks)
4453
+ * except inside fences, tables, and other block-level constructs.
4454
+ * Idempotent: calling multiple times produces the same result.
4455
+ * Can be used as a toolbar action or headless via the static method.
4456
+ */
4457
+ async convertLazyLinefeeds() {
4458
+ const converted = QuikdownEditor.convertLazyLinefeeds(this._markdown);
4459
+ await this.setMarkdown(converted);
4460
+
4461
+ // Visual feedback if toolbar button exists
4462
+ const btn = this.toolbar?.querySelector('[data-action="lazy-linefeeds"]');
4463
+ if (btn) {
4464
+ const originalText = btn.textContent;
4465
+ btn.textContent = 'Converted!';
4466
+ setTimeout(() => {
4467
+ btn.textContent = originalText;
4468
+ }, 1500);
4469
+ }
4470
+ }
4471
+
4472
+ /**
4473
+ * Static: convert lazy linefeeds in markdown source.
4474
+ * Turns single \n between non-blank lines into \n\n so each line becomes
4475
+ * its own paragraph / hard break. Idempotent — already-doubled newlines
4476
+ * are not doubled again. Fences, tables, lists, blockquotes, headings,
4477
+ * and HTML blocks are left untouched.
4478
+ * @param {string} markdown - source markdown
4479
+ * @returns {string} markdown with lazy linefeeds resolved
4480
+ */
4481
+ static convertLazyLinefeeds(markdown) {
4482
+ const lines = (markdown || '').split('\n');
4483
+ const result = [];
4484
+ let inFence = false;
4485
+ let fenceChar = null;
4486
+ let fenceLen = 0;
4487
+ let inHTMLBlock = false;
4488
+
4489
+ for (let i = 0; i < lines.length; i++) {
4490
+ const line = lines[i];
4491
+ const trimmed = line.trim();
4492
+
4493
+ // Track fence open/close
4494
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
4495
+ if (fenceMatch) {
4496
+ const matchChar = fenceMatch[1][0];
4497
+ const matchLen = fenceMatch[1].length;
4498
+ if (!inFence) {
4499
+ inFence = true;
4500
+ fenceChar = matchChar;
4501
+ fenceLen = matchLen;
4502
+ result.push(line);
4503
+ continue;
4504
+ } else if (matchChar === fenceChar && matchLen >= fenceLen && /^(`{3,}|~{3,})\s*$/.test(trimmed)) {
4505
+ inFence = false;
4506
+ fenceChar = null;
4507
+ fenceLen = 0;
4508
+ result.push(line);
4509
+ continue;
4510
+ }
4511
+ }
4512
+
4513
+ // Inside fence — pass through
4514
+ if (inFence) {
4515
+ result.push(line);
4516
+ continue;
4517
+ }
4518
+
4519
+ // Track HTML blocks (lines starting with < and ending with >)
4520
+ if (/^<[a-zA-Z]/.test(trimmed)) inHTMLBlock = true;
4521
+ if (inHTMLBlock) {
4522
+ result.push(line);
4523
+ if (/>$/.test(trimmed) || trimmed === '') inHTMLBlock = false;
4524
+ continue;
4525
+ }
4526
+
4527
+ // Always pass through blank lines, but never add extras
4528
+ if (trimmed === '') {
4529
+ // Avoid doubling: don't add blank line if the last result line is already blank
4530
+ if (result.length === 0 || result[result.length - 1].trim() !== '') {
4531
+ result.push(line);
4532
+ }
4533
+ continue;
4534
+ }
4535
+
4536
+ // Skip conversion for block-level constructs
4537
+ const isBlockElement = (
4538
+ /^#{1,6}\s/.test(trimmed) || // headings
4539
+ /^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed) || // horizontal rules
4540
+ /^(\d+\.|-|\*|\+)\s/.test(trimmed) || // list items
4541
+ /^>/.test(trimmed) || // blockquotes
4542
+ /^\|/.test(trimmed) // table rows
4543
+ );
4544
+
4545
+ if (isBlockElement) {
4546
+ result.push(line);
4547
+ continue;
4548
+ }
4549
+
4550
+ // For plain paragraph text: if previous result line is non-blank
4551
+ // plain text, insert a blank line between them (making the single
4552
+ // newline into a paragraph break). This is the lazy→strict conversion.
4553
+ if (result.length > 0) {
4554
+ const prevLine = result[result.length - 1];
4555
+ const prevTrimmed = prevLine.trim();
4556
+ // Only insert blank line if prev is non-blank, non-block text
4557
+ if (prevTrimmed !== '' &&
4558
+ !/^#{1,6}\s/.test(prevTrimmed) &&
4559
+ !/^[-_*](\s*[-_*]){2,}\s*$/.test(prevTrimmed) &&
4560
+ !/^(\d+\.|-|\*|\+)\s/.test(prevTrimmed) &&
4561
+ !/^>/.test(prevTrimmed) &&
4562
+ !/^\|/.test(prevTrimmed) &&
4563
+ !/^(`{3,}|~{3,})/.test(prevTrimmed)) {
4564
+ result.push('');
4565
+ }
4566
+ }
4567
+
4568
+ result.push(line);
4569
+ }
4570
+
4571
+ return result.join('\n');
4572
+ }
4204
4573
 
4205
4574
  /**
4206
4575
  * Copy rendered content as rich text
@@ -4244,6 +4613,13 @@ class QuikdownEditor {
4244
4613
  }
4245
4614
  }
4246
4615
 
4616
+ // --- Internal helpers for removeHR fence/table awareness ---
4617
+
4618
+ /** Heuristic: does this line look like a markdown table row? */
4619
+ function _looksLikeTableRow(line) {
4620
+ return line.includes('|');
4621
+ }
4622
+
4247
4623
  // Export for CommonJS (needed for bundled ESM to work with Jest)
4248
4624
  if (typeof module !== 'undefined' && module.exports) {
4249
4625
  module.exports = QuikdownEditor;