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
  */
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  // Version will be injected at build time
27
- const quikdownVersion = '1.2.2';
27
+ const quikdownVersion = '1.2.7';
28
28
 
29
29
  // Constants for reuse
30
30
  const CLASS_PREFIX = 'quikdown-';
@@ -72,6 +72,11 @@
72
72
  // Remove default text-align if we're adding a different alignment
73
73
  if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
74
74
  style = style.replace(/text-align:[^;]+;?/, '').trim();
75
+ // Ensure trailing semicolon before concatenating additionalStyle.
76
+ // Both short-circuit paths of this guard (empty `style` or
77
+ // already-has-`;`) are defensive and unreachable with the
78
+ // current QUIKDOWN_STYLES values — istanbul ignore next.
79
+ /* istanbul ignore next */
75
80
  if (style && !style.endsWith(';')) style += ';';
76
81
  }
77
82
 
@@ -94,7 +99,7 @@
94
99
  return '';
95
100
  }
96
101
 
97
- const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false } = options;
102
+ const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false, allow_unsafe_html = false } = options;
98
103
  const styles = QUIKDOWN_STYLES; // Use module-level styles
99
104
  const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
100
105
 
@@ -103,9 +108,12 @@
103
108
  return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
104
109
  }
105
110
 
106
- // Helper to add data-qd attributes for bidirectional support
111
+ // Helper to add data-qd attributes for bidirectional support.
112
+ // The non-bidirectional branch is a trivial no-op arrow; it's exercised in
113
+ // the core bundle but never in quikdown_bd (which always sets bidirectional=true).
114
+ /* istanbul ignore next - trivial no-op fallback */
107
115
  const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
108
-
116
+
109
117
  // Sanitize URLs to prevent XSS attacks
110
118
  function sanitizeUrl(url, allowUnsafe = false) {
111
119
  /* istanbul ignore next - defensive programming, regex ensures url is never empty */
@@ -177,8 +185,11 @@
177
185
  return placeholder;
178
186
  });
179
187
 
180
- // Now escape HTML in the rest of the content
181
- html = escapeHtml(html);
188
+ // Escape HTML in the rest of the content (skip if allow_unsafe_html is on —
189
+ // useful for trusted pipelines where the markdown contains intentional HTML)
190
+ if (!allow_unsafe_html) {
191
+ html = escapeHtml(html);
192
+ }
182
193
 
183
194
  // Phase 2: Process block elements
184
195
 
@@ -511,8 +522,13 @@
511
522
  const result = [];
512
523
  const listStack = []; // Track nested lists
513
524
 
514
- // Helper to escape HTML for data-qd attributes
515
- const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
525
+ // Helper to escape HTML for data-qd attributes. List markers (`-`, `*`,
526
+ // `+`, `1.`, etc.) never contain HTML-special chars, so the replace
527
+ // callback is defensive-only and never actually fires in practice.
528
+ const escapeHtml = (text) => text.replace(/[&<>"']/g,
529
+ /* istanbul ignore next - defensive: list markers never contain HTML specials */
530
+ m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
531
+ /* istanbul ignore next - trivial no-op fallback; not exercised via bd bundle */
516
532
  const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
517
533
 
518
534
  for (let i = 0; i < lines.length; i++) {
@@ -684,8 +700,11 @@
684
700
  return quikdown(markdown, { ...options, bidirectional: true });
685
701
  }
686
702
 
687
- // Copy all properties and methods from quikdown (including version)
703
+ // Copy all properties and methods from quikdown (including version).
704
+ // Skip `configure` — quikdown_bd provides its own override below, so the
705
+ // inner quikdown.configure is dead code in this bundle.
688
706
  Object.keys(quikdown).forEach(key => {
707
+ if (key === 'configure') return;
689
708
  quikdown_bd[key] = quikdown[key];
690
709
  });
691
710
 
@@ -1049,10 +1068,13 @@
1049
1068
  return markdown;
1050
1069
  };
1051
1070
 
1052
- // Override the configure method to return a bidirectional version
1071
+ // Override the configure method to return a bidirectional version.
1072
+ // We delegate to the inner quikdown.configure so the shared closure
1073
+ // machinery is exercised in both bundles (no dead code).
1053
1074
  quikdown_bd.configure = function(options) {
1075
+ const innerParser = quikdown.configure({ ...options, bidirectional: true });
1054
1076
  return function(markdown) {
1055
- return quikdown_bd(markdown, options);
1077
+ return innerParser(markdown);
1056
1078
  };
1057
1079
  };
1058
1080
 
@@ -2558,12 +2580,75 @@
2558
2580
  highlightjs: false,
2559
2581
  mermaid: false
2560
2582
  },
2583
+ /**
2584
+ * Preload fence-rendering libraries at construction time so the FIRST
2585
+ * encounter with a fence type renders instantly (no lazy load delay).
2586
+ *
2587
+ * Accepts:
2588
+ * - 'all' — preload every known library
2589
+ * - ['highlightjs','mermaid','math',
2590
+ * 'geojson','stl'] — preload specific libraries
2591
+ * - [{ name: 'mylib', script: 'https://...', css: '...' }]
2592
+ * — preload an arbitrary library
2593
+ *
2594
+ * Without this, fence libraries are loaded on demand the first time their
2595
+ * fence type is encountered. That keeps the editor lightweight, but the
2596
+ * first SVG/Mermaid/Math/GeoJSON/STL fence will show "loading..." for a
2597
+ * moment. Set `preloadFences` if you want zero-delay rendering — at the
2598
+ * cost of a few hundred KB of upfront network.
2599
+ *
2600
+ * Developer's choice. The editor itself is still ~70 KB minified;
2601
+ * `preloadFences` only affects the OPTIONAL fence renderers.
2602
+ */
2603
+ preloadFences: null,
2561
2604
  customFences: {}, // { 'language': (code, lang) => html }
2562
2605
  enableComplexFences: true, // Enable CSV tables, math rendering, SVG, etc.
2563
2606
  showUndoRedo: false, // Show undo/redo toolbar buttons
2564
2607
  undoStackSize: 100 // Maximum number of undo states to keep
2565
2608
  };
2566
2609
 
2610
+ // Library catalog used by preloadFences. Each entry knows how to:
2611
+ // - check if the library is already on the page (so we don't double-load)
2612
+ // - load it via script (and optional CSS)
2613
+ const FENCE_LIBRARIES = {
2614
+ highlightjs: {
2615
+ check: () => typeof window.hljs !== 'undefined',
2616
+ script: 'https://unpkg.com/@highlightjs/cdn-assets/highlight.min.js',
2617
+ css: 'https://unpkg.com/@highlightjs/cdn-assets/styles/github.min.css',
2618
+ cssDark: 'https://unpkg.com/@highlightjs/cdn-assets/styles/github-dark.min.css'
2619
+ },
2620
+ mermaid: {
2621
+ check: () => typeof window.mermaid !== 'undefined',
2622
+ script: 'https://unpkg.com/mermaid/dist/mermaid.min.js',
2623
+ afterLoad: () => {
2624
+ if (window.mermaid) window.mermaid.initialize({ startOnLoad: false });
2625
+ }
2626
+ },
2627
+ math: {
2628
+ check: () => typeof window.MathJax !== 'undefined',
2629
+ script: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js',
2630
+ beforeLoad: () => {
2631
+ // Configure MathJax before loading (must be set on window before script runs)
2632
+ if (!window.MathJax) {
2633
+ window.MathJax = {
2634
+ tex: { inlineMath: [['$', '$'], ['\\(', '\\)']], displayMath: [['$$', '$$'], ['\\[', '\\]']] },
2635
+ svg: { fontCache: 'global' },
2636
+ startup: { typeset: false }
2637
+ };
2638
+ }
2639
+ }
2640
+ },
2641
+ geojson: {
2642
+ check: () => typeof window.L !== 'undefined',
2643
+ script: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
2644
+ css: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
2645
+ },
2646
+ stl: {
2647
+ check: () => typeof window.THREE !== 'undefined',
2648
+ script: 'https://unpkg.com/three@0.147.0/build/three.min.js'
2649
+ }
2650
+ };
2651
+
2567
2652
  /**
2568
2653
  * Quikdown Editor - A complete markdown editing solution
2569
2654
  */
@@ -2647,6 +2732,7 @@
2647
2732
 
2648
2733
  this.sourceTextarea = document.createElement('textarea');
2649
2734
  this.sourceTextarea.className = 'qde-textarea';
2735
+ this.sourceTextarea.spellcheck = false;
2650
2736
  this.sourceTextarea.placeholder = this.options.placeholder;
2651
2737
  this.sourcePanel.appendChild(this.sourceTextarea);
2652
2738
 
@@ -2654,6 +2740,7 @@
2654
2740
  this.previewPanel = document.createElement('div');
2655
2741
  this.previewPanel.className = 'qde-preview';
2656
2742
  this.previewPanel.contentEditable = true;
2743
+ this.previewPanel.spellcheck = false;
2657
2744
 
2658
2745
  // Add panels to editor
2659
2746
  this.editorArea.appendChild(this.sourcePanel);
@@ -2761,6 +2848,7 @@
2761
2848
  border-radius: 4px;
2762
2849
  overflow: hidden;
2763
2850
  background: white;
2851
+ color: #1f2937;
2764
2852
  }
2765
2853
 
2766
2854
  .qde-toolbar {
@@ -2809,24 +2897,45 @@
2809
2897
  }
2810
2898
 
2811
2899
  .qde-source, .qde-preview {
2812
- flex: 1;
2900
+ flex: 1 1 0;
2901
+ min-width: 0; /* allow flex shrinking below content size */
2902
+ min-height: 0;
2813
2903
  overflow: auto;
2814
2904
  padding: 16px;
2905
+ box-sizing: border-box;
2815
2906
  }
2816
-
2907
+
2817
2908
  .qde-source {
2818
2909
  border-right: 1px solid #ddd;
2910
+ /* Source pane is just a container for the textarea — make it
2911
+ a positioning context so the textarea can fill it absolutely */
2912
+ position: relative;
2913
+ padding: 0; /* textarea brings its own padding */
2819
2914
  }
2820
-
2915
+
2821
2916
  .qde-textarea {
2917
+ display: block;
2918
+ position: absolute;
2919
+ inset: 0;
2822
2920
  width: 100%;
2823
2921
  height: 100%;
2824
2922
  border: none;
2825
2923
  outline: none;
2826
2924
  resize: none;
2925
+ padding: 16px;
2926
+ box-sizing: border-box;
2827
2927
  font-family: 'Monaco', 'Courier New', monospace;
2828
2928
  font-size: 14px;
2829
2929
  line-height: 1.5;
2930
+ background: transparent;
2931
+ color: inherit;
2932
+ /* Wrap long lines so the textarea only scrolls VERTICALLY.
2933
+ pre-wrap preserves intentional line breaks/whitespace
2934
+ while soft-wrapping at the right edge. */
2935
+ white-space: pre-wrap;
2936
+ word-wrap: break-word;
2937
+ overflow-x: hidden;
2938
+ overflow-y: auto;
2830
2939
  }
2831
2940
 
2832
2941
  .qde-preview {
@@ -2835,14 +2944,69 @@
2835
2944
  line-height: 1.6;
2836
2945
  outline: none;
2837
2946
  cursor: text; /* Standard text cursor */
2947
+ overflow-x: hidden; /* never scroll horizontally; clip wide content */
2838
2948
  }
2839
-
2949
+
2950
+ /* Code blocks and inline code — self-contained so the editor
2951
+ does not depend on any external stylesheet for these. */
2952
+ .qde-preview pre {
2953
+ background: #f4f4f4;
2954
+ color: #1f2937;
2955
+ padding: 10px;
2956
+ border-radius: 4px;
2957
+ overflow-x: auto;
2958
+ margin: 0.6em 0;
2959
+ font-size: 0.9em;
2960
+ line-height: 1.5;
2961
+ font-family: ui-monospace, "SF Mono", Monaco, "Cascadia Code",
2962
+ "Roboto Mono", Consolas, "Courier New", monospace;
2963
+ }
2964
+ .qde-preview code {
2965
+ padding: 2px 4px;
2966
+ font-size: 0.9em;
2967
+ border-radius: 3px;
2968
+ background: #f0f0f0;
2969
+ color: #1f2937;
2970
+ font-family: ui-monospace, "SF Mono", Monaco, "Cascadia Code",
2971
+ "Roboto Mono", Consolas, "Courier New", monospace;
2972
+ }
2973
+ .qde-preview pre code {
2974
+ padding: 0;
2975
+ font-size: inherit;
2976
+ border-radius: 0;
2977
+ background: transparent;
2978
+ color: inherit;
2979
+ }
2980
+
2981
+ /* Wide fence content (Leaflet maps, large SVGs, STL canvases,
2982
+ iframes, raw <img>) must never overflow the preview pane */
2983
+ .qde-preview .geojson-container,
2984
+ .qde-preview .qde-stl-container,
2985
+ .qde-preview .qde-svg-container,
2986
+ .qde-preview .leaflet-container,
2987
+ .qde-preview iframe,
2988
+ .qde-preview img,
2989
+ .qde-preview > svg {
2990
+ max-width: 100%;
2991
+ }
2992
+ .qde-preview .leaflet-container { box-sizing: border-box; }
2993
+
2994
+ /* Standard markdown tables (the .quikdown-table class) need to
2995
+ scroll horizontally inside their own wrapper rather than
2996
+ making the whole preview pane scroll */
2997
+ .qde-preview table.quikdown-table,
2998
+ .qde-preview table.qde-csv-table {
2999
+ display: block;
3000
+ max-width: 100%;
3001
+ overflow-x: auto;
3002
+ }
3003
+
2840
3004
  /* Fence-specific styles */
2841
3005
  .qde-svg-container {
2842
3006
  max-width: 100%;
2843
3007
  overflow: auto;
2844
3008
  }
2845
-
3009
+
2846
3010
  .qde-svg-container svg {
2847
3011
  max-width: 100%;
2848
3012
  height: auto;
@@ -2914,6 +3078,45 @@
2914
3078
  position: relative;
2915
3079
  }
2916
3080
 
3081
+ /* Reset headings inside the preview to plain browser defaults so
3082
+ parent-page styles (site navs, marketing pages, design systems)
3083
+ cannot bleed in. Business-casual: black text, decreasing sizes,
3084
+ no decorative borders. See docs/quikdown-editor.md for how
3085
+ embedders can override these with their own stylesheet. */
3086
+ .qde-preview h1 { font-size: 2em; }
3087
+ .qde-preview h2 { font-size: 1.5em; }
3088
+ .qde-preview h3 { font-size: 1.25em; }
3089
+ .qde-preview h4 { font-size: 1em; }
3090
+ .qde-preview h5 { font-size: 0.875em; }
3091
+ .qde-preview h6 { font-size: 0.85em; }
3092
+ .qde-preview h1,
3093
+ .qde-preview h2,
3094
+ .qde-preview h3,
3095
+ .qde-preview h4,
3096
+ .qde-preview h5,
3097
+ .qde-preview h6 {
3098
+ font-weight: bold;
3099
+ color: inherit;
3100
+ border: none;
3101
+ margin: 0.6em 0 0.3em 0;
3102
+ line-height: 1.25;
3103
+ }
3104
+ .qde-preview p {
3105
+ margin: 0.35em 0;
3106
+ }
3107
+ .qde-preview ul,
3108
+ .qde-preview ol {
3109
+ padding-left: 1.8em;
3110
+ margin: 0.4em 0;
3111
+ }
3112
+ .qde-preview li {
3113
+ margin: 0.15em 0;
3114
+ }
3115
+ .qde-preview blockquote {
3116
+ margin: 0.5em 0;
3117
+ padding-left: 1em;
3118
+ }
3119
+
2917
3120
  /* Ensure proper cursor for editable text elements */
2918
3121
  .qde-preview p,
2919
3122
  .qde-preview h1,
@@ -2976,6 +3179,7 @@
2976
3179
  .qde-dark {
2977
3180
  background: #1e1e1e;
2978
3181
  color: #e0e0e0;
3182
+ border-color: #444;
2979
3183
  }
2980
3184
 
2981
3185
  .qde-dark .qde-toolbar {
@@ -3007,6 +3211,20 @@
3007
3211
  color: #e0e0e0;
3008
3212
  }
3009
3213
 
3214
+ /* Dark mode code blocks */
3215
+ .qde-dark .qde-preview pre {
3216
+ background: #2d2d3a;
3217
+ color: #e6e6f0;
3218
+ }
3219
+ .qde-dark .qde-preview code {
3220
+ background: #2a2a3a;
3221
+ color: #e6e6f0;
3222
+ }
3223
+ .qde-dark .qde-preview pre code {
3224
+ background: transparent;
3225
+ color: inherit;
3226
+ }
3227
+
3010
3228
  /* Dark mode table styles */
3011
3229
  .qde-dark .qde-preview table th,
3012
3230
  .qde-dark .qde-preview table td {
@@ -3026,11 +3244,14 @@
3026
3244
  .qde-mode-split .qde-editor {
3027
3245
  flex-direction: column;
3028
3246
  }
3029
-
3247
+
3030
3248
  .qde-mode-split .qde-source {
3031
3249
  border-right: none;
3032
3250
  border-bottom: 1px solid #ddd;
3033
3251
  }
3252
+ .qde-dark.qde-mode-split .qde-source {
3253
+ border-bottom-color: #444;
3254
+ }
3034
3255
  }
3035
3256
  `;
3036
3257
 
@@ -3177,24 +3398,34 @@
3177
3398
  updateFromHTML() {
3178
3399
  // Clone the preview panel to avoid modifying the actual DOM
3179
3400
  const clonedPanel = this.previewPanel.cloneNode(true);
3180
-
3401
+
3181
3402
  // Pre-process special elements on the clone
3182
3403
  this.preprocessSpecialElements(clonedPanel);
3183
-
3404
+
3184
3405
  this._html = this.previewPanel.innerHTML;
3185
- this._markdown = quikdown_bd.toMarkdown(clonedPanel, {
3406
+ const newMarkdown = quikdown_bd.toMarkdown(clonedPanel, {
3186
3407
  fence_plugin: this.createFencePlugin()
3187
3408
  });
3188
-
3409
+
3410
+ // Push previous state to undo stack (now that we know the new markdown)
3411
+ if (!this._isUndoRedo) {
3412
+ this._pushUndoState(newMarkdown);
3413
+ }
3414
+ this._isUndoRedo = false;
3415
+
3416
+ this._markdown = newMarkdown;
3417
+
3189
3418
  // Update source if visible
3190
3419
  if (this.currentMode !== 'preview') {
3191
3420
  this.sourceTextarea.value = this._markdown;
3192
3421
  }
3193
-
3422
+
3194
3423
  // Trigger change event
3195
3424
  if (this.options.onChange) {
3196
3425
  this.options.onChange(this._markdown, this._html);
3197
3426
  }
3427
+
3428
+ this._updateUndoButtons();
3198
3429
  }
3199
3430
 
3200
3431
  /**
@@ -3791,18 +4022,12 @@
3791
4022
  */
3792
4023
  renderSTL(code) {
3793
4024
  const id = `qde-stl-viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
3794
-
3795
- // Function to render the 3D model
4025
+
4026
+ // Function to render the 3D model (assumes window.THREE is loaded)
3796
4027
  const render3D = () => {
3797
4028
  const element = document.getElementById(id);
3798
4029
  if (!element) return;
3799
-
3800
- // Check if Three.js is available
3801
- if (typeof window.THREE === 'undefined') {
3802
- 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>';
3803
- return;
3804
- }
3805
-
4030
+
3806
4031
  try {
3807
4032
  const THREE = window.THREE;
3808
4033
 
@@ -3860,9 +4085,34 @@
3860
4085
  }
3861
4086
  };
3862
4087
 
3863
- // Render after DOM update
3864
- setTimeout(render3D, 0);
3865
-
4088
+ // If Three.js is already loaded, render immediately. Otherwise lazy-load
4089
+ // it from a CDN (matches the GeoJSON/Leaflet pattern).
4090
+ if (window.THREE) {
4091
+ setTimeout(render3D, 0);
4092
+ } else {
4093
+ if (!window._qde_three_loading) {
4094
+ window._qde_three_loading = this.lazyLoadLibrary(
4095
+ 'Three.js',
4096
+ () => window.THREE,
4097
+ 'https://unpkg.com/three@0.147.0/build/three.min.js'
4098
+ ).catch(_err => {
4099
+ console.warn('Failed to load Three.js for STL rendering');
4100
+ window._qde_three_loading = null;
4101
+ return false;
4102
+ });
4103
+ }
4104
+ window._qde_three_loading.then(loaded => {
4105
+ if (loaded) {
4106
+ render3D();
4107
+ } else {
4108
+ const element = document.getElementById(id);
4109
+ if (element) {
4110
+ element.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">Failed to load Three.js for STL rendering</div>';
4111
+ }
4112
+ }
4113
+ });
4114
+ }
4115
+
3866
4116
  // Return placeholder with data-stl-id for copy functionality
3867
4117
  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>`;
3868
4118
  }
@@ -3954,30 +4204,64 @@
3954
4204
  }
3955
4205
 
3956
4206
  /**
3957
- * Load plugins dynamically
4207
+ * Load plugins dynamically — honors both `plugins: { highlightjs, mermaid }`
4208
+ * (legacy) and the newer `preloadFences` option which can preload any
4209
+ * combination of fence libraries (or 'all') at construction time.
3958
4210
  */
3959
4211
  async loadPlugins() {
3960
- const promises = [];
3961
-
3962
- // Load highlight.js (check if already loaded)
3963
- if (this.options.plugins.highlightjs && !window.hljs) {
3964
- promises.push(
3965
- this.loadScript('https://unpkg.com/@highlightjs/cdn-assets/highlight.min.js'),
3966
- this.loadCSS('https://unpkg.com/@highlightjs/cdn-assets/styles/github.min.css')
3967
- );
4212
+ const namesToLoad = new Set();
4213
+
4214
+ // Legacy plugins option
4215
+ if (this.options.plugins) {
4216
+ if (this.options.plugins.highlightjs) namesToLoad.add('highlightjs');
4217
+ if (this.options.plugins.mermaid) namesToLoad.add('mermaid');
3968
4218
  }
3969
-
3970
- // Load mermaid (check if already loaded)
3971
- if (this.options.plugins.mermaid && !window.mermaid) {
3972
- promises.push(
3973
- this.loadScript('https://unpkg.com/mermaid/dist/mermaid.min.js').then(() => {
3974
- if (window.mermaid) {
3975
- mermaid.initialize({ startOnLoad: false });
3976
- }
3977
- })
3978
- );
4219
+
4220
+ // New preloadFences option
4221
+ const pf = this.options.preloadFences;
4222
+ if (pf === 'all') {
4223
+ Object.keys(FENCE_LIBRARIES).forEach(n => namesToLoad.add(n));
4224
+ } else if (Array.isArray(pf)) {
4225
+ for (const entry of pf) {
4226
+ if (typeof entry === 'string') {
4227
+ if (FENCE_LIBRARIES[entry]) namesToLoad.add(entry);
4228
+ else console.warn(`QuikdownEditor: unknown preloadFences entry "${entry}"`);
4229
+ } else if (entry && typeof entry === 'object' && entry.script) {
4230
+ // Custom library: { name, script, css? }
4231
+ namesToLoad.add('__custom__:' + (entry.name || entry.script));
4232
+ FENCE_LIBRARIES['__custom__:' + (entry.name || entry.script)] = {
4233
+ check: () => false,
4234
+ script: entry.script,
4235
+ css: entry.css
4236
+ };
4237
+ }
4238
+ }
4239
+ } else if (pf) {
4240
+ console.warn('QuikdownEditor: preloadFences should be "all", an array, or null');
3979
4241
  }
3980
-
4242
+
4243
+ // Load each in parallel; respect already-loaded state
4244
+ const promises = [];
4245
+ for (const name of namesToLoad) {
4246
+ const lib = FENCE_LIBRARIES[name];
4247
+ if (!lib || lib.check()) continue;
4248
+ if (lib.beforeLoad) lib.beforeLoad();
4249
+ const p = (async () => {
4250
+ try {
4251
+ const tasks = [];
4252
+ if (lib.script) tasks.push(this.loadScript(lib.script));
4253
+ if (lib.css) tasks.push(this.loadCSS(lib.css, 'qde-hljs-light'));
4254
+ if (lib.cssDark) tasks.push(this.loadCSS(lib.cssDark, 'qde-hljs-dark'));
4255
+ await Promise.all(tasks);
4256
+ if (lib.css && lib.cssDark) this._syncHljsTheme();
4257
+ if (lib.afterLoad) lib.afterLoad();
4258
+ } catch (err) {
4259
+ console.warn(`QuikdownEditor: failed to preload ${name}:`, err);
4260
+ }
4261
+ })();
4262
+ promises.push(p);
4263
+ }
4264
+
3981
4265
  await Promise.all(promises);
3982
4266
  }
3983
4267
 
@@ -4029,18 +4313,31 @@
4029
4313
  /**
4030
4314
  * Load external CSS
4031
4315
  */
4032
- loadCSS(href) {
4316
+ loadCSS(href, id) {
4033
4317
  return new Promise((resolve) => {
4034
4318
  const link = document.createElement('link');
4035
4319
  link.rel = 'stylesheet';
4036
4320
  link.href = href;
4321
+ if (id) link.id = id;
4037
4322
  link.onload = resolve;
4038
4323
  document.head.appendChild(link);
4039
4324
  // Resolve anyway after timeout (CSS doesn't always fire onload)
4040
4325
  setTimeout(resolve, 1000);
4041
4326
  });
4042
4327
  }
4043
-
4328
+
4329
+ /**
4330
+ * Enable the hljs stylesheet matching the current theme and disable
4331
+ * the other one. Called from applyTheme and after hljs CSS loads.
4332
+ */
4333
+ _syncHljsTheme() {
4334
+ const isDark = this.container.classList.contains('qde-dark');
4335
+ const light = document.getElementById('qde-hljs-light');
4336
+ const dark = document.getElementById('qde-hljs-dark');
4337
+ if (light) light.disabled = isDark;
4338
+ if (dark) dark.disabled = !isDark;
4339
+ }
4340
+
4044
4341
  /**
4045
4342
  * Apply the current theme (based on this.options.theme)
4046
4343
  */
@@ -4058,11 +4355,13 @@
4058
4355
  this.container.classList.toggle('qde-dark', mq.matches);
4059
4356
  this._autoThemeListener = (e) => {
4060
4357
  this.container.classList.toggle('qde-dark', e.matches);
4358
+ this._syncHljsTheme();
4061
4359
  };
4062
4360
  mq.addEventListener('change', this._autoThemeListener);
4063
4361
  } else {
4064
4362
  this.container.classList.toggle('qde-dark', theme === 'dark');
4065
4363
  }
4364
+ this._syncHljsTheme();
4066
4365
  }
4067
4366
 
4068
4367
  /**
@@ -4124,10 +4423,18 @@
4124
4423
  */
4125
4424
  setMode(mode) {
4126
4425
  if (!['source', 'preview', 'split'].includes(mode)) return;
4127
-
4426
+
4427
+ // Preserve theme class across mode swap (the assignment to className
4428
+ // below would otherwise wipe it out — this used to be a no-op bug
4429
+ // where dark mode was lost on every setMode call).
4430
+ const wasDark = this.container.classList.contains('qde-dark');
4431
+
4128
4432
  this.currentMode = mode;
4129
4433
  this.container.className = `qde-container qde-mode-${mode}`;
4130
-
4434
+ if (wasDark) {
4435
+ this.container.classList.add('qde-dark');
4436
+ }
4437
+
4131
4438
  // Update toolbar buttons
4132
4439
  if (this.toolbar) {
4133
4440
  this.toolbar.querySelectorAll('.qde-btn[data-mode]').forEach(btn => {
@@ -4135,11 +4442,6 @@
4135
4442
  });
4136
4443
  }
4137
4444
 
4138
- // Apply theme class
4139
- if (this.container.classList.contains('qde-dark')) {
4140
- this.container.classList.add('qde-dark');
4141
- }
4142
-
4143
4445
  // Make fence blocks non-editable when showing preview
4144
4446
  if (mode !== 'source') {
4145
4447
  setTimeout(() => this.makeFencesNonEditable(), 0);
@@ -4485,95 +4787,140 @@
4485
4787
  * @returns {string} markdown with lazy linefeeds resolved
4486
4788
  */
4487
4789
  static convertLazyLinefeeds(markdown) {
4488
- const lines = (markdown || '').split('\n');
4489
- const result = [];
4790
+ // Two-phase approach (much cleaner than the old single pass):
4791
+ //
4792
+ // Phase A: walk lines, classify each as { content, blank, fence }.
4793
+ // Inside fences, lines are passed through verbatim.
4794
+ // Phase B: emit lines with the rule:
4795
+ // "between two adjacent CONTENT lines, ensure exactly one
4796
+ // blank line — never zero, never more than one."
4797
+ //
4798
+ // The rule applies regardless of whether the content lines are
4799
+ // headings, lists, blockquotes, table rows, paragraphs, or HR — any
4800
+ // adjacent pair of non-fence non-blank lines gets exactly one blank
4801
+ // between them. This produces the cleanest possible output for any
4802
+ // input and is fully idempotent.
4803
+ //
4804
+ // Lines that are whitespace-only (e.g. " ") are normalized to
4805
+ // empty strings, eliminating "phantom" blank lines.
4806
+ //
4807
+ // Lists are a special case: adjacent list items (same marker type)
4808
+ // should NOT get a blank line between them, otherwise we'd break
4809
+ // tight lists.
4810
+ //
4811
+ // Same applies to blockquote lines and table rows — adjacent rows
4812
+ // belong to the same block.
4813
+
4814
+ const inputLines = (markdown || '').split('\n');
4815
+
4816
+ // -------- Phase A: classify lines, normalize whitespace-only --------
4817
+ // Each entry: { line, kind } where kind is one of:
4818
+ // 'fence-open', 'fence-close', 'fence-body', 'blank', 'content'
4819
+ // Plus a 'category' for content lines: 'list-ul', 'list-ol',
4820
+ // 'blockquote', 'table', 'heading', 'hr', 'paragraph'
4821
+ const items = [];
4490
4822
  let inFence = false;
4491
4823
  let fenceChar = null;
4492
4824
  let fenceLen = 0;
4493
- let inHTMLBlock = false;
4494
4825
 
4495
- for (let i = 0; i < lines.length; i++) {
4496
- const line = lines[i];
4826
+ for (const rawLine of inputLines) {
4827
+ const line = rawLine;
4497
4828
  const trimmed = line.trim();
4498
4829
 
4499
- // Track fence open/close
4830
+ // Fence tracking
4500
4831
  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)) {
4832
+ if (fenceMatch && !inFence) {
4833
+ inFence = true;
4834
+ fenceChar = fenceMatch[1][0];
4835
+ fenceLen = fenceMatch[1].length;
4836
+ items.push({ line, kind: 'fence-open' });
4837
+ continue;
4838
+ }
4839
+ if (inFence) {
4840
+ if (fenceMatch &&
4841
+ fenceMatch[1][0] === fenceChar &&
4842
+ fenceMatch[1].length >= fenceLen &&
4843
+ /^(`{3,}|~{3,})\s*$/.test(trimmed)) {
4511
4844
  inFence = false;
4512
4845
  fenceChar = null;
4513
4846
  fenceLen = 0;
4514
- result.push(line);
4515
- continue;
4847
+ items.push({ line, kind: 'fence-close' });
4848
+ } else {
4849
+ items.push({ line, kind: 'fence-body' });
4516
4850
  }
4851
+ continue;
4517
4852
  }
4518
4853
 
4519
- // Inside fence pass through
4520
- if (inFence) {
4521
- result.push(line);
4854
+ // Outside fence: whitespace-only lines become canonical blanks
4855
+ if (trimmed === '') {
4856
+ items.push({ line: '', kind: 'blank' });
4522
4857
  continue;
4523
4858
  }
4524
4859
 
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;
4860
+ // Categorize content lines so we can recognize adjacent same-kind blocks
4861
+ let category = 'paragraph';
4862
+ if (/^#{1,6}\s/.test(trimmed)) category = 'heading';
4863
+ else if (/^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed)) category = 'hr';
4864
+ else if (/^(\d+\.)\s/.test(trimmed)) category = 'list-ol';
4865
+ else if (/^[-*+]\s/.test(trimmed)) category = 'list-ul';
4866
+ else if (/^>/.test(trimmed)) category = 'blockquote';
4867
+ else if (/^\|/.test(trimmed)) category = 'table';
4868
+ // Indented continuation of a list (2+ leading spaces or tab)
4869
+ else if (/^(?: {4}|\t| {2,}[-*+]| {2,}\d+\.)/.test(line)) category = 'list-cont';
4870
+
4871
+ items.push({ line, kind: 'content', category });
4872
+ }
4873
+
4874
+ // -------- Phase B: emit with exactly-one-blank-line normalization --------
4875
+ // Same-block adjacent lines (lists, blockquotes, tables) stay
4876
+ // touching; any other adjacent content pair gets exactly one blank.
4877
+ const result = [];
4878
+ let prev = null; // last emitted non-blank content item
4879
+
4880
+ function inSameBlock(a, b) {
4881
+ if (!a || !b) return false;
4882
+ // Lists: same marker family OR list-content continuation
4883
+ if ((a.category === 'list-ul' || a.category === 'list-ol' || a.category === 'list-cont') &&
4884
+ (b.category === 'list-ul' || b.category === 'list-ol' || b.category === 'list-cont')) {
4885
+ return true;
4531
4886
  }
4887
+ // Blockquotes
4888
+ if (a.category === 'blockquote' && b.category === 'blockquote') return true;
4889
+ // Table rows
4890
+ if (a.category === 'table' && b.category === 'table') return true;
4891
+ return false;
4892
+ }
4532
4893
 
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);
4894
+ for (const item of items) {
4895
+ if (item.kind === 'fence-open' || item.kind === 'fence-body' || item.kind === 'fence-close') {
4896
+ // Fences: ensure exactly one blank line before the fence-open
4897
+ if (item.kind === 'fence-open' && prev && result.length > 0 && result[result.length - 1] !== '') {
4898
+ result.push('');
4538
4899
  }
4900
+ result.push(item.line);
4901
+ if (item.kind === 'fence-close') prev = { kind: 'content', category: 'fence' };
4539
4902
  continue;
4540
4903
  }
4541
4904
 
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);
4905
+ if (item.kind === 'blank') {
4906
+ // Skip Phase B inserts its own blank lines as needed
4553
4907
  continue;
4554
4908
  }
4555
4909
 
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('');
4910
+ // item.kind === 'content'
4911
+ if (prev) {
4912
+ if (inSameBlock(prev, item)) ; else {
4913
+ // Different blocks (or paragraphs): exactly one blank
4914
+ if (result[result.length - 1] !== '') result.push('');
4571
4915
  }
4572
4916
  }
4573
-
4574
- result.push(line);
4917
+ result.push(item.line);
4918
+ prev = item;
4575
4919
  }
4576
4920
 
4921
+ // Trim trailing blank lines so output has exactly one terminal newline
4922
+ while (result.length > 0 && result[result.length - 1] === '') result.pop();
4923
+
4577
4924
  return result.join('\n');
4578
4925
  }
4579
4926