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