quikdown 1.2.2 → 1.2.7

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 (60) hide show
  1. package/README.md +22 -27
  2. package/dist/quikdown.cjs +25 -9
  3. package/dist/quikdown.dark.css +1 -1
  4. package/dist/quikdown.esm.js +25 -9
  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 +25 -9
  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 +2 -2
  14. package/dist/quikdown_ast.esm.js +2 -2
  15. package/dist/quikdown_ast.esm.min.js +2 -2
  16. package/dist/quikdown_ast.esm.min.js.gz +0 -0
  17. package/dist/quikdown_ast.umd.js +2 -2
  18. package/dist/quikdown_ast.umd.min.js +2 -2
  19. package/dist/quikdown_ast.umd.min.js.gz +0 -0
  20. package/dist/quikdown_ast_html.cjs +3 -3
  21. package/dist/quikdown_ast_html.esm.js +3 -3
  22. package/dist/quikdown_ast_html.esm.min.js +2 -2
  23. package/dist/quikdown_ast_html.esm.min.js.gz +0 -0
  24. package/dist/quikdown_ast_html.umd.js +3 -3
  25. package/dist/quikdown_ast_html.umd.min.js +2 -2
  26. package/dist/quikdown_ast_html.umd.min.js.gz +0 -0
  27. package/dist/quikdown_bd.cjs +34 -12
  28. package/dist/quikdown_bd.esm.js +34 -12
  29. package/dist/quikdown_bd.esm.min.js +2 -2
  30. package/dist/quikdown_bd.esm.min.js.gz +0 -0
  31. package/dist/quikdown_bd.esm.min.js.map +1 -1
  32. package/dist/quikdown_bd.umd.js +34 -12
  33. package/dist/quikdown_bd.umd.min.js +2 -2
  34. package/dist/quikdown_bd.umd.min.js.gz +0 -0
  35. package/dist/quikdown_bd.umd.min.js.map +1 -1
  36. package/dist/quikdown_edit.cjs +471 -124
  37. package/dist/quikdown_edit.d.ts +15 -1
  38. package/dist/quikdown_edit.esm.js +471 -124
  39. package/dist/quikdown_edit.esm.min.js +3 -3
  40. package/dist/quikdown_edit.esm.min.js.gz +0 -0
  41. package/dist/quikdown_edit.esm.min.js.map +1 -1
  42. package/dist/quikdown_edit.umd.js +471 -124
  43. package/dist/quikdown_edit.umd.min.js +3 -3
  44. package/dist/quikdown_edit.umd.min.js.gz +0 -0
  45. package/dist/quikdown_edit.umd.min.js.map +1 -1
  46. package/dist/quikdown_json.cjs +3 -3
  47. package/dist/quikdown_json.esm.js +3 -3
  48. package/dist/quikdown_json.esm.min.js +2 -2
  49. package/dist/quikdown_json.esm.min.js.gz +0 -0
  50. package/dist/quikdown_json.umd.js +3 -3
  51. package/dist/quikdown_json.umd.min.js +2 -2
  52. package/dist/quikdown_json.umd.min.js.gz +0 -0
  53. package/dist/quikdown_yaml.cjs +3 -3
  54. package/dist/quikdown_yaml.esm.js +3 -3
  55. package/dist/quikdown_yaml.esm.min.js +2 -2
  56. package/dist/quikdown_yaml.esm.min.js.gz +0 -0
  57. package/dist/quikdown_yaml.umd.js +3 -3
  58. package/dist/quikdown_yaml.umd.min.js +2 -2
  59. package/dist/quikdown_yaml.umd.min.js.gz +0 -0
  60. package/package.json +17 -14
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Quikdown Editor - Drop-in Markdown Parser
3
- * @version 1.2.2
3
+ * @version 1.2.7
4
4
  * @license BSD-2-Clause
5
5
  * @copyright DeftIO 2025
6
6
  */
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  // Version will be injected at build time
23
- const quikdownVersion = '1.2.2';
23
+ const quikdownVersion = '1.2.7';
24
24
 
25
25
  // Constants for reuse
26
26
  const CLASS_PREFIX = 'quikdown-';
@@ -68,6 +68,11 @@ function createGetAttr(inline_styles, styles) {
68
68
  // Remove default text-align if we're adding a different alignment
69
69
  if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
70
70
  style = style.replace(/text-align:[^;]+;?/, '').trim();
71
+ // Ensure trailing semicolon before concatenating additionalStyle.
72
+ // Both short-circuit paths of this guard (empty `style` or
73
+ // already-has-`;`) are defensive and unreachable with the
74
+ // current QUIKDOWN_STYLES values — istanbul ignore next.
75
+ /* istanbul ignore next */
71
76
  if (style && !style.endsWith(';')) style += ';';
72
77
  }
73
78
 
@@ -90,7 +95,7 @@ function quikdown(markdown, options = {}) {
90
95
  return '';
91
96
  }
92
97
 
93
- const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false } = options;
98
+ const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false, allow_unsafe_html = false } = options;
94
99
  const styles = QUIKDOWN_STYLES; // Use module-level styles
95
100
  const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
96
101
 
@@ -99,9 +104,12 @@ function quikdown(markdown, options = {}) {
99
104
  return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
100
105
  }
101
106
 
102
- // Helper to add data-qd attributes for bidirectional support
107
+ // Helper to add data-qd attributes for bidirectional support.
108
+ // The non-bidirectional branch is a trivial no-op arrow; it's exercised in
109
+ // the core bundle but never in quikdown_bd (which always sets bidirectional=true).
110
+ /* istanbul ignore next - trivial no-op fallback */
103
111
  const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
104
-
112
+
105
113
  // Sanitize URLs to prevent XSS attacks
106
114
  function sanitizeUrl(url, allowUnsafe = false) {
107
115
  /* istanbul ignore next - defensive programming, regex ensures url is never empty */
@@ -173,8 +181,11 @@ function quikdown(markdown, options = {}) {
173
181
  return placeholder;
174
182
  });
175
183
 
176
- // Now escape HTML in the rest of the content
177
- html = escapeHtml(html);
184
+ // Escape HTML in the rest of the content (skip if allow_unsafe_html is on —
185
+ // useful for trusted pipelines where the markdown contains intentional HTML)
186
+ if (!allow_unsafe_html) {
187
+ html = escapeHtml(html);
188
+ }
178
189
 
179
190
  // Phase 2: Process block elements
180
191
 
@@ -507,8 +518,13 @@ function processLists(text, getAttr, inline_styles, bidirectional) {
507
518
  const result = [];
508
519
  const listStack = []; // Track nested lists
509
520
 
510
- // Helper to escape HTML for data-qd attributes
511
- const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
521
+ // Helper to escape HTML for data-qd attributes. List markers (`-`, `*`,
522
+ // `+`, `1.`, etc.) never contain HTML-special chars, so the replace
523
+ // callback is defensive-only and never actually fires in practice.
524
+ const escapeHtml = (text) => text.replace(/[&<>"']/g,
525
+ /* istanbul ignore next - defensive: list markers never contain HTML specials */
526
+ m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
527
+ /* istanbul ignore next - trivial no-op fallback; not exercised via bd bundle */
512
528
  const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
513
529
 
514
530
  for (let i = 0; i < lines.length; i++) {
@@ -680,8 +696,11 @@ function quikdown_bd(markdown, options = {}) {
680
696
  return quikdown(markdown, { ...options, bidirectional: true });
681
697
  }
682
698
 
683
- // Copy all properties and methods from quikdown (including version)
699
+ // Copy all properties and methods from quikdown (including version).
700
+ // Skip `configure` — quikdown_bd provides its own override below, so the
701
+ // inner quikdown.configure is dead code in this bundle.
684
702
  Object.keys(quikdown).forEach(key => {
703
+ if (key === 'configure') return;
685
704
  quikdown_bd[key] = quikdown[key];
686
705
  });
687
706
 
@@ -1045,10 +1064,13 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
1045
1064
  return markdown;
1046
1065
  };
1047
1066
 
1048
- // Override the configure method to return a bidirectional version
1067
+ // Override the configure method to return a bidirectional version.
1068
+ // We delegate to the inner quikdown.configure so the shared closure
1069
+ // machinery is exercised in both bundles (no dead code).
1049
1070
  quikdown_bd.configure = function(options) {
1071
+ const innerParser = quikdown.configure({ ...options, bidirectional: true });
1050
1072
  return function(markdown) {
1051
- return quikdown_bd(markdown, options);
1073
+ return innerParser(markdown);
1052
1074
  };
1053
1075
  };
1054
1076
 
@@ -2554,12 +2576,75 @@ const DEFAULT_OPTIONS = {
2554
2576
  highlightjs: false,
2555
2577
  mermaid: false
2556
2578
  },
2579
+ /**
2580
+ * Preload fence-rendering libraries at construction time so the FIRST
2581
+ * encounter with a fence type renders instantly (no lazy load delay).
2582
+ *
2583
+ * Accepts:
2584
+ * - 'all' — preload every known library
2585
+ * - ['highlightjs','mermaid','math',
2586
+ * 'geojson','stl'] — preload specific libraries
2587
+ * - [{ name: 'mylib', script: 'https://...', css: '...' }]
2588
+ * — preload an arbitrary library
2589
+ *
2590
+ * Without this, fence libraries are loaded on demand the first time their
2591
+ * fence type is encountered. That keeps the editor lightweight, but the
2592
+ * first SVG/Mermaid/Math/GeoJSON/STL fence will show "loading..." for a
2593
+ * moment. Set `preloadFences` if you want zero-delay rendering — at the
2594
+ * cost of a few hundred KB of upfront network.
2595
+ *
2596
+ * Developer's choice. The editor itself is still ~70 KB minified;
2597
+ * `preloadFences` only affects the OPTIONAL fence renderers.
2598
+ */
2599
+ preloadFences: null,
2557
2600
  customFences: {}, // { 'language': (code, lang) => html }
2558
2601
  enableComplexFences: true, // Enable CSV tables, math rendering, SVG, etc.
2559
2602
  showUndoRedo: false, // Show undo/redo toolbar buttons
2560
2603
  undoStackSize: 100 // Maximum number of undo states to keep
2561
2604
  };
2562
2605
 
2606
+ // Library catalog used by preloadFences. Each entry knows how to:
2607
+ // - check if the library is already on the page (so we don't double-load)
2608
+ // - load it via script (and optional CSS)
2609
+ const FENCE_LIBRARIES = {
2610
+ highlightjs: {
2611
+ check: () => typeof window.hljs !== 'undefined',
2612
+ script: 'https://unpkg.com/@highlightjs/cdn-assets/highlight.min.js',
2613
+ css: 'https://unpkg.com/@highlightjs/cdn-assets/styles/github.min.css',
2614
+ cssDark: 'https://unpkg.com/@highlightjs/cdn-assets/styles/github-dark.min.css'
2615
+ },
2616
+ mermaid: {
2617
+ check: () => typeof window.mermaid !== 'undefined',
2618
+ script: 'https://unpkg.com/mermaid/dist/mermaid.min.js',
2619
+ afterLoad: () => {
2620
+ if (window.mermaid) window.mermaid.initialize({ startOnLoad: false });
2621
+ }
2622
+ },
2623
+ math: {
2624
+ check: () => typeof window.MathJax !== 'undefined',
2625
+ script: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js',
2626
+ beforeLoad: () => {
2627
+ // Configure MathJax before loading (must be set on window before script runs)
2628
+ if (!window.MathJax) {
2629
+ window.MathJax = {
2630
+ tex: { inlineMath: [['$', '$'], ['\\(', '\\)']], displayMath: [['$$', '$$'], ['\\[', '\\]']] },
2631
+ svg: { fontCache: 'global' },
2632
+ startup: { typeset: false }
2633
+ };
2634
+ }
2635
+ }
2636
+ },
2637
+ geojson: {
2638
+ check: () => typeof window.L !== 'undefined',
2639
+ script: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
2640
+ css: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
2641
+ },
2642
+ stl: {
2643
+ check: () => typeof window.THREE !== 'undefined',
2644
+ script: 'https://unpkg.com/three@0.147.0/build/three.min.js'
2645
+ }
2646
+ };
2647
+
2563
2648
  /**
2564
2649
  * Quikdown Editor - A complete markdown editing solution
2565
2650
  */
@@ -2643,6 +2728,7 @@ class QuikdownEditor {
2643
2728
 
2644
2729
  this.sourceTextarea = document.createElement('textarea');
2645
2730
  this.sourceTextarea.className = 'qde-textarea';
2731
+ this.sourceTextarea.spellcheck = false;
2646
2732
  this.sourceTextarea.placeholder = this.options.placeholder;
2647
2733
  this.sourcePanel.appendChild(this.sourceTextarea);
2648
2734
 
@@ -2650,6 +2736,7 @@ class QuikdownEditor {
2650
2736
  this.previewPanel = document.createElement('div');
2651
2737
  this.previewPanel.className = 'qde-preview';
2652
2738
  this.previewPanel.contentEditable = true;
2739
+ this.previewPanel.spellcheck = false;
2653
2740
 
2654
2741
  // Add panels to editor
2655
2742
  this.editorArea.appendChild(this.sourcePanel);
@@ -2757,6 +2844,7 @@ class QuikdownEditor {
2757
2844
  border-radius: 4px;
2758
2845
  overflow: hidden;
2759
2846
  background: white;
2847
+ color: #1f2937;
2760
2848
  }
2761
2849
 
2762
2850
  .qde-toolbar {
@@ -2805,24 +2893,45 @@ class QuikdownEditor {
2805
2893
  }
2806
2894
 
2807
2895
  .qde-source, .qde-preview {
2808
- flex: 1;
2896
+ flex: 1 1 0;
2897
+ min-width: 0; /* allow flex shrinking below content size */
2898
+ min-height: 0;
2809
2899
  overflow: auto;
2810
2900
  padding: 16px;
2901
+ box-sizing: border-box;
2811
2902
  }
2812
-
2903
+
2813
2904
  .qde-source {
2814
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 */
2815
2910
  }
2816
-
2911
+
2817
2912
  .qde-textarea {
2913
+ display: block;
2914
+ position: absolute;
2915
+ inset: 0;
2818
2916
  width: 100%;
2819
2917
  height: 100%;
2820
2918
  border: none;
2821
2919
  outline: none;
2822
2920
  resize: none;
2921
+ padding: 16px;
2922
+ box-sizing: border-box;
2823
2923
  font-family: 'Monaco', 'Courier New', monospace;
2824
2924
  font-size: 14px;
2825
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;
2826
2935
  }
2827
2936
 
2828
2937
  .qde-preview {
@@ -2831,14 +2940,69 @@ class QuikdownEditor {
2831
2940
  line-height: 1.6;
2832
2941
  outline: none;
2833
2942
  cursor: text; /* Standard text cursor */
2943
+ overflow-x: hidden; /* never scroll horizontally; clip wide content */
2834
2944
  }
2835
-
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
+
2836
3000
  /* Fence-specific styles */
2837
3001
  .qde-svg-container {
2838
3002
  max-width: 100%;
2839
3003
  overflow: auto;
2840
3004
  }
2841
-
3005
+
2842
3006
  .qde-svg-container svg {
2843
3007
  max-width: 100%;
2844
3008
  height: auto;
@@ -2910,6 +3074,45 @@ class QuikdownEditor {
2910
3074
  position: relative;
2911
3075
  }
2912
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
+
2913
3116
  /* Ensure proper cursor for editable text elements */
2914
3117
  .qde-preview p,
2915
3118
  .qde-preview h1,
@@ -2972,6 +3175,7 @@ class QuikdownEditor {
2972
3175
  .qde-dark {
2973
3176
  background: #1e1e1e;
2974
3177
  color: #e0e0e0;
3178
+ border-color: #444;
2975
3179
  }
2976
3180
 
2977
3181
  .qde-dark .qde-toolbar {
@@ -3003,6 +3207,20 @@ class QuikdownEditor {
3003
3207
  color: #e0e0e0;
3004
3208
  }
3005
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
+
3006
3224
  /* Dark mode table styles */
3007
3225
  .qde-dark .qde-preview table th,
3008
3226
  .qde-dark .qde-preview table td {
@@ -3022,11 +3240,14 @@ class QuikdownEditor {
3022
3240
  .qde-mode-split .qde-editor {
3023
3241
  flex-direction: column;
3024
3242
  }
3025
-
3243
+
3026
3244
  .qde-mode-split .qde-source {
3027
3245
  border-right: none;
3028
3246
  border-bottom: 1px solid #ddd;
3029
3247
  }
3248
+ .qde-dark.qde-mode-split .qde-source {
3249
+ border-bottom-color: #444;
3250
+ }
3030
3251
  }
3031
3252
  `;
3032
3253
 
@@ -3173,24 +3394,34 @@ class QuikdownEditor {
3173
3394
  updateFromHTML() {
3174
3395
  // Clone the preview panel to avoid modifying the actual DOM
3175
3396
  const clonedPanel = this.previewPanel.cloneNode(true);
3176
-
3397
+
3177
3398
  // Pre-process special elements on the clone
3178
3399
  this.preprocessSpecialElements(clonedPanel);
3179
-
3400
+
3180
3401
  this._html = this.previewPanel.innerHTML;
3181
- this._markdown = quikdown_bd.toMarkdown(clonedPanel, {
3402
+ const newMarkdown = quikdown_bd.toMarkdown(clonedPanel, {
3182
3403
  fence_plugin: this.createFencePlugin()
3183
3404
  });
3184
-
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
+
3185
3414
  // Update source if visible
3186
3415
  if (this.currentMode !== 'preview') {
3187
3416
  this.sourceTextarea.value = this._markdown;
3188
3417
  }
3189
-
3418
+
3190
3419
  // Trigger change event
3191
3420
  if (this.options.onChange) {
3192
3421
  this.options.onChange(this._markdown, this._html);
3193
3422
  }
3423
+
3424
+ this._updateUndoButtons();
3194
3425
  }
3195
3426
 
3196
3427
  /**
@@ -3787,18 +4018,12 @@ class QuikdownEditor {
3787
4018
  */
3788
4019
  renderSTL(code) {
3789
4020
  const id = `qde-stl-viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
3790
-
3791
- // Function to render the 3D model
4021
+
4022
+ // Function to render the 3D model (assumes window.THREE is loaded)
3792
4023
  const render3D = () => {
3793
4024
  const element = document.getElementById(id);
3794
4025
  if (!element) return;
3795
-
3796
- // Check if Three.js is available
3797
- if (typeof window.THREE === 'undefined') {
3798
- 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>';
3799
- return;
3800
- }
3801
-
4026
+
3802
4027
  try {
3803
4028
  const THREE = window.THREE;
3804
4029
 
@@ -3856,9 +4081,34 @@ class QuikdownEditor {
3856
4081
  }
3857
4082
  };
3858
4083
 
3859
- // Render after DOM update
3860
- setTimeout(render3D, 0);
3861
-
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
+
3862
4112
  // Return placeholder with data-stl-id for copy functionality
3863
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>`;
3864
4114
  }
@@ -3950,30 +4200,64 @@ class QuikdownEditor {
3950
4200
  }
3951
4201
 
3952
4202
  /**
3953
- * 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.
3954
4206
  */
3955
4207
  async loadPlugins() {
3956
- const promises = [];
3957
-
3958
- // Load highlight.js (check if already loaded)
3959
- if (this.options.plugins.highlightjs && !window.hljs) {
3960
- promises.push(
3961
- this.loadScript('https://unpkg.com/@highlightjs/cdn-assets/highlight.min.js'),
3962
- this.loadCSS('https://unpkg.com/@highlightjs/cdn-assets/styles/github.min.css')
3963
- );
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');
3964
4214
  }
3965
-
3966
- // Load mermaid (check if already loaded)
3967
- if (this.options.plugins.mermaid && !window.mermaid) {
3968
- promises.push(
3969
- this.loadScript('https://unpkg.com/mermaid/dist/mermaid.min.js').then(() => {
3970
- if (window.mermaid) {
3971
- mermaid.initialize({ startOnLoad: false });
3972
- }
3973
- })
3974
- );
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');
3975
4237
  }
3976
-
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
+
3977
4261
  await Promise.all(promises);
3978
4262
  }
3979
4263
 
@@ -4025,18 +4309,31 @@ class QuikdownEditor {
4025
4309
  /**
4026
4310
  * Load external CSS
4027
4311
  */
4028
- loadCSS(href) {
4312
+ loadCSS(href, id) {
4029
4313
  return new Promise((resolve) => {
4030
4314
  const link = document.createElement('link');
4031
4315
  link.rel = 'stylesheet';
4032
4316
  link.href = href;
4317
+ if (id) link.id = id;
4033
4318
  link.onload = resolve;
4034
4319
  document.head.appendChild(link);
4035
4320
  // Resolve anyway after timeout (CSS doesn't always fire onload)
4036
4321
  setTimeout(resolve, 1000);
4037
4322
  });
4038
4323
  }
4039
-
4324
+
4325
+ /**
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
+
4040
4337
  /**
4041
4338
  * Apply the current theme (based on this.options.theme)
4042
4339
  */
@@ -4054,11 +4351,13 @@ class QuikdownEditor {
4054
4351
  this.container.classList.toggle('qde-dark', mq.matches);
4055
4352
  this._autoThemeListener = (e) => {
4056
4353
  this.container.classList.toggle('qde-dark', e.matches);
4354
+ this._syncHljsTheme();
4057
4355
  };
4058
4356
  mq.addEventListener('change', this._autoThemeListener);
4059
4357
  } else {
4060
4358
  this.container.classList.toggle('qde-dark', theme === 'dark');
4061
4359
  }
4360
+ this._syncHljsTheme();
4062
4361
  }
4063
4362
 
4064
4363
  /**
@@ -4120,10 +4419,18 @@ class QuikdownEditor {
4120
4419
  */
4121
4420
  setMode(mode) {
4122
4421
  if (!['source', 'preview', 'split'].includes(mode)) return;
4123
-
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
+
4124
4428
  this.currentMode = mode;
4125
4429
  this.container.className = `qde-container qde-mode-${mode}`;
4126
-
4430
+ if (wasDark) {
4431
+ this.container.classList.add('qde-dark');
4432
+ }
4433
+
4127
4434
  // Update toolbar buttons
4128
4435
  if (this.toolbar) {
4129
4436
  this.toolbar.querySelectorAll('.qde-btn[data-mode]').forEach(btn => {
@@ -4131,11 +4438,6 @@ class QuikdownEditor {
4131
4438
  });
4132
4439
  }
4133
4440
 
4134
- // Apply theme class
4135
- if (this.container.classList.contains('qde-dark')) {
4136
- this.container.classList.add('qde-dark');
4137
- }
4138
-
4139
4441
  // Make fence blocks non-editable when showing preview
4140
4442
  if (mode !== 'source') {
4141
4443
  setTimeout(() => this.makeFencesNonEditable(), 0);
@@ -4481,95 +4783,140 @@ class QuikdownEditor {
4481
4783
  * @returns {string} markdown with lazy linefeeds resolved
4482
4784
  */
4483
4785
  static convertLazyLinefeeds(markdown) {
4484
- const lines = (markdown || '').split('\n');
4485
- const result = [];
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 = [];
4486
4818
  let inFence = false;
4487
4819
  let fenceChar = null;
4488
4820
  let fenceLen = 0;
4489
- let inHTMLBlock = false;
4490
4821
 
4491
- for (let i = 0; i < lines.length; i++) {
4492
- const line = lines[i];
4822
+ for (const rawLine of inputLines) {
4823
+ const line = rawLine;
4493
4824
  const trimmed = line.trim();
4494
4825
 
4495
- // Track fence open/close
4826
+ // Fence tracking
4496
4827
  const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
4497
- if (fenceMatch) {
4498
- const matchChar = fenceMatch[1][0];
4499
- const matchLen = fenceMatch[1].length;
4500
- if (!inFence) {
4501
- inFence = true;
4502
- fenceChar = matchChar;
4503
- fenceLen = matchLen;
4504
- result.push(line);
4505
- continue;
4506
- } else if (matchChar === fenceChar && matchLen >= fenceLen && /^(`{3,}|~{3,})\s*$/.test(trimmed)) {
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)) {
4507
4840
  inFence = false;
4508
4841
  fenceChar = null;
4509
4842
  fenceLen = 0;
4510
- result.push(line);
4511
- continue;
4843
+ items.push({ line, kind: 'fence-close' });
4844
+ } else {
4845
+ items.push({ line, kind: 'fence-body' });
4512
4846
  }
4847
+ continue;
4513
4848
  }
4514
4849
 
4515
- // Inside fence pass through
4516
- if (inFence) {
4517
- result.push(line);
4850
+ // Outside fence: whitespace-only lines become canonical blanks
4851
+ if (trimmed === '') {
4852
+ items.push({ line: '', kind: 'blank' });
4518
4853
  continue;
4519
4854
  }
4520
4855
 
4521
- // Track HTML blocks (lines starting with < and ending with >)
4522
- if (/^<[a-zA-Z]/.test(trimmed)) inHTMLBlock = true;
4523
- if (inHTMLBlock) {
4524
- result.push(line);
4525
- if (/>$/.test(trimmed) || trimmed === '') inHTMLBlock = false;
4526
- continue;
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;
4527
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
+ }
4528
4889
 
4529
- // Always pass through blank lines, but never add extras
4530
- if (trimmed === '') {
4531
- // Avoid doubling: don't add blank line if the last result line is already blank
4532
- if (result.length === 0 || result[result.length - 1].trim() !== '') {
4533
- result.push(line);
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('');
4534
4895
  }
4896
+ result.push(item.line);
4897
+ if (item.kind === 'fence-close') prev = { kind: 'content', category: 'fence' };
4535
4898
  continue;
4536
4899
  }
4537
4900
 
4538
- // Skip conversion for block-level constructs
4539
- const isBlockElement = (
4540
- /^#{1,6}\s/.test(trimmed) || // headings
4541
- /^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed) || // horizontal rules
4542
- /^(\d+\.|-|\*|\+)\s/.test(trimmed) || // list items
4543
- /^>/.test(trimmed) || // blockquotes
4544
- /^\|/.test(trimmed) // table rows
4545
- );
4546
-
4547
- if (isBlockElement) {
4548
- result.push(line);
4901
+ if (item.kind === 'blank') {
4902
+ // Skip Phase B inserts its own blank lines as needed
4549
4903
  continue;
4550
4904
  }
4551
4905
 
4552
- // For plain paragraph text: if previous result line is non-blank
4553
- // plain text, insert a blank line between them (making the single
4554
- // newline into a paragraph break). This is the lazy→strict conversion.
4555
- if (result.length > 0) {
4556
- const prevLine = result[result.length - 1];
4557
- const prevTrimmed = prevLine.trim();
4558
- // Only insert blank line if prev is non-blank, non-block text
4559
- if (prevTrimmed !== '' &&
4560
- !/^#{1,6}\s/.test(prevTrimmed) &&
4561
- !/^[-_*](\s*[-_*]){2,}\s*$/.test(prevTrimmed) &&
4562
- !/^(\d+\.|-|\*|\+)\s/.test(prevTrimmed) &&
4563
- !/^>/.test(prevTrimmed) &&
4564
- !/^\|/.test(prevTrimmed) &&
4565
- !/^(`{3,}|~{3,})/.test(prevTrimmed)) {
4566
- result.push('');
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('');
4567
4911
  }
4568
4912
  }
4569
-
4570
- result.push(line);
4913
+ result.push(item.line);
4914
+ prev = item;
4571
4915
  }
4572
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
+
4573
4920
  return result.join('\n');
4574
4921
  }
4575
4922