quikdown 1.1.0 → 1.2.2

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 (58) hide show
  1. package/README.md +46 -6
  2. package/dist/quikdown.cjs +5 -5
  3. package/dist/quikdown.dark.css +1 -1
  4. package/dist/quikdown.esm.js +5 -5
  5. package/dist/quikdown.esm.min.js +2 -2
  6. package/dist/quikdown.esm.min.js.map +1 -1
  7. package/dist/quikdown.light.css +1 -1
  8. package/dist/quikdown.umd.js +5 -5
  9. package/dist/quikdown.umd.min.js +2 -2
  10. package/dist/quikdown.umd.min.js.map +1 -1
  11. package/dist/quikdown_ast.cjs +513 -0
  12. package/dist/quikdown_ast.d.ts +227 -0
  13. package/dist/quikdown_ast.esm.js +511 -0
  14. package/dist/quikdown_ast.esm.min.js +8 -0
  15. package/dist/quikdown_ast.esm.min.js.map +1 -0
  16. package/dist/quikdown_ast.umd.js +519 -0
  17. package/dist/quikdown_ast.umd.min.js +8 -0
  18. package/dist/quikdown_ast.umd.min.js.map +1 -0
  19. package/dist/quikdown_ast_html.cjs +1058 -0
  20. package/dist/quikdown_ast_html.d.ts +68 -0
  21. package/dist/quikdown_ast_html.esm.js +1056 -0
  22. package/dist/quikdown_ast_html.esm.min.js +8 -0
  23. package/dist/quikdown_ast_html.esm.min.js.map +1 -0
  24. package/dist/quikdown_ast_html.umd.js +1064 -0
  25. package/dist/quikdown_ast_html.umd.min.js +8 -0
  26. package/dist/quikdown_ast_html.umd.min.js.map +1 -0
  27. package/dist/quikdown_bd.cjs +12 -12
  28. package/dist/quikdown_bd.esm.js +12 -12
  29. package/dist/quikdown_bd.esm.min.js +2 -2
  30. package/dist/quikdown_bd.esm.min.js.map +1 -1
  31. package/dist/quikdown_bd.umd.js +12 -12
  32. package/dist/quikdown_bd.umd.min.js +2 -2
  33. package/dist/quikdown_bd.umd.min.js.map +1 -1
  34. package/dist/quikdown_edit.cjs +2297 -136
  35. package/dist/quikdown_edit.d.ts +110 -132
  36. package/dist/quikdown_edit.esm.js +2297 -136
  37. package/dist/quikdown_edit.esm.min.js +3 -4
  38. package/dist/quikdown_edit.esm.min.js.map +1 -1
  39. package/dist/quikdown_edit.umd.js +2298 -137
  40. package/dist/quikdown_edit.umd.min.js +3 -4
  41. package/dist/quikdown_edit.umd.min.js.map +1 -1
  42. package/dist/quikdown_json.cjs +556 -0
  43. package/dist/quikdown_json.d.ts +48 -0
  44. package/dist/quikdown_json.esm.js +554 -0
  45. package/dist/quikdown_json.esm.min.js +8 -0
  46. package/dist/quikdown_json.esm.min.js.map +1 -0
  47. package/dist/quikdown_json.umd.js +562 -0
  48. package/dist/quikdown_json.umd.min.js +8 -0
  49. package/dist/quikdown_json.umd.min.js.map +1 -0
  50. package/dist/quikdown_yaml.cjs +717 -0
  51. package/dist/quikdown_yaml.d.ts +51 -0
  52. package/dist/quikdown_yaml.esm.js +715 -0
  53. package/dist/quikdown_yaml.esm.min.js +8 -0
  54. package/dist/quikdown_yaml.esm.min.js.map +1 -0
  55. package/dist/quikdown_yaml.umd.js +723 -0
  56. package/dist/quikdown_yaml.umd.min.js +8 -0
  57. package/dist/quikdown_yaml.umd.min.js.map +1 -0
  58. package/package.json +92 -39
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Quikdown Editor - Drop-in Markdown Parser
3
- * @version 1.1.0
3
+ * @version 1.2.2
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.1.0';
23
+ const quikdownVersion = '1.2.2';
24
24
 
25
25
  // Constants for reuse
26
26
  const CLASS_PREFIX = 'quikdown-';
@@ -270,7 +270,7 @@ function quikdown(markdown, options = {}) {
270
270
  html = '<p>' + html + '</p>';
271
271
  } else {
272
272
  // Standard: two spaces at end of line for line breaks
273
- html = html.replace(/ $/gm, `<br${getAttr('br')}>`);
273
+ html = html.replace(/ {2}$/gm, `<br${getAttr('br')}>`);
274
274
 
275
275
  // Paragraphs (double newlines)
276
276
  // Don't add </p> after block elements (they're not in paragraphs)
@@ -299,7 +299,7 @@ function quikdown(markdown, options = {}) {
299
299
  [/(<\/table>)<\/p>/g, '$1'],
300
300
  [/<p>(<pre[^>]*>)/g, '$1'],
301
301
  [/(<\/pre>)<\/p>/g, '$1'],
302
- [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)<\/p>`, 'g'), '$1']
302
+ [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)</p>`, 'g'), '$1']
303
303
  ];
304
304
 
305
305
  cleanupPatterns.forEach(([pattern, replacement]) => {
@@ -505,7 +505,7 @@ function processLists(text, getAttr, inline_styles, bidirectional) {
505
505
 
506
506
  const lines = text.split('\n');
507
507
  const result = [];
508
- let listStack = []; // Track nested lists
508
+ const listStack = []; // Track nested lists
509
509
 
510
510
  // Helper to escape HTML for data-qd attributes
511
511
  const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
@@ -715,7 +715,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
715
715
 
716
716
  // Process children with context
717
717
  let childContent = '';
718
- for (let child of node.childNodes) {
718
+ for (const child of node.childNodes) {
719
719
  childContent += walkNode(child, { parentTag: tag, ...parentContext });
720
720
  }
721
721
 
@@ -949,7 +949,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
949
949
  let index = 1;
950
950
  const indent = ' '.repeat(depth);
951
951
 
952
- for (let child of listNode.children) {
952
+ for (const child of listNode.children) {
953
953
  if (child.tagName !== 'LI') continue;
954
954
 
955
955
  const dataQd = child.getAttribute('data-qd');
@@ -962,7 +962,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
962
962
  marker = '-';
963
963
  // Get text without the checkbox
964
964
  let text = '';
965
- for (let node of child.childNodes) {
965
+ for (const node of child.childNodes) {
966
966
  if (node.nodeType === Node.TEXT_NODE) {
967
967
  text += node.textContent;
968
968
  } else if (node.tagName && node.tagName !== 'INPUT') {
@@ -973,7 +973,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
973
973
  } else {
974
974
  let itemContent = '';
975
975
 
976
- for (let node of child.childNodes) {
976
+ for (const node of child.childNodes) {
977
977
  if (node.tagName === 'UL' || node.tagName === 'OL') {
978
978
  itemContent += walkList(node, node.tagName === 'OL', depth + 1);
979
979
  } else {
@@ -1002,7 +1002,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
1002
1002
  const headerRow = thead.querySelector('tr');
1003
1003
  if (headerRow) {
1004
1004
  const headers = [];
1005
- for (let th of headerRow.querySelectorAll('th')) {
1005
+ for (const th of headerRow.querySelectorAll('th')) {
1006
1006
  headers.push(th.textContent.trim());
1007
1007
  }
1008
1008
  result += '| ' + headers.join(' | ') + ' |\n';
@@ -1021,9 +1021,9 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
1021
1021
  // Process body
1022
1022
  const tbody = table.querySelector('tbody');
1023
1023
  if (tbody) {
1024
- for (let row of tbody.querySelectorAll('tr')) {
1024
+ for (const row of tbody.querySelectorAll('tr')) {
1025
1025
  const cells = [];
1026
- for (let td of row.querySelectorAll('td')) {
1026
+ for (const td of row.querySelectorAll('td')) {
1027
1027
  cells.push(td.textContent.trim());
1028
1028
  }
1029
1029
  if (cells.length > 0) {
@@ -1066,6 +1066,1472 @@ if (typeof window !== 'undefined') {
1066
1066
  window.quikdown_bd = quikdown_bd;
1067
1067
  }
1068
1068
 
1069
+ /**
1070
+ * Rich copy functionality for QuikdownEditor
1071
+ * Handles copying rendered content with proper formatting for pasting into rich text editors
1072
+ */
1073
+
1074
+ /**
1075
+ * Get platform information
1076
+ * @returns {string} The detected platform: 'macos', 'windows', 'linux', or 'unknown'
1077
+ */
1078
+ function getPlatform() {
1079
+ const platform = navigator.platform?.toLowerCase() || '';
1080
+ const userAgent = navigator.userAgent?.toLowerCase() || '';
1081
+
1082
+ if (platform.includes('mac') || userAgent.includes('mac')) {
1083
+ return 'macos';
1084
+ } else if (userAgent.includes('windows')) {
1085
+ return 'windows';
1086
+ } else if (userAgent.includes('linux')) {
1087
+ return 'linux';
1088
+ }
1089
+ return 'unknown';
1090
+ }
1091
+
1092
+ /**
1093
+ * Copy to clipboard using HTML selection fallback (for Safari)
1094
+ * Uses div with selection to preserve HTML formatting
1095
+ * @param {string} html - HTML content to copy
1096
+ * @returns {boolean} Success status
1097
+ */
1098
+ function copyToClipboard(html) {
1099
+ let tempDiv;
1100
+ let result;
1101
+
1102
+ try {
1103
+ // Use a div instead of textarea to preserve HTML formatting
1104
+ tempDiv = document.createElement('div');
1105
+ tempDiv.style.position = 'fixed';
1106
+ tempDiv.style.left = '-9999px';
1107
+ tempDiv.style.top = '0';
1108
+ tempDiv.style.width = '1px';
1109
+ tempDiv.style.height = '1px';
1110
+ tempDiv.style.overflow = 'hidden';
1111
+ tempDiv.innerHTML = html;
1112
+
1113
+ document.body.appendChild(tempDiv);
1114
+
1115
+ // Select the HTML content
1116
+ const range = document.createRange();
1117
+ range.selectNodeContents(tempDiv);
1118
+ const selection = window.getSelection();
1119
+ selection.removeAllRanges();
1120
+ selection.addRange(range);
1121
+
1122
+ // Try to copy
1123
+ result = document.execCommand('copy');
1124
+
1125
+ // Clear selection
1126
+ selection.removeAllRanges();
1127
+ } catch (err) {
1128
+ console.error('Fallback copy failed:', err);
1129
+ result = false;
1130
+ } finally {
1131
+ if (tempDiv && tempDiv.parentNode) {
1132
+ document.body.removeChild(tempDiv);
1133
+ }
1134
+ }
1135
+
1136
+ return result;
1137
+ }
1138
+
1139
+ /**
1140
+ * Convert SVG to PNG blob (based on squibview's implementation)
1141
+ * @param {SVGElement} svgElement - The SVG element to convert
1142
+ * @returns {Promise<Blob>} A promise that resolves with the PNG blob
1143
+ */
1144
+ async function svgToPng(svgElement, needsWhiteBackground = false) {
1145
+ return new Promise((resolve, reject) => {
1146
+ const svgString = new XMLSerializer().serializeToString(svgElement);
1147
+ const canvas = document.createElement('canvas');
1148
+ const ctx = canvas.getContext('2d');
1149
+ const img = new Image();
1150
+
1151
+ const scale = 2;
1152
+
1153
+ // Check if this is a Mermaid-generated SVG (they don't have explicit width/height attributes)
1154
+ const isMermaidSvg = svgElement.closest('.mermaid') || svgElement.classList.contains('mermaid');
1155
+ const hasExplicitDimensions = svgElement.getAttribute('width') && svgElement.getAttribute('height');
1156
+
1157
+ let svgWidth, svgHeight;
1158
+
1159
+ if (isMermaidSvg || !hasExplicitDimensions) {
1160
+ // For Mermaid or other generated SVGs, prioritize computed dimensions
1161
+ svgWidth = svgElement.clientWidth ||
1162
+ (svgElement.viewBox && svgElement.viewBox.baseVal.width) ||
1163
+ parseFloat(svgElement.getAttribute('width')) || 400;
1164
+ svgHeight = svgElement.clientHeight ||
1165
+ (svgElement.viewBox && svgElement.viewBox.baseVal.height) ||
1166
+ parseFloat(svgElement.getAttribute('height')) || 300;
1167
+ } else {
1168
+ // For explicit SVGs (like fenced SVG blocks), prioritize explicit attributes
1169
+ svgWidth = parseFloat(svgElement.getAttribute('width')) ||
1170
+ (svgElement.viewBox && svgElement.viewBox.baseVal.width) ||
1171
+ svgElement.clientWidth || 400;
1172
+ svgHeight = parseFloat(svgElement.getAttribute('height')) ||
1173
+ (svgElement.viewBox && svgElement.viewBox.baseVal.height) ||
1174
+ svgElement.clientHeight || 300;
1175
+ }
1176
+
1177
+ // Ensure the SVG string has explicit dimensions by modifying it if necessary
1178
+ let modifiedSvgString = svgString;
1179
+ if (svgWidth && svgHeight) {
1180
+ // Create a temporary SVG element to modify the serialized string
1181
+ const tempDiv = document.createElement('div');
1182
+ tempDiv.innerHTML = svgString;
1183
+ const tempSvg = tempDiv.querySelector('svg');
1184
+ if (tempSvg) {
1185
+ tempSvg.setAttribute('width', svgWidth.toString());
1186
+ tempSvg.setAttribute('height', svgHeight.toString());
1187
+ modifiedSvgString = new XMLSerializer().serializeToString(tempSvg);
1188
+ }
1189
+ }
1190
+
1191
+ canvas.width = svgWidth * scale;
1192
+ canvas.height = svgHeight * scale;
1193
+ ctx.scale(scale, scale);
1194
+
1195
+ img.onload = () => {
1196
+ try {
1197
+ // Add white background for math equations (they often have transparent backgrounds)
1198
+ if (needsWhiteBackground) {
1199
+ ctx.fillStyle = 'white';
1200
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
1201
+ }
1202
+
1203
+ ctx.drawImage(img, 0, 0, svgWidth, svgHeight);
1204
+ canvas.toBlob(blob => {
1205
+ resolve(blob);
1206
+ }, 'image/png', 1.0);
1207
+ } catch (err) {
1208
+ reject(err);
1209
+ }
1210
+ };
1211
+
1212
+ img.onerror = reject;
1213
+ // Use data URI instead of blob URL to avoid tainting the canvas
1214
+ const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(modifiedSvgString)}`;
1215
+ img.src = svgDataUrl;
1216
+ });
1217
+ }
1218
+
1219
+ /**
1220
+ * Rasterize a GeoJSON Leaflet map to PNG data URL (following Gem's guide)
1221
+ * @param {HTMLElement} liveContainer - The live map container element
1222
+ * @returns {Promise<string|null>} PNG data URL or null if failed
1223
+ */
1224
+ async function rasterizeGeoJSONMap(liveContainer) {
1225
+ try {
1226
+ const map = liveContainer._map;
1227
+ if (!map) {
1228
+ console.warn('No map found on container');
1229
+ return null;
1230
+ }
1231
+
1232
+ // Get container dimensions
1233
+ const mapRect = liveContainer.getBoundingClientRect();
1234
+ const width = Math.round(mapRect.width);
1235
+ const height = Math.round(mapRect.height);
1236
+
1237
+ if (width === 0 || height === 0) {
1238
+ console.warn('Map container has zero dimensions');
1239
+ return null;
1240
+ }
1241
+
1242
+ // Create canvas sized to the map container
1243
+ const canvas = document.createElement('canvas');
1244
+ const dpr = window.devicePixelRatio || 1;
1245
+
1246
+ // Set canvas size with DPR for sharpness
1247
+ canvas.width = width * dpr;
1248
+ canvas.height = height * dpr;
1249
+ canvas.style.width = width + 'px';
1250
+ canvas.style.height = height + 'px';
1251
+
1252
+ const ctx = canvas.getContext('2d');
1253
+ ctx.scale(dpr, dpr);
1254
+
1255
+ // White background
1256
+ ctx.fillStyle = '#FFFFFF';
1257
+ ctx.fillRect(0, 0, width, height);
1258
+
1259
+ // 1. Draw tiles from THIS container only
1260
+ const tiles = liveContainer.querySelectorAll('.leaflet-tile');
1261
+
1262
+ const tilePromises = [];
1263
+ for (const tile of tiles) {
1264
+ tilePromises.push(new Promise((resolve) => {
1265
+ const img = new Image();
1266
+ img.crossOrigin = 'anonymous';
1267
+
1268
+ img.onload = () => {
1269
+ try {
1270
+ // Calculate tile position relative to container
1271
+ const tileRect = tile.getBoundingClientRect();
1272
+ const offsetX = tileRect.left - mapRect.left;
1273
+ const offsetY = tileRect.top - mapRect.top;
1274
+
1275
+ // Draw tile at correct position
1276
+ ctx.drawImage(img, offsetX, offsetY, tileRect.width, tileRect.height);
1277
+ } catch (err) {
1278
+ console.warn('Failed to draw tile:', err);
1279
+ }
1280
+ resolve();
1281
+ };
1282
+
1283
+ img.onerror = () => {
1284
+ console.warn('Failed to load tile:', tile.src);
1285
+ resolve();
1286
+ };
1287
+
1288
+ img.src = tile.src;
1289
+ }));
1290
+ }
1291
+
1292
+ // Wait for all tiles to load
1293
+ await Promise.all(tilePromises);
1294
+
1295
+ // 2. Draw vector overlays (SVG paths for GeoJSON features)
1296
+ const svgOverlays = liveContainer.querySelectorAll('svg:not(.leaflet-attribution-flag)');
1297
+
1298
+ for (const svg of svgOverlays) {
1299
+ // Skip attribution/control overlays
1300
+ if (svg.closest('.leaflet-control')) continue;
1301
+
1302
+ try {
1303
+ const svgRect = svg.getBoundingClientRect();
1304
+ const offsetX = svgRect.left - mapRect.left;
1305
+ const offsetY = svgRect.top - mapRect.top;
1306
+
1307
+ // Serialize SVG
1308
+ const serializer = new XMLSerializer();
1309
+ const svgStr = serializer.serializeToString(svg);
1310
+ const svgBlob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
1311
+ const url = URL.createObjectURL(svgBlob);
1312
+
1313
+ // Draw SVG overlay
1314
+ await new Promise((resolve, reject) => {
1315
+ const img = new Image();
1316
+ img.onload = () => {
1317
+ ctx.drawImage(img, offsetX, offsetY, svgRect.width, svgRect.height);
1318
+ URL.revokeObjectURL(url);
1319
+ resolve();
1320
+ };
1321
+ img.onerror = () => {
1322
+ URL.revokeObjectURL(url);
1323
+ reject(new Error('Failed to load SVG overlay'));
1324
+ };
1325
+ img.src = url;
1326
+ });
1327
+ } catch (err) {
1328
+ console.warn('Failed to draw SVG overlay:', err);
1329
+ }
1330
+ }
1331
+
1332
+ // 3. Draw marker icons if any
1333
+ const markerIcons = liveContainer.querySelectorAll('.leaflet-marker-icon');
1334
+
1335
+ for (const marker of markerIcons) {
1336
+ try {
1337
+ const img = new Image();
1338
+ img.crossOrigin = 'anonymous';
1339
+
1340
+ await new Promise((resolve) => {
1341
+ img.onload = () => {
1342
+ const markerRect = marker.getBoundingClientRect();
1343
+ const offsetX = markerRect.left - mapRect.left;
1344
+ const offsetY = markerRect.top - mapRect.top;
1345
+ ctx.drawImage(img, offsetX, offsetY, markerRect.width, markerRect.height);
1346
+ resolve();
1347
+ };
1348
+ img.onerror = resolve;
1349
+ img.src = marker.src;
1350
+ });
1351
+ } catch (err) {
1352
+ console.warn('Failed to draw marker icon:', err);
1353
+ }
1354
+ }
1355
+
1356
+ // Return PNG data URL
1357
+ return canvas.toDataURL('image/png', 1.0);
1358
+
1359
+ } catch (error) {
1360
+ console.error('Failed to rasterize GeoJSON map:', error);
1361
+ return null;
1362
+ }
1363
+ }
1364
+
1365
+ /**
1366
+ * Get rendered content as rich HTML suitable for clipboard
1367
+ * @param {HTMLElement} previewPanel - The preview panel element to copy from
1368
+ * @returns {Promise<{success: boolean, html?: string, text?: string}>}
1369
+ */
1370
+ async function getRenderedContent(previewPanel) {
1371
+ if (!previewPanel) {
1372
+ throw new Error('No preview panel available');
1373
+ }
1374
+
1375
+ // Check if MathJax needs to render (only if not already rendered)
1376
+ const mathBlocks = previewPanel.querySelectorAll('.math-display');
1377
+ if (mathBlocks.length > 0) {
1378
+ // Check if already rendered (has mjx-container inside)
1379
+ const needsRendering = Array.from(mathBlocks).some(block => !block.querySelector('mjx-container'));
1380
+
1381
+ if (needsRendering && window.MathJax && window.MathJax.typesetPromise) {
1382
+ try {
1383
+ await window.MathJax.typesetPromise(Array.from(mathBlocks));
1384
+ } catch (err) {
1385
+ console.warn('MathJax typesetting failed:', err);
1386
+ }
1387
+ }
1388
+ }
1389
+
1390
+ // Clone the preview panel to avoid modifying the actual DOM
1391
+ const clone = previewPanel.cloneNode(true);
1392
+
1393
+ // Process different fence types for rich copy
1394
+ try {
1395
+ // Phase 1: Process basic markdown elements with inline styles
1396
+
1397
+ // 1.1 Text formatting - add inline styles
1398
+ clone.querySelectorAll('strong, b').forEach(el => {
1399
+ el.style.fontWeight = 'bold';
1400
+ });
1401
+
1402
+ clone.querySelectorAll('em, i').forEach(el => {
1403
+ el.style.fontStyle = 'italic';
1404
+ });
1405
+
1406
+ clone.querySelectorAll('del, s, strike').forEach(el => {
1407
+ el.style.textDecoration = 'line-through';
1408
+ });
1409
+
1410
+ clone.querySelectorAll('u').forEach(el => {
1411
+ el.style.textDecoration = 'underline';
1412
+ });
1413
+
1414
+ clone.querySelectorAll('code:not(pre code)').forEach(el => {
1415
+ el.style.backgroundColor = '#f4f4f4';
1416
+ el.style.padding = '2px 4px';
1417
+ el.style.borderRadius = '3px';
1418
+ el.style.fontFamily = 'monospace';
1419
+ el.style.fontSize = '0.9em';
1420
+ });
1421
+
1422
+ // 1.2 Block elements - add inline styles
1423
+ clone.querySelectorAll('h1').forEach(el => {
1424
+ el.style.fontSize = '2em';
1425
+ el.style.fontWeight = 'bold';
1426
+ el.style.marginTop = '0.67em';
1427
+ el.style.marginBottom = '0.67em';
1428
+ });
1429
+
1430
+ clone.querySelectorAll('h2').forEach(el => {
1431
+ el.style.fontSize = '1.5em';
1432
+ el.style.fontWeight = 'bold';
1433
+ el.style.marginTop = '0.83em';
1434
+ el.style.marginBottom = '0.83em';
1435
+ });
1436
+
1437
+ clone.querySelectorAll('h3').forEach(el => {
1438
+ el.style.fontSize = '1.17em';
1439
+ el.style.fontWeight = 'bold';
1440
+ el.style.marginTop = '1em';
1441
+ el.style.marginBottom = '1em';
1442
+ });
1443
+
1444
+ clone.querySelectorAll('h4').forEach(el => {
1445
+ el.style.fontSize = '1em';
1446
+ el.style.fontWeight = 'bold';
1447
+ el.style.marginTop = '1.33em';
1448
+ el.style.marginBottom = '1.33em';
1449
+ });
1450
+
1451
+ clone.querySelectorAll('h5').forEach(el => {
1452
+ el.style.fontSize = '0.83em';
1453
+ el.style.fontWeight = 'bold';
1454
+ el.style.marginTop = '1.67em';
1455
+ el.style.marginBottom = '1.67em';
1456
+ });
1457
+
1458
+ clone.querySelectorAll('h6').forEach(el => {
1459
+ el.style.fontSize = '0.67em';
1460
+ el.style.fontWeight = 'bold';
1461
+ el.style.marginTop = '2.33em';
1462
+ el.style.marginBottom = '2.33em';
1463
+ });
1464
+
1465
+ clone.querySelectorAll('blockquote').forEach(el => {
1466
+ el.style.borderLeft = '4px solid #ddd';
1467
+ el.style.marginLeft = '0';
1468
+ el.style.paddingLeft = '1em';
1469
+ el.style.color = '#666';
1470
+ });
1471
+
1472
+ clone.querySelectorAll('hr').forEach(el => {
1473
+ el.style.border = 'none';
1474
+ el.style.borderTop = '1px solid #ccc';
1475
+ el.style.margin = '1em 0';
1476
+ });
1477
+
1478
+ // 1.3 Tables - add inline styles
1479
+ clone.querySelectorAll('table').forEach(table => {
1480
+ table.style.borderCollapse = 'collapse';
1481
+ table.style.width = '100%';
1482
+ table.style.marginBottom = '1em';
1483
+ });
1484
+
1485
+ clone.querySelectorAll('th').forEach(th => {
1486
+ th.style.border = '1px solid #ccc';
1487
+ th.style.padding = '8px';
1488
+ th.style.textAlign = 'left';
1489
+ th.style.backgroundColor = '#f0f0f0';
1490
+ th.style.fontWeight = 'bold';
1491
+ });
1492
+
1493
+ clone.querySelectorAll('td').forEach(td => {
1494
+ td.style.border = '1px solid #ccc';
1495
+ td.style.padding = '8px';
1496
+ td.style.textAlign = 'left';
1497
+ });
1498
+
1499
+ // 1.4 Links - add inline styles
1500
+ clone.querySelectorAll('a').forEach(a => {
1501
+ a.style.color = '#0066cc';
1502
+ a.style.textDecoration = 'underline';
1503
+ });
1504
+
1505
+ // Process code blocks - wrap in table and add syntax highlighting colors
1506
+ clone.querySelectorAll('pre code').forEach(block => {
1507
+ const pre = block.parentElement;
1508
+
1509
+ // Add inline styles for syntax highlighting (GitHub theme colors)
1510
+ if (block.classList.contains('hljs')) {
1511
+ // Apply inline styles to all highlight.js elements
1512
+ block.querySelectorAll('.hljs-keyword').forEach(el => {
1513
+ el.style.color = '#d73a49';
1514
+ el.style.fontWeight = 'bold';
1515
+ });
1516
+ block.querySelectorAll('.hljs-string').forEach(el => {
1517
+ el.style.color = '#032f62';
1518
+ });
1519
+ block.querySelectorAll('.hljs-number').forEach(el => {
1520
+ el.style.color = '#005cc5';
1521
+ });
1522
+ block.querySelectorAll('.hljs-comment').forEach(el => {
1523
+ el.style.color = '#6a737d';
1524
+ el.style.fontStyle = 'italic';
1525
+ });
1526
+ block.querySelectorAll('.hljs-function').forEach(el => {
1527
+ el.style.color = '#6f42c1';
1528
+ });
1529
+ block.querySelectorAll('.hljs-class').forEach(el => {
1530
+ el.style.color = '#6f42c1';
1531
+ });
1532
+ block.querySelectorAll('.hljs-title').forEach(el => {
1533
+ el.style.color = '#6f42c1';
1534
+ });
1535
+ block.querySelectorAll('.hljs-built_in').forEach(el => {
1536
+ el.style.color = '#005cc5';
1537
+ });
1538
+ block.querySelectorAll('.hljs-literal').forEach(el => {
1539
+ el.style.color = '#005cc5';
1540
+ });
1541
+ block.querySelectorAll('.hljs-meta').forEach(el => {
1542
+ el.style.color = '#005cc5';
1543
+ });
1544
+ block.querySelectorAll('.hljs-attr').forEach(el => {
1545
+ el.style.color = '#22863a';
1546
+ });
1547
+ block.querySelectorAll('.hljs-variable').forEach(el => {
1548
+ el.style.color = '#e36209';
1549
+ });
1550
+ block.querySelectorAll('.hljs-regexp').forEach(el => {
1551
+ el.style.color = '#032f62';
1552
+ });
1553
+ block.querySelectorAll('.hljs-selector-class').forEach(el => {
1554
+ el.style.color = '#22863a';
1555
+ });
1556
+ block.querySelectorAll('.hljs-selector-id').forEach(el => {
1557
+ el.style.color = '#6f42c1';
1558
+ });
1559
+ block.querySelectorAll('.hljs-selector-tag').forEach(el => {
1560
+ el.style.color = '#22863a';
1561
+ });
1562
+ block.querySelectorAll('.hljs-tag').forEach(el => {
1563
+ el.style.color = '#22863a';
1564
+ });
1565
+ block.querySelectorAll('.hljs-name').forEach(el => {
1566
+ el.style.color = '#22863a';
1567
+ });
1568
+ block.querySelectorAll('.hljs-attribute').forEach(el => {
1569
+ el.style.color = '#6f42c1';
1570
+ });
1571
+ }
1572
+
1573
+ const table = document.createElement('table');
1574
+ table.style.width = '100%';
1575
+ table.style.borderCollapse = 'collapse';
1576
+ table.style.border = 'none';
1577
+ table.style.marginBottom = '1em';
1578
+
1579
+ const tr = document.createElement('tr');
1580
+ const td = document.createElement('td');
1581
+ td.style.backgroundColor = '#f7f7f7';
1582
+ td.style.padding = '12px';
1583
+ td.style.fontFamily = 'Consolas, Monaco, "Courier New", monospace';
1584
+ td.style.fontSize = '14px';
1585
+ td.style.lineHeight = '1.4';
1586
+ td.style.whiteSpace = 'pre';
1587
+ td.style.overflowX = 'auto';
1588
+ td.style.border = '1px solid #ddd';
1589
+ td.style.borderRadius = '4px';
1590
+
1591
+ // Move the formatted code content with inline styles
1592
+ td.innerHTML = block.innerHTML;
1593
+
1594
+ tr.appendChild(td);
1595
+ table.appendChild(tr);
1596
+
1597
+ // Replace the pre element with the table
1598
+ pre.parentNode.replaceChild(table, pre);
1599
+ });
1600
+
1601
+ // Process images - convert to data URLs and ensure proper dimensions
1602
+ const images = clone.querySelectorAll('img');
1603
+ for (const img of images) {
1604
+ // Ensure image has dimensions for Google Docs compatibility
1605
+ if (!img.width && img.naturalWidth) {
1606
+ img.width = img.naturalWidth;
1607
+ }
1608
+ if (!img.height && img.naturalHeight) {
1609
+ img.height = img.naturalHeight;
1610
+ }
1611
+
1612
+ // Set max dimensions to prevent huge images
1613
+ const maxWidth = 800;
1614
+ const maxHeight = 600;
1615
+ if (img.width > maxWidth || img.height > maxHeight) {
1616
+ const scale = Math.min(maxWidth / img.width, maxHeight / img.height);
1617
+ img.width = Math.round(img.width * scale);
1618
+ img.height = Math.round(img.height * scale);
1619
+ }
1620
+
1621
+ // Ensure width and height attributes are set
1622
+ if (img.width) {
1623
+ img.setAttribute('width', img.width.toString());
1624
+ img.style.width = img.width + 'px';
1625
+ }
1626
+ if (img.height) {
1627
+ img.setAttribute('height', img.height.toString());
1628
+ img.style.height = img.height + 'px';
1629
+ }
1630
+
1631
+ // Add v:shapes for Word compatibility
1632
+ if (!img.getAttribute('v:shapes')) {
1633
+ img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
1634
+ }
1635
+
1636
+ // Skip if already a data URL
1637
+ if (img.src && !img.src.startsWith('data:')) {
1638
+ try {
1639
+ // Try to convert to data URL
1640
+ const response = await fetch(img.src);
1641
+ const blob = await response.blob();
1642
+
1643
+ // Check if image is too large (Google Docs has limits)
1644
+ const maxSize = 2 * 1024 * 1024; // 2MB limit for inline images
1645
+ if (blob.size > maxSize) {
1646
+ console.warn('Image too large for inline data URL:', img.src, 'Size:', blob.size);
1647
+ // For large images, we might want to resize or keep the URL
1648
+ continue;
1649
+ }
1650
+
1651
+ const dataUrl = await new Promise(resolve => {
1652
+ const reader = new FileReader();
1653
+ reader.onloadend = () => resolve(reader.result);
1654
+ reader.readAsDataURL(blob);
1655
+ });
1656
+ img.src = dataUrl;
1657
+ } catch (err) {
1658
+ console.warn('Failed to convert image to data URL:', img.src, err);
1659
+ // Keep original src if conversion fails
1660
+ }
1661
+ }
1662
+ }
1663
+
1664
+ // Phase 2: Process fence block types
1665
+ // 1. Process STL 3D models - convert canvas to image or placeholder
1666
+ const stlContainers = clone.querySelectorAll('.qde-stl-container');
1667
+ for (const container of stlContainers) {
1668
+ try {
1669
+ // Find the corresponding original container to get the canvas
1670
+ const containerId = container.dataset.stlId;
1671
+ const originalContainer = previewPanel.querySelector(`.qde-stl-container[data-stl-id="${containerId}"]`);
1672
+
1673
+ if (originalContainer) {
1674
+ // Look for canvas element in the original container (Three.js WebGL canvas)
1675
+ const canvas = originalContainer.querySelector('canvas');
1676
+ if (canvas && canvas.width > 0 && canvas.height > 0) {
1677
+ try {
1678
+ // Get Three.js references stored on the container (like squibview)
1679
+ const renderer = originalContainer._threeRenderer;
1680
+ const scene = originalContainer._threeScene;
1681
+ const camera = originalContainer._threeCamera;
1682
+
1683
+ // If we have access to the Three.js objects, force render the scene
1684
+ if (renderer && scene && camera) {
1685
+ renderer.render(scene, camera);
1686
+ }
1687
+
1688
+ // Try to capture the canvas as an image
1689
+ const dataUrl = canvas.toDataURL('image/png', 1.0);
1690
+ const img = document.createElement('img');
1691
+ img.src = dataUrl;
1692
+
1693
+ // Use canvas dimensions for the image
1694
+ const imgWidth = canvas.width / 2; // Divide by scale factor (2x for retina)
1695
+ const imgHeight = canvas.height / 2;
1696
+
1697
+ // Set both HTML attributes and CSS properties for maximum compatibility
1698
+ img.width = imgWidth;
1699
+ img.height = imgHeight;
1700
+ img.setAttribute('width', imgWidth.toString());
1701
+ img.setAttribute('height', imgHeight.toString());
1702
+ img.style.width = imgWidth + 'px';
1703
+ img.style.height = imgHeight + 'px';
1704
+ img.style.maxWidth = 'none';
1705
+ img.style.maxHeight = 'none';
1706
+ img.style.border = '1px solid #ddd';
1707
+ img.style.borderRadius = '4px';
1708
+ img.style.margin = '0.5em 0';
1709
+ img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
1710
+ img.alt = 'STL 3D Model';
1711
+
1712
+ container.parentNode.replaceChild(img, container);
1713
+ continue;
1714
+ } catch (canvasErr) {
1715
+ console.warn('Failed to convert STL canvas to image (likely WebGL context issue):', canvasErr);
1716
+ }
1717
+ } else {
1718
+ console.warn('No valid canvas found in STL container');
1719
+ }
1720
+ } else {
1721
+ console.warn('Could not find original STL container');
1722
+ }
1723
+ } catch (err) {
1724
+ console.error('Error processing STL container for copy:', err);
1725
+ }
1726
+
1727
+ // Fallback to placeholder if canvas conversion fails
1728
+ const placeholder = document.createElement('div');
1729
+ placeholder.style.cssText = 'padding: 12px; background-color: #f0f0f0; border: 1px solid #ccc; text-align: center; margin: 0.5em 0; border-radius: 4px;';
1730
+ placeholder.textContent = '[STL 3D Model - Interactive content not available in copy]';
1731
+ container.parentNode.replaceChild(placeholder, container);
1732
+ }
1733
+
1734
+ // 2. Process Mermaid diagrams - convert SVG to PNG
1735
+ const mermaidContainers = clone.querySelectorAll('.mermaid');
1736
+ for (const container of mermaidContainers) {
1737
+ const svg = container.querySelector('svg');
1738
+ if (svg) {
1739
+ try {
1740
+ const pngBlob = await svgToPng(svg);
1741
+ const dataUrl = await new Promise(resolve => {
1742
+ const reader = new FileReader();
1743
+ reader.onloadend = () => resolve(reader.result);
1744
+ reader.readAsDataURL(pngBlob);
1745
+ });
1746
+
1747
+ const img = document.createElement('img');
1748
+ img.src = dataUrl;
1749
+
1750
+ // Use the exact same dimension calculation logic as svgToPng (like squibview)
1751
+ const isMermaidSvg = svg.closest('.mermaid') || svg.classList.contains('mermaid');
1752
+ const hasExplicitDimensions = svg.getAttribute('width') && svg.getAttribute('height');
1753
+
1754
+ let imgWidth, imgHeight;
1755
+
1756
+ if (isMermaidSvg || !hasExplicitDimensions) {
1757
+ // For Mermaid or other generated SVGs, prioritize computed dimensions
1758
+ imgWidth = svg.clientWidth ||
1759
+ (svg.viewBox && svg.viewBox.baseVal.width) ||
1760
+ parseFloat(svg.getAttribute('width')) || 400;
1761
+ imgHeight = svg.clientHeight ||
1762
+ (svg.viewBox && svg.viewBox.baseVal.height) ||
1763
+ parseFloat(svg.getAttribute('height')) || 300;
1764
+ } else {
1765
+ // For explicit SVGs (like fenced SVG blocks), prioritize explicit attributes
1766
+ imgWidth = parseFloat(svg.getAttribute('width')) ||
1767
+ (svg.viewBox && svg.viewBox.baseVal.width) ||
1768
+ svg.clientWidth || 400;
1769
+ imgHeight = parseFloat(svg.getAttribute('height')) ||
1770
+ (svg.viewBox && svg.viewBox.baseVal.height) ||
1771
+ svg.clientHeight || 300;
1772
+ }
1773
+
1774
+ // Set both HTML attributes and CSS properties for maximum compatibility (like squibview)
1775
+ img.width = imgWidth;
1776
+ img.height = imgHeight;
1777
+ img.setAttribute('width', imgWidth.toString());
1778
+ img.setAttribute('height', imgHeight.toString());
1779
+ img.style.width = imgWidth + 'px';
1780
+ img.style.height = imgHeight + 'px';
1781
+ img.style.maxWidth = 'none'; // Prevent CSS from constraining the image
1782
+ img.style.maxHeight = 'none';
1783
+ img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
1784
+ img.alt = 'Mermaid Diagram';
1785
+
1786
+ container.parentNode.replaceChild(img, container);
1787
+ } catch (err) {
1788
+ console.warn('Failed to convert Mermaid diagram:', err);
1789
+ // Fallback: leave as SVG
1790
+ }
1791
+ }
1792
+ }
1793
+
1794
+ // 3. Process Chart.js charts - convert canvas to image
1795
+ const chartContainers = clone.querySelectorAll('.qde-chart-container');
1796
+ for (const container of chartContainers) {
1797
+ try {
1798
+ const containerId = container.dataset.chartId;
1799
+ const originalContainer = previewPanel.querySelector(`.qde-chart-container[data-chart-id="${containerId}"]`);
1800
+
1801
+ if (originalContainer) {
1802
+ const canvas = originalContainer.querySelector('canvas');
1803
+ if (canvas && canvas.width > 0 && canvas.height > 0) {
1804
+ try {
1805
+ const dataUrl = canvas.toDataURL('image/png', 1.0);
1806
+ const img = document.createElement('img');
1807
+ img.src = dataUrl;
1808
+
1809
+ // Use canvas dimensions for the image
1810
+ const imgWidth = canvas.width;
1811
+ const imgHeight = canvas.height;
1812
+
1813
+ // Set both HTML attributes and CSS properties for maximum compatibility
1814
+ img.width = imgWidth;
1815
+ img.height = imgHeight;
1816
+ img.setAttribute('width', imgWidth.toString());
1817
+ img.setAttribute('height', imgHeight.toString());
1818
+ img.style.width = imgWidth + 'px';
1819
+ img.style.height = imgHeight + 'px';
1820
+ img.style.maxWidth = 'none';
1821
+ img.style.maxHeight = 'none';
1822
+ img.style.margin = '0.5em 0';
1823
+ img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
1824
+ img.alt = 'Chart';
1825
+
1826
+ container.parentNode.replaceChild(img, container);
1827
+ continue;
1828
+ } catch (canvasErr) {
1829
+ console.warn('Failed to convert chart canvas to image:', canvasErr);
1830
+ }
1831
+ }
1832
+ }
1833
+ } catch (err) {
1834
+ console.warn('Error processing chart container:', err);
1835
+ }
1836
+
1837
+ // Fallback to placeholder
1838
+ const placeholder = document.createElement('div');
1839
+ placeholder.style.cssText = 'padding: 12px; background-color: #f0f0f0; border: 1px solid #ccc; text-align: center; margin: 0.5em 0; border-radius: 4px;';
1840
+ placeholder.textContent = '[Chart - Interactive content not available in copy]';
1841
+ container.parentNode.replaceChild(placeholder, container);
1842
+ }
1843
+
1844
+ // 4. Process SVG fenced blocks - convert to PNG
1845
+ const svgContainers = clone.querySelectorAll('.qde-svg-container svg');
1846
+ for (const svg of svgContainers) {
1847
+ try {
1848
+ const pngBlob = await svgToPng(svg);
1849
+ const dataUrl = await new Promise(resolve => {
1850
+ const reader = new FileReader();
1851
+ reader.onloadend = () => resolve(reader.result);
1852
+ reader.readAsDataURL(pngBlob);
1853
+ });
1854
+
1855
+ const img = document.createElement('img');
1856
+ img.src = dataUrl;
1857
+
1858
+ // Calculate dimensions for the SVG
1859
+ const hasExplicitDimensions = svg.getAttribute('width') && svg.getAttribute('height');
1860
+
1861
+ let imgWidth, imgHeight;
1862
+
1863
+ if (hasExplicitDimensions) {
1864
+ // For explicit SVGs (like fenced SVG blocks), prioritize explicit attributes
1865
+ imgWidth = parseFloat(svg.getAttribute('width')) ||
1866
+ (svg.viewBox && svg.viewBox.baseVal.width) ||
1867
+ svg.clientWidth || 400;
1868
+ imgHeight = parseFloat(svg.getAttribute('height')) ||
1869
+ (svg.viewBox && svg.viewBox.baseVal.height) ||
1870
+ svg.clientHeight || 300;
1871
+ } else {
1872
+ // For generated SVGs, prioritize computed dimensions
1873
+ imgWidth = svg.clientWidth ||
1874
+ (svg.viewBox && svg.viewBox.baseVal.width) ||
1875
+ parseFloat(svg.getAttribute('width')) || 400;
1876
+ imgHeight = svg.clientHeight ||
1877
+ (svg.viewBox && svg.viewBox.baseVal.height) ||
1878
+ parseFloat(svg.getAttribute('height')) || 300;
1879
+ }
1880
+
1881
+ // Set both HTML attributes and CSS properties for maximum compatibility
1882
+ img.width = imgWidth;
1883
+ img.height = imgHeight;
1884
+ img.setAttribute('width', imgWidth.toString());
1885
+ img.setAttribute('height', imgHeight.toString());
1886
+ img.style.width = imgWidth + 'px';
1887
+ img.style.height = imgHeight + 'px';
1888
+ img.style.maxWidth = 'none'; // Prevent CSS from constraining the image
1889
+ img.style.maxHeight = 'none';
1890
+ img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
1891
+ img.alt = 'SVG Image';
1892
+
1893
+ svg.parentNode.replaceChild(img, svg);
1894
+ } catch (err) {
1895
+ console.warn('Failed to convert SVG to image:', err);
1896
+ // Leave as SVG if conversion fails
1897
+ }
1898
+ }
1899
+
1900
+ // 5. Process Math equations - convert to PNG images (exactly like SquibView)
1901
+ const mathElements = Array.from(clone.querySelectorAll('.math-display'));
1902
+
1903
+ if (mathElements.length > 0) {
1904
+ for (const mathEl of mathElements) {
1905
+ try {
1906
+ // Find SVG inside the math element (MathJax creates it)
1907
+ const svg = mathEl.querySelector('svg');
1908
+ if (!svg) {
1909
+ console.warn('No SVG found in math element, skipping');
1910
+ continue;
1911
+ }
1912
+
1913
+ // Convert SVG to PNG data URL (exactly like SquibView)
1914
+ const serializer = new XMLSerializer();
1915
+ const svgStr = serializer.serializeToString(svg);
1916
+ const svgBlob = new Blob([svgStr], { type: "image/svg+xml;charset=utf-8" });
1917
+ const url = URL.createObjectURL(svgBlob);
1918
+
1919
+ const img = new Image();
1920
+ const dataUrl = await new Promise((resolve, reject) => {
1921
+ img.onload = function () {
1922
+ const canvas = document.createElement('canvas');
1923
+
1924
+ // Determine SVG dimensions robustly (exactly like SquibView)
1925
+ let width, height;
1926
+ try {
1927
+ // First try baseVal.value (works for absolute units)
1928
+ width = svg.width.baseVal.value;
1929
+ height = svg.height.baseVal.value;
1930
+ } catch (_e) {
1931
+ // Fallback for relative units - use viewBox or rendered size
1932
+ if (svg.viewBox && svg.viewBox.baseVal) {
1933
+ width = svg.viewBox.baseVal.width;
1934
+ height = svg.viewBox.baseVal.height;
1935
+ } else {
1936
+ // Use the natural size of the loaded image
1937
+ width = img.naturalWidth || img.width || 200;
1938
+ height = img.naturalHeight || img.height || 50;
1939
+ }
1940
+ }
1941
+
1942
+ // Scale down for much smaller paste sizes
1943
+ const targetMaxWidth = 150; // Further reduced
1944
+ const targetMaxHeight = 45; // Further reduced
1945
+
1946
+ // Apply aggressive downsizing for MathJax SVGs
1947
+ let scaleFactor = 0.04; // Further reduced for smaller output
1948
+
1949
+ const scaledWidth = width * scaleFactor;
1950
+ const scaledHeight = height * scaleFactor;
1951
+
1952
+ // If still too large after base scaling, scale down further
1953
+ if (scaledWidth > targetMaxWidth || scaledHeight > targetMaxHeight) {
1954
+ const scaleX = targetMaxWidth / scaledWidth;
1955
+ const scaleY = targetMaxHeight / scaledHeight;
1956
+ scaleFactor *= Math.min(scaleX, scaleY);
1957
+ }
1958
+
1959
+ width *= scaleFactor;
1960
+ height *= scaleFactor;
1961
+
1962
+ // Use higher DPR for crisp rendering at smaller sizes
1963
+ const dpr = 2; // Fixed 2x for consistent quality
1964
+ canvas.width = width * dpr;
1965
+ canvas.height = height * dpr;
1966
+ canvas.style.width = width + 'px';
1967
+ canvas.style.height = height + 'px';
1968
+
1969
+ const ctx = canvas.getContext('2d');
1970
+ ctx.scale(dpr, dpr);
1971
+
1972
+ // White background
1973
+ ctx.fillStyle = "#FFFFFF";
1974
+ ctx.fillRect(0, 0, width, height);
1975
+
1976
+ // Draw the SVG image at logical size
1977
+ ctx.drawImage(img, 0, 0, width, height);
1978
+
1979
+ // Clean up URL
1980
+ URL.revokeObjectURL(url);
1981
+
1982
+ // Return data URL
1983
+ resolve(canvas.toDataURL('image/png'));
1984
+ };
1985
+
1986
+ img.onerror = () => {
1987
+ URL.revokeObjectURL(url);
1988
+ reject(new Error('Failed to load SVG image'));
1989
+ };
1990
+
1991
+ img.src = url;
1992
+ });
1993
+
1994
+ // Replace math element with img tag containing the PNG data URL
1995
+ const imgElement = document.createElement('img');
1996
+ imgElement.src = dataUrl;
1997
+
1998
+ // Extract dimensions from the data URL canvas
1999
+ const img2 = new Image();
2000
+ img2.src = dataUrl;
2001
+ await new Promise((resolve) => {
2002
+ img2.onload = resolve;
2003
+ img2.onerror = resolve;
2004
+ setTimeout(resolve, 100); // Timeout fallback
2005
+ });
2006
+
2007
+ // Set explicit dimensions (accounting for DPR)
2008
+ const displayWidth = img2.naturalWidth / 2; // Divide by DPR
2009
+ const displayHeight = img2.naturalHeight / 2;
2010
+
2011
+ imgElement.width = displayWidth;
2012
+ imgElement.height = displayHeight;
2013
+ imgElement.style.cssText = `display:inline-block;margin:0.5em 0;width:${displayWidth}px;height:${displayHeight}px;vertical-align:middle;`;
2014
+ imgElement.alt = 'Math equation';
2015
+
2016
+ mathEl.parentNode.replaceChild(imgElement, mathEl);
2017
+ } catch (error) {
2018
+ console.error('Failed to convert math element to image:', error);
2019
+ // Keep the original element if conversion fails
2020
+ }
2021
+ }
2022
+ }
2023
+
2024
+ // 2. Process GeoJSON maps - convert to static images (following Gem's guide)
2025
+ const geojsonContainers = clone.querySelectorAll('.geojson-container');
2026
+ if (geojsonContainers.length > 0) {
2027
+
2028
+ for (const clonedContainer of geojsonContainers) {
2029
+ try {
2030
+ // Find the corresponding live container by matching data-original-source
2031
+ const originalSource = clonedContainer.getAttribute('data-original-source');
2032
+ if (!originalSource) {
2033
+ console.warn('No original source found for GeoJSON container');
2034
+ continue;
2035
+ }
2036
+
2037
+ // Find live container with same source
2038
+ let liveContainer = null;
2039
+ const allLiveContainers = previewPanel.querySelectorAll('.geojson-container');
2040
+ for (const candidate of allLiveContainers) {
2041
+ if (candidate.getAttribute('data-original-source') === originalSource) {
2042
+ liveContainer = candidate;
2043
+ break;
2044
+ }
2045
+ }
2046
+
2047
+ if (!liveContainer) {
2048
+ console.warn('Could not find live GeoJSON container');
2049
+ const placeholder = document.createElement('div');
2050
+ placeholder.style.cssText = 'padding: 12px; background-color: #f0f0f0; border: 1px solid #ccc; text-align: center; margin: 0.5em 0; border-radius: 4px;';
2051
+ placeholder.textContent = '[GeoJSON Map - Interactive content not available in copy]';
2052
+ clonedContainer.parentNode.replaceChild(placeholder, clonedContainer);
2053
+ continue;
2054
+ }
2055
+
2056
+ // Check if map is ready
2057
+ const map = liveContainer._map;
2058
+ if (!map) {
2059
+ console.warn('Map not initialized yet');
2060
+ const placeholder = document.createElement('div');
2061
+ placeholder.style.cssText = 'padding: 12px; background-color: #f0f0f0; border: 1px solid #ccc; text-align: center; margin: 0.5em 0; border-radius: 4px;';
2062
+ placeholder.textContent = '[GeoJSON Map - Still loading]';
2063
+ clonedContainer.parentNode.replaceChild(placeholder, clonedContainer);
2064
+ continue;
2065
+ }
2066
+
2067
+ // Rasterize the map to PNG
2068
+ const dataUrl = await rasterizeGeoJSONMap(liveContainer);
2069
+
2070
+ if (dataUrl) {
2071
+ // Replace with image
2072
+ const img = document.createElement('img');
2073
+ img.src = dataUrl;
2074
+ img.style.cssText = 'width: 100%; height: 300px; border: 1px solid #ddd; border-radius: 4px; margin: 0.5em 0;';
2075
+ img.alt = 'GeoJSON Map';
2076
+ clonedContainer.parentNode.replaceChild(img, clonedContainer);
2077
+ } else {
2078
+ // Fallback placeholder
2079
+ const placeholder = document.createElement('div');
2080
+ placeholder.style.cssText = 'padding: 12px; background-color: #f0f0f0; border: 1px solid #ccc; text-align: center; margin: 0.5em 0; border-radius: 4px;';
2081
+ placeholder.textContent = '[GeoJSON Map - Interactive content not available in copy]';
2082
+ clonedContainer.parentNode.replaceChild(placeholder, clonedContainer);
2083
+ }
2084
+
2085
+ } catch (error) {
2086
+ console.error('Failed to process GeoJSON container:', error);
2087
+ // Replace with placeholder
2088
+ const placeholder = document.createElement('div');
2089
+ placeholder.style.cssText = 'padding: 12px; background-color: #f0f0f0; border: 1px solid #ccc; text-align: center; margin: 0.5em 0; border-radius: 4px;';
2090
+ placeholder.textContent = '[GeoJSON Map - Interactive content not available in copy]';
2091
+ clonedContainer.parentNode.replaceChild(placeholder, clonedContainer);
2092
+ }
2093
+ }
2094
+ }
2095
+
2096
+
2097
+
2098
+ // 6. Process GeoJSON/Leaflet maps - capture as single image (compose tiles + overlays)
2099
+ const mapContainers = clone.querySelectorAll('[data-qd-lang="geojson"]');
2100
+ for (const container of mapContainers) {
2101
+ try {
2102
+ const containerId = container.id;
2103
+ const originalContainer = containerId ? previewPanel.querySelector(`#${containerId}`) : null;
2104
+ if (!originalContainer) continue;
2105
+ const leafletContainer = originalContainer.querySelector('.leaflet-container');
2106
+ if (!leafletContainer) continue;
2107
+
2108
+ const dpr = Math.max(1, window.devicePixelRatio || 1);
2109
+ const width = leafletContainer.clientWidth || 600;
2110
+ const height = leafletContainer.clientHeight || 400;
2111
+ const canvas = document.createElement('canvas');
2112
+ canvas.width = Math.round(width * dpr);
2113
+ canvas.height = Math.round(height * dpr);
2114
+ const ctx = canvas.getContext('2d');
2115
+ ctx.scale(dpr, dpr);
2116
+ ctx.fillStyle = '#FFFFFF';
2117
+ ctx.fillRect(0, 0, width, height);
2118
+
2119
+ const leafRect = leafletContainer.getBoundingClientRect();
2120
+
2121
+ // Draw tiles (snap to integer pixels to avoid seams)
2122
+ const tiles = Array.from(leafletContainer.querySelectorAll('img.leaflet-tile'));
2123
+ for (const tile of tiles) {
2124
+ try {
2125
+ const r = tile.getBoundingClientRect();
2126
+ const x = Math.round(r.left - leafRect.left);
2127
+ const y = Math.round(r.top - leafRect.top);
2128
+ const w = Math.round(r.width);
2129
+ const h = Math.round(r.height);
2130
+ const overlaps = !(r.right <= leafRect.left || r.left >= leafRect.right || r.bottom <= leafRect.top || r.top >= leafRect.bottom);
2131
+ const style = window.getComputedStyle(tile);
2132
+ if (w > 0 && h > 0 && overlaps && style.display !== 'none' && style.visibility !== 'hidden') {
2133
+ ctx.drawImage(tile, x, y, w + 1, h + 1);
2134
+ }
2135
+ } catch (e) {
2136
+ console.warn('Failed to draw tile:', e);
2137
+ }
2138
+ }
2139
+
2140
+ // Draw SVG overlays (paths, markers)
2141
+ const overlaySvgs = originalContainer.querySelectorAll('.leaflet-overlay-pane svg');
2142
+ for (const svg of overlaySvgs) {
2143
+ try {
2144
+ const svgStr = new XMLSerializer().serializeToString(svg);
2145
+ const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgStr);
2146
+ const img = new Image();
2147
+ await new Promise((resolve) => { img.onload = resolve; img.onerror = resolve; img.src = dataUrl; });
2148
+ const r = svg.getBoundingClientRect();
2149
+ const x = Math.round(r.left - leafRect.left);
2150
+ const y = Math.round(r.top - leafRect.top);
2151
+ const w = Math.round(r.width);
2152
+ const h = Math.round(r.height);
2153
+ const overlaps = !(r.right <= leafRect.left || r.left >= leafRect.right || r.bottom <= leafRect.top || r.top >= leafRect.bottom);
2154
+ if (w > 0 && h > 0 && overlaps) ctx.drawImage(img, x, y, w, h);
2155
+ } catch (e) {
2156
+ console.warn('Failed to draw overlay SVG:', e);
2157
+ }
2158
+ }
2159
+
2160
+ // Draw marker icons (PNG/SVG img elements)
2161
+ const markerIcons = originalContainer.querySelectorAll('.leaflet-marker-pane img.leaflet-marker-icon');
2162
+ for (const icon of markerIcons) {
2163
+ try {
2164
+ const r = icon.getBoundingClientRect();
2165
+ const x = Math.round(r.left - leafRect.left);
2166
+ const y = Math.round(r.top - leafRect.top);
2167
+ const w = Math.round(r.width);
2168
+ const h = Math.round(r.height);
2169
+ const overlaps = !(r.right <= leafRect.left || r.left >= leafRect.right || r.bottom <= leafRect.top || r.top >= leafRect.bottom);
2170
+ const style = window.getComputedStyle(icon);
2171
+ if (w > 0 && h > 0 && overlaps && style.display !== 'none' && style.visibility !== 'hidden') {
2172
+ ctx.drawImage(icon, x, y, w, h);
2173
+ }
2174
+ } catch (e) {
2175
+ console.warn('Failed to draw marker icon:', e);
2176
+ }
2177
+ }
2178
+
2179
+ // Try to produce a data URL (may fail if canvas tainted by CORS tiles)
2180
+ let mapDataUrl = '';
2181
+ try {
2182
+ mapDataUrl = canvas.toDataURL('image/png', 1.0);
2183
+ } catch (_e) {
2184
+ console.warn('Map canvas tainted; falling back to placeholder');
2185
+ }
2186
+
2187
+ const img = document.createElement('img');
2188
+ if (mapDataUrl) {
2189
+ img.src = mapDataUrl;
2190
+ img.width = width;
2191
+ img.height = height;
2192
+ img.setAttribute('width', String(width));
2193
+ img.setAttribute('height', String(height));
2194
+ img.style.width = width + 'px';
2195
+ img.style.height = height + 'px';
2196
+ img.style.display = 'block';
2197
+ img.style.border = '1px solid #ddd';
2198
+ img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
2199
+ img.alt = 'Map';
2200
+ } else {
2201
+ img.alt = 'Map';
2202
+ img.style.width = width + 'px';
2203
+ img.style.height = height + 'px';
2204
+ img.style.border = '1px solid #ddd';
2205
+ img.style.backgroundColor = '#f0f0f0';
2206
+ }
2207
+
2208
+ container.parentNode.replaceChild(img, container);
2209
+ } catch (err) {
2210
+ console.warn('Failed to process map container:', err);
2211
+ }
2212
+ }
2213
+
2214
+ // 7. Process HTML fence blocks - render the HTML content and process images
2215
+ const htmlContainers = clone.querySelectorAll('.qde-html-container');
2216
+ for (const container of htmlContainers) {
2217
+ try {
2218
+ // Get the original source HTML
2219
+ const source = container.getAttribute('data-qd-source');
2220
+
2221
+ // Check if there's a pre element (fallback display) or actual HTML content
2222
+ const pre = container.querySelector('pre');
2223
+
2224
+ if (source) {
2225
+ // Parse the source HTML
2226
+ const tempDiv = document.createElement('div');
2227
+ tempDiv.innerHTML = source;
2228
+
2229
+ // Process all images in the HTML block
2230
+ const htmlImages = tempDiv.querySelectorAll('img');
2231
+ for (const img of htmlImages) {
2232
+ // Preserve original dimensions from HTML attributes
2233
+ const widthAttr = img.getAttribute('width');
2234
+ const heightAttr = img.getAttribute('height');
2235
+
2236
+ if (widthAttr) {
2237
+ img.width = parseInt(widthAttr);
2238
+ img.style.width = widthAttr.includes('%') ? widthAttr : `${img.width}px`;
2239
+ }
2240
+ if (heightAttr) {
2241
+ img.height = parseInt(heightAttr);
2242
+ img.style.height = heightAttr.includes('%') ? heightAttr : `${img.height}px`;
2243
+ }
2244
+
2245
+ // Convert to data URL using canvas (like squibview)
2246
+ if (img.src && !img.src.startsWith('data:')) {
2247
+ try {
2248
+ // Use canvas to convert image to data URL (avoids CORS issues)
2249
+ const canvas = document.createElement('canvas');
2250
+ const ctx = canvas.getContext('2d');
2251
+
2252
+ // Create new image and wait for it to load
2253
+ const tempImg = new Image();
2254
+ tempImg.crossOrigin = 'anonymous';
2255
+
2256
+ await new Promise((resolve, reject) => {
2257
+ tempImg.onload = function() {
2258
+
2259
+ // Calculate dimensions preserving aspect ratio
2260
+ let displayWidth = 0;
2261
+ let displayHeight = 0;
2262
+
2263
+ // Use the width specified in HTML (e.g. width="250")
2264
+ if (widthAttr && !widthAttr.includes('%')) {
2265
+ displayWidth = parseInt(widthAttr);
2266
+ }
2267
+
2268
+ // Use the height if specified
2269
+ if (heightAttr && !heightAttr.includes('%')) {
2270
+ displayHeight = parseInt(heightAttr);
2271
+ }
2272
+
2273
+
2274
+ // If only width is specified, calculate height based on aspect ratio
2275
+ if (displayWidth > 0 && displayHeight === 0) {
2276
+ if (tempImg.naturalWidth > 0) {
2277
+ const aspectRatio = tempImg.naturalHeight / tempImg.naturalWidth;
2278
+ displayHeight = Math.round(displayWidth * aspectRatio);
2279
+ }
2280
+ }
2281
+ // If only height is specified, calculate width based on aspect ratio
2282
+ else if (displayHeight > 0 && displayWidth === 0) {
2283
+ if (tempImg.naturalHeight > 0) {
2284
+ const aspectRatio = tempImg.naturalWidth / tempImg.naturalHeight;
2285
+ displayWidth = Math.round(displayHeight * aspectRatio);
2286
+ }
2287
+ }
2288
+ // If neither specified, use natural dimensions
2289
+ else if (displayWidth === 0 && displayHeight === 0) {
2290
+ displayWidth = tempImg.naturalWidth || 250;
2291
+ displayHeight = tempImg.naturalHeight || 200;
2292
+ }
2293
+
2294
+
2295
+ canvas.width = displayWidth;
2296
+ canvas.height = displayHeight;
2297
+
2298
+ // Draw image to canvas
2299
+ ctx.drawImage(tempImg, 0, 0, displayWidth, displayHeight);
2300
+
2301
+ // Convert to data URL
2302
+ const dataUrl = canvas.toDataURL('image/png', 1.0);
2303
+
2304
+ // Update original image
2305
+ img.src = dataUrl;
2306
+ img.width = displayWidth;
2307
+ img.height = displayHeight;
2308
+ img.setAttribute('width', displayWidth.toString());
2309
+ img.setAttribute('height', displayHeight.toString());
2310
+ img.style.width = displayWidth + 'px';
2311
+ img.style.height = displayHeight + 'px';
2312
+
2313
+ resolve();
2314
+ };
2315
+
2316
+ tempImg.onerror = function() {
2317
+ console.warn('Failed to load HTML fence image:', img.src);
2318
+ reject(new Error('Image load failed'));
2319
+ };
2320
+
2321
+ // Set source - resolve relative paths
2322
+ if (img.src.startsWith('http') || img.src.startsWith('//')) {
2323
+ tempImg.src = img.src;
2324
+ } else {
2325
+ // Relative path - let browser resolve it
2326
+ const absoluteImg = new Image();
2327
+ absoluteImg.src = img.src;
2328
+ tempImg.src = absoluteImg.src;
2329
+ }
2330
+ });
2331
+ } catch (err) {
2332
+ console.warn('Failed to convert HTML fence image:', img.src, err);
2333
+ }
2334
+ }
2335
+
2336
+ // Add v:shapes for Word compatibility
2337
+ img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
2338
+ }
2339
+
2340
+ // Replace container content with processed HTML (whether it had pre or not)
2341
+ container.innerHTML = tempDiv.innerHTML;
2342
+ } else if (!pre) {
2343
+ // Container has rendered HTML already, process its images directly
2344
+ const htmlImages = container.querySelectorAll('img');
2345
+ for (const img of htmlImages) {
2346
+ // Same image processing as above
2347
+ const widthAttr = img.getAttribute('width');
2348
+ const heightAttr = img.getAttribute('height');
2349
+
2350
+ if (widthAttr) {
2351
+ img.width = parseInt(widthAttr);
2352
+ img.style.width = widthAttr.includes('%') ? widthAttr : `${img.width}px`;
2353
+ }
2354
+ if (heightAttr) {
2355
+ img.height = parseInt(heightAttr);
2356
+ img.style.height = heightAttr.includes('%') ? heightAttr : `${img.height}px`;
2357
+ }
2358
+
2359
+ if (img.src && !img.src.startsWith('data:')) {
2360
+ try {
2361
+ // Use same canvas approach as above
2362
+ const canvas = document.createElement('canvas');
2363
+ const ctx = canvas.getContext('2d');
2364
+ const tempImg = new Image();
2365
+ tempImg.crossOrigin = 'anonymous';
2366
+
2367
+ await new Promise((resolve, reject) => {
2368
+ tempImg.onload = function() {
2369
+ // Calculate dimensions preserving aspect ratio
2370
+ let displayWidth = img.width || 0;
2371
+ let displayHeight = img.height || 0;
2372
+
2373
+ // If only width is specified, calculate height based on aspect ratio
2374
+ if (displayWidth && !displayHeight) {
2375
+ const aspectRatio = tempImg.naturalHeight / tempImg.naturalWidth;
2376
+ displayHeight = Math.round(displayWidth * aspectRatio);
2377
+ }
2378
+ // If only height is specified, calculate width based on aspect ratio
2379
+ else if (displayHeight && !displayWidth) {
2380
+ const aspectRatio = tempImg.naturalWidth / tempImg.naturalHeight;
2381
+ displayWidth = Math.round(displayHeight * aspectRatio);
2382
+ }
2383
+ // If neither specified, use natural dimensions
2384
+ else if (!displayWidth && !displayHeight) {
2385
+ displayWidth = tempImg.naturalWidth || 250;
2386
+ displayHeight = tempImg.naturalHeight || Math.round(250 * (tempImg.naturalHeight / tempImg.naturalWidth));
2387
+ }
2388
+
2389
+ canvas.width = displayWidth;
2390
+ canvas.height = displayHeight;
2391
+ ctx.drawImage(tempImg, 0, 0, displayWidth, displayHeight);
2392
+
2393
+ const dataUrl = canvas.toDataURL('image/png', 1.0);
2394
+ img.src = dataUrl;
2395
+ img.width = displayWidth;
2396
+ img.height = displayHeight;
2397
+ img.setAttribute('width', displayWidth.toString());
2398
+ img.setAttribute('height', displayHeight.toString());
2399
+ img.style.width = displayWidth + 'px';
2400
+ img.style.height = displayHeight + 'px';
2401
+
2402
+ resolve();
2403
+ };
2404
+
2405
+ tempImg.onerror = function() {
2406
+ console.warn('Failed to load HTML fence image:', img.src);
2407
+ reject(new Error('Image load failed'));
2408
+ };
2409
+
2410
+ if (img.src.startsWith('http') || img.src.startsWith('//')) {
2411
+ tempImg.src = img.src;
2412
+ } else {
2413
+ const absoluteImg = new Image();
2414
+ absoluteImg.src = img.src;
2415
+ tempImg.src = absoluteImg.src;
2416
+ }
2417
+ });
2418
+ } catch (err) {
2419
+ console.warn('Failed to convert HTML fence image:', img.src, err);
2420
+ }
2421
+ }
2422
+
2423
+ img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
2424
+ }
2425
+ }
2426
+ } catch (err) {
2427
+ console.warn('Failed to process HTML container:', err);
2428
+ }
2429
+ }
2430
+
2431
+ // 8. Tables are already HTML tables from the built-in renderer
2432
+ // No processing needed
2433
+
2434
+ // Wrap in proper HTML structure for rich text editors
2435
+ const fragment = clone.innerHTML;
2436
+ const htmlContent = `
2437
+ <!DOCTYPE html>
2438
+ <html xmlns:v="urn:schemas-microsoft-com:vml"
2439
+ xmlns:o="urn:schemas-microsoft-com:office:office"
2440
+ xmlns:w="urn:schemas-microsoft-com:office:word">
2441
+ <head>
2442
+ <meta charset="utf-8">
2443
+ <style>
2444
+ /* Table styling */
2445
+ table { border-collapse: collapse; width: 100%; margin-bottom: 1em; }
2446
+ th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
2447
+ th { background-color: #f0f0f0; font-weight: bold; }
2448
+
2449
+ /* Code block styling */
2450
+ pre { background-color: #f4f4f4; padding: 1em; border-radius: 4px; overflow-x: auto; }
2451
+ code { font-family: monospace; background-color: #f4f4f4; padding: 0.2em 0.4em; border-radius: 3px; }
2452
+
2453
+ /* Image handling */
2454
+ img { display: block; max-width: 100%; height: auto; margin: 0.5em 0; }
2455
+
2456
+ /* Blockquote */
2457
+ blockquote { border-left: 4px solid #ddd; margin-left: 0; padding-left: 1em; color: #666; }
2458
+
2459
+ /* Math equations centered like squibview */
2460
+ .math-display { text-align: center; margin: 1em 0; }
2461
+ .math-display img { display: inline-block; margin: 0 auto; }
2462
+ </style>
2463
+ </head>
2464
+ <body><!--StartFragment-->${fragment}<!--EndFragment--></body>
2465
+ </html>`;
2466
+
2467
+ // Get plain text version
2468
+ const text = clone.textContent || clone.innerText || '';
2469
+
2470
+ // Get platform for clipboard strategy (like squibview)
2471
+ const platform = getPlatform();
2472
+
2473
+ if (platform === 'macos') {
2474
+ // macOS approach (like squibview)
2475
+ try {
2476
+ await navigator.clipboard.write([
2477
+ new ClipboardItem({
2478
+ 'text/html': new Blob([htmlContent], { type: 'text/html' }),
2479
+ 'text/plain': new Blob([text], { type: 'text/plain' })
2480
+ })
2481
+ ]);
2482
+ return { success: true, html: htmlContent, text };
2483
+ } catch (modernErr) {
2484
+ console.warn('Modern clipboard API failed, trying Safari fallback:', modernErr);
2485
+ // Safari fallback (selection-based HTML of fragment)
2486
+ if (copyToClipboard(fragment)) {
2487
+ return { success: true, html: htmlContent, text };
2488
+ }
2489
+ throw new Error('Fallback copy failed');
2490
+ }
2491
+ } else {
2492
+ // Windows/Linux approach (like squibview)
2493
+ const tempDiv = document.createElement('div');
2494
+ tempDiv.style.position = 'fixed';
2495
+ tempDiv.style.left = '-9999px';
2496
+ tempDiv.style.top = '0';
2497
+ // Use fragment for selection-based fallback copy
2498
+ tempDiv.innerHTML = fragment;
2499
+ document.body.appendChild(tempDiv);
2500
+
2501
+ try {
2502
+ await navigator.clipboard.write([
2503
+ new ClipboardItem({
2504
+ 'text/html': new Blob([htmlContent], { type: 'text/html' }),
2505
+ 'text/plain': new Blob([text], { type: 'text/plain' })
2506
+ })
2507
+ ]);
2508
+ return { success: true, html: htmlContent, text };
2509
+ } catch (modernErr) {
2510
+ console.warn('Modern clipboard API failed, trying execCommand fallback:', modernErr);
2511
+ const selection = window.getSelection();
2512
+ const range = document.createRange();
2513
+ range.selectNodeContents(tempDiv);
2514
+ selection.removeAllRanges();
2515
+ selection.addRange(range);
2516
+
2517
+ const successful = document.execCommand('copy');
2518
+ if (!successful) {
2519
+ throw new Error('Fallback copy failed');
2520
+ }
2521
+ return { success: true, html: htmlContent, text };
2522
+ } finally {
2523
+ if (tempDiv && tempDiv.parentNode) {
2524
+ document.body.removeChild(tempDiv);
2525
+ }
2526
+ }
2527
+ }
2528
+
2529
+ } catch (err) {
2530
+ console.error('Failed to copy rendered content:', err);
2531
+ throw err;
2532
+ }
2533
+ }
2534
+
1069
2535
  /**
1070
2536
  * Quikdown Editor - A drop-in markdown editor control
1071
2537
  * @version 1.0.5
@@ -1077,7 +2543,8 @@ if (typeof window !== 'undefined') {
1077
2543
  const DEFAULT_OPTIONS = {
1078
2544
  mode: 'split', // 'source' | 'preview' | 'split'
1079
2545
  showToolbar: true,
1080
- showRemoveHR: false, // Show button to remove horizontal rules (---)
2546
+ showRemoveHR: false, // Show button to remove horizontal rules (---)
2547
+ showLazyLinefeeds: false, // Show button to convert lazy linefeeds
1081
2548
  theme: 'auto', // 'light' | 'dark' | 'auto'
1082
2549
  lazy_linefeeds: false,
1083
2550
  inline_styles: false, // Use CSS classes (false) or inline styles (true)
@@ -1088,7 +2555,9 @@ const DEFAULT_OPTIONS = {
1088
2555
  mermaid: false
1089
2556
  },
1090
2557
  customFences: {}, // { 'language': (code, lang) => html }
1091
- enableComplexFences: true // Enable CSV tables, math rendering, SVG, etc.
2558
+ enableComplexFences: true, // Enable CSV tables, math rendering, SVG, etc.
2559
+ showUndoRedo: false, // Show undo/redo toolbar buttons
2560
+ undoStackSize: 100 // Maximum number of undo states to keep
1092
2561
  };
1093
2562
 
1094
2563
  /**
@@ -1113,6 +2582,11 @@ class QuikdownEditor {
1113
2582
  this._html = '';
1114
2583
  this.currentMode = this.options.mode;
1115
2584
  this.updateTimer = null;
2585
+
2586
+ // Undo/redo state
2587
+ this._undoStack = [];
2588
+ this._redoStack = [];
2589
+ this._isUndoRedo = false;
1116
2590
 
1117
2591
  // Initialize
1118
2592
  this.initPromise = this.init();
@@ -1205,6 +2679,23 @@ class QuikdownEditor {
1205
2679
  toolbar.appendChild(btn);
1206
2680
  });
1207
2681
 
2682
+ // Undo/Redo buttons (if enabled)
2683
+ if (this.options.showUndoRedo) {
2684
+ const undoBtn = document.createElement('button');
2685
+ undoBtn.className = 'qde-btn disabled';
2686
+ undoBtn.dataset.action = 'undo';
2687
+ undoBtn.textContent = 'Undo';
2688
+ undoBtn.title = 'Undo (Ctrl+Z)';
2689
+ toolbar.appendChild(undoBtn);
2690
+
2691
+ const redoBtn = document.createElement('button');
2692
+ redoBtn.className = 'qde-btn disabled';
2693
+ redoBtn.dataset.action = 'redo';
2694
+ redoBtn.textContent = 'Redo';
2695
+ redoBtn.title = 'Redo (Ctrl+Shift+Z / Ctrl+Y)';
2696
+ toolbar.appendChild(redoBtn);
2697
+ }
2698
+
1208
2699
  // Spacer
1209
2700
  const spacer = document.createElement('span');
1210
2701
  spacer.className = 'qde-spacer';
@@ -1213,7 +2704,8 @@ class QuikdownEditor {
1213
2704
  // Copy buttons
1214
2705
  const copyButtons = [
1215
2706
  { action: 'copy-markdown', text: 'Copy MD', title: 'Copy markdown to clipboard' },
1216
- { action: 'copy-html', text: 'Copy HTML', title: 'Copy HTML to clipboard' }
2707
+ { action: 'copy-html', text: 'Copy HTML', title: 'Copy HTML to clipboard' },
2708
+ { action: 'copy-rendered', text: 'Copy Rendered', title: 'Copy rich text to clipboard' }
1217
2709
  ];
1218
2710
 
1219
2711
  copyButtons.forEach(({ action, text, title }) => {
@@ -1234,6 +2726,16 @@ class QuikdownEditor {
1234
2726
  removeHRBtn.title = 'Remove all horizontal rules (---) from markdown';
1235
2727
  toolbar.appendChild(removeHRBtn);
1236
2728
  }
2729
+
2730
+ // Lazy linefeeds button (if enabled)
2731
+ if (this.options.showLazyLinefeeds) {
2732
+ const lazyLFBtn = document.createElement('button');
2733
+ lazyLFBtn.className = 'qde-btn';
2734
+ lazyLFBtn.dataset.action = 'lazy-linefeeds';
2735
+ lazyLFBtn.textContent = 'Fix Linefeeds';
2736
+ lazyLFBtn.title = 'Convert single newlines to paragraph breaks (one-time transform)';
2737
+ toolbar.appendChild(lazyLFBtn);
2738
+ }
1237
2739
 
1238
2740
  return toolbar;
1239
2741
  }
@@ -1286,6 +2788,11 @@ class QuikdownEditor {
1286
2788
  color: white;
1287
2789
  border-color: #0056b3;
1288
2790
  }
2791
+
2792
+ .qde-btn.disabled {
2793
+ opacity: 0.4;
2794
+ pointer-events: none;
2795
+ }
1289
2796
 
1290
2797
  .qde-spacer {
1291
2798
  flex: 1;
@@ -1570,6 +3077,21 @@ class QuikdownEditor {
1570
3077
  e.preventDefault();
1571
3078
  this.setMode('preview');
1572
3079
  break;
3080
+ case 'z':
3081
+ case 'Z':
3082
+ if (e.shiftKey) {
3083
+ e.preventDefault();
3084
+ this.redo();
3085
+ } else {
3086
+ e.preventDefault();
3087
+ this.undo();
3088
+ }
3089
+ break;
3090
+ case 'y':
3091
+ case 'Y':
3092
+ e.preventDefault();
3093
+ this.redo();
3094
+ break;
1573
3095
  }
1574
3096
  }
1575
3097
  });
@@ -1599,6 +3121,12 @@ class QuikdownEditor {
1599
3121
  * Update from markdown source
1600
3122
  */
1601
3123
  updateFromMarkdown(markdown) {
3124
+ // Push current state to undo stack before changing (unless this is an undo/redo operation)
3125
+ if (!this._isUndoRedo) {
3126
+ this._pushUndoState(markdown || '');
3127
+ }
3128
+ this._isUndoRedo = false;
3129
+
1602
3130
  this._markdown = markdown || '';
1603
3131
 
1604
3132
  // Show placeholder if empty
@@ -1619,6 +3147,17 @@ class QuikdownEditor {
1619
3147
  this.previewPanel.innerHTML = this._html;
1620
3148
  // Make all fence blocks non-editable
1621
3149
  this.makeFencesNonEditable();
3150
+
3151
+ // Process all math elements with MathJax if loaded (like squibview)
3152
+ if (window.MathJax && window.MathJax.typesetPromise) {
3153
+ const mathElements = this.previewPanel.querySelectorAll('.math-display');
3154
+ if (mathElements.length > 0) {
3155
+ window.MathJax.typesetPromise(Array.from(mathElements))
3156
+ .catch(_err => {
3157
+ console.warn('MathJax batch processing failed:', _err);
3158
+ });
3159
+ }
3160
+ }
1622
3161
  }
1623
3162
  }
1624
3163
 
@@ -1640,7 +3179,7 @@ class QuikdownEditor {
1640
3179
 
1641
3180
  this._html = this.previewPanel.innerHTML;
1642
3181
  this._markdown = quikdown_bd.toMarkdown(clonedPanel, {
1643
- fence_plugin: this.options.fence_plugin
3182
+ fence_plugin: this.createFencePlugin()
1644
3183
  });
1645
3184
 
1646
3185
  // Update source if visible
@@ -1660,7 +3199,6 @@ class QuikdownEditor {
1660
3199
  preprocessSpecialElements(panel) {
1661
3200
  if (!panel) return;
1662
3201
 
1663
-
1664
3202
  // Restore non-editable complex fences from their data attributes
1665
3203
  const complexFences = panel.querySelectorAll('[contenteditable="false"][data-qd-source]');
1666
3204
  complexFences.forEach(element => {
@@ -1758,7 +3296,6 @@ class QuikdownEditor {
1758
3296
  return this.renderHTML(code);
1759
3297
 
1760
3298
  case 'math':
1761
- case 'katex':
1762
3299
  case 'tex':
1763
3300
  case 'latex':
1764
3301
  return this.renderMath(code, lang);
@@ -1772,11 +3309,20 @@ class QuikdownEditor {
1772
3309
  case 'json5':
1773
3310
  return this.renderJSON(code, lang);
1774
3311
 
3312
+ case 'katex': // Use MathJax for katex fence blocks (backward compatibility)
3313
+ return this.renderMath(code, 'katex');
3314
+
1775
3315
  case 'mermaid':
1776
3316
  if (window.mermaid) {
1777
3317
  return this.renderMermaid(code);
1778
3318
  }
1779
3319
  break;
3320
+
3321
+ case 'geojson':
3322
+ return this.renderGeoJSON(code);
3323
+
3324
+ case 'stl':
3325
+ return this.renderSTL(code);
1780
3326
  }
1781
3327
  }
1782
3328
 
@@ -1791,8 +3337,37 @@ class QuikdownEditor {
1791
3337
  return undefined;
1792
3338
  };
1793
3339
 
1794
- // Return object format for v1.1.0 API
1795
- return { render };
3340
+ // Reverse function to extract raw source from rendered HTML
3341
+ const reverse = (element) => {
3342
+ // Get the language from data attribute
3343
+ const lang = element.getAttribute('data-qd-lang') || '';
3344
+ let content = '';
3345
+
3346
+ // For syntax-highlighted code, extract the raw text
3347
+ if (element.querySelector('code.hljs')) {
3348
+ const code = element.querySelector('code.hljs');
3349
+ content = code.textContent || code.innerText || '';
3350
+ }
3351
+ // For other code blocks, just get the text content
3352
+ else if (element.querySelector('code')) {
3353
+ const codeEl = element.querySelector('code');
3354
+ content = codeEl.textContent || codeEl.innerText || '';
3355
+ }
3356
+ // Fallback to element text
3357
+ else {
3358
+ content = element.textContent || element.innerText || '';
3359
+ }
3360
+
3361
+ // Return in the format quikdown_bd expects
3362
+ return {
3363
+ content: content,
3364
+ lang: lang,
3365
+ fence: '```'
3366
+ };
3367
+ };
3368
+
3369
+ // Return object format for v1.1.0 API with both render and reverse
3370
+ return { render, reverse };
1796
3371
  }
1797
3372
 
1798
3373
  /**
@@ -1816,7 +3391,7 @@ class QuikdownEditor {
1816
3391
  // Remove event handlers
1817
3392
  const walker = document.createTreeWalker(svg, NodeFilter.SHOW_ELEMENT);
1818
3393
  let node;
1819
- while (node = walker.nextNode()) {
3394
+ while ((node = walker.nextNode())) {
1820
3395
  for (let i = node.attributes.length - 1; i >= 0; i--) {
1821
3396
  const attr = node.attributes[i];
1822
3397
  if (attr.name.startsWith('on') || attr.value.includes('javascript:')) {
@@ -1873,94 +3448,17 @@ class QuikdownEditor {
1873
3448
  this.lazyLoadLibrary(
1874
3449
  'DOMPurify',
1875
3450
  () => window.DOMPurify,
1876
- 'https://unpkg.com/dompurify/dist/purify.min.js'
1877
- ).then(loaded => {
1878
- if (loaded) {
1879
- const element = document.getElementById(id);
1880
- if (element) {
1881
- const clean = DOMPurify.sanitize(code);
1882
- element.innerHTML = clean;
1883
- // Update attributes after loading
1884
- element.setAttribute('data-qd-source', code);
1885
- element.setAttribute('data-qd-fence', '```');
1886
- element.setAttribute('data-qd-lang', 'html');
1887
- }
1888
- }
1889
- });
1890
-
1891
- // Return placeholder with bidirectional attributes - non-editable
1892
- const placeholder = document.createElement('div');
1893
- placeholder.id = id;
1894
- placeholder.className = 'qde-html-container';
1895
- placeholder.contentEditable = 'false';
1896
- placeholder.setAttribute('data-qd-fence', '```');
1897
- placeholder.setAttribute('data-qd-lang', 'html');
1898
- placeholder.setAttribute('data-qd-source', code);
1899
- const pre = document.createElement('pre');
1900
- pre.textContent = code;
1901
- placeholder.appendChild(pre);
1902
-
1903
- return placeholder.outerHTML;
1904
- }
1905
-
1906
- /**
1907
- * Render math with KaTeX if available
1908
- */
1909
- renderMath(code, lang) {
1910
- const id = `math-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1911
-
1912
- // If KaTeX is loaded, use it
1913
- if (window.katex) {
1914
- try {
1915
- const rendered = katex.renderToString(code, {
1916
- displayMode: true,
1917
- throwOnError: false
1918
- });
1919
-
1920
- // Create container programmatically
1921
- const container = document.createElement('div');
1922
- container.className = 'qde-math-container';
1923
- container.contentEditable = 'false';
1924
- container.setAttribute('data-qd-fence', '```');
1925
- container.setAttribute('data-qd-lang', lang);
1926
- container.setAttribute('data-qd-source', code);
1927
- container.innerHTML = rendered;
1928
-
1929
- return container.outerHTML;
1930
- } catch (err) {
1931
- const errorContainer = document.createElement('pre');
1932
- errorContainer.className = 'qde-error';
1933
- errorContainer.contentEditable = 'false';
1934
- errorContainer.setAttribute('data-qd-fence', '```');
1935
- errorContainer.setAttribute('data-qd-lang', lang);
1936
- errorContainer.setAttribute('data-qd-source', code);
1937
- errorContainer.textContent = `Math error: ${err.message}`;
1938
- return errorContainer.outerHTML;
1939
- }
1940
- }
1941
-
1942
- // Try to lazy load KaTeX
1943
- this.lazyLoadLibrary(
1944
- 'KaTeX',
1945
- () => window.katex,
1946
- 'https://unpkg.com/katex/dist/katex.min.js',
1947
- 'https://unpkg.com/katex/dist/katex.min.css'
3451
+ 'https://unpkg.com/dompurify/dist/purify.min.js'
1948
3452
  ).then(loaded => {
1949
3453
  if (loaded) {
1950
3454
  const element = document.getElementById(id);
1951
3455
  if (element) {
1952
- try {
1953
- katex.render(code, element, {
1954
- displayMode: true,
1955
- throwOnError: false
1956
- });
1957
- // Update attributes after rendering
1958
- element.setAttribute('data-qd-source', code);
1959
- element.setAttribute('data-qd-fence', '```');
1960
- element.setAttribute('data-qd-lang', lang);
1961
- } catch (err) {
1962
- element.innerHTML = `<pre class="qde-error">Math error: ${this.escapeHtml(err.message)}</pre>`;
1963
- }
3456
+ const clean = DOMPurify.sanitize(code);
3457
+ element.innerHTML = clean;
3458
+ // Update attributes after loading
3459
+ element.setAttribute('data-qd-source', code);
3460
+ element.setAttribute('data-qd-fence', '```');
3461
+ element.setAttribute('data-qd-lang', 'html');
1964
3462
  }
1965
3463
  }
1966
3464
  });
@@ -1968,10 +3466,10 @@ class QuikdownEditor {
1968
3466
  // Return placeholder with bidirectional attributes - non-editable
1969
3467
  const placeholder = document.createElement('div');
1970
3468
  placeholder.id = id;
1971
- placeholder.className = 'qde-math-container';
3469
+ placeholder.className = 'qde-html-container';
1972
3470
  placeholder.contentEditable = 'false';
1973
3471
  placeholder.setAttribute('data-qd-fence', '```');
1974
- placeholder.setAttribute('data-qd-lang', lang);
3472
+ placeholder.setAttribute('data-qd-lang', 'html');
1975
3473
  placeholder.setAttribute('data-qd-source', code);
1976
3474
  const pre = document.createElement('pre');
1977
3475
  pre.textContent = code;
@@ -1980,6 +3478,91 @@ class QuikdownEditor {
1980
3478
  return placeholder.outerHTML;
1981
3479
  }
1982
3480
 
3481
+ /**
3482
+ * Render math with MathJax (SVG output for better copy support)
3483
+ */
3484
+ renderMath(code, _lang) {
3485
+ const id = `math-${Math.random().toString(36).substring(2, 15)}`;
3486
+
3487
+ // Create container exactly like squibview
3488
+ const container = document.createElement('div');
3489
+ container.id = id;
3490
+ container.className = 'math-display';
3491
+ container.contentEditable = 'false';
3492
+ container.setAttribute('data-source-type', 'math');
3493
+
3494
+ // Format content for MathJax (display mode with $$) - exactly like squibview
3495
+ const singleLineContent = code.replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trim();
3496
+ container.textContent = `$$${singleLineContent}$$`;
3497
+
3498
+ // Add centering style
3499
+ container.style.textAlign = 'center';
3500
+ container.style.margin = '1em 0';
3501
+
3502
+
3503
+ // Ensure MathJax will be loaded (if not already)
3504
+ if (!window.MathJax || !window.MathJax.typesetPromise) {
3505
+ this.ensureMathJaxLoaded();
3506
+ }
3507
+
3508
+ // MathJax will be processed in batch after preview update
3509
+ return container.outerHTML;
3510
+ }
3511
+
3512
+ /**
3513
+ * Ensures MathJax is loaded (but doesn't process elements)
3514
+ */
3515
+ ensureMathJaxLoaded() {
3516
+ if (typeof window.MathJax === 'undefined' && !window.mathJaxLoading) {
3517
+ window.mathJaxLoading = true;
3518
+
3519
+ // Configure MathJax before loading
3520
+ if (!window.MathJax) {
3521
+ window.MathJax = {
3522
+ loader: { load: ['input/tex', 'output/svg'] },
3523
+ tex: {
3524
+ packages: { '[+]': ['ams'] },
3525
+ inlineMath: [['$', '$'], ['\\(', '\\)']],
3526
+ displayMath: [['$$', '$$'], ['\\[', '\\]']],
3527
+ processEscapes: true,
3528
+ processEnvironments: true
3529
+ },
3530
+ options: {
3531
+ renderActions: { addMenu: [] },
3532
+ ignoreHtmlClass: 'tex2jax_ignore',
3533
+ processHtmlClass: 'tex2jax_process'
3534
+ },
3535
+ svg: {
3536
+ fontCache: 'none' // Important: self-contained SVGs for copy
3537
+ },
3538
+ startup: { typeset: false }
3539
+ };
3540
+ }
3541
+
3542
+ const script = document.createElement('script');
3543
+ script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-svg.js';
3544
+ script.async = true;
3545
+ script.onload = () => {
3546
+ window.mathJaxLoading = false;
3547
+
3548
+ // Process any existing math elements (like squibview)
3549
+ if (window.MathJax && window.MathJax.typesetPromise) {
3550
+ const mathElements = document.querySelectorAll('.math-display');
3551
+ if (mathElements.length > 0) {
3552
+ window.MathJax.typesetPromise(Array.from(mathElements)).catch(err => {
3553
+ console.warn('Initial MathJax processing failed:', err);
3554
+ });
3555
+ }
3556
+ }
3557
+ };
3558
+ script.onerror = () => {
3559
+ window.mathJaxLoading = false;
3560
+ console.error('Failed to load MathJax');
3561
+ };
3562
+ document.head.appendChild(script);
3563
+ }
3564
+ }
3565
+
1983
3566
  /**
1984
3567
  * Render CSV/PSV/TSV as HTML table
1985
3568
  */
@@ -2021,11 +3604,11 @@ class QuikdownEditor {
2021
3604
 
2022
3605
  html += '</table>';
2023
3606
  return html;
2024
- } catch (err) {
3607
+ } catch (_err) {
2025
3608
  return `<pre data-qd-fence="\`\`\`" data-qd-lang="${lang}" data-qd-source="${escapedCode}">${escapedCode}</pre>`;
2026
3609
  }
2027
3610
  }
2028
-
3611
+
2029
3612
  /**
2030
3613
  * Parse CSV line handling quoted values
2031
3614
  */
@@ -2069,13 +3652,13 @@ class QuikdownEditor {
2069
3652
  try {
2070
3653
  const data = JSON.parse(code);
2071
3654
  toHighlight = JSON.stringify(data, null, 2);
2072
- } catch (e) {
3655
+ } catch (_e) {
2073
3656
  // Use original if not valid JSON
2074
3657
  }
2075
3658
 
2076
3659
  const highlighted = hljs.highlight(toHighlight, { language: 'json' }).value;
2077
3660
  return `<pre class="qde-json" data-qd-fence="\`\`\`" data-qd-lang="${lang}"><code class="hljs language-json">${highlighted}</code></pre>`;
2078
- } catch (e) {
3661
+ } catch (_e) {
2079
3662
  // Fall through if highlighting fails
2080
3663
  }
2081
3664
  }
@@ -2084,6 +3667,237 @@ class QuikdownEditor {
2084
3667
  return `<pre class="qde-json" data-qd-fence="\`\`\`" data-qd-lang="${lang}">${this.escapeHtml(code)}</pre>`;
2085
3668
  }
2086
3669
 
3670
+ /**
3671
+ * Render GeoJSON map
3672
+ */
3673
+ renderGeoJSON(code) {
3674
+ // Generate unique map ID (following SquibView pattern)
3675
+ const mapId = `map-${Math.random().toString(36).substr(2, 15)}`;
3676
+
3677
+ // Function to render the map
3678
+ const renderMap = () => {
3679
+ const container = document.getElementById(mapId + '-container');
3680
+ if (!container || !window.L) return;
3681
+
3682
+ try {
3683
+ const data = JSON.parse(code);
3684
+
3685
+ // Clear container and set deterministic size for rasterization
3686
+ const mapDiv = document.createElement('div');
3687
+ mapDiv.id = mapId;
3688
+ mapDiv.style.cssText = 'width: 100%; height: 300px;';
3689
+ container.innerHTML = '';
3690
+ container.appendChild(mapDiv);
3691
+
3692
+ // Create the map
3693
+ const map = L.map(mapId);
3694
+
3695
+ // Store back-reference for capture (per Gem's guide)
3696
+ container._map = map; // Avoid window pollution
3697
+
3698
+ // Add tile layer with CORS support
3699
+ const tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
3700
+ attribution: '',
3701
+ crossOrigin: 'anonymous' // Important for canvas capture
3702
+ });
3703
+ tileLayer.addTo(map);
3704
+
3705
+ // Add GeoJSON layer
3706
+ const geoJsonLayer = L.geoJSON(data);
3707
+ geoJsonLayer.addTo(map);
3708
+
3709
+ // Fit bounds if valid
3710
+ if (geoJsonLayer.getBounds().isValid()) {
3711
+ map.fitBounds(geoJsonLayer.getBounds());
3712
+ } else {
3713
+ map.setView([0, 0], 2);
3714
+ }
3715
+
3716
+ // Store references for copy-time capture
3717
+ container._tileLayer = tileLayer;
3718
+ container._geoJsonLayer = geoJsonLayer;
3719
+
3720
+ // Optional: Wait for tiles to load for better capture
3721
+ tileLayer.on('load', () => {
3722
+ container.setAttribute('data-tiles-loaded', 'true');
3723
+ });
3724
+
3725
+ } catch (err) {
3726
+ container.innerHTML = `<pre class="qde-error">GeoJSON error: ${this.escapeHtml(err.message)}</pre>`;
3727
+ }
3728
+ };
3729
+
3730
+ // Check if Leaflet is already loaded
3731
+ if (window.L) {
3732
+ // Render after DOM update
3733
+ setTimeout(renderMap, 0);
3734
+ } else {
3735
+ // Lazy load Leaflet only if not already loading
3736
+ if (!window._qde_leaflet_loading) {
3737
+ window._qde_leaflet_loading = this.lazyLoadLibrary(
3738
+ 'Leaflet',
3739
+ () => window.L,
3740
+ 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
3741
+ 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
3742
+ ).catch(err => {
3743
+ console.warn('Failed to load Leaflet:', err);
3744
+ // Clear the loading promise so it can be retried
3745
+ window._qde_leaflet_loading = null;
3746
+ return false;
3747
+ });
3748
+ }
3749
+
3750
+ window._qde_leaflet_loading.then(loaded => {
3751
+ if (loaded) {
3752
+ renderMap();
3753
+ } else {
3754
+ const element = document.getElementById(mapId + '-container');
3755
+ if (element) {
3756
+ element.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">Failed to load map library</div>';
3757
+ }
3758
+ }
3759
+ }).catch(() => {
3760
+ // Error already handled above
3761
+ });
3762
+ }
3763
+
3764
+ // Return container following SquibView pattern
3765
+ const container = document.createElement('div');
3766
+ container.className = 'geojson-container';
3767
+ container.id = mapId + '-container';
3768
+ container.style.cssText = 'width: 100%; height: 300px; border: 1px solid #ddd; border-radius: 4px; margin: 0.5em 0; background: #f0f0f0;';
3769
+ container.contentEditable = 'false';
3770
+
3771
+ // Preserve source for copy-time identification (per Gem's guide)
3772
+ container.setAttribute('data-source-type', 'geojson');
3773
+ container.setAttribute('data-original-source', this.escapeHtml(code));
3774
+
3775
+ // For bidirectional editing
3776
+ container.setAttribute('data-qd-fence', '```');
3777
+ container.setAttribute('data-qd-lang', 'geojson');
3778
+ container.setAttribute('data-qd-source', code);
3779
+
3780
+ container.textContent = 'Loading map...';
3781
+
3782
+ return container.outerHTML;
3783
+ }
3784
+
3785
+ /**
3786
+ * Render STL 3D model
3787
+ */
3788
+ renderSTL(code) {
3789
+ const id = `qde-stl-viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
3790
+
3791
+ // Function to render the 3D model
3792
+ const render3D = () => {
3793
+ const element = document.getElementById(id);
3794
+ 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
+
3802
+ try {
3803
+ const THREE = window.THREE;
3804
+
3805
+ // Create scene
3806
+ const scene = new THREE.Scene();
3807
+ scene.background = new THREE.Color(0xf0f0f0);
3808
+
3809
+ // Create camera
3810
+ const camera = new THREE.PerspectiveCamera(75, element.clientWidth / 400, 0.1, 1000);
3811
+
3812
+ // Create renderer
3813
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
3814
+ renderer.setSize(element.clientWidth, 400);
3815
+ element.innerHTML = '';
3816
+ element.appendChild(renderer.domElement);
3817
+
3818
+ // Store Three.js references for copy functionality (like squibview)
3819
+ element._threeScene = scene;
3820
+ element._threeCamera = camera;
3821
+ element._threeRenderer = renderer;
3822
+
3823
+ // Parse STL data (ASCII format)
3824
+ const geometry = this.parseSTL(code);
3825
+ const material = new THREE.MeshLambertMaterial({ color: 0x0066ff });
3826
+ const mesh = new THREE.Mesh(geometry, material);
3827
+ scene.add(mesh);
3828
+
3829
+ // Add lighting
3830
+ const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
3831
+ scene.add(ambientLight);
3832
+
3833
+ const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
3834
+ directionalLight.position.set(1, 1, 1).normalize();
3835
+ scene.add(directionalLight);
3836
+
3837
+ // Position camera based on object bounds
3838
+ const box = new THREE.Box3().setFromObject(mesh);
3839
+ const center = box.getCenter(new THREE.Vector3());
3840
+ const size = box.getSize(new THREE.Vector3());
3841
+ const maxDim = Math.max(size.x, size.y, size.z);
3842
+
3843
+ camera.position.set(center.x + maxDim, center.y + maxDim, center.z + maxDim);
3844
+ camera.lookAt(center);
3845
+
3846
+ // Animate
3847
+ const animate = () => {
3848
+ requestAnimationFrame(animate);
3849
+ mesh.rotation.y += 0.01;
3850
+ renderer.render(scene, camera);
3851
+ };
3852
+ animate();
3853
+ } catch (err) {
3854
+ console.error('STL rendering error:', err);
3855
+ element.innerHTML = `<pre class="qde-error">STL error: ${this.escapeHtml(err.message)}</pre>`;
3856
+ }
3857
+ };
3858
+
3859
+ // Render after DOM update
3860
+ setTimeout(render3D, 0);
3861
+
3862
+ // Return placeholder with data-stl-id for copy functionality
3863
+ 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
+ }
3865
+
3866
+ /**
3867
+ * Parse ASCII STL format
3868
+ * @param {string} stlData - The STL file content
3869
+ * @returns {THREE.BufferGeometry} - The parsed geometry
3870
+ */
3871
+ parseSTL(stlData) {
3872
+ const THREE = window.THREE;
3873
+ const geometry = new THREE.BufferGeometry();
3874
+ const vertices = [];
3875
+ const normals = [];
3876
+
3877
+ const lines = stlData.split('\n');
3878
+ let currentNormal = null;
3879
+
3880
+ for (let line of lines) {
3881
+ line = line.trim();
3882
+
3883
+ if (line.startsWith('facet normal')) {
3884
+ const parts = line.split(/\s+/);
3885
+ currentNormal = [parseFloat(parts[2]), parseFloat(parts[3]), parseFloat(parts[4])];
3886
+ } else if (line.startsWith('vertex')) {
3887
+ const parts = line.split(/\s+/);
3888
+ vertices.push(parseFloat(parts[1]), parseFloat(parts[2]), parseFloat(parts[3]));
3889
+ if (currentNormal) {
3890
+ normals.push(currentNormal[0], currentNormal[1], currentNormal[2]);
3891
+ }
3892
+ }
3893
+ }
3894
+
3895
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
3896
+ geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
3897
+
3898
+ return geometry;
3899
+ }
3900
+
2087
3901
  /**
2088
3902
  * Render Mermaid diagram
2089
3903
  */
@@ -2224,24 +4038,46 @@ class QuikdownEditor {
2224
4038
  }
2225
4039
 
2226
4040
  /**
2227
- * Apply theme
4041
+ * Apply the current theme (based on this.options.theme)
2228
4042
  */
2229
4043
  applyTheme() {
2230
4044
  const theme = this.options.theme;
2231
-
4045
+
4046
+ // Tear down any previous auto-mode listener so we don't stack them
4047
+ if (this._autoThemeListener) {
4048
+ window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this._autoThemeListener);
4049
+ this._autoThemeListener = null;
4050
+ }
4051
+
2232
4052
  if (theme === 'auto') {
2233
- // Check system preference
2234
- const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
2235
- this.container.classList.toggle('qde-dark', isDark);
2236
-
2237
- // Listen for changes
2238
- window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
4053
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
4054
+ this.container.classList.toggle('qde-dark', mq.matches);
4055
+ this._autoThemeListener = (e) => {
2239
4056
  this.container.classList.toggle('qde-dark', e.matches);
2240
- });
4057
+ };
4058
+ mq.addEventListener('change', this._autoThemeListener);
2241
4059
  } else {
2242
4060
  this.container.classList.toggle('qde-dark', theme === 'dark');
2243
4061
  }
2244
4062
  }
4063
+
4064
+ /**
4065
+ * Set theme at runtime. Accepts 'light', 'dark', or 'auto'.
4066
+ * @param {'light'|'dark'|'auto'} theme
4067
+ */
4068
+ setTheme(theme) {
4069
+ if (!['light', 'dark', 'auto'].includes(theme)) return;
4070
+ this.options.theme = theme;
4071
+ this.applyTheme();
4072
+ }
4073
+
4074
+ /**
4075
+ * Get the current theme option (as configured, not resolved).
4076
+ * @returns {'light'|'dark'|'auto'}
4077
+ */
4078
+ getTheme() {
4079
+ return this.options.theme;
4080
+ }
2245
4081
 
2246
4082
  /**
2247
4083
  * Set lazy linefeeds option
@@ -2251,7 +4087,7 @@ class QuikdownEditor {
2251
4087
  this.options.lazy_linefeeds = enabled;
2252
4088
  // Re-render if we have content
2253
4089
  if (this._markdown) {
2254
- this.updateFromSource();
4090
+ this.updateFromMarkdown(this._markdown);
2255
4091
  }
2256
4092
  }
2257
4093
 
@@ -2311,6 +4147,105 @@ class QuikdownEditor {
2311
4147
  }
2312
4148
  }
2313
4149
 
4150
+ // --- Undo / Redo ---
4151
+
4152
+ /**
4153
+ * Push current markdown state onto the undo stack (called before a change).
4154
+ * Only pushes if the new state differs from the current state.
4155
+ * @param {string} newMarkdown - the incoming markdown (used to detect no-op)
4156
+ * @private
4157
+ */
4158
+ _pushUndoState(newMarkdown) {
4159
+ // Don't push if the content hasn't actually changed
4160
+ if (newMarkdown === this._markdown) return;
4161
+
4162
+ this._undoStack.push(this._markdown);
4163
+
4164
+ // Enforce max stack size
4165
+ const max = this.options.undoStackSize || 100;
4166
+ if (this._undoStack.length > max) {
4167
+ this._undoStack.splice(0, this._undoStack.length - max);
4168
+ }
4169
+
4170
+ // Any new edit clears the redo stack
4171
+ this._redoStack = [];
4172
+ this._updateUndoButtons();
4173
+ }
4174
+
4175
+ /**
4176
+ * Undo the last change. Restores the previous markdown state.
4177
+ */
4178
+ undo() {
4179
+ if (!this.canUndo()) return;
4180
+ // Save current state to redo stack
4181
+ this._redoStack.push(this._markdown);
4182
+ const previous = this._undoStack.pop();
4183
+ this._isUndoRedo = true;
4184
+ // Update state directly (setMarkdown is async; keep it synchronous here)
4185
+ this._markdown = previous;
4186
+ if (this.sourceTextarea) {
4187
+ this.sourceTextarea.value = previous;
4188
+ }
4189
+ this.updateFromMarkdown(previous);
4190
+ this._updateUndoButtons();
4191
+ }
4192
+
4193
+ /**
4194
+ * Redo the last undone change.
4195
+ */
4196
+ redo() {
4197
+ if (!this.canRedo()) return;
4198
+ // Save current state to undo stack
4199
+ this._undoStack.push(this._markdown);
4200
+ const next = this._redoStack.pop();
4201
+ this._isUndoRedo = true;
4202
+ this._markdown = next;
4203
+ if (this.sourceTextarea) {
4204
+ this.sourceTextarea.value = next;
4205
+ }
4206
+ this.updateFromMarkdown(next);
4207
+ this._updateUndoButtons();
4208
+ }
4209
+
4210
+ /**
4211
+ * @returns {boolean} true if undo is possible
4212
+ */
4213
+ canUndo() {
4214
+ return this._undoStack.length > 0;
4215
+ }
4216
+
4217
+ /**
4218
+ * @returns {boolean} true if redo is possible
4219
+ */
4220
+ canRedo() {
4221
+ return this._redoStack.length > 0;
4222
+ }
4223
+
4224
+ /**
4225
+ * Clear the undo and redo history.
4226
+ */
4227
+ clearHistory() {
4228
+ this._undoStack = [];
4229
+ this._redoStack = [];
4230
+ this._updateUndoButtons();
4231
+ }
4232
+
4233
+ /**
4234
+ * Update the disabled state of the undo/redo toolbar buttons.
4235
+ * @private
4236
+ */
4237
+ _updateUndoButtons() {
4238
+ if (!this.toolbar) return;
4239
+ const undoBtn = this.toolbar.querySelector('[data-action="undo"]');
4240
+ const redoBtn = this.toolbar.querySelector('[data-action="redo"]');
4241
+ if (undoBtn) {
4242
+ undoBtn.classList.toggle('disabled', !this.canUndo());
4243
+ }
4244
+ if (redoBtn) {
4245
+ redoBtn.classList.toggle('disabled', !this.canRedo());
4246
+ }
4247
+ }
4248
+
2314
4249
  /**
2315
4250
  * Handle toolbar actions
2316
4251
  */
@@ -2322,9 +4257,21 @@ class QuikdownEditor {
2322
4257
  case 'copy-html':
2323
4258
  this.copy('html');
2324
4259
  break;
4260
+ case 'copy-rendered':
4261
+ this.copyRendered();
4262
+ break;
2325
4263
  case 'remove-hr':
2326
4264
  this.removeHR();
2327
4265
  break;
4266
+ case 'lazy-linefeeds':
4267
+ this.convertLazyLinefeeds();
4268
+ break;
4269
+ case 'undo':
4270
+ this.undo();
4271
+ break;
4272
+ case 'redo':
4273
+ this.redo();
4274
+ break;
2328
4275
  }
2329
4276
  }
2330
4277
 
@@ -2412,24 +4359,13 @@ class QuikdownEditor {
2412
4359
  }
2413
4360
 
2414
4361
  /**
2415
- * Remove all horizontal rules (---) from markdown
4362
+ * Remove all horizontal rules (---) from markdown source.
4363
+ * Preserves content inside fences (``` or ~~~) and table separator rows.
2416
4364
  */
2417
4365
  async removeHR() {
2418
- // Remove standalone HR lines (3 or more dashes/underscores/asterisks)
2419
- // Matches: ---, ___, ***, ----, etc. with optional spaces
2420
- const cleaned = this._markdown
2421
- .split('\n')
2422
- .filter(line => {
2423
- // Keep lines that aren't just HR patterns
2424
- const trimmed = line.trim();
2425
- // Match HR patterns: 3+ of -, _, or * with optional spaces between
2426
- return !(/^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed));
2427
- })
2428
- .join('\n');
2429
-
2430
- // Update the markdown
4366
+ const cleaned = QuikdownEditor.removeHRFromMarkdown(this._markdown);
2431
4367
  await this.setMarkdown(cleaned);
2432
-
4368
+
2433
4369
  // Visual feedback if toolbar button exists
2434
4370
  const btn = this.toolbar?.querySelector('[data-action="remove-hr"]');
2435
4371
  if (btn) {
@@ -2440,6 +4376,224 @@ class QuikdownEditor {
2440
4376
  }, 1500);
2441
4377
  }
2442
4378
  }
4379
+
4380
+ /**
4381
+ * Static: remove horizontal rules from markdown string.
4382
+ * Safe for fences, tables, and all markdown constructs.
4383
+ * Can be used headless without an editor instance.
4384
+ * @param {string} markdown - source markdown
4385
+ * @returns {string} markdown with standalone HRs removed
4386
+ */
4387
+ static removeHRFromMarkdown(markdown) {
4388
+ const lines = (markdown || '').split('\n');
4389
+ const result = [];
4390
+ let inFence = false;
4391
+ let fenceChar = null; // '`' or '~'
4392
+ let fenceLen = 0; // length of opening fence marker
4393
+
4394
+ for (let i = 0; i < lines.length; i++) {
4395
+ const line = lines[i];
4396
+ const trimmed = line.trim();
4397
+
4398
+ // Track fence open/close (``` or ~~~, 3+ chars)
4399
+ const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
4400
+ if (fenceMatch) {
4401
+ const matchChar = fenceMatch[1][0];
4402
+ const matchLen = fenceMatch[1].length;
4403
+ if (!inFence) {
4404
+ inFence = true;
4405
+ fenceChar = matchChar;
4406
+ fenceLen = matchLen;
4407
+ result.push(line);
4408
+ continue;
4409
+ } else if (matchChar === fenceChar && matchLen >= fenceLen && /^(`{3,}|~{3,})\s*$/.test(trimmed)) {
4410
+ // Closing fence: same char, at least as many chars, no trailing content
4411
+ inFence = false;
4412
+ fenceChar = null;
4413
+ fenceLen = 0;
4414
+ result.push(line);
4415
+ continue;
4416
+ }
4417
+ }
4418
+
4419
+ // Inside a fence — keep everything
4420
+ if (inFence) {
4421
+ result.push(line);
4422
+ continue;
4423
+ }
4424
+
4425
+ // Detect table row/separator with pipes — always keep
4426
+ if (/^\|.*\|$/.test(trimmed) || (/^[-| :]+$/.test(trimmed) && trimmed.includes('|'))) {
4427
+ result.push(line);
4428
+ continue;
4429
+ }
4430
+
4431
+ // Check if this line is a standalone HR
4432
+ const isHR = /^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed);
4433
+ if (isHR) {
4434
+ // Table separator heuristic: immediately adjacent lines (no blank
4435
+ // lines between) that look like table rows protect this HR-like line
4436
+ const prevLine = i > 0 ? lines[i - 1].trim() : '';
4437
+ const nextLine = i < lines.length - 1 ? lines[i + 1].trim() : '';
4438
+ if (_looksLikeTableRow(prevLine) || _looksLikeTableRow(nextLine)) {
4439
+ result.push(line);
4440
+ continue;
4441
+ }
4442
+ // It's a real HR — skip it
4443
+ continue;
4444
+ }
4445
+
4446
+ result.push(line);
4447
+ }
4448
+
4449
+ return result.join('\n');
4450
+ }
4451
+
4452
+ /**
4453
+ * Convert lazy linefeeds in markdown source.
4454
+ * Replaces single newlines with double newlines (adds real line breaks)
4455
+ * except inside fences, tables, and other block-level constructs.
4456
+ * Idempotent: calling multiple times produces the same result.
4457
+ * Can be used as a toolbar action or headless via the static method.
4458
+ */
4459
+ async convertLazyLinefeeds() {
4460
+ const converted = QuikdownEditor.convertLazyLinefeeds(this._markdown);
4461
+ await this.setMarkdown(converted);
4462
+
4463
+ // Visual feedback if toolbar button exists
4464
+ const btn = this.toolbar?.querySelector('[data-action="lazy-linefeeds"]');
4465
+ if (btn) {
4466
+ const originalText = btn.textContent;
4467
+ btn.textContent = 'Converted!';
4468
+ setTimeout(() => {
4469
+ btn.textContent = originalText;
4470
+ }, 1500);
4471
+ }
4472
+ }
4473
+
4474
+ /**
4475
+ * Static: convert lazy linefeeds in markdown source.
4476
+ * Turns single \n between non-blank lines into \n\n so each line becomes
4477
+ * its own paragraph / hard break. Idempotent — already-doubled newlines
4478
+ * are not doubled again. Fences, tables, lists, blockquotes, headings,
4479
+ * and HTML blocks are left untouched.
4480
+ * @param {string} markdown - source markdown
4481
+ * @returns {string} markdown with lazy linefeeds resolved
4482
+ */
4483
+ static convertLazyLinefeeds(markdown) {
4484
+ const lines = (markdown || '').split('\n');
4485
+ const result = [];
4486
+ let inFence = false;
4487
+ let fenceChar = null;
4488
+ let fenceLen = 0;
4489
+ let inHTMLBlock = false;
4490
+
4491
+ for (let i = 0; i < lines.length; i++) {
4492
+ const line = lines[i];
4493
+ const trimmed = line.trim();
4494
+
4495
+ // Track fence open/close
4496
+ 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)) {
4507
+ inFence = false;
4508
+ fenceChar = null;
4509
+ fenceLen = 0;
4510
+ result.push(line);
4511
+ continue;
4512
+ }
4513
+ }
4514
+
4515
+ // Inside fence — pass through
4516
+ if (inFence) {
4517
+ result.push(line);
4518
+ continue;
4519
+ }
4520
+
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;
4527
+ }
4528
+
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);
4534
+ }
4535
+ continue;
4536
+ }
4537
+
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);
4549
+ continue;
4550
+ }
4551
+
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('');
4567
+ }
4568
+ }
4569
+
4570
+ result.push(line);
4571
+ }
4572
+
4573
+ return result.join('\n');
4574
+ }
4575
+
4576
+ /**
4577
+ * Copy rendered content as rich text
4578
+ */
4579
+ async copyRendered() {
4580
+ try {
4581
+ const result = await getRenderedContent(this.previewPanel);
4582
+ if (result.success) {
4583
+ // Visual feedback
4584
+ const btn = this.toolbar?.querySelector('[data-action="copy-rendered"]');
4585
+ if (btn) {
4586
+ const originalText = btn.textContent;
4587
+ btn.textContent = 'Copied!';
4588
+ setTimeout(() => {
4589
+ btn.textContent = originalText;
4590
+ }, 1500);
4591
+ }
4592
+ }
4593
+ } catch (err) {
4594
+ console.error('Failed to copy rendered content:', err);
4595
+ }
4596
+ }
2443
4597
 
2444
4598
  /**
2445
4599
  * Destroy the editor
@@ -2461,6 +4615,13 @@ class QuikdownEditor {
2461
4615
  }
2462
4616
  }
2463
4617
 
4618
+ // --- Internal helpers for removeHR fence/table awareness ---
4619
+
4620
+ /** Heuristic: does this line look like a markdown table row? */
4621
+ function _looksLikeTableRow(line) {
4622
+ return line.includes('|');
4623
+ }
4624
+
2464
4625
  // Export for CommonJS (needed for bundled ESM to work with Jest)
2465
4626
  if (typeof module !== 'undefined' && module.exports) {
2466
4627
  module.exports = QuikdownEditor;