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.
- package/README.md +267 -118
- package/app/[...slug]/page.client.tsx +1 -0
- package/app/[...slug]/page.tsx +6 -0
- package/app/analytics.db +0 -0
- package/app/api/analytics/route.ts +64 -0
- package/app/api/auth/positions/route.ts +95 -33
- package/app/api/build-info/route.ts +19 -0
- package/app/api/chat/route.ts +13 -2
- package/app/api/og-image/route.ts +14 -0
- package/app/api/repo/file-content/route.ts +73 -20
- package/app/api/repo/load/route.test.ts +62 -0
- package/app/api/repo/load/route.ts +41 -1
- package/app/api/repo/pdf-thumb/route.ts +127 -0
- package/app/api/repo/resolve-slug/route.ts +51 -0
- package/app/api/repo/tree/route.ts +188 -104
- package/app/api/version/route.ts +26 -0
- package/app/globals.css +5706 -4938
- package/app/layout.tsx +1279 -490
- package/app/lib/auto-arrange.test.ts +158 -0
- package/app/lib/auto-arrange.ts +147 -0
- package/app/lib/canvas-export.ts +358 -358
- package/app/lib/canvas.ts +625 -564
- package/app/lib/cards.tsx +1361 -916
- package/app/lib/chat.tsx +65 -9
- package/app/lib/code-editor.ts +86 -2
- package/app/lib/context.test.ts +32 -0
- package/app/lib/context.ts +19 -3
- package/app/lib/cursor-sharing.ts +34 -0
- package/app/lib/events.tsx +71 -93
- package/app/lib/export-canvas.ts +287 -0
- package/app/lib/file-card-plugin.ts +148 -148
- package/app/lib/file-modal.tsx +49 -0
- package/app/lib/file-preview.ts +486 -427
- package/app/lib/github-import.test.ts +424 -0
- package/app/lib/initial-route-hydration.test.ts +283 -0
- package/app/lib/initial-route-hydration.ts +202 -0
- package/app/lib/landing-reset.test.ts +99 -0
- package/app/lib/landing-reset.ts +106 -0
- package/app/lib/landing-shell.test.ts +75 -0
- package/app/lib/large-repo-optimization.ts +37 -0
- package/app/lib/layout-snapshots.ts +320 -0
- package/app/lib/loading.test.ts +69 -0
- package/app/lib/loading.tsx +160 -45
- package/app/lib/mount-cleanup.test.ts +52 -0
- package/app/lib/mount-cleanup.ts +34 -0
- package/app/lib/mount-init.test.ts +123 -0
- package/app/lib/mount-init.ts +107 -0
- package/app/lib/mount-lifecycle.test.ts +39 -0
- package/app/lib/mount-lifecycle.ts +12 -0
- package/app/lib/mount-route-wiring.test.ts +87 -0
- package/app/lib/mount-route-wiring.ts +84 -0
- package/app/lib/multi-repo.ts +14 -0
- package/app/lib/onboarding-tutorial.ts +278 -0
- package/app/lib/positions.ts +190 -121
- package/app/lib/recent-commits.test.ts +947 -0
- package/app/lib/recent-commits.ts +227 -0
- package/app/lib/repo-handoff.test.ts +23 -0
- package/app/lib/repo-handoff.ts +16 -0
- package/app/lib/repo-progressive.ts +119 -0
- package/app/lib/repo-select.test.ts +61 -0
- package/app/lib/repo-select.ts +74 -0
- package/app/lib/repo.tsx +1383 -987
- package/app/lib/role.ts +228 -0
- package/app/lib/route-catchall.test.ts +27 -0
- package/app/lib/route-repo-entry.test.ts +95 -0
- package/app/lib/route-repo-entry.ts +36 -0
- package/app/lib/router-contract.test.ts +22 -0
- package/app/lib/router-contract.ts +19 -0
- package/app/lib/shared-layout.test.ts +86 -0
- package/app/lib/shared-layout.ts +82 -0
- package/app/lib/status-bar.test.ts +118 -0
- package/app/lib/status-bar.ts +365 -128
- package/app/lib/sync-controls.test.ts +43 -0
- package/app/lib/sync-controls.tsx +303 -0
- package/app/lib/test-dom.ts +145 -0
- package/app/lib/test-fixtures/router-contract/[...slug]/page.tsx +3 -0
- package/app/lib/test-fixtures/router-contract/api/health/route.ts +3 -0
- package/app/lib/test-fixtures/router-contract/api/version/route.ts +3 -0
- package/app/lib/test-fixtures/router-contract/galaxy-canvas/page.tsx +3 -0
- package/app/lib/test-fixtures/router-contract/page.tsx +3 -0
- package/app/lib/transclusion-smoke.test.ts +163 -0
- package/app/lib/tutorial.ts +301 -0
- package/app/lib/version.ts +93 -0
- package/app/lib/viewport-culling.ts +740 -735
- package/app/lib/virtual-files.ts +456 -0
- package/app/lib/webgl-text.ts +189 -0
- package/app/lib/{galaxydraw-bridge.ts → xydraw-bridge.ts} +485 -482
- package/app/lib/{galaxydraw.test.ts → xydraw.test.ts} +228 -229
- package/app/og-image.png +0 -0
- package/app/page.client.tsx +70 -269
- package/app/page.tsx +15 -16
- package/app/state/machine.js +13 -0
- package/package.json +84 -75
- package/server.ts +10 -0
- package/app/[owner]/[repo]/page.tsx +0 -6
- package/app/[slug]/page.tsx +0 -6
- package/packages/galaxydraw/README.md +0 -296
- package/packages/galaxydraw/banner.png +0 -0
- package/packages/galaxydraw/demo/build-static.ts +0 -100
- package/packages/galaxydraw/demo/client.ts +0 -154
- package/packages/galaxydraw/demo/dist/client.js +0 -8
- package/packages/galaxydraw/demo/index.html +0 -256
- package/packages/galaxydraw/demo/server.ts +0 -96
- package/packages/galaxydraw/dist/index.js +0 -984
- package/packages/galaxydraw/dist/index.js.map +0 -16
- package/packages/galaxydraw/node_modules/.bin/tsc.bunx +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsc.exe +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsserver.bunx +0 -0
- package/packages/galaxydraw/node_modules/.bin/tsserver.exe +0 -0
- package/packages/galaxydraw/package.json +0 -49
- package/packages/galaxydraw/perf.test.ts +0 -284
- package/packages/galaxydraw/src/core/cards.ts +0 -435
- package/packages/galaxydraw/src/core/engine.ts +0 -339
- package/packages/galaxydraw/src/core/events.ts +0 -81
- package/packages/galaxydraw/src/core/layout.ts +0 -136
- package/packages/galaxydraw/src/core/minimap.ts +0 -216
- package/packages/galaxydraw/src/core/state.ts +0 -177
- package/packages/galaxydraw/src/core/viewport.ts +0 -106
- package/packages/galaxydraw/src/galaxydraw.css +0 -166
- package/packages/galaxydraw/src/index.ts +0 -40
- package/packages/galaxydraw/tsconfig.json +0 -30
package/app/lib/events.tsx
CHANGED
|
@@ -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 './
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
|
414
|
+
// Text rendering mode toggle (DOM → Canvas → WebGL)
|
|
423
415
|
const textToggle = document.getElementById('toggleCanvasText');
|
|
424
416
|
if (textToggle) {
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
|
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 =
|
|
598
|
-
opt.textContent =
|
|
599
|
-
opt.title =
|
|
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
|
-
|
|
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
|
|
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
|
|
778
|
-
if ((e.target
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1757
|
-
|
|
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
|
+
|