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/canvas-export.ts
CHANGED
|
@@ -1,358 +1,358 @@
|
|
|
1
|
-
// @ts-nocheck
|
|
2
|
-
/**
|
|
3
|
-
* Canvas Export — capture the current canvas view as PNG.
|
|
4
|
-
*
|
|
5
|
-
* Two modes:
|
|
6
|
-
* 1. Viewport capture: exports what's currently visible on screen
|
|
7
|
-
* 2. Full canvas: exports ALL cards at their actual positions (may be large)
|
|
8
|
-
*
|
|
9
|
-
* Uses a <canvas> element to render card thumbnails with file names,
|
|
10
|
-
* diff markers, and connection lines. No external dependencies.
|
|
11
|
-
*/
|
|
12
|
-
import { measure } from 'measure-fn';
|
|
13
|
-
import type { CanvasContext } from './context';
|
|
14
|
-
import { getGalaxyDrawState } from './
|
|
15
|
-
|
|
16
|
-
// ─── Config ──────────────────────────────────────────────
|
|
17
|
-
const CARD_BG = '#1a1a2e';
|
|
18
|
-
const CARD_BORDER = '#2d2d44';
|
|
19
|
-
const CARD_HEADER_BG = '#252540';
|
|
20
|
-
const TEXT_COLOR = '#e2e8f0';
|
|
21
|
-
const MUTED_COLOR = '#64748b';
|
|
22
|
-
const ACCENT = '#7c3aed';
|
|
23
|
-
const ADD_COLOR = '#22c55e';
|
|
24
|
-
const DEL_COLOR = '#ef4444';
|
|
25
|
-
const MOD_COLOR = '#eab308';
|
|
26
|
-
|
|
27
|
-
const LANG_COLORS: Record<string, string> = {
|
|
28
|
-
ts: '#3178c6', tsx: '#3178c6', js: '#f7df1e', jsx: '#f7df1e',
|
|
29
|
-
py: '#3776ab', rs: '#ce412b', go: '#00add8', css: '#1572b6',
|
|
30
|
-
html: '#e34f26', json: '#5bc0de', md: '#083fa1', toml: '#9c4221',
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
function getLangColor(path: string): string {
|
|
34
|
-
const ext = path.split('.').pop()?.toLowerCase() || '';
|
|
35
|
-
return LANG_COLORS[ext] || '#888';
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ─── Get card bounds ─────────────────────────────────────
|
|
39
|
-
function getAllCardBounds(ctx: CanvasContext): { x: number; y: number; w: number; h: number; path: string; changed: boolean }[] {
|
|
40
|
-
const cards: { x: number; y: number; w: number; h: number; path: string; changed: boolean }[] = [];
|
|
41
|
-
|
|
42
|
-
for (const [path, card] of ctx.fileCards) {
|
|
43
|
-
const x = parseFloat(card.style.left) || 0;
|
|
44
|
-
const y = parseFloat(card.style.top) || 0;
|
|
45
|
-
const w = card.offsetWidth || 580;
|
|
46
|
-
const h = card.offsetHeight || 700;
|
|
47
|
-
const changed = card.dataset.changed === 'true';
|
|
48
|
-
cards.push({ x, y, w, h, path, changed });
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Include deferred cards
|
|
52
|
-
for (const [path, entry] of ctx.deferredCards) {
|
|
53
|
-
const { x, y, size, isChanged } = entry;
|
|
54
|
-
const w = size?.width || 580;
|
|
55
|
-
const h = size?.height || 700;
|
|
56
|
-
cards.push({ x, y, w, h, path, changed: isChanged || false });
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return cards;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function getBoundingRect(cards: { x: number; y: number; w: number; h: number }[]) {
|
|
63
|
-
if (cards.length === 0) return { x: 0, y: 0, w: 800, h: 600 };
|
|
64
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
65
|
-
for (const c of cards) {
|
|
66
|
-
minX = Math.min(minX, c.x);
|
|
67
|
-
minY = Math.min(minY, c.y);
|
|
68
|
-
maxX = Math.max(maxX, c.x + c.w);
|
|
69
|
-
maxY = Math.max(maxY, c.y + c.h);
|
|
70
|
-
}
|
|
71
|
-
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ─── Render cards to canvas ──────────────────────────────
|
|
75
|
-
function renderToCanvas(
|
|
76
|
-
cards: { x: number; y: number; w: number; h: number; path: string; changed: boolean }[],
|
|
77
|
-
bounds: { x: number; y: number; w: number; h: number },
|
|
78
|
-
scale: number,
|
|
79
|
-
repoName: string
|
|
80
|
-
): HTMLCanvasElement {
|
|
81
|
-
const padding = 60;
|
|
82
|
-
const headerHeight = 50;
|
|
83
|
-
const canvas = document.createElement('canvas');
|
|
84
|
-
const cw = Math.ceil(bounds.w * scale + padding * 2);
|
|
85
|
-
const ch = Math.ceil(bounds.h * scale + padding * 2 + headerHeight);
|
|
86
|
-
|
|
87
|
-
// Limit to reasonable size
|
|
88
|
-
const maxDim = 8192;
|
|
89
|
-
const finalScale = Math.min(scale, maxDim / Math.max(bounds.w, bounds.h));
|
|
90
|
-
canvas.width = Math.min(maxDim, Math.ceil(bounds.w * finalScale + padding * 2));
|
|
91
|
-
canvas.height = Math.min(maxDim, Math.ceil(bounds.h * finalScale + padding * 2 + headerHeight));
|
|
92
|
-
|
|
93
|
-
const c = canvas.getContext('2d')!;
|
|
94
|
-
|
|
95
|
-
// Background
|
|
96
|
-
c.fillStyle = '#0f0f1a';
|
|
97
|
-
c.fillRect(0, 0, canvas.width, canvas.height);
|
|
98
|
-
|
|
99
|
-
// Subtle grid
|
|
100
|
-
c.strokeStyle = 'rgba(124, 58, 237, 0.04)';
|
|
101
|
-
c.lineWidth = 1;
|
|
102
|
-
const gridSize = 100 * finalScale;
|
|
103
|
-
for (let gx = padding; gx < canvas.width; gx += gridSize) {
|
|
104
|
-
c.beginPath(); c.moveTo(gx, headerHeight); c.lineTo(gx, canvas.height); c.stroke();
|
|
105
|
-
}
|
|
106
|
-
for (let gy = padding + headerHeight; gy < canvas.height; gy += gridSize) {
|
|
107
|
-
c.beginPath(); c.moveTo(0, gy); c.lineTo(canvas.width, gy); c.stroke();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Header
|
|
111
|
-
c.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
|
112
|
-
c.fillRect(0, 0, canvas.width, headerHeight);
|
|
113
|
-
c.fillStyle = ACCENT;
|
|
114
|
-
c.font = `bold 16px system-ui, -apple-system, sans-serif`;
|
|
115
|
-
c.fillText('GitMaps', 20, 30);
|
|
116
|
-
c.fillStyle = MUTED_COLOR;
|
|
117
|
-
c.font = `13px system-ui, -apple-system, sans-serif`;
|
|
118
|
-
c.fillText(`${repoName} · ${cards.length} files`, 95, 30);
|
|
119
|
-
// Timestamp
|
|
120
|
-
const now = new Date().toLocaleString();
|
|
121
|
-
const timeW = c.measureText(now).width;
|
|
122
|
-
c.fillText(now, canvas.width - timeW - 20, 30);
|
|
123
|
-
|
|
124
|
-
// Draw cards
|
|
125
|
-
for (const card of cards) {
|
|
126
|
-
const cx = (card.x - bounds.x) * finalScale + padding;
|
|
127
|
-
const cy = (card.y - bounds.y) * finalScale + padding + headerHeight;
|
|
128
|
-
const cw = card.w * finalScale;
|
|
129
|
-
const ch = card.h * finalScale;
|
|
130
|
-
|
|
131
|
-
// Card shadow
|
|
132
|
-
c.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
|
133
|
-
c.fillRect(cx + 3, cy + 3, cw, ch);
|
|
134
|
-
|
|
135
|
-
// Card body
|
|
136
|
-
c.fillStyle = CARD_BG;
|
|
137
|
-
c.fillRect(cx, cy, cw, ch);
|
|
138
|
-
|
|
139
|
-
// Card border
|
|
140
|
-
c.strokeStyle = card.changed ? MOD_COLOR + '88' : CARD_BORDER;
|
|
141
|
-
c.lineWidth = card.changed ? 2 : 1;
|
|
142
|
-
c.strokeRect(cx, cy, cw, ch);
|
|
143
|
-
|
|
144
|
-
// Header bar
|
|
145
|
-
const hh = Math.min(28 * finalScale, 28);
|
|
146
|
-
c.fillStyle = CARD_HEADER_BG;
|
|
147
|
-
c.fillRect(cx, cy, cw, hh);
|
|
148
|
-
|
|
149
|
-
// Language dot
|
|
150
|
-
const langColor = getLangColor(card.path);
|
|
151
|
-
c.fillStyle = langColor;
|
|
152
|
-
c.beginPath();
|
|
153
|
-
c.arc(cx + 10, cy + hh / 2, 4, 0, Math.PI * 2);
|
|
154
|
-
c.fill();
|
|
155
|
-
|
|
156
|
-
// File name
|
|
157
|
-
const fileName = card.path.split('/').pop() || card.path;
|
|
158
|
-
const fontSize = Math.max(8, Math.min(12, 12 * finalScale));
|
|
159
|
-
c.fillStyle = TEXT_COLOR;
|
|
160
|
-
c.font = `600 ${fontSize}px system-ui, -apple-system, sans-serif`;
|
|
161
|
-
const maxTextW = cw - 24;
|
|
162
|
-
let displayName = fileName;
|
|
163
|
-
if (c.measureText(displayName).width > maxTextW) {
|
|
164
|
-
while (displayName.length > 3 && c.measureText(displayName + '…').width > maxTextW) {
|
|
165
|
-
displayName = displayName.slice(0, -1);
|
|
166
|
-
}
|
|
167
|
-
displayName += '…';
|
|
168
|
-
}
|
|
169
|
-
c.fillText(displayName, cx + 20, cy + hh / 2 + fontSize / 3);
|
|
170
|
-
|
|
171
|
-
// Changed marker
|
|
172
|
-
if (card.changed) {
|
|
173
|
-
c.fillStyle = MOD_COLOR;
|
|
174
|
-
c.beginPath();
|
|
175
|
-
c.arc(cx + cw - 10, cy + hh / 2, 4, 0, Math.PI * 2);
|
|
176
|
-
c.fill();
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Simulated code lines
|
|
180
|
-
const lineH = Math.max(2, 3 * finalScale);
|
|
181
|
-
const lineGap = Math.max(1, 1.5 * finalScale);
|
|
182
|
-
const startY = cy + hh + 8;
|
|
183
|
-
const endY = cy + ch - 4;
|
|
184
|
-
const lineX = cx + 6;
|
|
185
|
-
c.globalAlpha = 0.15;
|
|
186
|
-
for (let ly = startY; ly < endY; ly += lineH + lineGap) {
|
|
187
|
-
const lineW = (Math.random() * 0.5 + 0.3) * (cw - 16);
|
|
188
|
-
c.fillStyle = TEXT_COLOR;
|
|
189
|
-
c.fillRect(lineX, ly, lineW, lineH);
|
|
190
|
-
}
|
|
191
|
-
c.globalAlpha = 1;
|
|
192
|
-
|
|
193
|
-
// Full path (if cards are large enough)
|
|
194
|
-
if (ch > 60) {
|
|
195
|
-
const pathFontSize = Math.max(6, Math.min(9, 9 * finalScale));
|
|
196
|
-
c.fillStyle = MUTED_COLOR;
|
|
197
|
-
c.font = `${pathFontSize}px monospace`;
|
|
198
|
-
const dirPath = card.path.includes('/') ? card.path.substring(0, card.path.lastIndexOf('/')) : '';
|
|
199
|
-
if (dirPath) {
|
|
200
|
-
c.fillText(dirPath + '/', cx + 6, cy + hh + 6 + pathFontSize);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Watermark
|
|
206
|
-
c.fillStyle = 'rgba(124, 58, 237, 0.15)';
|
|
207
|
-
c.font = 'bold 11px system-ui';
|
|
208
|
-
c.fillText('Exported from GitMaps', canvas.width - 150, canvas.height - 12);
|
|
209
|
-
|
|
210
|
-
return canvas;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// ─── Download helper ─────────────────────────────────────
|
|
214
|
-
function downloadCanvas(canvas: HTMLCanvasElement, filename: string) {
|
|
215
|
-
canvas.toBlob((blob) => {
|
|
216
|
-
if (!blob) return;
|
|
217
|
-
const url = URL.createObjectURL(blob);
|
|
218
|
-
const a = document.createElement('a');
|
|
219
|
-
a.href = url;
|
|
220
|
-
a.download = filename;
|
|
221
|
-
document.body.appendChild(a);
|
|
222
|
-
a.click();
|
|
223
|
-
document.body.removeChild(a);
|
|
224
|
-
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
|
225
|
-
}, 'image/png');
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ─── Public API ──────────────────────────────────────────
|
|
229
|
-
|
|
230
|
-
/** Export the full canvas (all cards) as PNG */
|
|
231
|
-
export function exportCanvasAsPNG(ctx: CanvasContext) {
|
|
232
|
-
measure('export:png', () => {
|
|
233
|
-
const cards = getAllCardBounds(ctx);
|
|
234
|
-
if (cards.length === 0) {
|
|
235
|
-
console.warn('[canvas-export] No cards to export');
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const bounds = getBoundingRect(cards);
|
|
240
|
-
|
|
241
|
-
// Auto-scale: fit into ~4000px max dimension
|
|
242
|
-
const maxDim = 4096;
|
|
243
|
-
const scale = Math.min(1, maxDim / Math.max(bounds.w, bounds.h));
|
|
244
|
-
|
|
245
|
-
const repoName = (() => {
|
|
246
|
-
const el = document.querySelector('.repo-dropdown-trigger, .repo-name');
|
|
247
|
-
return el?.textContent?.trim() || 'Repository';
|
|
248
|
-
})();
|
|
249
|
-
|
|
250
|
-
const canvas = renderToCanvas(cards, bounds, scale, repoName);
|
|
251
|
-
|
|
252
|
-
const timestamp = new Date().toISOString().slice(0, 10);
|
|
253
|
-
const safeName = repoName.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
|
|
254
|
-
downloadCanvas(canvas, `gitmaps-${safeName}-${timestamp}.png`);
|
|
255
|
-
|
|
256
|
-
// Show toast
|
|
257
|
-
showExportToast(`Exported ${cards.length} files as PNG`);
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** Export just the current viewport as PNG */
|
|
262
|
-
export function exportViewportAsPNG(ctx: CanvasContext) {
|
|
263
|
-
measure('export:viewport', () => {
|
|
264
|
-
const gdState = getGalaxyDrawState();
|
|
265
|
-
if (!gdState) return;
|
|
266
|
-
|
|
267
|
-
const vpEl = ctx.canvasViewport;
|
|
268
|
-
if (!vpEl) return;
|
|
269
|
-
|
|
270
|
-
const vpW = vpEl.clientWidth;
|
|
271
|
-
const vpH = vpEl.clientHeight;
|
|
272
|
-
const zoom = gdState.zoom || 1;
|
|
273
|
-
const offsetX = gdState.offsetX || 0;
|
|
274
|
-
const offsetY = gdState.offsetY || 0;
|
|
275
|
-
|
|
276
|
-
// World coordinates of viewport
|
|
277
|
-
const worldLeft = -offsetX / zoom;
|
|
278
|
-
const worldTop = -offsetY / zoom;
|
|
279
|
-
const worldRight = (vpW - offsetX) / zoom;
|
|
280
|
-
const worldBottom = (vpH - offsetY) / zoom;
|
|
281
|
-
|
|
282
|
-
const allCards = getAllCardBounds(ctx);
|
|
283
|
-
// Filter to cards visible in viewport
|
|
284
|
-
const visibleCards = allCards.filter(card =>
|
|
285
|
-
card.x + card.w > worldLeft &&
|
|
286
|
-
card.x < worldRight &&
|
|
287
|
-
card.y + card.h > worldTop &&
|
|
288
|
-
card.y < worldBottom
|
|
289
|
-
);
|
|
290
|
-
|
|
291
|
-
if (visibleCards.length === 0) {
|
|
292
|
-
showExportToast('No cards visible in viewport');
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const bounds = {
|
|
297
|
-
x: worldLeft,
|
|
298
|
-
y: worldTop,
|
|
299
|
-
w: worldRight - worldLeft,
|
|
300
|
-
h: worldBottom - worldTop,
|
|
301
|
-
};
|
|
302
|
-
|
|
303
|
-
const scale = Math.min(2, vpW / bounds.w); // 2x for retina quality
|
|
304
|
-
|
|
305
|
-
const repoName = (() => {
|
|
306
|
-
const el = document.querySelector('.repo-dropdown-trigger, .repo-name');
|
|
307
|
-
return el?.textContent?.trim() || 'Repository';
|
|
308
|
-
})();
|
|
309
|
-
|
|
310
|
-
const canvas = renderToCanvas(visibleCards, bounds, scale, repoName);
|
|
311
|
-
|
|
312
|
-
const timestamp = new Date().toISOString().slice(0, 10);
|
|
313
|
-
const safeName = repoName.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
|
|
314
|
-
downloadCanvas(canvas, `gitmaps-viewport-${safeName}-${timestamp}.png`);
|
|
315
|
-
|
|
316
|
-
showExportToast(`Exported viewport (${visibleCards.length} files) as PNG`);
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// ─── Toast notification ──────────────────────────────────
|
|
321
|
-
function showExportToast(message: string) {
|
|
322
|
-
const existing = document.getElementById('exportToast');
|
|
323
|
-
if (existing) existing.remove();
|
|
324
|
-
|
|
325
|
-
const toast = document.createElement('div');
|
|
326
|
-
toast.id = 'exportToast';
|
|
327
|
-
toast.textContent = `📸 ${message}`;
|
|
328
|
-
toast.style.cssText = `
|
|
329
|
-
position: fixed;
|
|
330
|
-
bottom: 60px;
|
|
331
|
-
left: 50%;
|
|
332
|
-
transform: translateX(-50%) translateY(10px);
|
|
333
|
-
background: rgba(124, 58, 237, 0.9);
|
|
334
|
-
color: #fff;
|
|
335
|
-
padding: 10px 20px;
|
|
336
|
-
border-radius: 8px;
|
|
337
|
-
font-size: 13px;
|
|
338
|
-
font-weight: 500;
|
|
339
|
-
z-index: 10001;
|
|
340
|
-
backdrop-filter: blur(8px);
|
|
341
|
-
box-shadow: 0 4px 20px rgba(124, 58, 237, 0.3);
|
|
342
|
-
opacity: 0;
|
|
343
|
-
transition: opacity 0.3s ease, transform 0.3s ease;
|
|
344
|
-
pointer-events: none;
|
|
345
|
-
`;
|
|
346
|
-
|
|
347
|
-
document.body.appendChild(toast);
|
|
348
|
-
requestAnimationFrame(() => {
|
|
349
|
-
toast.style.opacity = '1';
|
|
350
|
-
toast.style.transform = 'translateX(-50%) translateY(0)';
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
setTimeout(() => {
|
|
354
|
-
toast.style.opacity = '0';
|
|
355
|
-
toast.style.transform = 'translateX(-50%) translateY(10px)';
|
|
356
|
-
setTimeout(() => toast.remove(), 300);
|
|
357
|
-
}, 3000);
|
|
358
|
-
}
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Canvas Export — capture the current canvas view as PNG.
|
|
4
|
+
*
|
|
5
|
+
* Two modes:
|
|
6
|
+
* 1. Viewport capture: exports what's currently visible on screen
|
|
7
|
+
* 2. Full canvas: exports ALL cards at their actual positions (may be large)
|
|
8
|
+
*
|
|
9
|
+
* Uses a <canvas> element to render card thumbnails with file names,
|
|
10
|
+
* diff markers, and connection lines. No external dependencies.
|
|
11
|
+
*/
|
|
12
|
+
import { measure } from 'measure-fn';
|
|
13
|
+
import type { CanvasContext } from './context';
|
|
14
|
+
import { getGalaxyDrawState } from './xydraw-bridge';
|
|
15
|
+
|
|
16
|
+
// ─── Config ──────────────────────────────────────────────
|
|
17
|
+
const CARD_BG = '#1a1a2e';
|
|
18
|
+
const CARD_BORDER = '#2d2d44';
|
|
19
|
+
const CARD_HEADER_BG = '#252540';
|
|
20
|
+
const TEXT_COLOR = '#e2e8f0';
|
|
21
|
+
const MUTED_COLOR = '#64748b';
|
|
22
|
+
const ACCENT = '#7c3aed';
|
|
23
|
+
const ADD_COLOR = '#22c55e';
|
|
24
|
+
const DEL_COLOR = '#ef4444';
|
|
25
|
+
const MOD_COLOR = '#eab308';
|
|
26
|
+
|
|
27
|
+
const LANG_COLORS: Record<string, string> = {
|
|
28
|
+
ts: '#3178c6', tsx: '#3178c6', js: '#f7df1e', jsx: '#f7df1e',
|
|
29
|
+
py: '#3776ab', rs: '#ce412b', go: '#00add8', css: '#1572b6',
|
|
30
|
+
html: '#e34f26', json: '#5bc0de', md: '#083fa1', toml: '#9c4221',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function getLangColor(path: string): string {
|
|
34
|
+
const ext = path.split('.').pop()?.toLowerCase() || '';
|
|
35
|
+
return LANG_COLORS[ext] || '#888';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Get card bounds ─────────────────────────────────────
|
|
39
|
+
function getAllCardBounds(ctx: CanvasContext): { x: number; y: number; w: number; h: number; path: string; changed: boolean }[] {
|
|
40
|
+
const cards: { x: number; y: number; w: number; h: number; path: string; changed: boolean }[] = [];
|
|
41
|
+
|
|
42
|
+
for (const [path, card] of ctx.fileCards) {
|
|
43
|
+
const x = parseFloat(card.style.left) || 0;
|
|
44
|
+
const y = parseFloat(card.style.top) || 0;
|
|
45
|
+
const w = card.offsetWidth || 580;
|
|
46
|
+
const h = card.offsetHeight || 700;
|
|
47
|
+
const changed = card.dataset.changed === 'true';
|
|
48
|
+
cards.push({ x, y, w, h, path, changed });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Include deferred cards
|
|
52
|
+
for (const [path, entry] of ctx.deferredCards) {
|
|
53
|
+
const { x, y, size, isChanged } = entry;
|
|
54
|
+
const w = size?.width || 580;
|
|
55
|
+
const h = size?.height || 700;
|
|
56
|
+
cards.push({ x, y, w, h, path, changed: isChanged || false });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return cards;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getBoundingRect(cards: { x: number; y: number; w: number; h: number }[]) {
|
|
63
|
+
if (cards.length === 0) return { x: 0, y: 0, w: 800, h: 600 };
|
|
64
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
65
|
+
for (const c of cards) {
|
|
66
|
+
minX = Math.min(minX, c.x);
|
|
67
|
+
minY = Math.min(minY, c.y);
|
|
68
|
+
maxX = Math.max(maxX, c.x + c.w);
|
|
69
|
+
maxY = Math.max(maxY, c.y + c.h);
|
|
70
|
+
}
|
|
71
|
+
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── Render cards to canvas ──────────────────────────────
|
|
75
|
+
function renderToCanvas(
|
|
76
|
+
cards: { x: number; y: number; w: number; h: number; path: string; changed: boolean }[],
|
|
77
|
+
bounds: { x: number; y: number; w: number; h: number },
|
|
78
|
+
scale: number,
|
|
79
|
+
repoName: string
|
|
80
|
+
): HTMLCanvasElement {
|
|
81
|
+
const padding = 60;
|
|
82
|
+
const headerHeight = 50;
|
|
83
|
+
const canvas = document.createElement('canvas');
|
|
84
|
+
const cw = Math.ceil(bounds.w * scale + padding * 2);
|
|
85
|
+
const ch = Math.ceil(bounds.h * scale + padding * 2 + headerHeight);
|
|
86
|
+
|
|
87
|
+
// Limit to reasonable size
|
|
88
|
+
const maxDim = 8192;
|
|
89
|
+
const finalScale = Math.min(scale, maxDim / Math.max(bounds.w, bounds.h));
|
|
90
|
+
canvas.width = Math.min(maxDim, Math.ceil(bounds.w * finalScale + padding * 2));
|
|
91
|
+
canvas.height = Math.min(maxDim, Math.ceil(bounds.h * finalScale + padding * 2 + headerHeight));
|
|
92
|
+
|
|
93
|
+
const c = canvas.getContext('2d')!;
|
|
94
|
+
|
|
95
|
+
// Background
|
|
96
|
+
c.fillStyle = '#0f0f1a';
|
|
97
|
+
c.fillRect(0, 0, canvas.width, canvas.height);
|
|
98
|
+
|
|
99
|
+
// Subtle grid
|
|
100
|
+
c.strokeStyle = 'rgba(124, 58, 237, 0.04)';
|
|
101
|
+
c.lineWidth = 1;
|
|
102
|
+
const gridSize = 100 * finalScale;
|
|
103
|
+
for (let gx = padding; gx < canvas.width; gx += gridSize) {
|
|
104
|
+
c.beginPath(); c.moveTo(gx, headerHeight); c.lineTo(gx, canvas.height); c.stroke();
|
|
105
|
+
}
|
|
106
|
+
for (let gy = padding + headerHeight; gy < canvas.height; gy += gridSize) {
|
|
107
|
+
c.beginPath(); c.moveTo(0, gy); c.lineTo(canvas.width, gy); c.stroke();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Header
|
|
111
|
+
c.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
|
112
|
+
c.fillRect(0, 0, canvas.width, headerHeight);
|
|
113
|
+
c.fillStyle = ACCENT;
|
|
114
|
+
c.font = `bold 16px system-ui, -apple-system, sans-serif`;
|
|
115
|
+
c.fillText('GitMaps', 20, 30);
|
|
116
|
+
c.fillStyle = MUTED_COLOR;
|
|
117
|
+
c.font = `13px system-ui, -apple-system, sans-serif`;
|
|
118
|
+
c.fillText(`${repoName} · ${cards.length} files`, 95, 30);
|
|
119
|
+
// Timestamp
|
|
120
|
+
const now = new Date().toLocaleString();
|
|
121
|
+
const timeW = c.measureText(now).width;
|
|
122
|
+
c.fillText(now, canvas.width - timeW - 20, 30);
|
|
123
|
+
|
|
124
|
+
// Draw cards
|
|
125
|
+
for (const card of cards) {
|
|
126
|
+
const cx = (card.x - bounds.x) * finalScale + padding;
|
|
127
|
+
const cy = (card.y - bounds.y) * finalScale + padding + headerHeight;
|
|
128
|
+
const cw = card.w * finalScale;
|
|
129
|
+
const ch = card.h * finalScale;
|
|
130
|
+
|
|
131
|
+
// Card shadow
|
|
132
|
+
c.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
|
133
|
+
c.fillRect(cx + 3, cy + 3, cw, ch);
|
|
134
|
+
|
|
135
|
+
// Card body
|
|
136
|
+
c.fillStyle = CARD_BG;
|
|
137
|
+
c.fillRect(cx, cy, cw, ch);
|
|
138
|
+
|
|
139
|
+
// Card border
|
|
140
|
+
c.strokeStyle = card.changed ? MOD_COLOR + '88' : CARD_BORDER;
|
|
141
|
+
c.lineWidth = card.changed ? 2 : 1;
|
|
142
|
+
c.strokeRect(cx, cy, cw, ch);
|
|
143
|
+
|
|
144
|
+
// Header bar
|
|
145
|
+
const hh = Math.min(28 * finalScale, 28);
|
|
146
|
+
c.fillStyle = CARD_HEADER_BG;
|
|
147
|
+
c.fillRect(cx, cy, cw, hh);
|
|
148
|
+
|
|
149
|
+
// Language dot
|
|
150
|
+
const langColor = getLangColor(card.path);
|
|
151
|
+
c.fillStyle = langColor;
|
|
152
|
+
c.beginPath();
|
|
153
|
+
c.arc(cx + 10, cy + hh / 2, 4, 0, Math.PI * 2);
|
|
154
|
+
c.fill();
|
|
155
|
+
|
|
156
|
+
// File name
|
|
157
|
+
const fileName = card.path.split('/').pop() || card.path;
|
|
158
|
+
const fontSize = Math.max(8, Math.min(12, 12 * finalScale));
|
|
159
|
+
c.fillStyle = TEXT_COLOR;
|
|
160
|
+
c.font = `600 ${fontSize}px system-ui, -apple-system, sans-serif`;
|
|
161
|
+
const maxTextW = cw - 24;
|
|
162
|
+
let displayName = fileName;
|
|
163
|
+
if (c.measureText(displayName).width > maxTextW) {
|
|
164
|
+
while (displayName.length > 3 && c.measureText(displayName + '…').width > maxTextW) {
|
|
165
|
+
displayName = displayName.slice(0, -1);
|
|
166
|
+
}
|
|
167
|
+
displayName += '…';
|
|
168
|
+
}
|
|
169
|
+
c.fillText(displayName, cx + 20, cy + hh / 2 + fontSize / 3);
|
|
170
|
+
|
|
171
|
+
// Changed marker
|
|
172
|
+
if (card.changed) {
|
|
173
|
+
c.fillStyle = MOD_COLOR;
|
|
174
|
+
c.beginPath();
|
|
175
|
+
c.arc(cx + cw - 10, cy + hh / 2, 4, 0, Math.PI * 2);
|
|
176
|
+
c.fill();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Simulated code lines
|
|
180
|
+
const lineH = Math.max(2, 3 * finalScale);
|
|
181
|
+
const lineGap = Math.max(1, 1.5 * finalScale);
|
|
182
|
+
const startY = cy + hh + 8;
|
|
183
|
+
const endY = cy + ch - 4;
|
|
184
|
+
const lineX = cx + 6;
|
|
185
|
+
c.globalAlpha = 0.15;
|
|
186
|
+
for (let ly = startY; ly < endY; ly += lineH + lineGap) {
|
|
187
|
+
const lineW = (Math.random() * 0.5 + 0.3) * (cw - 16);
|
|
188
|
+
c.fillStyle = TEXT_COLOR;
|
|
189
|
+
c.fillRect(lineX, ly, lineW, lineH);
|
|
190
|
+
}
|
|
191
|
+
c.globalAlpha = 1;
|
|
192
|
+
|
|
193
|
+
// Full path (if cards are large enough)
|
|
194
|
+
if (ch > 60) {
|
|
195
|
+
const pathFontSize = Math.max(6, Math.min(9, 9 * finalScale));
|
|
196
|
+
c.fillStyle = MUTED_COLOR;
|
|
197
|
+
c.font = `${pathFontSize}px monospace`;
|
|
198
|
+
const dirPath = card.path.includes('/') ? card.path.substring(0, card.path.lastIndexOf('/')) : '';
|
|
199
|
+
if (dirPath) {
|
|
200
|
+
c.fillText(dirPath + '/', cx + 6, cy + hh + 6 + pathFontSize);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Watermark
|
|
206
|
+
c.fillStyle = 'rgba(124, 58, 237, 0.15)';
|
|
207
|
+
c.font = 'bold 11px system-ui';
|
|
208
|
+
c.fillText('Exported from GitMaps', canvas.width - 150, canvas.height - 12);
|
|
209
|
+
|
|
210
|
+
return canvas;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── Download helper ─────────────────────────────────────
|
|
214
|
+
function downloadCanvas(canvas: HTMLCanvasElement, filename: string) {
|
|
215
|
+
canvas.toBlob((blob) => {
|
|
216
|
+
if (!blob) return;
|
|
217
|
+
const url = URL.createObjectURL(blob);
|
|
218
|
+
const a = document.createElement('a');
|
|
219
|
+
a.href = url;
|
|
220
|
+
a.download = filename;
|
|
221
|
+
document.body.appendChild(a);
|
|
222
|
+
a.click();
|
|
223
|
+
document.body.removeChild(a);
|
|
224
|
+
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
|
225
|
+
}, 'image/png');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── Public API ──────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
/** Export the full canvas (all cards) as PNG */
|
|
231
|
+
export function exportCanvasAsPNG(ctx: CanvasContext) {
|
|
232
|
+
measure('export:png', () => {
|
|
233
|
+
const cards = getAllCardBounds(ctx);
|
|
234
|
+
if (cards.length === 0) {
|
|
235
|
+
console.warn('[canvas-export] No cards to export');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const bounds = getBoundingRect(cards);
|
|
240
|
+
|
|
241
|
+
// Auto-scale: fit into ~4000px max dimension
|
|
242
|
+
const maxDim = 4096;
|
|
243
|
+
const scale = Math.min(1, maxDim / Math.max(bounds.w, bounds.h));
|
|
244
|
+
|
|
245
|
+
const repoName = (() => {
|
|
246
|
+
const el = document.querySelector('.repo-dropdown-trigger, .repo-name');
|
|
247
|
+
return el?.textContent?.trim() || 'Repository';
|
|
248
|
+
})();
|
|
249
|
+
|
|
250
|
+
const canvas = renderToCanvas(cards, bounds, scale, repoName);
|
|
251
|
+
|
|
252
|
+
const timestamp = new Date().toISOString().slice(0, 10);
|
|
253
|
+
const safeName = repoName.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
|
|
254
|
+
downloadCanvas(canvas, `gitmaps-${safeName}-${timestamp}.png`);
|
|
255
|
+
|
|
256
|
+
// Show toast
|
|
257
|
+
showExportToast(`Exported ${cards.length} files as PNG`);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Export just the current viewport as PNG */
|
|
262
|
+
export function exportViewportAsPNG(ctx: CanvasContext) {
|
|
263
|
+
measure('export:viewport', () => {
|
|
264
|
+
const gdState = getGalaxyDrawState();
|
|
265
|
+
if (!gdState) return;
|
|
266
|
+
|
|
267
|
+
const vpEl = ctx.canvasViewport;
|
|
268
|
+
if (!vpEl) return;
|
|
269
|
+
|
|
270
|
+
const vpW = vpEl.clientWidth;
|
|
271
|
+
const vpH = vpEl.clientHeight;
|
|
272
|
+
const zoom = gdState.zoom || 1;
|
|
273
|
+
const offsetX = gdState.offsetX || 0;
|
|
274
|
+
const offsetY = gdState.offsetY || 0;
|
|
275
|
+
|
|
276
|
+
// World coordinates of viewport
|
|
277
|
+
const worldLeft = -offsetX / zoom;
|
|
278
|
+
const worldTop = -offsetY / zoom;
|
|
279
|
+
const worldRight = (vpW - offsetX) / zoom;
|
|
280
|
+
const worldBottom = (vpH - offsetY) / zoom;
|
|
281
|
+
|
|
282
|
+
const allCards = getAllCardBounds(ctx);
|
|
283
|
+
// Filter to cards visible in viewport
|
|
284
|
+
const visibleCards = allCards.filter(card =>
|
|
285
|
+
card.x + card.w > worldLeft &&
|
|
286
|
+
card.x < worldRight &&
|
|
287
|
+
card.y + card.h > worldTop &&
|
|
288
|
+
card.y < worldBottom
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
if (visibleCards.length === 0) {
|
|
292
|
+
showExportToast('No cards visible in viewport');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const bounds = {
|
|
297
|
+
x: worldLeft,
|
|
298
|
+
y: worldTop,
|
|
299
|
+
w: worldRight - worldLeft,
|
|
300
|
+
h: worldBottom - worldTop,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const scale = Math.min(2, vpW / bounds.w); // 2x for retina quality
|
|
304
|
+
|
|
305
|
+
const repoName = (() => {
|
|
306
|
+
const el = document.querySelector('.repo-dropdown-trigger, .repo-name');
|
|
307
|
+
return el?.textContent?.trim() || 'Repository';
|
|
308
|
+
})();
|
|
309
|
+
|
|
310
|
+
const canvas = renderToCanvas(visibleCards, bounds, scale, repoName);
|
|
311
|
+
|
|
312
|
+
const timestamp = new Date().toISOString().slice(0, 10);
|
|
313
|
+
const safeName = repoName.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
|
|
314
|
+
downloadCanvas(canvas, `gitmaps-viewport-${safeName}-${timestamp}.png`);
|
|
315
|
+
|
|
316
|
+
showExportToast(`Exported viewport (${visibleCards.length} files) as PNG`);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ─── Toast notification ──────────────────────────────────
|
|
321
|
+
function showExportToast(message: string) {
|
|
322
|
+
const existing = document.getElementById('exportToast');
|
|
323
|
+
if (existing) existing.remove();
|
|
324
|
+
|
|
325
|
+
const toast = document.createElement('div');
|
|
326
|
+
toast.id = 'exportToast';
|
|
327
|
+
toast.textContent = `📸 ${message}`;
|
|
328
|
+
toast.style.cssText = `
|
|
329
|
+
position: fixed;
|
|
330
|
+
bottom: 60px;
|
|
331
|
+
left: 50%;
|
|
332
|
+
transform: translateX(-50%) translateY(10px);
|
|
333
|
+
background: rgba(124, 58, 237, 0.9);
|
|
334
|
+
color: #fff;
|
|
335
|
+
padding: 10px 20px;
|
|
336
|
+
border-radius: 8px;
|
|
337
|
+
font-size: 13px;
|
|
338
|
+
font-weight: 500;
|
|
339
|
+
z-index: 10001;
|
|
340
|
+
backdrop-filter: blur(8px);
|
|
341
|
+
box-shadow: 0 4px 20px rgba(124, 58, 237, 0.3);
|
|
342
|
+
opacity: 0;
|
|
343
|
+
transition: opacity 0.3s ease, transform 0.3s ease;
|
|
344
|
+
pointer-events: none;
|
|
345
|
+
`;
|
|
346
|
+
|
|
347
|
+
document.body.appendChild(toast);
|
|
348
|
+
requestAnimationFrame(() => {
|
|
349
|
+
toast.style.opacity = '1';
|
|
350
|
+
toast.style.transform = 'translateX(-50%) translateY(0)';
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
setTimeout(() => {
|
|
354
|
+
toast.style.opacity = '0';
|
|
355
|
+
toast.style.transform = 'translateX(-50%) translateY(10px)';
|
|
356
|
+
setTimeout(() => toast.remove(), 300);
|
|
357
|
+
}, 3000);
|
|
358
|
+
}
|