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