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
  */
@@ -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.2';
28
28
 
29
29
  // Constants for reuse
30
30
  const CLASS_PREFIX = 'quikdown-';
@@ -274,7 +274,7 @@
274
274
  html = '<p>' + html + '</p>';
275
275
  } else {
276
276
  // Standard: two spaces at end of line for line breaks
277
- html = html.replace(/ $/gm, `<br${getAttr('br')}>`);
277
+ html = html.replace(/ {2}$/gm, `<br${getAttr('br')}>`);
278
278
 
279
279
  // Paragraphs (double newlines)
280
280
  // Don't add </p> after block elements (they're not in paragraphs)
@@ -303,7 +303,7 @@
303
303
  [/(<\/table>)<\/p>/g, '$1'],
304
304
  [/<p>(<pre[^>]*>)/g, '$1'],
305
305
  [/(<\/pre>)<\/p>/g, '$1'],
306
- [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)<\/p>`, 'g'), '$1']
306
+ [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)</p>`, 'g'), '$1']
307
307
  ];
308
308
 
309
309
  cleanupPatterns.forEach(([pattern, replacement]) => {
@@ -509,7 +509,7 @@
509
509
 
510
510
  const lines = text.split('\n');
511
511
  const result = [];
512
- let listStack = []; // Track nested lists
512
+ const listStack = []; // Track nested lists
513
513
 
514
514
  // Helper to escape HTML for data-qd attributes
515
515
  const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
@@ -719,7 +719,7 @@
719
719
 
720
720
  // Process children with context
721
721
  let childContent = '';
722
- for (let child of node.childNodes) {
722
+ for (const child of node.childNodes) {
723
723
  childContent += walkNode(child, { parentTag: tag, ...parentContext });
724
724
  }
725
725
 
@@ -953,7 +953,7 @@
953
953
  let index = 1;
954
954
  const indent = ' '.repeat(depth);
955
955
 
956
- for (let child of listNode.children) {
956
+ for (const child of listNode.children) {
957
957
  if (child.tagName !== 'LI') continue;
958
958
 
959
959
  const dataQd = child.getAttribute('data-qd');
@@ -966,7 +966,7 @@
966
966
  marker = '-';
967
967
  // Get text without the checkbox
968
968
  let text = '';
969
- for (let node of child.childNodes) {
969
+ for (const node of child.childNodes) {
970
970
  if (node.nodeType === Node.TEXT_NODE) {
971
971
  text += node.textContent;
972
972
  } else if (node.tagName && node.tagName !== 'INPUT') {
@@ -977,7 +977,7 @@
977
977
  } else {
978
978
  let itemContent = '';
979
979
 
980
- for (let node of child.childNodes) {
980
+ for (const node of child.childNodes) {
981
981
  if (node.tagName === 'UL' || node.tagName === 'OL') {
982
982
  itemContent += walkList(node, node.tagName === 'OL', depth + 1);
983
983
  } else {
@@ -1006,7 +1006,7 @@
1006
1006
  const headerRow = thead.querySelector('tr');
1007
1007
  if (headerRow) {
1008
1008
  const headers = [];
1009
- for (let th of headerRow.querySelectorAll('th')) {
1009
+ for (const th of headerRow.querySelectorAll('th')) {
1010
1010
  headers.push(th.textContent.trim());
1011
1011
  }
1012
1012
  result += '| ' + headers.join(' | ') + ' |\n';
@@ -1025,9 +1025,9 @@
1025
1025
  // Process body
1026
1026
  const tbody = table.querySelector('tbody');
1027
1027
  if (tbody) {
1028
- for (let row of tbody.querySelectorAll('tr')) {
1028
+ for (const row of tbody.querySelectorAll('tr')) {
1029
1029
  const cells = [];
1030
- for (let td of row.querySelectorAll('td')) {
1030
+ for (const td of row.querySelectorAll('td')) {
1031
1031
  cells.push(td.textContent.trim());
1032
1032
  }
1033
1033
  if (cells.length > 0) {
@@ -1931,7 +1931,7 @@
1931
1931
  // First try baseVal.value (works for absolute units)
1932
1932
  width = svg.width.baseVal.value;
1933
1933
  height = svg.height.baseVal.value;
1934
- } catch (e) {
1934
+ } catch (_e) {
1935
1935
  // Fallback for relative units - use viewBox or rendered size
1936
1936
  if (svg.viewBox && svg.viewBox.baseVal) {
1937
1937
  width = svg.viewBox.baseVal.width;
@@ -1950,8 +1950,8 @@
1950
1950
  // Apply aggressive downsizing for MathJax SVGs
1951
1951
  let scaleFactor = 0.04; // Further reduced for smaller output
1952
1952
 
1953
- let scaledWidth = width * scaleFactor;
1954
- let scaledHeight = height * scaleFactor;
1953
+ const scaledWidth = width * scaleFactor;
1954
+ const scaledHeight = height * scaleFactor;
1955
1955
 
1956
1956
  // If still too large after base scaling, scale down further
1957
1957
  if (scaledWidth > targetMaxWidth || scaledHeight > targetMaxHeight) {
@@ -2184,7 +2184,7 @@
2184
2184
  let mapDataUrl = '';
2185
2185
  try {
2186
2186
  mapDataUrl = canvas.toDataURL('image/png', 1.0);
2187
- } catch (e) {
2187
+ } catch (_e) {
2188
2188
  console.warn('Map canvas tainted; falling back to placeholder');
2189
2189
  }
2190
2190
 
@@ -2547,7 +2547,8 @@
2547
2547
  const DEFAULT_OPTIONS = {
2548
2548
  mode: 'split', // 'source' | 'preview' | 'split'
2549
2549
  showToolbar: true,
2550
- showRemoveHR: false, // Show button to remove horizontal rules (---)
2550
+ showRemoveHR: false, // Show button to remove horizontal rules (---)
2551
+ showLazyLinefeeds: false, // Show button to convert lazy linefeeds
2551
2552
  theme: 'auto', // 'light' | 'dark' | 'auto'
2552
2553
  lazy_linefeeds: false,
2553
2554
  inline_styles: false, // Use CSS classes (false) or inline styles (true)
@@ -2558,7 +2559,9 @@
2558
2559
  mermaid: false
2559
2560
  },
2560
2561
  customFences: {}, // { 'language': (code, lang) => html }
2561
- enableComplexFences: true // Enable CSV tables, math rendering, SVG, etc.
2562
+ enableComplexFences: true, // Enable CSV tables, math rendering, SVG, etc.
2563
+ showUndoRedo: false, // Show undo/redo toolbar buttons
2564
+ undoStackSize: 100 // Maximum number of undo states to keep
2562
2565
  };
2563
2566
 
2564
2567
  /**
@@ -2583,6 +2586,11 @@
2583
2586
  this._html = '';
2584
2587
  this.currentMode = this.options.mode;
2585
2588
  this.updateTimer = null;
2589
+
2590
+ // Undo/redo state
2591
+ this._undoStack = [];
2592
+ this._redoStack = [];
2593
+ this._isUndoRedo = false;
2586
2594
 
2587
2595
  // Initialize
2588
2596
  this.initPromise = this.init();
@@ -2675,6 +2683,23 @@
2675
2683
  toolbar.appendChild(btn);
2676
2684
  });
2677
2685
 
2686
+ // Undo/Redo buttons (if enabled)
2687
+ if (this.options.showUndoRedo) {
2688
+ const undoBtn = document.createElement('button');
2689
+ undoBtn.className = 'qde-btn disabled';
2690
+ undoBtn.dataset.action = 'undo';
2691
+ undoBtn.textContent = 'Undo';
2692
+ undoBtn.title = 'Undo (Ctrl+Z)';
2693
+ toolbar.appendChild(undoBtn);
2694
+
2695
+ const redoBtn = document.createElement('button');
2696
+ redoBtn.className = 'qde-btn disabled';
2697
+ redoBtn.dataset.action = 'redo';
2698
+ redoBtn.textContent = 'Redo';
2699
+ redoBtn.title = 'Redo (Ctrl+Shift+Z / Ctrl+Y)';
2700
+ toolbar.appendChild(redoBtn);
2701
+ }
2702
+
2678
2703
  // Spacer
2679
2704
  const spacer = document.createElement('span');
2680
2705
  spacer.className = 'qde-spacer';
@@ -2705,6 +2730,16 @@
2705
2730
  removeHRBtn.title = 'Remove all horizontal rules (---) from markdown';
2706
2731
  toolbar.appendChild(removeHRBtn);
2707
2732
  }
2733
+
2734
+ // Lazy linefeeds button (if enabled)
2735
+ if (this.options.showLazyLinefeeds) {
2736
+ const lazyLFBtn = document.createElement('button');
2737
+ lazyLFBtn.className = 'qde-btn';
2738
+ lazyLFBtn.dataset.action = 'lazy-linefeeds';
2739
+ lazyLFBtn.textContent = 'Fix Linefeeds';
2740
+ lazyLFBtn.title = 'Convert single newlines to paragraph breaks (one-time transform)';
2741
+ toolbar.appendChild(lazyLFBtn);
2742
+ }
2708
2743
 
2709
2744
  return toolbar;
2710
2745
  }
@@ -2757,6 +2792,11 @@
2757
2792
  color: white;
2758
2793
  border-color: #0056b3;
2759
2794
  }
2795
+
2796
+ .qde-btn.disabled {
2797
+ opacity: 0.4;
2798
+ pointer-events: none;
2799
+ }
2760
2800
 
2761
2801
  .qde-spacer {
2762
2802
  flex: 1;
@@ -3041,6 +3081,21 @@
3041
3081
  e.preventDefault();
3042
3082
  this.setMode('preview');
3043
3083
  break;
3084
+ case 'z':
3085
+ case 'Z':
3086
+ if (e.shiftKey) {
3087
+ e.preventDefault();
3088
+ this.redo();
3089
+ } else {
3090
+ e.preventDefault();
3091
+ this.undo();
3092
+ }
3093
+ break;
3094
+ case 'y':
3095
+ case 'Y':
3096
+ e.preventDefault();
3097
+ this.redo();
3098
+ break;
3044
3099
  }
3045
3100
  }
3046
3101
  });
@@ -3070,6 +3125,12 @@
3070
3125
  * Update from markdown source
3071
3126
  */
3072
3127
  updateFromMarkdown(markdown) {
3128
+ // Push current state to undo stack before changing (unless this is an undo/redo operation)
3129
+ if (!this._isUndoRedo) {
3130
+ this._pushUndoState(markdown || '');
3131
+ }
3132
+ this._isUndoRedo = false;
3133
+
3073
3134
  this._markdown = markdown || '';
3074
3135
 
3075
3136
  // Show placeholder if empty
@@ -3095,16 +3156,9 @@
3095
3156
  if (window.MathJax && window.MathJax.typesetPromise) {
3096
3157
  const mathElements = this.previewPanel.querySelectorAll('.math-display');
3097
3158
  if (mathElements.length > 0) {
3098
- mathElements.forEach(el => {
3099
- });
3100
3159
  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);
3160
+ .catch(_err => {
3161
+ console.warn('MathJax batch processing failed:', _err);
3108
3162
  });
3109
3163
  }
3110
3164
  }
@@ -3341,7 +3395,7 @@
3341
3395
  // Remove event handlers
3342
3396
  const walker = document.createTreeWalker(svg, NodeFilter.SHOW_ELEMENT);
3343
3397
  let node;
3344
- while (node = walker.nextNode()) {
3398
+ while ((node = walker.nextNode())) {
3345
3399
  for (let i = node.attributes.length - 1; i >= 0; i--) {
3346
3400
  const attr = node.attributes[i];
3347
3401
  if (attr.name.startsWith('on') || attr.value.includes('javascript:')) {
@@ -3431,7 +3485,7 @@
3431
3485
  /**
3432
3486
  * Render math with MathJax (SVG output for better copy support)
3433
3487
  */
3434
- renderMath(code, lang) {
3488
+ renderMath(code, _lang) {
3435
3489
  const id = `math-${Math.random().toString(36).substring(2, 15)}`;
3436
3490
 
3437
3491
  // Create container exactly like squibview
@@ -3554,11 +3608,11 @@
3554
3608
 
3555
3609
  html += '</table>';
3556
3610
  return html;
3557
- } catch (err) {
3611
+ } catch (_err) {
3558
3612
  return `<pre data-qd-fence="\`\`\`" data-qd-lang="${lang}" data-qd-source="${escapedCode}">${escapedCode}</pre>`;
3559
3613
  }
3560
3614
  }
3561
-
3615
+
3562
3616
  /**
3563
3617
  * Parse CSV line handling quoted values
3564
3618
  */
@@ -3602,13 +3656,13 @@
3602
3656
  try {
3603
3657
  const data = JSON.parse(code);
3604
3658
  toHighlight = JSON.stringify(data, null, 2);
3605
- } catch (e) {
3659
+ } catch (_e) {
3606
3660
  // Use original if not valid JSON
3607
3661
  }
3608
3662
 
3609
3663
  const highlighted = hljs.highlight(toHighlight, { language: 'json' }).value;
3610
3664
  return `<pre class="qde-json" data-qd-fence="\`\`\`" data-qd-lang="${lang}"><code class="hljs language-json">${highlighted}</code></pre>`;
3611
- } catch (e) {
3665
+ } catch (_e) {
3612
3666
  // Fall through if highlighting fails
3613
3667
  }
3614
3668
  }
@@ -3701,7 +3755,7 @@
3701
3755
  if (loaded) {
3702
3756
  renderMap();
3703
3757
  } else {
3704
- const element = document.getElementById(id);
3758
+ const element = document.getElementById(mapId + '-container');
3705
3759
  if (element) {
3706
3760
  element.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">Failed to load map library</div>';
3707
3761
  }
@@ -3988,24 +4042,46 @@
3988
4042
  }
3989
4043
 
3990
4044
  /**
3991
- * Apply theme
4045
+ * Apply the current theme (based on this.options.theme)
3992
4046
  */
3993
4047
  applyTheme() {
3994
4048
  const theme = this.options.theme;
3995
-
4049
+
4050
+ // Tear down any previous auto-mode listener so we don't stack them
4051
+ if (this._autoThemeListener) {
4052
+ window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this._autoThemeListener);
4053
+ this._autoThemeListener = null;
4054
+ }
4055
+
3996
4056
  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) => {
4057
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
4058
+ this.container.classList.toggle('qde-dark', mq.matches);
4059
+ this._autoThemeListener = (e) => {
4003
4060
  this.container.classList.toggle('qde-dark', e.matches);
4004
- });
4061
+ };
4062
+ mq.addEventListener('change', this._autoThemeListener);
4005
4063
  } else {
4006
4064
  this.container.classList.toggle('qde-dark', theme === 'dark');
4007
4065
  }
4008
4066
  }
4067
+
4068
+ /**
4069
+ * Set theme at runtime. Accepts 'light', 'dark', or 'auto'.
4070
+ * @param {'light'|'dark'|'auto'} theme
4071
+ */
4072
+ setTheme(theme) {
4073
+ if (!['light', 'dark', 'auto'].includes(theme)) return;
4074
+ this.options.theme = theme;
4075
+ this.applyTheme();
4076
+ }
4077
+
4078
+ /**
4079
+ * Get the current theme option (as configured, not resolved).
4080
+ * @returns {'light'|'dark'|'auto'}
4081
+ */
4082
+ getTheme() {
4083
+ return this.options.theme;
4084
+ }
4009
4085
 
4010
4086
  /**
4011
4087
  * Set lazy linefeeds option
@@ -4075,6 +4151,105 @@
4075
4151
  }
4076
4152
  }
4077
4153
 
4154
+ // --- Undo / Redo ---
4155
+
4156
+ /**
4157
+ * Push current markdown state onto the undo stack (called before a change).
4158
+ * Only pushes if the new state differs from the current state.
4159
+ * @param {string} newMarkdown - the incoming markdown (used to detect no-op)
4160
+ * @private
4161
+ */
4162
+ _pushUndoState(newMarkdown) {
4163
+ // Don't push if the content hasn't actually changed
4164
+ if (newMarkdown === this._markdown) return;
4165
+
4166
+ this._undoStack.push(this._markdown);
4167
+
4168
+ // Enforce max stack size
4169
+ const max = this.options.undoStackSize || 100;
4170
+ if (this._undoStack.length > max) {
4171
+ this._undoStack.splice(0, this._undoStack.length - max);
4172
+ }
4173
+
4174
+ // Any new edit clears the redo stack
4175
+ this._redoStack = [];
4176
+ this._updateUndoButtons();
4177
+ }
4178
+
4179
+ /**
4180
+ * Undo the last change. Restores the previous markdown state.
4181
+ */
4182
+ undo() {
4183
+ if (!this.canUndo()) return;
4184
+ // Save current state to redo stack
4185
+ this._redoStack.push(this._markdown);
4186
+ const previous = this._undoStack.pop();
4187
+ this._isUndoRedo = true;
4188
+ // Update state directly (setMarkdown is async; keep it synchronous here)
4189
+ this._markdown = previous;
4190
+ if (this.sourceTextarea) {
4191
+ this.sourceTextarea.value = previous;
4192
+ }
4193
+ this.updateFromMarkdown(previous);
4194
+ this._updateUndoButtons();
4195
+ }
4196
+
4197
+ /**
4198
+ * Redo the last undone change.
4199
+ */
4200
+ redo() {
4201
+ if (!this.canRedo()) return;
4202
+ // Save current state to undo stack
4203
+ this._undoStack.push(this._markdown);
4204
+ const next = this._redoStack.pop();
4205
+ this._isUndoRedo = true;
4206
+ this._markdown = next;
4207
+ if (this.sourceTextarea) {
4208
+ this.sourceTextarea.value = next;
4209
+ }
4210
+ this.updateFromMarkdown(next);
4211
+ this._updateUndoButtons();
4212
+ }
4213
+
4214
+ /**
4215
+ * @returns {boolean} true if undo is possible
4216
+ */
4217
+ canUndo() {
4218
+ return this._undoStack.length > 0;
4219
+ }
4220
+
4221
+ /**
4222
+ * @returns {boolean} true if redo is possible
4223
+ */
4224
+ canRedo() {
4225
+ return this._redoStack.length > 0;
4226
+ }
4227
+
4228
+ /**
4229
+ * Clear the undo and redo history.
4230
+ */
4231
+ clearHistory() {
4232
+ this._undoStack = [];
4233
+ this._redoStack = [];
4234
+ this._updateUndoButtons();
4235
+ }
4236
+
4237
+ /**
4238
+ * Update the disabled state of the undo/redo toolbar buttons.
4239
+ * @private
4240
+ */
4241
+ _updateUndoButtons() {
4242
+ if (!this.toolbar) return;
4243
+ const undoBtn = this.toolbar.querySelector('[data-action="undo"]');
4244
+ const redoBtn = this.toolbar.querySelector('[data-action="redo"]');
4245
+ if (undoBtn) {
4246
+ undoBtn.classList.toggle('disabled', !this.canUndo());
4247
+ }
4248
+ if (redoBtn) {
4249
+ redoBtn.classList.toggle('disabled', !this.canRedo());
4250
+ }
4251
+ }
4252
+
4078
4253
  /**
4079
4254
  * Handle toolbar actions
4080
4255
  */
@@ -4092,6 +4267,15 @@
4092
4267
  case 'remove-hr':
4093
4268
  this.removeHR();
4094
4269
  break;
4270
+ case 'lazy-linefeeds':
4271
+ this.convertLazyLinefeeds();
4272
+ break;
4273
+ case 'undo':
4274
+ this.undo();
4275
+ break;
4276
+ case 'redo':
4277
+ this.redo();
4278
+ break;
4095
4279
  }
4096
4280
  }
4097
4281
 
@@ -4179,24 +4363,13 @@
4179
4363
  }
4180
4364
 
4181
4365
  /**
4182
- * Remove all horizontal rules (---) from markdown
4366
+ * Remove all horizontal rules (---) from markdown source.
4367
+ * Preserves content inside fences (``` or ~~~) and table separator rows.
4183
4368
  */
4184
4369
  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
4370
+ const cleaned = QuikdownEditor.removeHRFromMarkdown(this._markdown);
4198
4371
  await this.setMarkdown(cleaned);
4199
-
4372
+
4200
4373
  // Visual feedback if toolbar button exists
4201
4374
  const btn = this.toolbar?.querySelector('[data-action="remove-hr"]');
4202
4375
  if (btn) {
@@ -4207,6 +4380,202 @@
4207
4380
  }, 1500);
4208
4381
  }
4209
4382
  }
4383
+
4384
+ /**
4385
+ * Static: remove horizontal rules from markdown string.
4386
+ * Safe for fences, tables, and all markdown constructs.
4387
+ * Can be used headless without an editor instance.
4388
+ * @param {string} markdown - source markdown
4389
+ * @returns {string} markdown with standalone HRs removed
4390
+ */
4391
+ static removeHRFromMarkdown(markdown) {
4392
+ const lines = (markdown || '').split('\n');
4393
+ const result = [];
4394
+ let inFence = false;
4395
+ let fenceChar = null; // '`' or '~'
4396
+ let fenceLen = 0; // length of opening fence marker
4397
+
4398
+ for (let i = 0; i < lines.length; i++) {
4399
+ const line = lines[i];
4400
+ const trimmed = line.trim();
4401
+
4402
+ // Track fence open/close (``` or ~~~, 3+ chars)
4403
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
4404
+ if (fenceMatch) {
4405
+ const matchChar = fenceMatch[1][0];
4406
+ const matchLen = fenceMatch[1].length;
4407
+ if (!inFence) {
4408
+ inFence = true;
4409
+ fenceChar = matchChar;
4410
+ fenceLen = matchLen;
4411
+ result.push(line);
4412
+ continue;
4413
+ } else if (matchChar === fenceChar && matchLen >= fenceLen && /^(`{3,}|~{3,})\s*$/.test(trimmed)) {
4414
+ // Closing fence: same char, at least as many chars, no trailing content
4415
+ inFence = false;
4416
+ fenceChar = null;
4417
+ fenceLen = 0;
4418
+ result.push(line);
4419
+ continue;
4420
+ }
4421
+ }
4422
+
4423
+ // Inside a fence — keep everything
4424
+ if (inFence) {
4425
+ result.push(line);
4426
+ continue;
4427
+ }
4428
+
4429
+ // Detect table row/separator with pipes — always keep
4430
+ if (/^\|.*\|$/.test(trimmed) || (/^[-| :]+$/.test(trimmed) && trimmed.includes('|'))) {
4431
+ result.push(line);
4432
+ continue;
4433
+ }
4434
+
4435
+ // Check if this line is a standalone HR
4436
+ const isHR = /^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed);
4437
+ if (isHR) {
4438
+ // Table separator heuristic: immediately adjacent lines (no blank
4439
+ // lines between) that look like table rows protect this HR-like line
4440
+ const prevLine = i > 0 ? lines[i - 1].trim() : '';
4441
+ const nextLine = i < lines.length - 1 ? lines[i + 1].trim() : '';
4442
+ if (_looksLikeTableRow(prevLine) || _looksLikeTableRow(nextLine)) {
4443
+ result.push(line);
4444
+ continue;
4445
+ }
4446
+ // It's a real HR — skip it
4447
+ continue;
4448
+ }
4449
+
4450
+ result.push(line);
4451
+ }
4452
+
4453
+ return result.join('\n');
4454
+ }
4455
+
4456
+ /**
4457
+ * Convert lazy linefeeds in markdown source.
4458
+ * Replaces single newlines with double newlines (adds real line breaks)
4459
+ * except inside fences, tables, and other block-level constructs.
4460
+ * Idempotent: calling multiple times produces the same result.
4461
+ * Can be used as a toolbar action or headless via the static method.
4462
+ */
4463
+ async convertLazyLinefeeds() {
4464
+ const converted = QuikdownEditor.convertLazyLinefeeds(this._markdown);
4465
+ await this.setMarkdown(converted);
4466
+
4467
+ // Visual feedback if toolbar button exists
4468
+ const btn = this.toolbar?.querySelector('[data-action="lazy-linefeeds"]');
4469
+ if (btn) {
4470
+ const originalText = btn.textContent;
4471
+ btn.textContent = 'Converted!';
4472
+ setTimeout(() => {
4473
+ btn.textContent = originalText;
4474
+ }, 1500);
4475
+ }
4476
+ }
4477
+
4478
+ /**
4479
+ * Static: convert lazy linefeeds in markdown source.
4480
+ * Turns single \n between non-blank lines into \n\n so each line becomes
4481
+ * its own paragraph / hard break. Idempotent — already-doubled newlines
4482
+ * are not doubled again. Fences, tables, lists, blockquotes, headings,
4483
+ * and HTML blocks are left untouched.
4484
+ * @param {string} markdown - source markdown
4485
+ * @returns {string} markdown with lazy linefeeds resolved
4486
+ */
4487
+ static convertLazyLinefeeds(markdown) {
4488
+ const lines = (markdown || '').split('\n');
4489
+ const result = [];
4490
+ let inFence = false;
4491
+ let fenceChar = null;
4492
+ let fenceLen = 0;
4493
+ let inHTMLBlock = false;
4494
+
4495
+ for (let i = 0; i < lines.length; i++) {
4496
+ const line = lines[i];
4497
+ const trimmed = line.trim();
4498
+
4499
+ // Track fence open/close
4500
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
4501
+ if (fenceMatch) {
4502
+ const matchChar = fenceMatch[1][0];
4503
+ const matchLen = fenceMatch[1].length;
4504
+ if (!inFence) {
4505
+ inFence = true;
4506
+ fenceChar = matchChar;
4507
+ fenceLen = matchLen;
4508
+ result.push(line);
4509
+ continue;
4510
+ } else if (matchChar === fenceChar && matchLen >= fenceLen && /^(`{3,}|~{3,})\s*$/.test(trimmed)) {
4511
+ inFence = false;
4512
+ fenceChar = null;
4513
+ fenceLen = 0;
4514
+ result.push(line);
4515
+ continue;
4516
+ }
4517
+ }
4518
+
4519
+ // Inside fence — pass through
4520
+ if (inFence) {
4521
+ result.push(line);
4522
+ continue;
4523
+ }
4524
+
4525
+ // Track HTML blocks (lines starting with < and ending with >)
4526
+ if (/^<[a-zA-Z]/.test(trimmed)) inHTMLBlock = true;
4527
+ if (inHTMLBlock) {
4528
+ result.push(line);
4529
+ if (/>$/.test(trimmed) || trimmed === '') inHTMLBlock = false;
4530
+ continue;
4531
+ }
4532
+
4533
+ // Always pass through blank lines, but never add extras
4534
+ if (trimmed === '') {
4535
+ // Avoid doubling: don't add blank line if the last result line is already blank
4536
+ if (result.length === 0 || result[result.length - 1].trim() !== '') {
4537
+ result.push(line);
4538
+ }
4539
+ continue;
4540
+ }
4541
+
4542
+ // Skip conversion for block-level constructs
4543
+ const isBlockElement = (
4544
+ /^#{1,6}\s/.test(trimmed) || // headings
4545
+ /^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed) || // horizontal rules
4546
+ /^(\d+\.|-|\*|\+)\s/.test(trimmed) || // list items
4547
+ /^>/.test(trimmed) || // blockquotes
4548
+ /^\|/.test(trimmed) // table rows
4549
+ );
4550
+
4551
+ if (isBlockElement) {
4552
+ result.push(line);
4553
+ continue;
4554
+ }
4555
+
4556
+ // For plain paragraph text: if previous result line is non-blank
4557
+ // plain text, insert a blank line between them (making the single
4558
+ // newline into a paragraph break). This is the lazy→strict conversion.
4559
+ if (result.length > 0) {
4560
+ const prevLine = result[result.length - 1];
4561
+ const prevTrimmed = prevLine.trim();
4562
+ // Only insert blank line if prev is non-blank, non-block text
4563
+ if (prevTrimmed !== '' &&
4564
+ !/^#{1,6}\s/.test(prevTrimmed) &&
4565
+ !/^[-_*](\s*[-_*]){2,}\s*$/.test(prevTrimmed) &&
4566
+ !/^(\d+\.|-|\*|\+)\s/.test(prevTrimmed) &&
4567
+ !/^>/.test(prevTrimmed) &&
4568
+ !/^\|/.test(prevTrimmed) &&
4569
+ !/^(`{3,}|~{3,})/.test(prevTrimmed)) {
4570
+ result.push('');
4571
+ }
4572
+ }
4573
+
4574
+ result.push(line);
4575
+ }
4576
+
4577
+ return result.join('\n');
4578
+ }
4210
4579
 
4211
4580
  /**
4212
4581
  * Copy rendered content as rich text
@@ -4250,6 +4619,13 @@
4250
4619
  }
4251
4620
  }
4252
4621
 
4622
+ // --- Internal helpers for removeHR fence/table awareness ---
4623
+
4624
+ /** Heuristic: does this line look like a markdown table row? */
4625
+ function _looksLikeTableRow(line) {
4626
+ return line.includes('|');
4627
+ }
4628
+
4253
4629
  // Export for CommonJS (needed for bundled ESM to work with Jest)
4254
4630
  if (typeof module !== 'undefined' && module.exports) {
4255
4631
  module.exports = QuikdownEditor;