gitmaps 1.1.0 → 1.1.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 (121) hide show
  1. package/README.md +267 -118
  2. package/app/[...slug]/page.client.tsx +1 -0
  3. package/app/[...slug]/page.tsx +6 -0
  4. package/app/analytics.db +0 -0
  5. package/app/api/analytics/route.ts +64 -0
  6. package/app/api/auth/positions/route.ts +95 -33
  7. package/app/api/build-info/route.ts +19 -0
  8. package/app/api/chat/route.ts +13 -2
  9. package/app/api/og-image/route.ts +14 -0
  10. package/app/api/repo/file-content/route.ts +73 -20
  11. package/app/api/repo/load/route.test.ts +62 -0
  12. package/app/api/repo/load/route.ts +41 -1
  13. package/app/api/repo/pdf-thumb/route.ts +127 -0
  14. package/app/api/repo/resolve-slug/route.ts +51 -0
  15. package/app/api/repo/tree/route.ts +188 -104
  16. package/app/api/version/route.ts +26 -0
  17. package/app/globals.css +5706 -4938
  18. package/app/layout.tsx +1279 -490
  19. package/app/lib/auto-arrange.test.ts +158 -0
  20. package/app/lib/auto-arrange.ts +147 -0
  21. package/app/lib/canvas-export.ts +358 -358
  22. package/app/lib/canvas.ts +625 -564
  23. package/app/lib/cards.tsx +1361 -916
  24. package/app/lib/chat.tsx +65 -9
  25. package/app/lib/code-editor.ts +86 -2
  26. package/app/lib/context.test.ts +32 -0
  27. package/app/lib/context.ts +19 -3
  28. package/app/lib/cursor-sharing.ts +34 -0
  29. package/app/lib/events.tsx +71 -93
  30. package/app/lib/export-canvas.ts +287 -0
  31. package/app/lib/file-card-plugin.ts +148 -148
  32. package/app/lib/file-modal.tsx +49 -0
  33. package/app/lib/file-preview.ts +486 -427
  34. package/app/lib/github-import.test.ts +424 -0
  35. package/app/lib/initial-route-hydration.test.ts +283 -0
  36. package/app/lib/initial-route-hydration.ts +202 -0
  37. package/app/lib/landing-reset.test.ts +99 -0
  38. package/app/lib/landing-reset.ts +106 -0
  39. package/app/lib/landing-shell.test.ts +75 -0
  40. package/app/lib/large-repo-optimization.ts +37 -0
  41. package/app/lib/layout-snapshots.ts +320 -0
  42. package/app/lib/loading.test.ts +69 -0
  43. package/app/lib/loading.tsx +160 -45
  44. package/app/lib/mount-cleanup.test.ts +52 -0
  45. package/app/lib/mount-cleanup.ts +34 -0
  46. package/app/lib/mount-init.test.ts +123 -0
  47. package/app/lib/mount-init.ts +107 -0
  48. package/app/lib/mount-lifecycle.test.ts +39 -0
  49. package/app/lib/mount-lifecycle.ts +12 -0
  50. package/app/lib/mount-route-wiring.test.ts +87 -0
  51. package/app/lib/mount-route-wiring.ts +84 -0
  52. package/app/lib/multi-repo.ts +14 -0
  53. package/app/lib/onboarding-tutorial.ts +278 -0
  54. package/app/lib/positions.ts +190 -121
  55. package/app/lib/recent-commits.test.ts +947 -0
  56. package/app/lib/recent-commits.ts +227 -0
  57. package/app/lib/repo-handoff.test.ts +23 -0
  58. package/app/lib/repo-handoff.ts +16 -0
  59. package/app/lib/repo-progressive.ts +119 -0
  60. package/app/lib/repo-select.test.ts +61 -0
  61. package/app/lib/repo-select.ts +74 -0
  62. package/app/lib/repo.tsx +1383 -987
  63. package/app/lib/role.ts +228 -0
  64. package/app/lib/route-catchall.test.ts +27 -0
  65. package/app/lib/route-repo-entry.test.ts +95 -0
  66. package/app/lib/route-repo-entry.ts +36 -0
  67. package/app/lib/router-contract.test.ts +22 -0
  68. package/app/lib/router-contract.ts +19 -0
  69. package/app/lib/shared-layout.test.ts +86 -0
  70. package/app/lib/shared-layout.ts +82 -0
  71. package/app/lib/status-bar.test.ts +118 -0
  72. package/app/lib/status-bar.ts +365 -128
  73. package/app/lib/sync-controls.test.ts +43 -0
  74. package/app/lib/sync-controls.tsx +303 -0
  75. package/app/lib/test-dom.ts +145 -0
  76. package/app/lib/test-fixtures/router-contract/[...slug]/page.tsx +3 -0
  77. package/app/lib/test-fixtures/router-contract/api/health/route.ts +3 -0
  78. package/app/lib/test-fixtures/router-contract/api/version/route.ts +3 -0
  79. package/app/lib/test-fixtures/router-contract/galaxy-canvas/page.tsx +3 -0
  80. package/app/lib/test-fixtures/router-contract/page.tsx +3 -0
  81. package/app/lib/transclusion-smoke.test.ts +163 -0
  82. package/app/lib/tutorial.ts +301 -0
  83. package/app/lib/version.ts +93 -0
  84. package/app/lib/viewport-culling.ts +740 -735
  85. package/app/lib/virtual-files.ts +456 -0
  86. package/app/lib/webgl-text.ts +189 -0
  87. package/app/lib/{galaxydraw-bridge.ts → xydraw-bridge.ts} +485 -482
  88. package/app/lib/{galaxydraw.test.ts → xydraw.test.ts} +228 -229
  89. package/app/og-image.png +0 -0
  90. package/app/page.client.tsx +70 -269
  91. package/app/page.tsx +15 -16
  92. package/app/state/machine.js +13 -0
  93. package/package.json +84 -75
  94. package/server.ts +10 -0
  95. package/app/[owner]/[repo]/page.tsx +0 -6
  96. package/app/[slug]/page.tsx +0 -6
  97. package/packages/galaxydraw/README.md +0 -296
  98. package/packages/galaxydraw/banner.png +0 -0
  99. package/packages/galaxydraw/demo/build-static.ts +0 -100
  100. package/packages/galaxydraw/demo/client.ts +0 -154
  101. package/packages/galaxydraw/demo/dist/client.js +0 -8
  102. package/packages/galaxydraw/demo/index.html +0 -256
  103. package/packages/galaxydraw/demo/server.ts +0 -96
  104. package/packages/galaxydraw/dist/index.js +0 -984
  105. package/packages/galaxydraw/dist/index.js.map +0 -16
  106. package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
  107. package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
  108. package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
  109. package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
  110. package/packages/galaxydraw/package.json +0 -49
  111. package/packages/galaxydraw/perf.test.ts +0 -284
  112. package/packages/galaxydraw/src/core/cards.ts +0 -435
  113. package/packages/galaxydraw/src/core/engine.ts +0 -339
  114. package/packages/galaxydraw/src/core/events.ts +0 -81
  115. package/packages/galaxydraw/src/core/layout.ts +0 -136
  116. package/packages/galaxydraw/src/core/minimap.ts +0 -216
  117. package/packages/galaxydraw/src/core/state.ts +0 -177
  118. package/packages/galaxydraw/src/core/viewport.ts +0 -106
  119. package/packages/galaxydraw/src/galaxydraw.css +0 -166
  120. package/packages/galaxydraw/src/index.ts +0 -40
  121. package/packages/galaxydraw/tsconfig.json +0 -30
@@ -25,43 +25,35 @@ import type { CanvasContext } from './context';
25
25
  import { showToast, escapeHtml } from './utils';
26
26
  import { createLayer, getActiveLayer, addSectionToLayer } from './layers';
27
27
  import { updateCanvasTransform, updateZoomUI, updateMinimap, fitAllFiles, setupMinimapClick } from './canvas';
28
- import { zoomTowardScreen, panByDelta, screenToWorld, getCardManager } from './galaxydraw-bridge';
28
+ import { zoomTowardScreen, panByDelta, screenToWorld, getCardManager } from './xydraw-bridge';
29
29
  import { hideSelectedFiles, showHiddenFilesModal as showHiddenModal } from './hidden-files';
30
30
  import { updatePillSelectionHighlights } from './viewport-culling';
31
31
  import { clearSelectionHighlights, updateSelectionHighlights, updateArrangeToolbar, arrangeRow, arrangeColumn, arrangeGrid, toggleCardExpand, fitScreenSize, changeCardsFontSize } from './cards';
32
32
  import { loadRepository, rerenderCurrentView, selectCommit } from './repo';
33
+ import { handoffRepoLoad, syncRepoSelection } from './repo-handoff';
33
34
  import { toggleCanvasChat } from './chat';
34
35
  import { exportCanvasAsPNG, exportViewportAsPNG } from './canvas-export';
35
36
  import { cancelPendingConnection, hasPendingConnection } from './connections';
36
37
  import { promptAddSection } from './layers';
38
+ import { addRecentRepo, getRecentRepos } from './recent-commits';
39
+ import { appendDiscoveredRepos, populateRepoSelect } from './repo-select';
37
40
 
38
41
  // ─── Recent repos helper ────────────────────────────────
39
- function _addRecentRepo(path: string) {
40
- const key = 'gitcanvas:recentRepos';
41
- const recent: string[] = JSON.parse(localStorage.getItem(key) || '[]');
42
- // Remove if already exists, then prepend
43
- const filtered = recent.filter(r => r !== path);
44
- filtered.unshift(path);
45
- // Keep max 10
46
- localStorage.setItem(key, JSON.stringify(filtered.slice(0, 10)));
42
+ function _addRecentRepo(path: string, commitCount: number = 0) {
43
+ addRecentRepo(path, commitCount);
47
44
  }
48
45
 
49
46
  function _refreshRepoDropdown() {
50
47
  const repoSel = document.getElementById('repoSelect') as HTMLSelectElement;
51
48
  if (!repoSel) return;
52
- const updatedRepos: string[] = JSON.parse(localStorage.getItem('gitcanvas:recentRepos') || '[]');
53
- while (repoSel.options.length > 1) repoSel.remove(1);
54
- updatedRepos.forEach(repo => {
55
- const opt = document.createElement('option');
56
- opt.value = repo;
57
- opt.textContent = repo.replace(/\\/g, '/').split('/').filter(Boolean).pop() || repo;
58
- opt.title = repo;
59
- repoSel.add(opt);
60
- });
61
- const newOpt = document.createElement('option');
62
- newOpt.value = '__new__';
63
- newOpt.textContent = '+ Open new repo...';
64
- repoSel.add(newOpt);
49
+ populateRepoSelect(repoSel, getRecentRepos(), { hashPath: '' });
50
+ }
51
+
52
+ function isTypingTarget(target: EventTarget | null): boolean {
53
+ const el = target instanceof HTMLElement ? target : null;
54
+ if (!el) return false;
55
+ if (el.isContentEditable) return true;
56
+ return !!el.closest('input, textarea, select, [contenteditable], .cm-editor, .cm-content');
65
57
  }
66
58
 
67
59
  // ─── Canvas interaction (pan/zoom/select) ───────────────
@@ -198,7 +190,7 @@ export function setupCanvasInteraction(ctx: CanvasContext) {
198
190
  // ── Global mousemove (pan + rect select) ──
199
191
  window.addEventListener('mousemove', (e) => {
200
192
  if (ctx.isDragging) {
201
- // Delta-based pan via galaxydraw engine
193
+ // Delta-based pan via xydraw engine
202
194
  const dx = e.clientX - lastDragX;
203
195
  const dy = e.clientY - lastDragY;
204
196
  lastDragX = e.clientX;
@@ -419,15 +411,40 @@ export function setupEventListeners(ctx: CanvasContext) {
419
411
  setupChangedFilesPanel();
420
412
  setupConnectionsPanel(ctx);
421
413
 
422
- // Text rendering mode toggle (Canvas vs DOM)
414
+ // Text rendering mode toggle (DOM → Canvas WebGL)
423
415
  const textToggle = document.getElementById('toggleCanvasText');
424
416
  if (textToggle) {
425
- ctx.useCanvasText = localStorage.getItem('gitcanvas:useCanvasText') !== 'false';
426
- textToggle.classList.toggle('active', ctx.useCanvasText);
417
+ const modeLabels: Record<string, string> = {
418
+ 'dom': 'DOM',
419
+ 'canvas': 'Canvas',
420
+ 'webgl': 'WebGL'
421
+ };
422
+
423
+ const updateTextToggleUI = () => {
424
+ textToggle.title = `Text rendering: ${modeLabels[ctx.textRendererMode]} (click to switch)`;
425
+ // Show mode indicator
426
+ const icon = textToggle.querySelector('svg');
427
+ if (icon) {
428
+ if (ctx.textRendererMode === 'webgl') {
429
+ icon.innerHTML = '<path d="M4 4h16v16H4z"/><circle cx="12" cy="12" r="3" fill="currentColor"/>';
430
+ } else if (ctx.textRendererMode === 'canvas') {
431
+ icon.innerHTML = '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/>';
432
+ } else {
433
+ icon.innerHTML = '<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/>';
434
+ }
435
+ }
436
+ };
437
+
438
+ updateTextToggleUI();
439
+
427
440
  textToggle.addEventListener('click', () => {
428
- ctx.useCanvasText = !ctx.useCanvasText;
429
- localStorage.setItem('gitcanvas:useCanvasText', String(ctx.useCanvasText));
430
- textToggle.classList.toggle('active', ctx.useCanvasText);
441
+ // Cycle: DOM → Canvas → WebGL → DOM
442
+ const modes: Array<'dom' | 'canvas' | 'webgl'> = ['dom', 'canvas', 'webgl'];
443
+ const currentIndex = modes.indexOf(ctx.textRendererMode);
444
+ ctx.textRendererMode = modes[(currentIndex + 1) % modes.length];
445
+ localStorage.setItem('gitcanvas:textRendererMode', ctx.textRendererMode);
446
+ updateTextToggleUI();
447
+ showToast(`Text rendering: ${modeLabels[ctx.textRendererMode]}`, 'info');
431
448
 
432
449
  // Re-render currently visible cards
433
450
  rerenderCurrentView(ctx);
@@ -529,55 +546,16 @@ export function setupEventListeners(ctx: CanvasContext) {
529
546
  const repoSelect = document.getElementById('repoSelect') as HTMLSelectElement;
530
547
  if (repoSelect) {
531
548
  // Populate dropdown from recent repos
532
- const recentRepos: string[] = JSON.parse(localStorage.getItem('gitcanvas:recentRepos') || '[]');
533
- // Clear except first placeholder
534
- while (repoSelect.options.length > 1) repoSelect.remove(1);
535
- recentRepos.forEach(repo => {
536
- const opt = document.createElement('option');
537
- opt.value = repo;
538
- // Show short name (last folder part) + full path
539
- const shortName = repo.replace(/\\/g, '/').split('/').filter(Boolean).pop() || repo;
540
- opt.textContent = shortName;
541
- opt.title = repo;
542
- repoSelect.add(opt);
543
- });
544
- // "Open new repo..." option at the end
545
- const newOpt = document.createElement('option');
546
- newOpt.value = '__new__';
547
- newOpt.textContent = '+ Open new repo...';
548
- repoSelect.add(newOpt);
549
-
550
- // Set initial value from hash — otherwise keep placeholder
549
+ const recentRepos = getRecentRepos();
551
550
  const hashPath = decodeURIComponent(location.hash.slice(1));
552
- if (hashPath && recentRepos.includes(hashPath)) {
553
- repoSelect.value = hashPath;
554
- } else if (!hashPath) {
555
- repoSelect.value = ''; // Keep "Select a repository..." shown
556
- }
551
+ populateRepoSelect(repoSelect, recentRepos, { hashPath });
557
552
 
558
553
  // ── Also discover on-disk repos that may not be in localStorage ──
559
554
  fetch('/api/repo/list').then(r => r.json()).then((data: any) => {
560
555
  if (!data.repos || data.repos.length === 0) return;
561
- const currentPaths = new Set(recentRepos);
562
- let added = false;
563
- for (const repo of data.repos) {
564
- if (!currentPaths.has(repo.path)) {
565
- // Add to localStorage recent repos
566
- _addRecentRepo(repo.path);
567
- // Add to dropdown (before the __new__ option)
568
- const opt = document.createElement('option');
569
- opt.value = repo.path;
570
- opt.textContent = repo.name;
571
- opt.title = repo.path;
572
- const newOpt2 = repoSelect.querySelector('option[value="__new__"]');
573
- if (newOpt2) {
574
- repoSelect.insertBefore(opt, newOpt2);
575
- } else {
576
- repoSelect.add(opt);
577
- }
578
- added = true;
579
- }
580
- }
556
+ appendDiscoveredRepos(repoSelect, recentRepos, data.repos, (repoPath) => {
557
+ _addRecentRepo(repoPath);
558
+ });
581
559
  }).catch(() => { });
582
560
 
583
561
  repoSelect.addEventListener('change', async () => {
@@ -590,13 +568,15 @@ export function setupEventListeners(ctx: CanvasContext) {
590
568
  _addRecentRepo(cleanPath);
591
569
  loadRepository(ctx, cleanPath);
592
570
  // Re-populate dropdown options
593
- const updatedRepos: string[] = JSON.parse(localStorage.getItem('gitcanvas:recentRepos') || '[]');
571
+ const updatedRepos = getRecentRepos();
594
572
  while (repoSelect.options.length > 1) repoSelect.remove(1);
595
- updatedRepos.forEach(repo => {
573
+ updatedRepos.forEach((repo: any) => {
574
+ const repoPath = typeof repo === "string" ? repo : repo.path || "";
575
+ if (!repoPath) return;
596
576
  const opt = document.createElement('option');
597
- opt.value = repo;
598
- opt.textContent = repo.replace(/\\/g, '/').split('/').filter(Boolean).pop() || repo;
599
- opt.title = repo;
577
+ opt.value = repoPath;
578
+ opt.textContent = repoPath.replace(/\\/g, '/').split('/').filter(Boolean).pop() || repoPath;
579
+ opt.title = repoPath;
600
580
  repoSelect.add(opt);
601
581
  });
602
582
  const newOptRefresh = document.createElement('option');
@@ -610,7 +590,7 @@ export function setupEventListeners(ctx: CanvasContext) {
610
590
  repoSelect.value = '';
611
591
  }
612
592
  } else if (val) {
613
- loadRepository(ctx, val);
593
+ handoffRepoLoad(ctx, val);
614
594
  }
615
595
  });
616
596
 
@@ -708,11 +688,6 @@ export function setupEventListeners(ctx: CanvasContext) {
708
688
  // AI chat toggle
709
689
  document.getElementById('toggleCanvasChat')?.addEventListener('click', () => toggleCanvasChat(ctx));
710
690
 
711
- // Replayable onboarding
712
- document.getElementById('helpOnboarding')?.addEventListener('click', () => {
713
- import('./onboarding').then(m => m.startOnboarding(ctx));
714
- });
715
-
716
691
  // Share Layout
717
692
  document.getElementById('shareLayout')?.addEventListener('click', () => {
718
693
  measure('share:layout', () => {
@@ -767,15 +742,15 @@ export function setupEventListeners(ctx: CanvasContext) {
767
742
  window.addEventListener('keydown', (e) => {
768
743
  // Space-bar canvas panning
769
744
  if (e.code === 'Space' && !e.repeat) {
770
- if ((e.target as HTMLElement).tagName === 'INPUT' || (e.target as HTMLElement).tagName === 'TEXTAREA') return;
745
+ if (isTypingTarget(e.target)) return;
771
746
  e.preventDefault();
772
747
  ctx.spaceHeld = true;
773
748
  ctx.canvasViewport.classList.add('space-panning');
774
749
  return;
775
750
  }
776
751
 
777
- // Don't interfere with input fields for all other shortcuts
778
- if ((e.target as HTMLElement).tagName === 'INPUT' || (e.target as HTMLElement).tagName === 'TEXTAREA') return;
752
+ // Don't interfere with editors / form controls for all other shortcuts
753
+ if (isTypingTarget(e.target)) return;
779
754
 
780
755
  if (e.key === 'Escape') {
781
756
  closePreview();
@@ -1309,7 +1284,7 @@ const LANG_COLORS: Record<string, string> = {
1309
1284
  };
1310
1285
 
1311
1286
  // ─── GitHub Import Modal Handler ────────────────────────
1312
- function setupGithubImport(ctx: CanvasContext) {
1287
+ export function setupGithubImport(ctx: CanvasContext) {
1313
1288
  const modal = document.getElementById('githubModal');
1314
1289
  const openBtn = document.getElementById('githubImportBtn');
1315
1290
  const closeBtn = document.getElementById('githubModalClose');
@@ -1546,6 +1521,10 @@ function setupGithubImport(ctx: CanvasContext) {
1546
1521
  if (lastUser && userInput) userInput.value = lastUser;
1547
1522
  }
1548
1523
 
1524
+ function _handoffClonedRepo(ctx: CanvasContext, path: string) {
1525
+ handoffRepoLoad(ctx, path);
1526
+ }
1527
+
1549
1528
  // ─── Trigger clone (self-contained, uses clone-stream API) ──
1550
1529
  function _triggerClone(ctx: CanvasContext, url: string) {
1551
1530
  const cloneStatus = document.getElementById('cloneStatus');
@@ -1582,7 +1561,7 @@ function _triggerClone(ctx: CanvasContext, url: string) {
1582
1561
  _refreshRepoDropdown();
1583
1562
  const repoSel = document.getElementById('repoSelect') as HTMLSelectElement;
1584
1563
  if (repoSel) repoSel.value = data.path;
1585
- loadRepository(ctx, data.path);
1564
+ _handoffClonedRepo(ctx, data.path);
1586
1565
  setTimeout(() => { cloneStatus.style.display = 'none'; }, 3000);
1587
1566
  return;
1588
1567
  }
@@ -1615,7 +1594,7 @@ function _triggerClone(ctx: CanvasContext, url: string) {
1615
1594
  _refreshRepoDropdown();
1616
1595
  const repoSel2 = document.getElementById('repoSelect') as HTMLSelectElement;
1617
1596
  if (repoSel2) repoSel2.value = payload.path;
1618
- loadRepository(ctx, payload.path);
1597
+ _handoffClonedRepo(ctx, payload.path);
1619
1598
  setTimeout(() => { cloneStatus.style.display = 'none'; }, 3000);
1620
1599
  } else if (evtType === 'error') {
1621
1600
  cloneStatus.className = 'clone-status error';
@@ -1753,9 +1732,8 @@ function setupDragAndDrop(ctx: CanvasContext) {
1753
1732
  const repoPath = data.path;
1754
1733
  _addRecentRepo(repoPath);
1755
1734
  _refreshRepoDropdown();
1756
- const repoSel = document.getElementById('repoSelect') as HTMLSelectElement;
1757
- if (repoSel) repoSel.value = repoPath;
1758
- import('./repo').then(m => m.loadRepository(ctx, repoPath));
1735
+ syncRepoSelection(repoPath);
1736
+ handoffRepoLoad(ctx, repoPath);
1759
1737
 
1760
1738
  setTimeout(() => cloneStatus.style.display = 'none', 3000);
1761
1739
  }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Export Canvas — Save canvas layout as image/PDF
3
+ *
4
+ * Features:
5
+ * - Export as PNG with high resolution
6
+ * - Export visible area or full canvas
7
+ * - Include/hide UI elements
8
+ * - Auto-download or copy to clipboard
9
+ */
10
+
11
+ import type { CanvasContext } from './context';
12
+
13
+ export interface ExportOptions {
14
+ format: 'png' | 'jpeg' | 'webp';
15
+ quality: number;
16
+ scale: number;
17
+ includeBackground: boolean;
18
+ visibleOnly: boolean;
19
+ }
20
+
21
+ /**
22
+ * Export canvas to image file
23
+ */
24
+ export async function exportCanvasToImage(
25
+ ctx: CanvasContext,
26
+ options: ExportOptions = {
27
+ format: 'png',
28
+ quality: 1,
29
+ scale: 2,
30
+ includeBackground: true,
31
+ visibleOnly: false,
32
+ }
33
+ ): Promise<void> {
34
+ const { measure } = await import('measure-fn');
35
+
36
+ return measure('canvas:export', async () => {
37
+ try {
38
+ const canvas = ctx.canvas || ctx.canvasViewport;
39
+ if (!canvas) throw new Error('Canvas not found');
40
+
41
+ // Get all file cards
42
+ const cards = Array.from(ctx.fileCards.values());
43
+ if (cards.length === 0) throw new Error('No cards to export');
44
+
45
+ // Calculate bounds
46
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
47
+
48
+ cards.forEach(card => {
49
+ const x = parseFloat(card.style.left) || 0;
50
+ const y = parseFloat(card.style.top) || 0;
51
+ const w = card.offsetWidth || 580;
52
+ const h = card.offsetHeight || 700;
53
+
54
+ minX = Math.min(minX, x);
55
+ minY = Math.min(minY, y);
56
+ maxX = Math.max(maxX, x + w);
57
+ maxY = Math.max(maxY, y + h);
58
+ });
59
+
60
+ // Add padding
61
+ const padding = 50;
62
+ minX -= padding;
63
+ minY -= padding;
64
+ maxX += padding;
65
+ maxY += padding;
66
+
67
+ const width = Math.max(maxX - minX, 1);
68
+ const height = Math.max(maxY - minY, 1);
69
+
70
+ // Create export canvas
71
+ const exportCanvas = document.createElement('canvas');
72
+ const scale = options.scale || 2;
73
+ exportCanvas.width = width * scale;
74
+ exportCanvas.height = height * scale;
75
+
76
+ const exportCtx = exportCanvas.getContext('2d');
77
+ if (!exportCtx) throw new Error('Could not get 2D context');
78
+
79
+ // Scale for high DPI
80
+ exportCtx.scale(scale, scale);
81
+
82
+ // Background
83
+ if (options.includeBackground) {
84
+ exportCtx.fillStyle = '#0a0a0f';
85
+ exportCtx.fillRect(0, 0, width, height);
86
+ }
87
+
88
+ // Translate to origin
89
+ exportCtx.save();
90
+ exportCtx.translate(-minX, -minY);
91
+
92
+ // Draw each card
93
+ for (const card of cards) {
94
+ if (options.visibleOnly && !isCardVisible(card)) continue;
95
+
96
+ await drawCardToCanvas(exportCtx, card, scale);
97
+ }
98
+
99
+ exportCtx.restore();
100
+
101
+ // Export
102
+ const mimeType = `image/${options.format}`;
103
+ const dataUrl = exportCanvas.toDataURL(mimeType, options.quality);
104
+
105
+ // Download
106
+ const repoName = ctx.snap().context.repoPath?.split(/[\/]/).pop() || 'canvas';
107
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
108
+ const filename = `${repoName}-${timestamp}.${options.format}`;
109
+
110
+ const link = document.createElement('a');
111
+ link.download = filename;
112
+ link.href = dataUrl;
113
+ link.click();
114
+
115
+ // Show toast
116
+ const { showToast } = await import('./utils');
117
+ showToast(`Exported ${cards.length} cards as ${filename}`, 'success');
118
+
119
+ } catch (err: any) {
120
+ const { showToast } = await import('./utils');
121
+ showToast(`Export failed: ${err.message}`, 'error');
122
+ throw err;
123
+ }
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Draw a single card to canvas
129
+ */
130
+ async function drawCardToCanvas(
131
+ ctx: CanvasRenderingContext2D,
132
+ card: HTMLElement,
133
+ scale: number
134
+ ): Promise<void> {
135
+ const x = parseFloat(card.style.left) || 0;
136
+ const y = parseFloat(card.style.top) || 0;
137
+ const width = card.offsetWidth || 580;
138
+ const height = card.offsetHeight || 700;
139
+
140
+ // Card background
141
+ ctx.fillStyle = '#1e293b';
142
+ ctx.beginPath();
143
+ roundRect(ctx, x, y, width, height, 8);
144
+ ctx.fill();
145
+
146
+ // Border
147
+ ctx.strokeStyle = '#334155';
148
+ ctx.lineWidth = 1;
149
+ ctx.stroke();
150
+
151
+ // Header
152
+ ctx.fillStyle = '#0f172a';
153
+ ctx.beginPath();
154
+ roundRect(ctx, x, y, width, 32, [8, 8, 0, 0]);
155
+ ctx.fill();
156
+
157
+ // File name
158
+ const fileNameEl = card.querySelector('.file-name') as HTMLElement;
159
+ if (fileNameEl) {
160
+ ctx.fillStyle = '#e2e8f0';
161
+ ctx.font = '600 11px Inter';
162
+ ctx.fillText(fileNameEl.textContent || '', x + 32, y + 20);
163
+ }
164
+
165
+ // Code preview (first 20 lines)
166
+ const codeLines = card.querySelectorAll('.card-line');
167
+ ctx.font = '10px JetBrains Mono';
168
+ ctx.textBaseline = 'top';
169
+
170
+ codeLines.forEach((lineEl, i) => {
171
+ if (i >= 20) return;
172
+
173
+ const line = lineEl as HTMLElement;
174
+ const style = window.getComputedStyle(line);
175
+ ctx.fillStyle = style.color;
176
+
177
+ const text = line.textContent || '';
178
+ ctx.fillText(text.substring(0, 80), x + 12, y + 44 + (i * 16));
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Check if card is visible in viewport
184
+ */
185
+ function isCardVisible(card: HTMLElement): boolean {
186
+ const rect = card.getBoundingClientRect();
187
+ return (
188
+ rect.right > 0 &&
189
+ rect.left < window.innerWidth &&
190
+ rect.bottom > 0 &&
191
+ rect.top < window.innerHeight
192
+ );
193
+ }
194
+
195
+ /**
196
+ * Draw rounded rectangle
197
+ */
198
+ function roundRect(
199
+ ctx: CanvasRenderingContext2D,
200
+ x: number,
201
+ y: number,
202
+ width: number,
203
+ height: number,
204
+ radius: number | number[]
205
+ ): void {
206
+ const radii = Array.isArray(radius) ? radius : [radius, radius, radius, radius];
207
+
208
+ ctx.beginPath();
209
+ ctx.moveTo(x + radii[0], y);
210
+ ctx.lineTo(x + width - radii[1], y);
211
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radii[1]);
212
+ ctx.lineTo(x + width, y + height - radii[2]);
213
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radii[2], y + height);
214
+ ctx.lineTo(x + radii[3], y + height);
215
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radii[3]);
216
+ ctx.lineTo(x, y + radii[0]);
217
+ ctx.quadraticCurveTo(x, y, x + radii[0], y);
218
+ ctx.closePath();
219
+ }
220
+
221
+ /**
222
+ * Export UI component
223
+ */
224
+ export function createExportUI(ctx: CanvasContext): HTMLElement {
225
+ const container = document.createElement('div');
226
+ container.className = 'export-ui';
227
+
228
+ container.innerHTML = `
229
+ <button class="btn-ghost btn-sm" id="exportBtn" title="Export canvas as image">
230
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
231
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
232
+ <polyline points="7 10 12 15 17 10"></polyline>
233
+ <line x1="12" y1="15" x2="12" y2="3"></line>
234
+ </svg>
235
+ Export
236
+ </button>
237
+ `;
238
+
239
+ const exportBtn = container.querySelector('#exportBtn') as HTMLButtonElement;
240
+ exportBtn.addEventListener('click', () => {
241
+ showExportDialog(ctx);
242
+ });
243
+
244
+ return container;
245
+ }
246
+
247
+ /**
248
+ * Show export options dialog
249
+ */
250
+ async function showExportDialog(ctx: CanvasContext): Promise<void> {
251
+ const { render } = await import('melina/client');
252
+
253
+ const dialog = document.createElement('div');
254
+ dialog.className = 'export-dialog';
255
+ dialog.innerHTML = `
256
+ <div class="export-backdrop"></div>
257
+ <div class="export-content">
258
+ <h3>Export Canvas</h3>
259
+ <div class="export-options">
260
+ <label>
261
+ Format:
262
+ <select id="exportFormat">
263
+ <option value="png">PNG (Best quality)</option>
264
+ <option value="jpeg">JPEG (Smaller file)</option>
265
+ <option value="webp">WebP (Modern)</option>
266
+ </select>
267
+ </label>
268
+ <label>
269
+ Scale:
270
+ <select id="exportScale">
271
+ <option value="1">1x (Screen resolution)</option>
272
+ <option value="2" selected>2x (High quality)</option>
273
+ <option value="3">3x (Print quality)</option>
274
+ </select>
275
+ </label>
276
+ <label>
277
+ <input type="checkbox" id="exportBackground" checked>
278
+ Include background
279
+ </label>
280
+ <label>
281
+ <input type="checkbox" id="exportVisibleOnly">
282
+ Visible cards only
283
+ </label>
284
+ </div>
285
+ <div class="export-actions">
286
+ <button class="btn-ghost" id="exportCancel">Cancel</button>
287
+