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.
- package/README.md +265 -122
- package/app/[...slug]/page.client.tsx +1 -0
- package/app/[...slug]/page.tsx +6 -0
- package/app/[owner]/[repo]/page.client.tsx +5 -0
- package/app/[slug]/page.client.tsx +5 -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/manifest.json/route.ts +20 -0
- package/app/api/og-image/route.ts +14 -0
- package/app/api/pwa-icon/route.ts +14 -0
- package/app/api/repo/clone-stream/route.ts +20 -12
- package/app/api/repo/file-content/route.ts +73 -20
- package/app/api/repo/imports/route.ts +21 -3
- package/app/api/repo/list/route.ts +30 -0
- 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/repo/upload/route.ts +6 -9
- package/app/api/sw.js/route.ts +70 -0
- package/app/api/version/route.ts +26 -0
- package/app/galaxy-canvas/page.client.tsx +2 -0
- package/app/galaxy-canvas/page.tsx +5 -0
- package/app/globals.css +5844 -4694
- package/app/icon.png +0 -0
- package/app/layout.tsx +1284 -467
- 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-text.ts +4 -72
- package/app/lib/canvas.ts +625 -564
- package/app/lib/card-arrangement.ts +21 -7
- package/app/lib/card-context-menu.tsx +2 -2
- package/app/lib/card-groups.ts +9 -2
- package/app/lib/cards.tsx +1361 -914
- package/app/lib/chat.tsx +65 -9
- package/app/lib/code-editor.ts +86 -2
- package/app/lib/connections.tsx +34 -43
- 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 +76 -73
- package/app/lib/export-canvas.ts +287 -0
- package/app/lib/file-card-plugin.ts +148 -134
- package/app/lib/file-modal.tsx +49 -0
- package/app/lib/file-preview.ts +486 -400
- package/app/lib/github-import.test.ts +424 -0
- package/app/lib/global-search.ts +48 -27
- 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/layers.tsx +17 -18
- 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/perf-overlay.ts +78 -0
- package/app/lib/positions.ts +191 -122
- package/app/lib/recent-commits.test.ts +869 -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 -977
- 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/shortcuts-panel.ts +2 -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 -728
- 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 -477
- package/app/lib/{galaxydraw.test.ts → xydraw.test.ts} +228 -229
- package/app/og-image.png +0 -0
- package/app/page.client.tsx +70 -215
- package/app/page.tsx +27 -92
- package/app/state/machine.js +13 -0
- package/banner.png +0 -0
- package/package.json +17 -8
- package/server.ts +11 -1
- package/app/api/connections/route.ts +0 -72
- package/app/api/positions/route.ts +0 -80
- package/app/api/repo/browse/route.ts +0 -55
- package/app/lib/pr-review.ts +0 -374
- 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,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
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
|
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 =
|
|
573
|
-
opt.textContent =
|
|
574
|
-
opt.title =
|
|
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
|
-
|
|
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
|
|
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
|
|
753
|
-
if ((e.target
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1732
|
-
|
|
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
|
+
|