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