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