gitmaps 1.0.0 → 1.1.1

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 (145) hide show
  1. package/README.md +265 -122
  2. package/app/[...slug]/page.client.tsx +1 -0
  3. package/app/[...slug]/page.tsx +6 -0
  4. package/app/[owner]/[repo]/page.client.tsx +5 -0
  5. package/app/[slug]/page.client.tsx +5 -0
  6. package/app/analytics.db +0 -0
  7. package/app/api/analytics/route.ts +64 -0
  8. package/app/api/auth/positions/route.ts +95 -33
  9. package/app/api/build-info/route.ts +19 -0
  10. package/app/api/chat/route.ts +13 -2
  11. package/app/api/manifest.json/route.ts +20 -0
  12. package/app/api/og-image/route.ts +14 -0
  13. package/app/api/pwa-icon/route.ts +14 -0
  14. package/app/api/repo/clone-stream/route.ts +20 -12
  15. package/app/api/repo/file-content/route.ts +73 -20
  16. package/app/api/repo/imports/route.ts +21 -3
  17. package/app/api/repo/list/route.ts +30 -0
  18. package/app/api/repo/load/route.test.ts +62 -0
  19. package/app/api/repo/load/route.ts +41 -1
  20. package/app/api/repo/pdf-thumb/route.ts +127 -0
  21. package/app/api/repo/resolve-slug/route.ts +51 -0
  22. package/app/api/repo/tree/route.ts +188 -104
  23. package/app/api/repo/upload/route.ts +6 -9
  24. package/app/api/sw.js/route.ts +70 -0
  25. package/app/api/version/route.ts +26 -0
  26. package/app/galaxy-canvas/page.client.tsx +2 -0
  27. package/app/galaxy-canvas/page.tsx +5 -0
  28. package/app/globals.css +5844 -4694
  29. package/app/icon.png +0 -0
  30. package/app/layout.tsx +1284 -467
  31. package/app/lib/auto-arrange.test.ts +158 -0
  32. package/app/lib/auto-arrange.ts +147 -0
  33. package/app/lib/canvas-export.ts +358 -358
  34. package/app/lib/canvas-text.ts +4 -72
  35. package/app/lib/canvas.ts +625 -564
  36. package/app/lib/card-arrangement.ts +21 -7
  37. package/app/lib/card-context-menu.tsx +2 -2
  38. package/app/lib/card-groups.ts +9 -2
  39. package/app/lib/cards.tsx +1361 -914
  40. package/app/lib/chat.tsx +65 -9
  41. package/app/lib/code-editor.ts +86 -2
  42. package/app/lib/connections.tsx +34 -43
  43. package/app/lib/context.test.ts +32 -0
  44. package/app/lib/context.ts +19 -3
  45. package/app/lib/cursor-sharing.ts +34 -0
  46. package/app/lib/events.tsx +76 -73
  47. package/app/lib/export-canvas.ts +287 -0
  48. package/app/lib/file-card-plugin.ts +148 -134
  49. package/app/lib/file-modal.tsx +49 -0
  50. package/app/lib/file-preview.ts +486 -400
  51. package/app/lib/github-import.test.ts +424 -0
  52. package/app/lib/global-search.ts +48 -27
  53. package/app/lib/initial-route-hydration.test.ts +283 -0
  54. package/app/lib/initial-route-hydration.ts +202 -0
  55. package/app/lib/landing-reset.test.ts +99 -0
  56. package/app/lib/landing-reset.ts +106 -0
  57. package/app/lib/landing-shell.test.ts +75 -0
  58. package/app/lib/large-repo-optimization.ts +37 -0
  59. package/app/lib/layers.tsx +17 -18
  60. package/app/lib/layout-snapshots.ts +320 -0
  61. package/app/lib/loading.test.ts +69 -0
  62. package/app/lib/loading.tsx +160 -45
  63. package/app/lib/mount-cleanup.test.ts +52 -0
  64. package/app/lib/mount-cleanup.ts +34 -0
  65. package/app/lib/mount-init.test.ts +123 -0
  66. package/app/lib/mount-init.ts +107 -0
  67. package/app/lib/mount-lifecycle.test.ts +39 -0
  68. package/app/lib/mount-lifecycle.ts +12 -0
  69. package/app/lib/mount-route-wiring.test.ts +87 -0
  70. package/app/lib/mount-route-wiring.ts +84 -0
  71. package/app/lib/multi-repo.ts +14 -0
  72. package/app/lib/onboarding-tutorial.ts +278 -0
  73. package/app/lib/perf-overlay.ts +78 -0
  74. package/app/lib/positions.ts +191 -122
  75. package/app/lib/recent-commits.test.ts +869 -0
  76. package/app/lib/recent-commits.ts +227 -0
  77. package/app/lib/repo-handoff.test.ts +23 -0
  78. package/app/lib/repo-handoff.ts +16 -0
  79. package/app/lib/repo-progressive.ts +119 -0
  80. package/app/lib/repo-select.test.ts +61 -0
  81. package/app/lib/repo-select.ts +74 -0
  82. package/app/lib/repo.tsx +1383 -977
  83. package/app/lib/role.ts +228 -0
  84. package/app/lib/route-catchall.test.ts +27 -0
  85. package/app/lib/route-repo-entry.test.ts +95 -0
  86. package/app/lib/route-repo-entry.ts +36 -0
  87. package/app/lib/router-contract.test.ts +22 -0
  88. package/app/lib/router-contract.ts +19 -0
  89. package/app/lib/shared-layout.test.ts +86 -0
  90. package/app/lib/shared-layout.ts +82 -0
  91. package/app/lib/shortcuts-panel.ts +2 -0
  92. package/app/lib/status-bar.test.ts +118 -0
  93. package/app/lib/status-bar.ts +365 -128
  94. package/app/lib/sync-controls.test.ts +43 -0
  95. package/app/lib/sync-controls.tsx +303 -0
  96. package/app/lib/test-dom.ts +145 -0
  97. package/app/lib/test-fixtures/router-contract/[...slug]/page.tsx +3 -0
  98. package/app/lib/test-fixtures/router-contract/api/health/route.ts +3 -0
  99. package/app/lib/test-fixtures/router-contract/api/version/route.ts +3 -0
  100. package/app/lib/test-fixtures/router-contract/galaxy-canvas/page.tsx +3 -0
  101. package/app/lib/test-fixtures/router-contract/page.tsx +3 -0
  102. package/app/lib/transclusion-smoke.test.ts +163 -0
  103. package/app/lib/tutorial.ts +301 -0
  104. package/app/lib/version.ts +93 -0
  105. package/app/lib/viewport-culling.ts +740 -728
  106. package/app/lib/virtual-files.ts +456 -0
  107. package/app/lib/webgl-text.ts +189 -0
  108. package/app/lib/{galaxydraw-bridge.ts → xydraw-bridge.ts} +485 -477
  109. package/app/lib/{galaxydraw.test.ts → xydraw.test.ts} +228 -229
  110. package/app/og-image.png +0 -0
  111. package/app/page.client.tsx +70 -215
  112. package/app/page.tsx +27 -92
  113. package/app/state/machine.js +13 -0
  114. package/banner.png +0 -0
  115. package/package.json +17 -8
  116. package/server.ts +11 -1
  117. package/app/api/connections/route.ts +0 -72
  118. package/app/api/positions/route.ts +0 -80
  119. package/app/api/repo/browse/route.ts +0 -55
  120. package/app/lib/pr-review.ts +0 -374
  121. package/packages/galaxydraw/README.md +0 -296
  122. package/packages/galaxydraw/banner.png +0 -0
  123. package/packages/galaxydraw/demo/build-static.ts +0 -100
  124. package/packages/galaxydraw/demo/client.ts +0 -154
  125. package/packages/galaxydraw/demo/dist/client.js +0 -8
  126. package/packages/galaxydraw/demo/index.html +0 -256
  127. package/packages/galaxydraw/demo/server.ts +0 -96
  128. package/packages/galaxydraw/dist/index.js +0 -984
  129. package/packages/galaxydraw/dist/index.js.map +0 -16
  130. package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
  131. package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
  132. package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
  133. package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
  134. package/packages/galaxydraw/package.json +0 -49
  135. package/packages/galaxydraw/perf.test.ts +0 -284
  136. package/packages/galaxydraw/src/core/cards.ts +0 -435
  137. package/packages/galaxydraw/src/core/engine.ts +0 -339
  138. package/packages/galaxydraw/src/core/events.ts +0 -81
  139. package/packages/galaxydraw/src/core/layout.ts +0 -136
  140. package/packages/galaxydraw/src/core/minimap.ts +0 -216
  141. package/packages/galaxydraw/src/core/state.ts +0 -177
  142. package/packages/galaxydraw/src/core/viewport.ts +0 -106
  143. package/packages/galaxydraw/src/galaxydraw.css +0 -166
  144. package/packages/galaxydraw/src/index.ts +0 -40
  145. 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,31 +546,17 @@ 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 });
552
+
553
+ // ── Also discover on-disk repos that may not be in localStorage ──
554
+ fetch('/api/repo/list').then(r => r.json()).then((data: any) => {
555
+ if (!data.repos || data.repos.length === 0) return;
556
+ appendDiscoveredRepos(repoSelect, recentRepos, data.repos, (repoPath) => {
557
+ _addRecentRepo(repoPath);
558
+ });
559
+ }).catch(() => { });
557
560
 
558
561
  repoSelect.addEventListener('change', async () => {
559
562
  const val = repoSelect.value;
@@ -565,13 +568,15 @@ export function setupEventListeners(ctx: CanvasContext) {
565
568
  _addRecentRepo(cleanPath);
566
569
  loadRepository(ctx, cleanPath);
567
570
  // Re-populate dropdown options
568
- const updatedRepos: string[] = JSON.parse(localStorage.getItem('gitcanvas:recentRepos') || '[]');
571
+ const updatedRepos = getRecentRepos();
569
572
  while (repoSelect.options.length > 1) repoSelect.remove(1);
570
- updatedRepos.forEach(repo => {
573
+ updatedRepos.forEach((repo: any) => {
574
+ const repoPath = typeof repo === "string" ? repo : repo.path || "";
575
+ if (!repoPath) return;
571
576
  const opt = document.createElement('option');
572
- opt.value = repo;
573
- opt.textContent = repo.replace(/\\/g, '/').split('/').filter(Boolean).pop() || repo;
574
- opt.title = repo;
577
+ opt.value = repoPath;
578
+ opt.textContent = repoPath.replace(/\\/g, '/').split('/').filter(Boolean).pop() || repoPath;
579
+ opt.title = repoPath;
575
580
  repoSelect.add(opt);
576
581
  });
577
582
  const newOptRefresh = document.createElement('option');
@@ -585,7 +590,7 @@ export function setupEventListeners(ctx: CanvasContext) {
585
590
  repoSelect.value = '';
586
591
  }
587
592
  } else if (val) {
588
- loadRepository(ctx, val);
593
+ handoffRepoLoad(ctx, val);
589
594
  }
590
595
  });
591
596
 
@@ -683,11 +688,6 @@ export function setupEventListeners(ctx: CanvasContext) {
683
688
  // AI chat toggle
684
689
  document.getElementById('toggleCanvasChat')?.addEventListener('click', () => toggleCanvasChat(ctx));
685
690
 
686
- // Replayable onboarding
687
- document.getElementById('helpOnboarding')?.addEventListener('click', () => {
688
- import('./onboarding').then(m => m.startOnboarding(ctx));
689
- });
690
-
691
691
  // Share Layout
692
692
  document.getElementById('shareLayout')?.addEventListener('click', () => {
693
693
  measure('share:layout', () => {
@@ -742,15 +742,15 @@ export function setupEventListeners(ctx: CanvasContext) {
742
742
  window.addEventListener('keydown', (e) => {
743
743
  // Space-bar canvas panning
744
744
  if (e.code === 'Space' && !e.repeat) {
745
- if ((e.target as HTMLElement).tagName === 'INPUT' || (e.target as HTMLElement).tagName === 'TEXTAREA') return;
745
+ if (isTypingTarget(e.target)) return;
746
746
  e.preventDefault();
747
747
  ctx.spaceHeld = true;
748
748
  ctx.canvasViewport.classList.add('space-panning');
749
749
  return;
750
750
  }
751
751
 
752
- // Don't interfere with input fields for all other shortcuts
753
- 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;
754
754
 
755
755
  if (e.key === 'Escape') {
756
756
  closePreview();
@@ -1284,7 +1284,7 @@ const LANG_COLORS: Record<string, string> = {
1284
1284
  };
1285
1285
 
1286
1286
  // ─── GitHub Import Modal Handler ────────────────────────
1287
- function setupGithubImport(ctx: CanvasContext) {
1287
+ export function setupGithubImport(ctx: CanvasContext) {
1288
1288
  const modal = document.getElementById('githubModal');
1289
1289
  const openBtn = document.getElementById('githubImportBtn');
1290
1290
  const closeBtn = document.getElementById('githubModalClose');
@@ -1521,6 +1521,10 @@ function setupGithubImport(ctx: CanvasContext) {
1521
1521
  if (lastUser && userInput) userInput.value = lastUser;
1522
1522
  }
1523
1523
 
1524
+ function _handoffClonedRepo(ctx: CanvasContext, path: string) {
1525
+ handoffRepoLoad(ctx, path);
1526
+ }
1527
+
1524
1528
  // ─── Trigger clone (self-contained, uses clone-stream API) ──
1525
1529
  function _triggerClone(ctx: CanvasContext, url: string) {
1526
1530
  const cloneStatus = document.getElementById('cloneStatus');
@@ -1557,7 +1561,7 @@ function _triggerClone(ctx: CanvasContext, url: string) {
1557
1561
  _refreshRepoDropdown();
1558
1562
  const repoSel = document.getElementById('repoSelect') as HTMLSelectElement;
1559
1563
  if (repoSel) repoSel.value = data.path;
1560
- loadRepository(ctx, data.path);
1564
+ _handoffClonedRepo(ctx, data.path);
1561
1565
  setTimeout(() => { cloneStatus.style.display = 'none'; }, 3000);
1562
1566
  return;
1563
1567
  }
@@ -1590,7 +1594,7 @@ function _triggerClone(ctx: CanvasContext, url: string) {
1590
1594
  _refreshRepoDropdown();
1591
1595
  const repoSel2 = document.getElementById('repoSelect') as HTMLSelectElement;
1592
1596
  if (repoSel2) repoSel2.value = payload.path;
1593
- loadRepository(ctx, payload.path);
1597
+ _handoffClonedRepo(ctx, payload.path);
1594
1598
  setTimeout(() => { cloneStatus.style.display = 'none'; }, 3000);
1595
1599
  } else if (evtType === 'error') {
1596
1600
  cloneStatus.className = 'clone-status error';
@@ -1728,9 +1732,8 @@ function setupDragAndDrop(ctx: CanvasContext) {
1728
1732
  const repoPath = data.path;
1729
1733
  _addRecentRepo(repoPath);
1730
1734
  _refreshRepoDropdown();
1731
- const repoSel = document.getElementById('repoSelect') as HTMLSelectElement;
1732
- if (repoSel) repoSel.value = repoPath;
1733
- import('./repo').then(m => m.loadRepository(ctx, repoPath));
1735
+ syncRepoSelection(repoPath);
1736
+ handoffRepoLoad(ctx, repoPath);
1734
1737
 
1735
1738
  setTimeout(() => cloneStatus.style.display = 'none', 3000);
1736
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
+