quikdown 1.2.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +6 -6
  2. package/dist/quikdown.cjs +19 -6
  3. package/dist/quikdown.dark.css +1 -1
  4. package/dist/quikdown.esm.js +19 -6
  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 +19 -6
  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 +28 -9
  28. package/dist/quikdown_bd.esm.js +28 -9
  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 +28 -9
  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 +464 -121
  37. package/dist/quikdown_edit.d.ts +15 -1
  38. package/dist/quikdown_edit.esm.js +464 -121
  39. package/dist/quikdown_edit.esm.min.js +2 -2
  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 +464 -121
  43. package/dist/quikdown_edit.umd.min.js +2 -2
  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 +15 -13
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Quikdown Editor - Drop-in Markdown Parser
3
- * @version 1.2.2
3
+ * @version 1.2.3
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.3';
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
 
@@ -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 */
@@ -507,8 +515,13 @@ function processLists(text, getAttr, inline_styles, bidirectional) {
507
515
  const result = [];
508
516
  const listStack = []; // Track nested lists
509
517
 
510
- // Helper to escape HTML for data-qd attributes
511
- const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
518
+ // Helper to escape HTML for data-qd attributes. List markers (`-`, `*`,
519
+ // `+`, `1.`, etc.) never contain HTML-special chars, so the replace
520
+ // callback is defensive-only and never actually fires in practice.
521
+ const escapeHtml = (text) => text.replace(/[&<>"']/g,
522
+ /* istanbul ignore next - defensive: list markers never contain HTML specials */
523
+ m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
524
+ /* istanbul ignore next - trivial no-op fallback; not exercised via bd bundle */
512
525
  const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
513
526
 
514
527
  for (let i = 0; i < lines.length; i++) {
@@ -680,8 +693,11 @@ function quikdown_bd(markdown, options = {}) {
680
693
  return quikdown(markdown, { ...options, bidirectional: true });
681
694
  }
682
695
 
683
- // Copy all properties and methods from quikdown (including version)
696
+ // Copy all properties and methods from quikdown (including version).
697
+ // Skip `configure` — quikdown_bd provides its own override below, so the
698
+ // inner quikdown.configure is dead code in this bundle.
684
699
  Object.keys(quikdown).forEach(key => {
700
+ if (key === 'configure') return;
685
701
  quikdown_bd[key] = quikdown[key];
686
702
  });
687
703
 
@@ -1045,10 +1061,13 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
1045
1061
  return markdown;
1046
1062
  };
1047
1063
 
1048
- // Override the configure method to return a bidirectional version
1064
+ // Override the configure method to return a bidirectional version.
1065
+ // We delegate to the inner quikdown.configure so the shared closure
1066
+ // machinery is exercised in both bundles (no dead code).
1049
1067
  quikdown_bd.configure = function(options) {
1068
+ const innerParser = quikdown.configure({ ...options, bidirectional: true });
1050
1069
  return function(markdown) {
1051
- return quikdown_bd(markdown, options);
1070
+ return innerParser(markdown);
1052
1071
  };
1053
1072
  };
1054
1073
 
@@ -2554,12 +2573,75 @@ const DEFAULT_OPTIONS = {
2554
2573
  highlightjs: false,
2555
2574
  mermaid: false
2556
2575
  },
2576
+ /**
2577
+ * Preload fence-rendering libraries at construction time so the FIRST
2578
+ * encounter with a fence type renders instantly (no lazy load delay).
2579
+ *
2580
+ * Accepts:
2581
+ * - 'all' — preload every known library
2582
+ * - ['highlightjs','mermaid','math',
2583
+ * 'geojson','stl'] — preload specific libraries
2584
+ * - [{ name: 'mylib', script: 'https://...', css: '...' }]
2585
+ * — preload an arbitrary library
2586
+ *
2587
+ * Without this, fence libraries are loaded on demand the first time their
2588
+ * fence type is encountered. That keeps the editor lightweight, but the
2589
+ * first SVG/Mermaid/Math/GeoJSON/STL fence will show "loading..." for a
2590
+ * moment. Set `preloadFences` if you want zero-delay rendering — at the
2591
+ * cost of a few hundred KB of upfront network.
2592
+ *
2593
+ * Developer's choice. The editor itself is still ~70 KB minified;
2594
+ * `preloadFences` only affects the OPTIONAL fence renderers.
2595
+ */
2596
+ preloadFences: null,
2557
2597
  customFences: {}, // { 'language': (code, lang) => html }
2558
2598
  enableComplexFences: true, // Enable CSV tables, math rendering, SVG, etc.
2559
2599
  showUndoRedo: false, // Show undo/redo toolbar buttons
2560
2600
  undoStackSize: 100 // Maximum number of undo states to keep
2561
2601
  };
2562
2602
 
2603
+ // Library catalog used by preloadFences. Each entry knows how to:
2604
+ // - check if the library is already on the page (so we don't double-load)
2605
+ // - load it via script (and optional CSS)
2606
+ const FENCE_LIBRARIES = {
2607
+ highlightjs: {
2608
+ check: () => typeof window.hljs !== 'undefined',
2609
+ script: 'https://unpkg.com/@highlightjs/cdn-assets/highlight.min.js',
2610
+ css: 'https://unpkg.com/@highlightjs/cdn-assets/styles/github.min.css',
2611
+ cssDark: 'https://unpkg.com/@highlightjs/cdn-assets/styles/github-dark.min.css'
2612
+ },
2613
+ mermaid: {
2614
+ check: () => typeof window.mermaid !== 'undefined',
2615
+ script: 'https://unpkg.com/mermaid/dist/mermaid.min.js',
2616
+ afterLoad: () => {
2617
+ if (window.mermaid) window.mermaid.initialize({ startOnLoad: false });
2618
+ }
2619
+ },
2620
+ math: {
2621
+ check: () => typeof window.MathJax !== 'undefined',
2622
+ script: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js',
2623
+ beforeLoad: () => {
2624
+ // Configure MathJax before loading (must be set on window before script runs)
2625
+ if (!window.MathJax) {
2626
+ window.MathJax = {
2627
+ tex: { inlineMath: [['$', '$'], ['\\(', '\\)']], displayMath: [['$$', '$$'], ['\\[', '\\]']] },
2628
+ svg: { fontCache: 'global' },
2629
+ startup: { typeset: false }
2630
+ };
2631
+ }
2632
+ }
2633
+ },
2634
+ geojson: {
2635
+ check: () => typeof window.L !== 'undefined',
2636
+ script: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
2637
+ css: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
2638
+ },
2639
+ stl: {
2640
+ check: () => typeof window.THREE !== 'undefined',
2641
+ script: 'https://unpkg.com/three@0.147.0/build/three.min.js'
2642
+ }
2643
+ };
2644
+
2563
2645
  /**
2564
2646
  * Quikdown Editor - A complete markdown editing solution
2565
2647
  */
@@ -2643,6 +2725,7 @@ class QuikdownEditor {
2643
2725
 
2644
2726
  this.sourceTextarea = document.createElement('textarea');
2645
2727
  this.sourceTextarea.className = 'qde-textarea';
2728
+ this.sourceTextarea.spellcheck = false;
2646
2729
  this.sourceTextarea.placeholder = this.options.placeholder;
2647
2730
  this.sourcePanel.appendChild(this.sourceTextarea);
2648
2731
 
@@ -2650,6 +2733,7 @@ class QuikdownEditor {
2650
2733
  this.previewPanel = document.createElement('div');
2651
2734
  this.previewPanel.className = 'qde-preview';
2652
2735
  this.previewPanel.contentEditable = true;
2736
+ this.previewPanel.spellcheck = false;
2653
2737
 
2654
2738
  // Add panels to editor
2655
2739
  this.editorArea.appendChild(this.sourcePanel);
@@ -2805,24 +2889,45 @@ class QuikdownEditor {
2805
2889
  }
2806
2890
 
2807
2891
  .qde-source, .qde-preview {
2808
- flex: 1;
2892
+ flex: 1 1 0;
2893
+ min-width: 0; /* allow flex shrinking below content size */
2894
+ min-height: 0;
2809
2895
  overflow: auto;
2810
2896
  padding: 16px;
2897
+ box-sizing: border-box;
2811
2898
  }
2812
-
2899
+
2813
2900
  .qde-source {
2814
2901
  border-right: 1px solid #ddd;
2902
+ /* Source pane is just a container for the textarea — make it
2903
+ a positioning context so the textarea can fill it absolutely */
2904
+ position: relative;
2905
+ padding: 0; /* textarea brings its own padding */
2815
2906
  }
2816
-
2907
+
2817
2908
  .qde-textarea {
2909
+ display: block;
2910
+ position: absolute;
2911
+ inset: 0;
2818
2912
  width: 100%;
2819
2913
  height: 100%;
2820
2914
  border: none;
2821
2915
  outline: none;
2822
2916
  resize: none;
2917
+ padding: 16px;
2918
+ box-sizing: border-box;
2823
2919
  font-family: 'Monaco', 'Courier New', monospace;
2824
2920
  font-size: 14px;
2825
2921
  line-height: 1.5;
2922
+ background: transparent;
2923
+ color: inherit;
2924
+ /* Wrap long lines so the textarea only scrolls VERTICALLY.
2925
+ pre-wrap preserves intentional line breaks/whitespace
2926
+ while soft-wrapping at the right edge. */
2927
+ white-space: pre-wrap;
2928
+ word-wrap: break-word;
2929
+ overflow-x: hidden;
2930
+ overflow-y: auto;
2826
2931
  }
2827
2932
 
2828
2933
  .qde-preview {
@@ -2831,14 +2936,69 @@ class QuikdownEditor {
2831
2936
  line-height: 1.6;
2832
2937
  outline: none;
2833
2938
  cursor: text; /* Standard text cursor */
2939
+ overflow-x: hidden; /* never scroll horizontally; clip wide content */
2834
2940
  }
2835
-
2941
+
2942
+ /* Code blocks and inline code — self-contained so the editor
2943
+ does not depend on any external stylesheet for these. */
2944
+ .qde-preview pre {
2945
+ background: #f4f4f4;
2946
+ color: #1f2937;
2947
+ padding: 10px;
2948
+ border-radius: 4px;
2949
+ overflow-x: auto;
2950
+ margin: 0.6em 0;
2951
+ font-size: 0.9em;
2952
+ line-height: 1.5;
2953
+ font-family: ui-monospace, "SF Mono", Monaco, "Cascadia Code",
2954
+ "Roboto Mono", Consolas, "Courier New", monospace;
2955
+ }
2956
+ .qde-preview code {
2957
+ padding: 2px 4px;
2958
+ font-size: 0.9em;
2959
+ border-radius: 3px;
2960
+ background: #f0f0f0;
2961
+ color: #1f2937;
2962
+ font-family: ui-monospace, "SF Mono", Monaco, "Cascadia Code",
2963
+ "Roboto Mono", Consolas, "Courier New", monospace;
2964
+ }
2965
+ .qde-preview pre code {
2966
+ padding: 0;
2967
+ font-size: inherit;
2968
+ border-radius: 0;
2969
+ background: transparent;
2970
+ color: inherit;
2971
+ }
2972
+
2973
+ /* Wide fence content (Leaflet maps, large SVGs, STL canvases,
2974
+ iframes, raw <img>) must never overflow the preview pane */
2975
+ .qde-preview .geojson-container,
2976
+ .qde-preview .qde-stl-container,
2977
+ .qde-preview .qde-svg-container,
2978
+ .qde-preview .leaflet-container,
2979
+ .qde-preview iframe,
2980
+ .qde-preview img,
2981
+ .qde-preview > svg {
2982
+ max-width: 100%;
2983
+ }
2984
+ .qde-preview .leaflet-container { box-sizing: border-box; }
2985
+
2986
+ /* Standard markdown tables (the .quikdown-table class) need to
2987
+ scroll horizontally inside their own wrapper rather than
2988
+ making the whole preview pane scroll */
2989
+ .qde-preview table.quikdown-table,
2990
+ .qde-preview table.qde-csv-table {
2991
+ display: block;
2992
+ max-width: 100%;
2993
+ overflow-x: auto;
2994
+ }
2995
+
2836
2996
  /* Fence-specific styles */
2837
2997
  .qde-svg-container {
2838
2998
  max-width: 100%;
2839
2999
  overflow: auto;
2840
3000
  }
2841
-
3001
+
2842
3002
  .qde-svg-container svg {
2843
3003
  max-width: 100%;
2844
3004
  height: auto;
@@ -2910,6 +3070,45 @@ class QuikdownEditor {
2910
3070
  position: relative;
2911
3071
  }
2912
3072
 
3073
+ /* Reset headings inside the preview to plain browser defaults so
3074
+ parent-page styles (site navs, marketing pages, design systems)
3075
+ cannot bleed in. Business-casual: black text, decreasing sizes,
3076
+ no decorative borders. See docs/quikdown-editor.md for how
3077
+ embedders can override these with their own stylesheet. */
3078
+ .qde-preview h1 { font-size: 2em; }
3079
+ .qde-preview h2 { font-size: 1.5em; }
3080
+ .qde-preview h3 { font-size: 1.25em; }
3081
+ .qde-preview h4 { font-size: 1em; }
3082
+ .qde-preview h5 { font-size: 0.875em; }
3083
+ .qde-preview h6 { font-size: 0.85em; }
3084
+ .qde-preview h1,
3085
+ .qde-preview h2,
3086
+ .qde-preview h3,
3087
+ .qde-preview h4,
3088
+ .qde-preview h5,
3089
+ .qde-preview h6 {
3090
+ font-weight: bold;
3091
+ color: inherit;
3092
+ border: none;
3093
+ margin: 0.6em 0 0.3em 0;
3094
+ line-height: 1.25;
3095
+ }
3096
+ .qde-preview p {
3097
+ margin: 0.35em 0;
3098
+ }
3099
+ .qde-preview ul,
3100
+ .qde-preview ol {
3101
+ padding-left: 1.8em;
3102
+ margin: 0.4em 0;
3103
+ }
3104
+ .qde-preview li {
3105
+ margin: 0.15em 0;
3106
+ }
3107
+ .qde-preview blockquote {
3108
+ margin: 0.5em 0;
3109
+ padding-left: 1em;
3110
+ }
3111
+
2913
3112
  /* Ensure proper cursor for editable text elements */
2914
3113
  .qde-preview p,
2915
3114
  .qde-preview h1,
@@ -2972,6 +3171,7 @@ class QuikdownEditor {
2972
3171
  .qde-dark {
2973
3172
  background: #1e1e1e;
2974
3173
  color: #e0e0e0;
3174
+ border-color: #444;
2975
3175
  }
2976
3176
 
2977
3177
  .qde-dark .qde-toolbar {
@@ -3003,6 +3203,20 @@ class QuikdownEditor {
3003
3203
  color: #e0e0e0;
3004
3204
  }
3005
3205
 
3206
+ /* Dark mode code blocks */
3207
+ .qde-dark .qde-preview pre {
3208
+ background: #2d2d3a;
3209
+ color: #e6e6f0;
3210
+ }
3211
+ .qde-dark .qde-preview code {
3212
+ background: #2a2a3a;
3213
+ color: #e6e6f0;
3214
+ }
3215
+ .qde-dark .qde-preview pre code {
3216
+ background: transparent;
3217
+ color: inherit;
3218
+ }
3219
+
3006
3220
  /* Dark mode table styles */
3007
3221
  .qde-dark .qde-preview table th,
3008
3222
  .qde-dark .qde-preview table td {
@@ -3022,11 +3236,14 @@ class QuikdownEditor {
3022
3236
  .qde-mode-split .qde-editor {
3023
3237
  flex-direction: column;
3024
3238
  }
3025
-
3239
+
3026
3240
  .qde-mode-split .qde-source {
3027
3241
  border-right: none;
3028
3242
  border-bottom: 1px solid #ddd;
3029
3243
  }
3244
+ .qde-dark.qde-mode-split .qde-source {
3245
+ border-bottom-color: #444;
3246
+ }
3030
3247
  }
3031
3248
  `;
3032
3249
 
@@ -3173,24 +3390,34 @@ class QuikdownEditor {
3173
3390
  updateFromHTML() {
3174
3391
  // Clone the preview panel to avoid modifying the actual DOM
3175
3392
  const clonedPanel = this.previewPanel.cloneNode(true);
3176
-
3393
+
3177
3394
  // Pre-process special elements on the clone
3178
3395
  this.preprocessSpecialElements(clonedPanel);
3179
-
3396
+
3180
3397
  this._html = this.previewPanel.innerHTML;
3181
- this._markdown = quikdown_bd.toMarkdown(clonedPanel, {
3398
+ const newMarkdown = quikdown_bd.toMarkdown(clonedPanel, {
3182
3399
  fence_plugin: this.createFencePlugin()
3183
3400
  });
3184
-
3401
+
3402
+ // Push previous state to undo stack (now that we know the new markdown)
3403
+ if (!this._isUndoRedo) {
3404
+ this._pushUndoState(newMarkdown);
3405
+ }
3406
+ this._isUndoRedo = false;
3407
+
3408
+ this._markdown = newMarkdown;
3409
+
3185
3410
  // Update source if visible
3186
3411
  if (this.currentMode !== 'preview') {
3187
3412
  this.sourceTextarea.value = this._markdown;
3188
3413
  }
3189
-
3414
+
3190
3415
  // Trigger change event
3191
3416
  if (this.options.onChange) {
3192
3417
  this.options.onChange(this._markdown, this._html);
3193
3418
  }
3419
+
3420
+ this._updateUndoButtons();
3194
3421
  }
3195
3422
 
3196
3423
  /**
@@ -3787,18 +4014,12 @@ class QuikdownEditor {
3787
4014
  */
3788
4015
  renderSTL(code) {
3789
4016
  const id = `qde-stl-viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
3790
-
3791
- // Function to render the 3D model
4017
+
4018
+ // Function to render the 3D model (assumes window.THREE is loaded)
3792
4019
  const render3D = () => {
3793
4020
  const element = document.getElementById(id);
3794
4021
  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
-
4022
+
3802
4023
  try {
3803
4024
  const THREE = window.THREE;
3804
4025
 
@@ -3856,9 +4077,34 @@ class QuikdownEditor {
3856
4077
  }
3857
4078
  };
3858
4079
 
3859
- // Render after DOM update
3860
- setTimeout(render3D, 0);
3861
-
4080
+ // If Three.js is already loaded, render immediately. Otherwise lazy-load
4081
+ // it from a CDN (matches the GeoJSON/Leaflet pattern).
4082
+ if (window.THREE) {
4083
+ setTimeout(render3D, 0);
4084
+ } else {
4085
+ if (!window._qde_three_loading) {
4086
+ window._qde_three_loading = this.lazyLoadLibrary(
4087
+ 'Three.js',
4088
+ () => window.THREE,
4089
+ 'https://unpkg.com/three@0.147.0/build/three.min.js'
4090
+ ).catch(_err => {
4091
+ console.warn('Failed to load Three.js for STL rendering');
4092
+ window._qde_three_loading = null;
4093
+ return false;
4094
+ });
4095
+ }
4096
+ window._qde_three_loading.then(loaded => {
4097
+ if (loaded) {
4098
+ render3D();
4099
+ } else {
4100
+ const element = document.getElementById(id);
4101
+ if (element) {
4102
+ element.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">Failed to load Three.js for STL rendering</div>';
4103
+ }
4104
+ }
4105
+ });
4106
+ }
4107
+
3862
4108
  // Return placeholder with data-stl-id for copy functionality
3863
4109
  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
4110
  }
@@ -3950,30 +4196,64 @@ class QuikdownEditor {
3950
4196
  }
3951
4197
 
3952
4198
  /**
3953
- * Load plugins dynamically
4199
+ * Load plugins dynamically — honors both `plugins: { highlightjs, mermaid }`
4200
+ * (legacy) and the newer `preloadFences` option which can preload any
4201
+ * combination of fence libraries (or 'all') at construction time.
3954
4202
  */
3955
4203
  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
- );
4204
+ const namesToLoad = new Set();
4205
+
4206
+ // Legacy plugins option
4207
+ if (this.options.plugins) {
4208
+ if (this.options.plugins.highlightjs) namesToLoad.add('highlightjs');
4209
+ if (this.options.plugins.mermaid) namesToLoad.add('mermaid');
3964
4210
  }
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
- );
4211
+
4212
+ // New preloadFences option
4213
+ const pf = this.options.preloadFences;
4214
+ if (pf === 'all') {
4215
+ Object.keys(FENCE_LIBRARIES).forEach(n => namesToLoad.add(n));
4216
+ } else if (Array.isArray(pf)) {
4217
+ for (const entry of pf) {
4218
+ if (typeof entry === 'string') {
4219
+ if (FENCE_LIBRARIES[entry]) namesToLoad.add(entry);
4220
+ else console.warn(`QuikdownEditor: unknown preloadFences entry "${entry}"`);
4221
+ } else if (entry && typeof entry === 'object' && entry.script) {
4222
+ // Custom library: { name, script, css? }
4223
+ namesToLoad.add('__custom__:' + (entry.name || entry.script));
4224
+ FENCE_LIBRARIES['__custom__:' + (entry.name || entry.script)] = {
4225
+ check: () => false,
4226
+ script: entry.script,
4227
+ css: entry.css
4228
+ };
4229
+ }
4230
+ }
4231
+ } else if (pf) {
4232
+ console.warn('QuikdownEditor: preloadFences should be "all", an array, or null');
3975
4233
  }
3976
-
4234
+
4235
+ // Load each in parallel; respect already-loaded state
4236
+ const promises = [];
4237
+ for (const name of namesToLoad) {
4238
+ const lib = FENCE_LIBRARIES[name];
4239
+ if (!lib || lib.check()) continue;
4240
+ if (lib.beforeLoad) lib.beforeLoad();
4241
+ const p = (async () => {
4242
+ try {
4243
+ const tasks = [];
4244
+ if (lib.script) tasks.push(this.loadScript(lib.script));
4245
+ if (lib.css) tasks.push(this.loadCSS(lib.css, 'qde-hljs-light'));
4246
+ if (lib.cssDark) tasks.push(this.loadCSS(lib.cssDark, 'qde-hljs-dark'));
4247
+ await Promise.all(tasks);
4248
+ if (lib.css && lib.cssDark) this._syncHljsTheme();
4249
+ if (lib.afterLoad) lib.afterLoad();
4250
+ } catch (err) {
4251
+ console.warn(`QuikdownEditor: failed to preload ${name}:`, err);
4252
+ }
4253
+ })();
4254
+ promises.push(p);
4255
+ }
4256
+
3977
4257
  await Promise.all(promises);
3978
4258
  }
3979
4259
 
@@ -4025,18 +4305,31 @@ class QuikdownEditor {
4025
4305
  /**
4026
4306
  * Load external CSS
4027
4307
  */
4028
- loadCSS(href) {
4308
+ loadCSS(href, id) {
4029
4309
  return new Promise((resolve) => {
4030
4310
  const link = document.createElement('link');
4031
4311
  link.rel = 'stylesheet';
4032
4312
  link.href = href;
4313
+ if (id) link.id = id;
4033
4314
  link.onload = resolve;
4034
4315
  document.head.appendChild(link);
4035
4316
  // Resolve anyway after timeout (CSS doesn't always fire onload)
4036
4317
  setTimeout(resolve, 1000);
4037
4318
  });
4038
4319
  }
4039
-
4320
+
4321
+ /**
4322
+ * Enable the hljs stylesheet matching the current theme and disable
4323
+ * the other one. Called from applyTheme and after hljs CSS loads.
4324
+ */
4325
+ _syncHljsTheme() {
4326
+ const isDark = this.container.classList.contains('qde-dark');
4327
+ const light = document.getElementById('qde-hljs-light');
4328
+ const dark = document.getElementById('qde-hljs-dark');
4329
+ if (light) light.disabled = isDark;
4330
+ if (dark) dark.disabled = !isDark;
4331
+ }
4332
+
4040
4333
  /**
4041
4334
  * Apply the current theme (based on this.options.theme)
4042
4335
  */
@@ -4054,11 +4347,13 @@ class QuikdownEditor {
4054
4347
  this.container.classList.toggle('qde-dark', mq.matches);
4055
4348
  this._autoThemeListener = (e) => {
4056
4349
  this.container.classList.toggle('qde-dark', e.matches);
4350
+ this._syncHljsTheme();
4057
4351
  };
4058
4352
  mq.addEventListener('change', this._autoThemeListener);
4059
4353
  } else {
4060
4354
  this.container.classList.toggle('qde-dark', theme === 'dark');
4061
4355
  }
4356
+ this._syncHljsTheme();
4062
4357
  }
4063
4358
 
4064
4359
  /**
@@ -4120,10 +4415,18 @@ class QuikdownEditor {
4120
4415
  */
4121
4416
  setMode(mode) {
4122
4417
  if (!['source', 'preview', 'split'].includes(mode)) return;
4123
-
4418
+
4419
+ // Preserve theme class across mode swap (the assignment to className
4420
+ // below would otherwise wipe it out — this used to be a no-op bug
4421
+ // where dark mode was lost on every setMode call).
4422
+ const wasDark = this.container.classList.contains('qde-dark');
4423
+
4124
4424
  this.currentMode = mode;
4125
4425
  this.container.className = `qde-container qde-mode-${mode}`;
4126
-
4426
+ if (wasDark) {
4427
+ this.container.classList.add('qde-dark');
4428
+ }
4429
+
4127
4430
  // Update toolbar buttons
4128
4431
  if (this.toolbar) {
4129
4432
  this.toolbar.querySelectorAll('.qde-btn[data-mode]').forEach(btn => {
@@ -4131,11 +4434,6 @@ class QuikdownEditor {
4131
4434
  });
4132
4435
  }
4133
4436
 
4134
- // Apply theme class
4135
- if (this.container.classList.contains('qde-dark')) {
4136
- this.container.classList.add('qde-dark');
4137
- }
4138
-
4139
4437
  // Make fence blocks non-editable when showing preview
4140
4438
  if (mode !== 'source') {
4141
4439
  setTimeout(() => this.makeFencesNonEditable(), 0);
@@ -4481,95 +4779,140 @@ class QuikdownEditor {
4481
4779
  * @returns {string} markdown with lazy linefeeds resolved
4482
4780
  */
4483
4781
  static convertLazyLinefeeds(markdown) {
4484
- const lines = (markdown || '').split('\n');
4485
- const result = [];
4782
+ // Two-phase approach (much cleaner than the old single pass):
4783
+ //
4784
+ // Phase A: walk lines, classify each as { content, blank, fence }.
4785
+ // Inside fences, lines are passed through verbatim.
4786
+ // Phase B: emit lines with the rule:
4787
+ // "between two adjacent CONTENT lines, ensure exactly one
4788
+ // blank line — never zero, never more than one."
4789
+ //
4790
+ // The rule applies regardless of whether the content lines are
4791
+ // headings, lists, blockquotes, table rows, paragraphs, or HR — any
4792
+ // adjacent pair of non-fence non-blank lines gets exactly one blank
4793
+ // between them. This produces the cleanest possible output for any
4794
+ // input and is fully idempotent.
4795
+ //
4796
+ // Lines that are whitespace-only (e.g. " ") are normalized to
4797
+ // empty strings, eliminating "phantom" blank lines.
4798
+ //
4799
+ // Lists are a special case: adjacent list items (same marker type)
4800
+ // should NOT get a blank line between them, otherwise we'd break
4801
+ // tight lists.
4802
+ //
4803
+ // Same applies to blockquote lines and table rows — adjacent rows
4804
+ // belong to the same block.
4805
+
4806
+ const inputLines = (markdown || '').split('\n');
4807
+
4808
+ // -------- Phase A: classify lines, normalize whitespace-only --------
4809
+ // Each entry: { line, kind } where kind is one of:
4810
+ // 'fence-open', 'fence-close', 'fence-body', 'blank', 'content'
4811
+ // Plus a 'category' for content lines: 'list-ul', 'list-ol',
4812
+ // 'blockquote', 'table', 'heading', 'hr', 'paragraph'
4813
+ const items = [];
4486
4814
  let inFence = false;
4487
4815
  let fenceChar = null;
4488
4816
  let fenceLen = 0;
4489
- let inHTMLBlock = false;
4490
4817
 
4491
- for (let i = 0; i < lines.length; i++) {
4492
- const line = lines[i];
4818
+ for (const rawLine of inputLines) {
4819
+ const line = rawLine;
4493
4820
  const trimmed = line.trim();
4494
4821
 
4495
- // Track fence open/close
4822
+ // Fence tracking
4496
4823
  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)) {
4824
+ if (fenceMatch && !inFence) {
4825
+ inFence = true;
4826
+ fenceChar = fenceMatch[1][0];
4827
+ fenceLen = fenceMatch[1].length;
4828
+ items.push({ line, kind: 'fence-open' });
4829
+ continue;
4830
+ }
4831
+ if (inFence) {
4832
+ if (fenceMatch &&
4833
+ fenceMatch[1][0] === fenceChar &&
4834
+ fenceMatch[1].length >= fenceLen &&
4835
+ /^(`{3,}|~{3,})\s*$/.test(trimmed)) {
4507
4836
  inFence = false;
4508
4837
  fenceChar = null;
4509
4838
  fenceLen = 0;
4510
- result.push(line);
4511
- continue;
4839
+ items.push({ line, kind: 'fence-close' });
4840
+ } else {
4841
+ items.push({ line, kind: 'fence-body' });
4512
4842
  }
4843
+ continue;
4513
4844
  }
4514
4845
 
4515
- // Inside fence pass through
4516
- if (inFence) {
4517
- result.push(line);
4846
+ // Outside fence: whitespace-only lines become canonical blanks
4847
+ if (trimmed === '') {
4848
+ items.push({ line: '', kind: 'blank' });
4518
4849
  continue;
4519
4850
  }
4520
4851
 
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;
4852
+ // Categorize content lines so we can recognize adjacent same-kind blocks
4853
+ let category = 'paragraph';
4854
+ if (/^#{1,6}\s/.test(trimmed)) category = 'heading';
4855
+ else if (/^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed)) category = 'hr';
4856
+ else if (/^(\d+\.)\s/.test(trimmed)) category = 'list-ol';
4857
+ else if (/^[-*+]\s/.test(trimmed)) category = 'list-ul';
4858
+ else if (/^>/.test(trimmed)) category = 'blockquote';
4859
+ else if (/^\|/.test(trimmed)) category = 'table';
4860
+ // Indented continuation of a list (2+ leading spaces or tab)
4861
+ else if (/^(?: {4}|\t| {2,}[-*+]| {2,}\d+\.)/.test(line)) category = 'list-cont';
4862
+
4863
+ items.push({ line, kind: 'content', category });
4864
+ }
4865
+
4866
+ // -------- Phase B: emit with exactly-one-blank-line normalization --------
4867
+ // Same-block adjacent lines (lists, blockquotes, tables) stay
4868
+ // touching; any other adjacent content pair gets exactly one blank.
4869
+ const result = [];
4870
+ let prev = null; // last emitted non-blank content item
4871
+
4872
+ function inSameBlock(a, b) {
4873
+ if (!a || !b) return false;
4874
+ // Lists: same marker family OR list-content continuation
4875
+ if ((a.category === 'list-ul' || a.category === 'list-ol' || a.category === 'list-cont') &&
4876
+ (b.category === 'list-ul' || b.category === 'list-ol' || b.category === 'list-cont')) {
4877
+ return true;
4527
4878
  }
4879
+ // Blockquotes
4880
+ if (a.category === 'blockquote' && b.category === 'blockquote') return true;
4881
+ // Table rows
4882
+ if (a.category === 'table' && b.category === 'table') return true;
4883
+ return false;
4884
+ }
4528
4885
 
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);
4886
+ for (const item of items) {
4887
+ if (item.kind === 'fence-open' || item.kind === 'fence-body' || item.kind === 'fence-close') {
4888
+ // Fences: ensure exactly one blank line before the fence-open
4889
+ if (item.kind === 'fence-open' && prev && result.length > 0 && result[result.length - 1] !== '') {
4890
+ result.push('');
4534
4891
  }
4892
+ result.push(item.line);
4893
+ if (item.kind === 'fence-close') prev = { kind: 'content', category: 'fence' };
4535
4894
  continue;
4536
4895
  }
4537
4896
 
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);
4897
+ if (item.kind === 'blank') {
4898
+ // Skip Phase B inserts its own blank lines as needed
4549
4899
  continue;
4550
4900
  }
4551
4901
 
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('');
4902
+ // item.kind === 'content'
4903
+ if (prev) {
4904
+ if (inSameBlock(prev, item)) ; else {
4905
+ // Different blocks (or paragraphs): exactly one blank
4906
+ if (result[result.length - 1] !== '') result.push('');
4567
4907
  }
4568
4908
  }
4569
-
4570
- result.push(line);
4909
+ result.push(item.line);
4910
+ prev = item;
4571
4911
  }
4572
4912
 
4913
+ // Trim trailing blank lines so output has exactly one terminal newline
4914
+ while (result.length > 0 && result[result.length - 1] === '') result.pop();
4915
+
4573
4916
  return result.join('\n');
4574
4917
  }
4575
4918