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
|
@@ -1,728 +1,740 @@
|
|
|
1
|
-
// @ts-nocheck
|
|
2
|
-
/**
|
|
3
|
-
* Viewport culling + LOD (Level of Detail) system
|
|
4
|
-
*
|
|
5
|
-
* Cards outside the viewport have their content stripped (innerHTML = '')
|
|
6
|
-
* and get `data-culled="true"`. When they scroll back into view, their
|
|
7
|
-
* content is rebuilt from the stored file data.
|
|
8
|
-
*
|
|
9
|
-
* LOD System (zoom-aware):
|
|
10
|
-
* zoom > LOD_ZOOM_THRESHOLD (0.25): Full file cards with content
|
|
11
|
-
* zoom <= LOD_ZOOM_THRESHOLD: Lightweight "pill" placeholders
|
|
12
|
-
*
|
|
13
|
-
* This prevents mass-materialization when zooming out on large repos
|
|
14
|
-
* (e.g. React with 6833 files). Instead of creating 6833 full DOM cards,
|
|
15
|
-
* we create tiny colored rectangles that swap to full cards on zoom-in.
|
|
16
|
-
*
|
|
17
|
-
* Materialization throttle: When many deferred cards enter viewport at
|
|
18
|
-
* once, we materialize them in batches (MAX_MATERIALIZE_PER_FRAME) to
|
|
19
|
-
* prevent frame drops.
|
|
20
|
-
*
|
|
21
|
-
* Performance: O(n) per frame with n = total cards. The check is a simple
|
|
22
|
-
* AABB overlap test — no spatial indexing needed for < 10K cards.
|
|
23
|
-
*/
|
|
24
|
-
import { measure } from 'measure-fn';
|
|
25
|
-
import type { CanvasContext } from './context';
|
|
26
|
-
import { materializeViewport } from './
|
|
27
|
-
|
|
28
|
-
// ── Culling state ──────────────────────────────────────────
|
|
29
|
-
let _cullRafPending = false;
|
|
30
|
-
let _cullEnabled = true;
|
|
31
|
-
|
|
32
|
-
// Margin in viewport pixels — cards within this margin outside the visible
|
|
33
|
-
// area are pre-rendered so scrolling feels instant.
|
|
34
|
-
const VIEWPORT_MARGIN = 500;
|
|
35
|
-
|
|
36
|
-
// LOD threshold: below this zoom level, use lightweight pill placeholders
|
|
37
|
-
const LOD_ZOOM_THRESHOLD = 0.25;
|
|
38
|
-
|
|
39
|
-
// Maximum deferred cards to materialize per animation frame
|
|
40
|
-
// Prevents frame drops when zooming out then back in on huge repos
|
|
41
|
-
const MAX_MATERIALIZE_PER_FRAME = 8;
|
|
42
|
-
|
|
43
|
-
// Cooldown: don't materialize during rapid pan/zoom — wait until settled
|
|
44
|
-
let _lastTransformTime = 0;
|
|
45
|
-
const MATERIALIZE_COOLDOWN_MS = 150;
|
|
46
|
-
|
|
47
|
-
/** Call from updateCanvasTransform to signal active interaction */
|
|
48
|
-
export function markTransformActive() {
|
|
49
|
-
_lastTransformTime = performance.now();
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Track current LOD mode so we can detect transitions
|
|
53
|
-
let _currentLodMode: 'full' | 'pill' = 'full';
|
|
54
|
-
|
|
55
|
-
// Track pill elements for cleanup
|
|
56
|
-
const pillCards = new Map<string, HTMLElement>();
|
|
57
|
-
|
|
58
|
-
// ── Pinned cards — stay visible at any zoom level ──
|
|
59
|
-
const PINNED_STORAGE_KEY = 'gitmaps:pinnedCards';
|
|
60
|
-
let _pinnedCards: Set<string> = new Set();
|
|
61
|
-
|
|
62
|
-
try {
|
|
63
|
-
const stored = localStorage.getItem(PINNED_STORAGE_KEY);
|
|
64
|
-
if (stored) _pinnedCards = new Set(JSON.parse(stored));
|
|
65
|
-
} catch { }
|
|
66
|
-
|
|
67
|
-
function _savePinnedCards() {
|
|
68
|
-
localStorage.setItem(PINNED_STORAGE_KEY, JSON.stringify([..._pinnedCards]));
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Toggle pin state for a card. Returns new pin state. */
|
|
72
|
-
export function togglePinCard(path: string): boolean {
|
|
73
|
-
if (_pinnedCards.has(path)) {
|
|
74
|
-
_pinnedCards.delete(path);
|
|
75
|
-
} else {
|
|
76
|
-
_pinnedCards.add(path);
|
|
77
|
-
}
|
|
78
|
-
_savePinnedCards();
|
|
79
|
-
return _pinnedCards.has(path);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Check if a card is pinned */
|
|
83
|
-
export function isPinned(path: string): boolean {
|
|
84
|
-
return _pinnedCards.has(path);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Get all pinned card paths */
|
|
88
|
-
export function getPinnedCards(): Set<string> {
|
|
89
|
-
return _pinnedCards;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ── Status colors for pill cards
|
|
93
|
-
const PILL_COLORS: Record<string, string> = {
|
|
94
|
-
'ts': '#3178c6',
|
|
95
|
-
'tsx': '#3178c6',
|
|
96
|
-
'js': '#f7df1e',
|
|
97
|
-
'jsx': '#f7df1e',
|
|
98
|
-
'json': '#292929',
|
|
99
|
-
'css': '#264de4',
|
|
100
|
-
'scss': '#cd6799',
|
|
101
|
-
'html': '#e34f26',
|
|
102
|
-
'md': '#083fa1',
|
|
103
|
-
'py': '#3776ab',
|
|
104
|
-
'rs': '#dea584',
|
|
105
|
-
'go': '#00add8',
|
|
106
|
-
'vue': '#42b883',
|
|
107
|
-
'svelte': '#ff3e00',
|
|
108
|
-
'toml': '#9c4221',
|
|
109
|
-
'yaml': '#cb171e',
|
|
110
|
-
'yml': '#cb171e',
|
|
111
|
-
'sh': '#89e051',
|
|
112
|
-
'sql': '#e38c00',
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
function getPillColor(path: string, isChanged: boolean): string {
|
|
116
|
-
if (isChanged) return '#eab308'; // Yellow for changed files
|
|
117
|
-
const ext = path.split('.').pop()?.toLowerCase() || '';
|
|
118
|
-
return PILL_COLORS[ext] || '#6b7280'; // Default gray
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Create a lightweight pill placeholder for a file.
|
|
123
|
-
* ~3 DOM nodes vs ~100+ for a full card = massive perf win at low zoom.
|
|
124
|
-
* Uses vertical text to fit file names in compact card footprint.
|
|
125
|
-
*/
|
|
126
|
-
function createPillCard(path: string, x: number, y: number, w: number, h: number, isChanged: boolean, animate = false): HTMLElement {
|
|
127
|
-
const pill = document.createElement('div');
|
|
128
|
-
pill.className = 'file-pill';
|
|
129
|
-
pill.dataset.path = path;
|
|
130
|
-
pill.style.cssText = `
|
|
131
|
-
position: absolute;
|
|
132
|
-
left: ${x}px;
|
|
133
|
-
top: ${y}px;
|
|
134
|
-
width: ${w}px;
|
|
135
|
-
height: ${h}px;
|
|
136
|
-
background: ${getPillColor(path, isChanged)};
|
|
137
|
-
border-radius: 6px;
|
|
138
|
-
opacity: ${animate ? '0' : '0.9'};
|
|
139
|
-
contain: layout style;
|
|
140
|
-
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
141
|
-
border: 1px solid rgba(255,255,255,0.12);
|
|
142
|
-
overflow: hidden;
|
|
143
|
-
cursor: pointer;
|
|
144
|
-
user-select: none;
|
|
145
|
-
transition: opacity 0.25s ease, box-shadow 0.2s ease, transform 0.25s ease;
|
|
146
|
-
transform: ${animate ? 'scale(0.92)' : 'scale(1)'};
|
|
147
|
-
`;
|
|
148
|
-
|
|
149
|
-
// Animate pill entrance
|
|
150
|
-
if (animate) {
|
|
151
|
-
requestAnimationFrame(() => {
|
|
152
|
-
pill.style.opacity = '0.9';
|
|
153
|
-
pill.style.transform = 'scale(1)';
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// File name label — show parent dir for common ambiguous filenames
|
|
158
|
-
const parts = path.split('/');
|
|
159
|
-
const filename = parts.pop() || path;
|
|
160
|
-
const AMBIGUOUS = ['route.ts', 'route.tsx', 'page.tsx', 'page.ts', 'index.ts', 'index.tsx', 'index.js', 'layout.tsx', 'middleware.ts'];
|
|
161
|
-
const name = AMBIGUOUS.includes(filename) && parts.length > 0
|
|
162
|
-
? `${parts[parts.length - 1]}/${filename}`
|
|
163
|
-
: filename;
|
|
164
|
-
const label = document.createElement('span');
|
|
165
|
-
label.className = 'file-pill-label';
|
|
166
|
-
label.textContent = name;
|
|
167
|
-
label.style.cssText = `
|
|
168
|
-
position: absolute;
|
|
169
|
-
top: 50%;
|
|
170
|
-
left: 50%;
|
|
171
|
-
transform: translate(-50%, -50%) rotate(-90deg);
|
|
172
|
-
white-space: nowrap;
|
|
173
|
-
font-size: 48px;
|
|
174
|
-
font-weight: 700;
|
|
175
|
-
color: #fff;
|
|
176
|
-
overflow: hidden;
|
|
177
|
-
text-overflow: ellipsis;
|
|
178
|
-
max-width: ${h - 40}px;
|
|
179
|
-
line-height: 1;
|
|
180
|
-
letter-spacing: 2px;
|
|
181
|
-
font-family: 'JetBrains Mono', monospace;
|
|
182
|
-
text-shadow: 0 2px 8px rgba(0,0,0,0.7);
|
|
183
|
-
pointer-events: none;
|
|
184
|
-
`;
|
|
185
|
-
pill.appendChild(label);
|
|
186
|
-
|
|
187
|
-
return pill;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Computes the visible world-coordinate rectangle from the current
|
|
192
|
-
* viewport size, zoom, and offset.
|
|
193
|
-
* Also returns zoom so callers don't need a separate ctx.snap().
|
|
194
|
-
*/
|
|
195
|
-
function getVisibleWorldRect(ctx: CanvasContext) {
|
|
196
|
-
const state = ctx.snap().context;
|
|
197
|
-
const vp = ctx.canvasViewport;
|
|
198
|
-
if (!vp) return null;
|
|
199
|
-
|
|
200
|
-
const vpW = vp.clientWidth;
|
|
201
|
-
const vpH = vp.clientHeight;
|
|
202
|
-
const { zoom, offsetX, offsetY } = state;
|
|
203
|
-
|
|
204
|
-
// Convert viewport corners to world coordinates
|
|
205
|
-
// viewport pixel (0,0) → world: (-offsetX / zoom, -offsetY / zoom)
|
|
206
|
-
// viewport pixel (vpW,vpH) → world: ((vpW - offsetX) / zoom, (vpH - offsetY) / zoom)
|
|
207
|
-
const worldLeft = (-offsetX - VIEWPORT_MARGIN) / zoom;
|
|
208
|
-
const worldTop = (-offsetY - VIEWPORT_MARGIN) / zoom;
|
|
209
|
-
const worldRight = (vpW - offsetX + VIEWPORT_MARGIN) / zoom;
|
|
210
|
-
const worldBottom = (vpH - offsetY + VIEWPORT_MARGIN) / zoom;
|
|
211
|
-
|
|
212
|
-
return { left: worldLeft, top: worldTop, right: worldRight, bottom: worldBottom, zoom };
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Checks if a card overlaps the visible world rectangle.
|
|
217
|
-
*/
|
|
218
|
-
function isCardVisible(card: HTMLElement, worldRect: { left: number; top: number; right: number; bottom: number }): boolean {
|
|
219
|
-
const x = parseFloat(card.style.left) || 0;
|
|
220
|
-
const y = parseFloat(card.style.top) || 0;
|
|
221
|
-
// Use offsetWidth/Height if available, otherwise use reasonable defaults
|
|
222
|
-
const w = card.offsetWidth || 580;
|
|
223
|
-
const h = card.offsetHeight || 700;
|
|
224
|
-
|
|
225
|
-
return (
|
|
226
|
-
x + w > worldRect.left &&
|
|
227
|
-
x < worldRect.right &&
|
|
228
|
-
y + h > worldRect.top &&
|
|
229
|
-
y < worldRect.bottom
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Remove all pill placeholders from the canvas.
|
|
235
|
-
*/
|
|
236
|
-
export function clearAllPills(ctx: CanvasContext) {
|
|
237
|
-
for (const [, pill] of pillCards) {
|
|
238
|
-
pill.remove();
|
|
239
|
-
}
|
|
240
|
-
pillCards.clear();
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Fade out all pills, then call cleanup callback.
|
|
245
|
-
*/
|
|
246
|
-
function fadeOutPills(onComplete?: () => void) {
|
|
247
|
-
for (const [, pill] of pillCards) {
|
|
248
|
-
pill.style.opacity = '0';
|
|
249
|
-
pill.style.transform = 'scale(0.92)';
|
|
250
|
-
}
|
|
251
|
-
if (onComplete) {
|
|
252
|
-
setTimeout(onComplete, 250);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Transition from pill mode to full mode: remove pills for cards that
|
|
258
|
-
* have been fully materialized. Uses fade-out if pill is visible.
|
|
259
|
-
*/
|
|
260
|
-
function removePillForPath(path: string) {
|
|
261
|
-
const pill = pillCards.get(path);
|
|
262
|
-
if (pill) {
|
|
263
|
-
// Fade out the pill as the card replaces it
|
|
264
|
-
pill.style.opacity = '0';
|
|
265
|
-
pill.style.transform = 'scale(0.92)';
|
|
266
|
-
pillCards.delete(path);
|
|
267
|
-
setTimeout(() => pill.remove(), 250);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Performs viewport culling on all file cards.
|
|
273
|
-
* Cards outside the viewport get visibility:hidden + content-visibility:hidden
|
|
274
|
-
* Cards inside the viewport get shown.
|
|
275
|
-
* Also materializes deferred cards that enter the viewport (virtualization).
|
|
276
|
-
*
|
|
277
|
-
* LOD: At low zoom, uses pill placeholders instead of full cards.
|
|
278
|
-
*/
|
|
279
|
-
export function performViewportCulling(ctx: CanvasContext) {
|
|
280
|
-
if (!_cullEnabled || !ctx.canvas || ctx.fileCards.size === 0 && ctx.deferredCards.size === 0) return;
|
|
281
|
-
|
|
282
|
-
const worldRect = getVisibleWorldRect(ctx);
|
|
283
|
-
if (!worldRect) return;
|
|
284
|
-
|
|
285
|
-
// Phase 4c: also materialize deferred CardManager cards
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
card.style.
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
card.style.
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
culled
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}
|
|
483
|
-
ctx
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
*
|
|
533
|
-
*/
|
|
534
|
-
export function
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
ctx.canvas
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Viewport culling + LOD (Level of Detail) system
|
|
4
|
+
*
|
|
5
|
+
* Cards outside the viewport have their content stripped (innerHTML = '')
|
|
6
|
+
* and get `data-culled="true"`. When they scroll back into view, their
|
|
7
|
+
* content is rebuilt from the stored file data.
|
|
8
|
+
*
|
|
9
|
+
* LOD System (zoom-aware):
|
|
10
|
+
* zoom > LOD_ZOOM_THRESHOLD (0.25): Full file cards with content
|
|
11
|
+
* zoom <= LOD_ZOOM_THRESHOLD: Lightweight "pill" placeholders
|
|
12
|
+
*
|
|
13
|
+
* This prevents mass-materialization when zooming out on large repos
|
|
14
|
+
* (e.g. React with 6833 files). Instead of creating 6833 full DOM cards,
|
|
15
|
+
* we create tiny colored rectangles that swap to full cards on zoom-in.
|
|
16
|
+
*
|
|
17
|
+
* Materialization throttle: When many deferred cards enter viewport at
|
|
18
|
+
* once, we materialize them in batches (MAX_MATERIALIZE_PER_FRAME) to
|
|
19
|
+
* prevent frame drops.
|
|
20
|
+
*
|
|
21
|
+
* Performance: O(n) per frame with n = total cards. The check is a simple
|
|
22
|
+
* AABB overlap test — no spatial indexing needed for < 10K cards.
|
|
23
|
+
*/
|
|
24
|
+
import { measure } from 'measure-fn';
|
|
25
|
+
import type { CanvasContext } from './context';
|
|
26
|
+
import { materializeViewport } from './xydraw-bridge';
|
|
27
|
+
|
|
28
|
+
// ── Culling state ──────────────────────────────────────────
|
|
29
|
+
let _cullRafPending = false;
|
|
30
|
+
let _cullEnabled = true;
|
|
31
|
+
|
|
32
|
+
// Margin in viewport pixels — cards within this margin outside the visible
|
|
33
|
+
// area are pre-rendered so scrolling feels instant.
|
|
34
|
+
const VIEWPORT_MARGIN = 500;
|
|
35
|
+
|
|
36
|
+
// LOD threshold: below this zoom level, use lightweight pill placeholders
|
|
37
|
+
const LOD_ZOOM_THRESHOLD = 0.25;
|
|
38
|
+
|
|
39
|
+
// Maximum deferred cards to materialize per animation frame
|
|
40
|
+
// Prevents frame drops when zooming out then back in on huge repos
|
|
41
|
+
const MAX_MATERIALIZE_PER_FRAME = 8;
|
|
42
|
+
|
|
43
|
+
// Cooldown: don't materialize during rapid pan/zoom — wait until settled
|
|
44
|
+
let _lastTransformTime = 0;
|
|
45
|
+
const MATERIALIZE_COOLDOWN_MS = 150;
|
|
46
|
+
|
|
47
|
+
/** Call from updateCanvasTransform to signal active interaction */
|
|
48
|
+
export function markTransformActive() {
|
|
49
|
+
_lastTransformTime = performance.now();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Track current LOD mode so we can detect transitions
|
|
53
|
+
let _currentLodMode: 'full' | 'pill' = 'full';
|
|
54
|
+
|
|
55
|
+
// Track pill elements for cleanup
|
|
56
|
+
const pillCards = new Map<string, HTMLElement>();
|
|
57
|
+
|
|
58
|
+
// ── Pinned cards — stay visible at any zoom level ──
|
|
59
|
+
const PINNED_STORAGE_KEY = 'gitmaps:pinnedCards';
|
|
60
|
+
let _pinnedCards: Set<string> = new Set();
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const stored = localStorage.getItem(PINNED_STORAGE_KEY);
|
|
64
|
+
if (stored) _pinnedCards = new Set(JSON.parse(stored));
|
|
65
|
+
} catch { }
|
|
66
|
+
|
|
67
|
+
function _savePinnedCards() {
|
|
68
|
+
localStorage.setItem(PINNED_STORAGE_KEY, JSON.stringify([..._pinnedCards]));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Toggle pin state for a card. Returns new pin state. */
|
|
72
|
+
export function togglePinCard(path: string): boolean {
|
|
73
|
+
if (_pinnedCards.has(path)) {
|
|
74
|
+
_pinnedCards.delete(path);
|
|
75
|
+
} else {
|
|
76
|
+
_pinnedCards.add(path);
|
|
77
|
+
}
|
|
78
|
+
_savePinnedCards();
|
|
79
|
+
return _pinnedCards.has(path);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Check if a card is pinned */
|
|
83
|
+
export function isPinned(path: string): boolean {
|
|
84
|
+
return _pinnedCards.has(path);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Get all pinned card paths */
|
|
88
|
+
export function getPinnedCards(): Set<string> {
|
|
89
|
+
return _pinnedCards;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Status colors for pill cards
|
|
93
|
+
const PILL_COLORS: Record<string, string> = {
|
|
94
|
+
'ts': '#3178c6',
|
|
95
|
+
'tsx': '#3178c6',
|
|
96
|
+
'js': '#f7df1e',
|
|
97
|
+
'jsx': '#f7df1e',
|
|
98
|
+
'json': '#292929',
|
|
99
|
+
'css': '#264de4',
|
|
100
|
+
'scss': '#cd6799',
|
|
101
|
+
'html': '#e34f26',
|
|
102
|
+
'md': '#083fa1',
|
|
103
|
+
'py': '#3776ab',
|
|
104
|
+
'rs': '#dea584',
|
|
105
|
+
'go': '#00add8',
|
|
106
|
+
'vue': '#42b883',
|
|
107
|
+
'svelte': '#ff3e00',
|
|
108
|
+
'toml': '#9c4221',
|
|
109
|
+
'yaml': '#cb171e',
|
|
110
|
+
'yml': '#cb171e',
|
|
111
|
+
'sh': '#89e051',
|
|
112
|
+
'sql': '#e38c00',
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function getPillColor(path: string, isChanged: boolean): string {
|
|
116
|
+
if (isChanged) return '#eab308'; // Yellow for changed files
|
|
117
|
+
const ext = path.split('.').pop()?.toLowerCase() || '';
|
|
118
|
+
return PILL_COLORS[ext] || '#6b7280'; // Default gray
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Create a lightweight pill placeholder for a file.
|
|
123
|
+
* ~3 DOM nodes vs ~100+ for a full card = massive perf win at low zoom.
|
|
124
|
+
* Uses vertical text to fit file names in compact card footprint.
|
|
125
|
+
*/
|
|
126
|
+
function createPillCard(path: string, x: number, y: number, w: number, h: number, isChanged: boolean, animate = false): HTMLElement {
|
|
127
|
+
const pill = document.createElement('div');
|
|
128
|
+
pill.className = 'file-pill';
|
|
129
|
+
pill.dataset.path = path;
|
|
130
|
+
pill.style.cssText = `
|
|
131
|
+
position: absolute;
|
|
132
|
+
left: ${x}px;
|
|
133
|
+
top: ${y}px;
|
|
134
|
+
width: ${w}px;
|
|
135
|
+
height: ${h}px;
|
|
136
|
+
background: ${getPillColor(path, isChanged)};
|
|
137
|
+
border-radius: 6px;
|
|
138
|
+
opacity: ${animate ? '0' : '0.9'};
|
|
139
|
+
contain: layout style;
|
|
140
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
141
|
+
border: 1px solid rgba(255,255,255,0.12);
|
|
142
|
+
overflow: hidden;
|
|
143
|
+
cursor: pointer;
|
|
144
|
+
user-select: none;
|
|
145
|
+
transition: opacity 0.25s ease, box-shadow 0.2s ease, transform 0.25s ease;
|
|
146
|
+
transform: ${animate ? 'scale(0.92)' : 'scale(1)'};
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
// Animate pill entrance
|
|
150
|
+
if (animate) {
|
|
151
|
+
requestAnimationFrame(() => {
|
|
152
|
+
pill.style.opacity = '0.9';
|
|
153
|
+
pill.style.transform = 'scale(1)';
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// File name label — show parent dir for common ambiguous filenames
|
|
158
|
+
const parts = path.split('/');
|
|
159
|
+
const filename = parts.pop() || path;
|
|
160
|
+
const AMBIGUOUS = ['route.ts', 'route.tsx', 'page.tsx', 'page.ts', 'index.ts', 'index.tsx', 'index.js', 'layout.tsx', 'middleware.ts'];
|
|
161
|
+
const name = AMBIGUOUS.includes(filename) && parts.length > 0
|
|
162
|
+
? `${parts[parts.length - 1]}/${filename}`
|
|
163
|
+
: filename;
|
|
164
|
+
const label = document.createElement('span');
|
|
165
|
+
label.className = 'file-pill-label';
|
|
166
|
+
label.textContent = name;
|
|
167
|
+
label.style.cssText = `
|
|
168
|
+
position: absolute;
|
|
169
|
+
top: 50%;
|
|
170
|
+
left: 50%;
|
|
171
|
+
transform: translate(-50%, -50%) rotate(-90deg);
|
|
172
|
+
white-space: nowrap;
|
|
173
|
+
font-size: 48px;
|
|
174
|
+
font-weight: 700;
|
|
175
|
+
color: #fff;
|
|
176
|
+
overflow: hidden;
|
|
177
|
+
text-overflow: ellipsis;
|
|
178
|
+
max-width: ${h - 40}px;
|
|
179
|
+
line-height: 1;
|
|
180
|
+
letter-spacing: 2px;
|
|
181
|
+
font-family: 'JetBrains Mono', monospace;
|
|
182
|
+
text-shadow: 0 2px 8px rgba(0,0,0,0.7);
|
|
183
|
+
pointer-events: none;
|
|
184
|
+
`;
|
|
185
|
+
pill.appendChild(label);
|
|
186
|
+
|
|
187
|
+
return pill;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Computes the visible world-coordinate rectangle from the current
|
|
192
|
+
* viewport size, zoom, and offset.
|
|
193
|
+
* Also returns zoom so callers don't need a separate ctx.snap().
|
|
194
|
+
*/
|
|
195
|
+
function getVisibleWorldRect(ctx: CanvasContext) {
|
|
196
|
+
const state = ctx.snap().context;
|
|
197
|
+
const vp = ctx.canvasViewport;
|
|
198
|
+
if (!vp) return null;
|
|
199
|
+
|
|
200
|
+
const vpW = vp.clientWidth;
|
|
201
|
+
const vpH = vp.clientHeight;
|
|
202
|
+
const { zoom, offsetX, offsetY } = state;
|
|
203
|
+
|
|
204
|
+
// Convert viewport corners to world coordinates
|
|
205
|
+
// viewport pixel (0,0) → world: (-offsetX / zoom, -offsetY / zoom)
|
|
206
|
+
// viewport pixel (vpW,vpH) → world: ((vpW - offsetX) / zoom, (vpH - offsetY) / zoom)
|
|
207
|
+
const worldLeft = (-offsetX - VIEWPORT_MARGIN) / zoom;
|
|
208
|
+
const worldTop = (-offsetY - VIEWPORT_MARGIN) / zoom;
|
|
209
|
+
const worldRight = (vpW - offsetX + VIEWPORT_MARGIN) / zoom;
|
|
210
|
+
const worldBottom = (vpH - offsetY + VIEWPORT_MARGIN) / zoom;
|
|
211
|
+
|
|
212
|
+
return { left: worldLeft, top: worldTop, right: worldRight, bottom: worldBottom, zoom };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Checks if a card overlaps the visible world rectangle.
|
|
217
|
+
*/
|
|
218
|
+
function isCardVisible(card: HTMLElement, worldRect: { left: number; top: number; right: number; bottom: number }): boolean {
|
|
219
|
+
const x = parseFloat(card.style.left) || 0;
|
|
220
|
+
const y = parseFloat(card.style.top) || 0;
|
|
221
|
+
// Use offsetWidth/Height if available, otherwise use reasonable defaults
|
|
222
|
+
const w = card.offsetWidth || 580;
|
|
223
|
+
const h = card.offsetHeight || 700;
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
x + w > worldRect.left &&
|
|
227
|
+
x < worldRect.right &&
|
|
228
|
+
y + h > worldRect.top &&
|
|
229
|
+
y < worldRect.bottom
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Remove all pill placeholders from the canvas.
|
|
235
|
+
*/
|
|
236
|
+
export function clearAllPills(ctx: CanvasContext) {
|
|
237
|
+
for (const [, pill] of pillCards) {
|
|
238
|
+
pill.remove();
|
|
239
|
+
}
|
|
240
|
+
pillCards.clear();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Fade out all pills, then call cleanup callback.
|
|
245
|
+
*/
|
|
246
|
+
function fadeOutPills(onComplete?: () => void) {
|
|
247
|
+
for (const [, pill] of pillCards) {
|
|
248
|
+
pill.style.opacity = '0';
|
|
249
|
+
pill.style.transform = 'scale(0.92)';
|
|
250
|
+
}
|
|
251
|
+
if (onComplete) {
|
|
252
|
+
setTimeout(onComplete, 250);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Transition from pill mode to full mode: remove pills for cards that
|
|
258
|
+
* have been fully materialized. Uses fade-out if pill is visible.
|
|
259
|
+
*/
|
|
260
|
+
function removePillForPath(path: string) {
|
|
261
|
+
const pill = pillCards.get(path);
|
|
262
|
+
if (pill) {
|
|
263
|
+
// Fade out the pill as the card replaces it
|
|
264
|
+
pill.style.opacity = '0';
|
|
265
|
+
pill.style.transform = 'scale(0.92)';
|
|
266
|
+
pillCards.delete(path);
|
|
267
|
+
setTimeout(() => pill.remove(), 250);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Performs viewport culling on all file cards.
|
|
273
|
+
* Cards outside the viewport get visibility:hidden + content-visibility:hidden
|
|
274
|
+
* Cards inside the viewport get shown.
|
|
275
|
+
* Also materializes deferred cards that enter the viewport (virtualization).
|
|
276
|
+
*
|
|
277
|
+
* LOD: At low zoom, uses pill placeholders instead of full cards.
|
|
278
|
+
*/
|
|
279
|
+
export function performViewportCulling(ctx: CanvasContext) {
|
|
280
|
+
if (!_cullEnabled || !ctx.canvas || ctx.fileCards.size === 0 && ctx.deferredCards.size === 0) return;
|
|
281
|
+
|
|
282
|
+
const worldRect = getVisibleWorldRect(ctx);
|
|
283
|
+
if (!worldRect) return;
|
|
284
|
+
|
|
285
|
+
// Phase 4c: also materialize deferred CardManager cards
|
|
286
|
+
// Reuse zoom from worldRect (already snapped) — avoids redundant ctx.snap()
|
|
287
|
+
const zoom = worldRect.zoom;
|
|
288
|
+
const isLowZoom = zoom <= LOD_ZOOM_THRESHOLD;
|
|
289
|
+
|
|
290
|
+
// Important: never materialize full cards while in low-zoom pill mode.
|
|
291
|
+
// Otherwise CardManager keeps mounting heavyweight cards right when the
|
|
292
|
+
// UI is supposed to collapse into lightweight placeholders.
|
|
293
|
+
if (!isLowZoom) {
|
|
294
|
+
materializeViewport(ctx);
|
|
295
|
+
}
|
|
296
|
+
const newLodMode = isLowZoom ? 'pill' : 'full';
|
|
297
|
+
|
|
298
|
+
let culled = 0;
|
|
299
|
+
let shown = 0;
|
|
300
|
+
|
|
301
|
+
// ── LOD mode transition (with smooth animation) ──
|
|
302
|
+
if (newLodMode !== _currentLodMode) {
|
|
303
|
+
if (newLodMode === 'pill') {
|
|
304
|
+
// Transitioning to pill mode: hide full cards EXCEPT pinned ones
|
|
305
|
+
for (const [path, card] of ctx.fileCards) {
|
|
306
|
+
if (_pinnedCards.has(path)) {
|
|
307
|
+
// Pinned cards stay visible — scale down for readability
|
|
308
|
+
card.style.display = '';
|
|
309
|
+
card.dataset.culled = 'false';
|
|
310
|
+
card.dataset.pinned = 'true';
|
|
311
|
+
card.style.zIndex = '50';
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
card.style.display = 'none';
|
|
315
|
+
card.dataset.culled = 'true';
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
// Transitioning to full mode:
|
|
319
|
+
// 1. Force-show ALL materialized full cards (they were hidden in pill mode)
|
|
320
|
+
for (const [path, card] of ctx.fileCards) {
|
|
321
|
+
card.style.display = '';
|
|
322
|
+
card.style.contentVisibility = '';
|
|
323
|
+
card.style.visibility = '';
|
|
324
|
+
card.dataset.culled = 'false';
|
|
325
|
+
}
|
|
326
|
+
// 2. Remove all pills immediately (no fade — avoids ghost overlap)
|
|
327
|
+
for (const [path, pill] of pillCards) {
|
|
328
|
+
pill.style.display = 'none';
|
|
329
|
+
pill.remove();
|
|
330
|
+
}
|
|
331
|
+
pillCards.clear();
|
|
332
|
+
}
|
|
333
|
+
_currentLodMode = newLodMode;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// 1. Handle existing DOM cards (cull/show)
|
|
337
|
+
for (const [path, card] of ctx.fileCards) {
|
|
338
|
+
if (isLowZoom) {
|
|
339
|
+
// Pinned cards stay visible even in pill mode
|
|
340
|
+
if (_pinnedCards.has(path)) {
|
|
341
|
+
card.style.display = '';
|
|
342
|
+
card.style.contentVisibility = '';
|
|
343
|
+
card.style.visibility = '';
|
|
344
|
+
card.dataset.culled = 'false';
|
|
345
|
+
card.dataset.pinned = 'true';
|
|
346
|
+
card.style.zIndex = '50';
|
|
347
|
+
shown++;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
// In pill mode: force-hide non-pinned full cards
|
|
351
|
+
card.style.display = 'none';
|
|
352
|
+
card.dataset.culled = 'true';
|
|
353
|
+
delete card.dataset.pinned;
|
|
354
|
+
culled++;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const visible = isCardVisible(card, worldRect);
|
|
359
|
+
const wasCulled = card.dataset.culled === 'true';
|
|
360
|
+
|
|
361
|
+
if (visible && wasCulled) {
|
|
362
|
+
// Card entering viewport — show it with fade-in
|
|
363
|
+
card.style.contentVisibility = '';
|
|
364
|
+
card.style.visibility = '';
|
|
365
|
+
card.style.opacity = '0';
|
|
366
|
+
card.style.transition = 'opacity 0.25s ease';
|
|
367
|
+
card.dataset.culled = 'false';
|
|
368
|
+
removePillForPath(path);
|
|
369
|
+
requestAnimationFrame(() => { card.style.opacity = ''; });
|
|
370
|
+
// Cleanup transition once done to avoid overhead
|
|
371
|
+
setTimeout(() => { card.style.transition = ''; }, 300);
|
|
372
|
+
shown++;
|
|
373
|
+
} else if (!visible && !wasCulled) {
|
|
374
|
+
// Card leaving viewport — hide it (keep dimensions for layout)
|
|
375
|
+
card.style.contentVisibility = 'hidden';
|
|
376
|
+
card.style.visibility = 'hidden';
|
|
377
|
+
card.dataset.culled = 'true';
|
|
378
|
+
culled++;
|
|
379
|
+
} else if (visible) {
|
|
380
|
+
shown++;
|
|
381
|
+
} else {
|
|
382
|
+
culled++;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 2. Handle pill mode — always create pills for visible items (deferred or materialized)
|
|
387
|
+
if (isLowZoom) {
|
|
388
|
+
// Create pills for deferred cards that are visible
|
|
389
|
+
for (const [path, entry] of ctx.deferredCards) {
|
|
390
|
+
const { file, x, y, size, isChanged } = entry;
|
|
391
|
+
const cardW = size?.width || 580;
|
|
392
|
+
const cardH = size?.height || 700;
|
|
393
|
+
|
|
394
|
+
const inView = (
|
|
395
|
+
x + cardW > worldRect.left &&
|
|
396
|
+
x < worldRect.right &&
|
|
397
|
+
y + cardH > worldRect.top &&
|
|
398
|
+
y < worldRect.bottom
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
if (inView && !pillCards.has(path)) {
|
|
402
|
+
const pill = createPillCard(path, x, y, cardW, cardH, !!isChanged, true);
|
|
403
|
+
ctx.canvas.appendChild(pill);
|
|
404
|
+
pillCards.set(path, pill);
|
|
405
|
+
} else if (!inView && pillCards.has(path)) {
|
|
406
|
+
removePillForPath(path);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Always create pills for existing DOM cards (even if deferredCards is empty)
|
|
411
|
+
for (const [path, card] of ctx.fileCards) {
|
|
412
|
+
if (!pillCards.has(path)) {
|
|
413
|
+
const x = parseFloat(card.style.left) || 0;
|
|
414
|
+
const y = parseFloat(card.style.top) || 0;
|
|
415
|
+
const w = card.offsetWidth || 580;
|
|
416
|
+
const h = card.offsetHeight || 700;
|
|
417
|
+
const isChanged = card.dataset.changed === 'true';
|
|
418
|
+
|
|
419
|
+
const inView = (
|
|
420
|
+
x + w > worldRect.left &&
|
|
421
|
+
x < worldRect.right &&
|
|
422
|
+
y + h > worldRect.top &&
|
|
423
|
+
y < worldRect.bottom
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
if (inView) {
|
|
427
|
+
const pill = createPillCard(path, x, y, w, h, isChanged, true);
|
|
428
|
+
ctx.canvas.appendChild(pill);
|
|
429
|
+
pillCards.set(path, pill);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Clean up pills that scrolled out of view
|
|
435
|
+
for (const [path, pill] of pillCards) {
|
|
436
|
+
const x = parseFloat(pill.style.left) || 0;
|
|
437
|
+
const y = parseFloat(pill.style.top) || 0;
|
|
438
|
+
const w = parseFloat(pill.style.width) || 580;
|
|
439
|
+
const h = parseFloat(pill.style.height) || 80;
|
|
440
|
+
const inView = (
|
|
441
|
+
x + w > worldRect.left &&
|
|
442
|
+
x < worldRect.right &&
|
|
443
|
+
y + h > worldRect.top &&
|
|
444
|
+
y < worldRect.bottom
|
|
445
|
+
);
|
|
446
|
+
if (!inView) {
|
|
447
|
+
removePillForPath(path);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
} else if (ctx.deferredCards.size > 0) {
|
|
451
|
+
// 3. Full mode: materialize deferred cards (throttled)
|
|
452
|
+
// Skip materialization during active pan/zoom to keep frames smooth
|
|
453
|
+
const timeSinceTransform = performance.now() - _lastTransformTime;
|
|
454
|
+
if (timeSinceTransform < MATERIALIZE_COOLDOWN_MS) {
|
|
455
|
+
// Still actively panning — schedule a retry after cooldown
|
|
456
|
+
setTimeout(() => scheduleViewportCulling(ctx), MATERIALIZE_COOLDOWN_MS);
|
|
457
|
+
return { culled, shown, total: ctx.fileCards.size };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
let materialized = 0;
|
|
461
|
+
const toRemove: string[] = [];
|
|
462
|
+
|
|
463
|
+
for (const [path, entry] of ctx.deferredCards) {
|
|
464
|
+
if (materialized >= MAX_MATERIALIZE_PER_FRAME) break;
|
|
465
|
+
// Skip hidden files — don't materialize them
|
|
466
|
+
if (ctx.hiddenFiles.has(path)) { toRemove.push(path); continue; }
|
|
467
|
+
|
|
468
|
+
const { file, x, y, size, isChanged } = entry;
|
|
469
|
+
const cardW = size?.width || 580;
|
|
470
|
+
const cardH = size?.height || 700;
|
|
471
|
+
|
|
472
|
+
// AABB check against world rect
|
|
473
|
+
const inView = (
|
|
474
|
+
x + cardW > worldRect.left &&
|
|
475
|
+
x < worldRect.right &&
|
|
476
|
+
y + cardH > worldRect.top &&
|
|
477
|
+
y < worldRect.bottom
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
if (inView) {
|
|
481
|
+
// Lazy-import to avoid circular dependency
|
|
482
|
+
const { createAllFileCard, setupCardInteraction } = require('./cards');
|
|
483
|
+
const card = createAllFileCard(ctx, file, x, y, size);
|
|
484
|
+
if (isChanged) {
|
|
485
|
+
card.classList.add('file-card--changed');
|
|
486
|
+
card.dataset.changed = 'true';
|
|
487
|
+
}
|
|
488
|
+
ctx.canvas.appendChild(card);
|
|
489
|
+
ctx.fileCards.set(path, card);
|
|
490
|
+
removePillForPath(path);
|
|
491
|
+
toRemove.push(path);
|
|
492
|
+
materialized++;
|
|
493
|
+
shown++;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Remove materialized entries from deferred map
|
|
498
|
+
for (const path of toRemove) {
|
|
499
|
+
ctx.deferredCards.delete(path);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (materialized > 0) {
|
|
503
|
+
console.log(`[cull] Materialized ${materialized} deferred cards (${ctx.deferredCards.size} remaining)`);
|
|
504
|
+
// If more cards need materializing, schedule another pass
|
|
505
|
+
if (ctx.deferredCards.size > 0) {
|
|
506
|
+
scheduleViewportCulling(ctx);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return { culled, shown, total: ctx.fileCards.size };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Schedules a viewport culling pass on the next animation frame.
|
|
516
|
+
* Debounced — multiple calls per frame only result in one culling pass.
|
|
517
|
+
*/
|
|
518
|
+
export function scheduleViewportCulling(ctx: CanvasContext) {
|
|
519
|
+
if (_cullRafPending || !_cullEnabled) return;
|
|
520
|
+
_cullRafPending = true;
|
|
521
|
+
requestAnimationFrame(() => {
|
|
522
|
+
_cullRafPending = false;
|
|
523
|
+
const t0 = performance.now();
|
|
524
|
+
performViewportCulling(ctx);
|
|
525
|
+
const elapsed = performance.now() - t0;
|
|
526
|
+
// Report to perf overlay (lazy import avoids circular dep)
|
|
527
|
+
try { require('./perf-overlay').reportRenderTiming('cull', elapsed); } catch { }
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Enable/disable viewport culling.
|
|
533
|
+
*/
|
|
534
|
+
export function setViewportCullingEnabled(enabled: boolean) {
|
|
535
|
+
_cullEnabled = enabled;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Force all cards to be visible (disable culling effect).
|
|
540
|
+
* Call this before operations that need to measure all cards (e.g. fitAll).
|
|
541
|
+
* Also materializes all deferred cards so they can be measured.
|
|
542
|
+
*/
|
|
543
|
+
export function uncullAllCards(ctx: CanvasContext) {
|
|
544
|
+
// Clear any pill placeholders
|
|
545
|
+
clearAllPills(ctx);
|
|
546
|
+
_currentLodMode = 'full';
|
|
547
|
+
|
|
548
|
+
for (const [, card] of ctx.fileCards) {
|
|
549
|
+
card.style.contentVisibility = '';
|
|
550
|
+
card.style.visibility = '';
|
|
551
|
+
card.dataset.culled = 'false';
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Materialize ALL deferred cards (needed for fitAll, arrangeGrid etc.)
|
|
555
|
+
if (ctx.deferredCards.size > 0) {
|
|
556
|
+
const { createAllFileCard } = require('./cards');
|
|
557
|
+
for (const [path, entry] of ctx.deferredCards) {
|
|
558
|
+
// Skip hidden files
|
|
559
|
+
if (ctx.hiddenFiles.has(path)) continue;
|
|
560
|
+
const { file, x, y, size, isChanged } = entry;
|
|
561
|
+
const card = createAllFileCard(ctx, file, x, y, size);
|
|
562
|
+
if (isChanged) {
|
|
563
|
+
card.classList.add('file-card--changed');
|
|
564
|
+
card.dataset.changed = 'true';
|
|
565
|
+
}
|
|
566
|
+
ctx.canvas.appendChild(card);
|
|
567
|
+
ctx.fileCards.set(path, card);
|
|
568
|
+
}
|
|
569
|
+
console.log(`[uncull] Materialized all ${ctx.deferredCards.size} deferred cards`);
|
|
570
|
+
ctx.deferredCards.clear();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Setup event-delegated interaction for pill cards.
|
|
576
|
+
* One listener on the canvas handles all pill clicks/drags/double-clicks.
|
|
577
|
+
* Much more efficient than per-pill listeners.
|
|
578
|
+
*/
|
|
579
|
+
let _pillInteractionSetup = false;
|
|
580
|
+
export function setupPillInteraction(ctx: CanvasContext) {
|
|
581
|
+
if (_pillInteractionSetup || !ctx.canvas) return;
|
|
582
|
+
_pillInteractionSetup = true;
|
|
583
|
+
|
|
584
|
+
let pillAction: null | 'pending' | 'move' = null;
|
|
585
|
+
let pillTarget: HTMLElement | null = null;
|
|
586
|
+
let pillStartX = 0, pillStartY = 0;
|
|
587
|
+
let pillMoveInfos: { pill: HTMLElement; path: string; startLeft: number; startTop: number }[] = [];
|
|
588
|
+
const DRAG_THRESHOLD = 5;
|
|
589
|
+
|
|
590
|
+
ctx.canvas.addEventListener('mousedown', (e: MouseEvent) => {
|
|
591
|
+
if (e.button !== 0) return;
|
|
592
|
+
const pill = (e.target as HTMLElement).closest('.file-pill') as HTMLElement;
|
|
593
|
+
if (!pill) return;
|
|
594
|
+
|
|
595
|
+
e.stopPropagation();
|
|
596
|
+
pillTarget = pill;
|
|
597
|
+
pillAction = 'pending';
|
|
598
|
+
pillStartX = e.clientX;
|
|
599
|
+
pillStartY = e.clientY;
|
|
600
|
+
|
|
601
|
+
window.addEventListener('mousemove', onPillMove);
|
|
602
|
+
window.addEventListener('mouseup', onPillUp);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// Native dblclick to open editor modal (consistent with card dblclick)
|
|
606
|
+
ctx.canvas.addEventListener('dblclick', (e: MouseEvent) => {
|
|
607
|
+
const pill = (e.target as HTMLElement).closest('.file-pill') as HTMLElement;
|
|
608
|
+
if (!pill) return;
|
|
609
|
+
e.stopPropagation();
|
|
610
|
+
e.preventDefault();
|
|
611
|
+
const pillPath = pill.dataset.path || '';
|
|
612
|
+
if (pillPath) {
|
|
613
|
+
const file = ctx.allFilesData?.find(f => f.path === pillPath) ||
|
|
614
|
+
{ path: pillPath, name: pillPath.split('/').pop(), lines: 0 };
|
|
615
|
+
import('./file-modal').then(({ openFileModal }) => openFileModal(ctx, file));
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
function onPillMove(e: MouseEvent) {
|
|
620
|
+
if (!pillTarget) return;
|
|
621
|
+
const state = ctx.snap().context;
|
|
622
|
+
const dx = (e.clientX - pillStartX) / state.zoom;
|
|
623
|
+
const dy = (e.clientY - pillStartY) / state.zoom;
|
|
624
|
+
|
|
625
|
+
if (pillAction === 'pending') {
|
|
626
|
+
const dist = Math.sqrt((e.clientX - pillStartX) ** 2 + (e.clientY - pillStartY) ** 2);
|
|
627
|
+
if (dist < DRAG_THRESHOLD) return;
|
|
628
|
+
|
|
629
|
+
pillAction = 'move';
|
|
630
|
+
const pillPath = pillTarget.dataset.path || '';
|
|
631
|
+
|
|
632
|
+
// If this pill isn't selected yet, select it
|
|
633
|
+
const selected = state.selectedCards;
|
|
634
|
+
if (!selected.includes(pillPath)) {
|
|
635
|
+
if (!e.shiftKey && !e.ctrlKey) {
|
|
636
|
+
ctx.actor.send({ type: 'SELECT_CARD', path: pillPath, shift: false });
|
|
637
|
+
} else {
|
|
638
|
+
ctx.actor.send({ type: 'SELECT_CARD', path: pillPath, shift: true });
|
|
639
|
+
}
|
|
640
|
+
updatePillSelectionHighlights(ctx);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Collect all selected pills for multi-drag
|
|
644
|
+
const nowSelected = ctx.snap().context.selectedCards;
|
|
645
|
+
pillMoveInfos = [];
|
|
646
|
+
nowSelected.forEach(path => {
|
|
647
|
+
const p = pillCards.get(path);
|
|
648
|
+
if (p) {
|
|
649
|
+
pillMoveInfos.push({
|
|
650
|
+
pill: p,
|
|
651
|
+
path,
|
|
652
|
+
startLeft: parseFloat(p.style.left) || 0,
|
|
653
|
+
startTop: parseFloat(p.style.top) || 0,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (pillAction === 'move') {
|
|
660
|
+
pillMoveInfos.forEach(info => {
|
|
661
|
+
info.pill.style.left = `${info.startLeft + dx}px`;
|
|
662
|
+
info.pill.style.top = `${info.startTop + dy}px`;
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function onPillUp(e: MouseEvent) {
|
|
668
|
+
window.removeEventListener('mousemove', onPillMove);
|
|
669
|
+
window.removeEventListener('mouseup', onPillUp);
|
|
670
|
+
|
|
671
|
+
if (!pillTarget) return;
|
|
672
|
+
const pillPath = pillTarget.dataset.path || '';
|
|
673
|
+
|
|
674
|
+
if (pillAction === 'pending') {
|
|
675
|
+
// Single click → select (double-click handled by native dblclick listener)
|
|
676
|
+
if (e.shiftKey || e.ctrlKey) {
|
|
677
|
+
ctx.actor.send({ type: 'SELECT_CARD', path: pillPath, shift: true });
|
|
678
|
+
} else {
|
|
679
|
+
ctx.actor.send({ type: 'SELECT_CARD', path: pillPath, shift: false });
|
|
680
|
+
}
|
|
681
|
+
updatePillSelectionHighlights(ctx);
|
|
682
|
+
} else if (pillAction === 'move') {
|
|
683
|
+
// Save new positions for all moved pills
|
|
684
|
+
const { savePosition } = require('./positions');
|
|
685
|
+
pillMoveInfos.forEach(info => {
|
|
686
|
+
const newX = parseFloat(info.pill.style.left) || 0;
|
|
687
|
+
const newY = parseFloat(info.pill.style.top) || 0;
|
|
688
|
+
|
|
689
|
+
// Update deferred card position
|
|
690
|
+
const deferred = ctx.deferredCards.get(info.path);
|
|
691
|
+
if (deferred) {
|
|
692
|
+
deferred.x = newX;
|
|
693
|
+
deferred.y = newY;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Update materialized card position too (if exists)
|
|
697
|
+
const card = ctx.fileCards.get(info.path);
|
|
698
|
+
if (card) {
|
|
699
|
+
card.style.left = `${newX}px`;
|
|
700
|
+
card.style.top = `${newY}px`;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
savePosition(ctx, 'allfiles', info.path, newX, newY);
|
|
704
|
+
});
|
|
705
|
+
pillMoveInfos = [];
|
|
706
|
+
// Force minimap rebuild so dot positions reflect the drag result
|
|
707
|
+
const { forceMinimapRebuild } = require('./canvas');
|
|
708
|
+
forceMinimapRebuild(ctx);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
pillAction = null;
|
|
712
|
+
pillTarget = null;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Update pill selection highlights based on XState selectedCards.
|
|
718
|
+
*/
|
|
719
|
+
export function updatePillSelectionHighlights(ctx: CanvasContext) {
|
|
720
|
+
const selected = ctx.snap().context.selectedCards;
|
|
721
|
+
for (const [path, pill] of pillCards) {
|
|
722
|
+
if (selected.includes(path)) {
|
|
723
|
+
pill.style.outline = '8px solid rgba(124, 58, 237, 1)';
|
|
724
|
+
pill.style.outlineOffset = '6px';
|
|
725
|
+
pill.style.boxShadow = '0 0 0 6px rgba(124, 58, 237, 0.5), 0 0 60px 20px rgba(124, 58, 237, 0.6), 0 0 100px 40px rgba(124, 58, 237, 0.3)';
|
|
726
|
+
pill.style.zIndex = '100';
|
|
727
|
+
pill.style.filter = 'brightness(1.3)';
|
|
728
|
+
} else {
|
|
729
|
+
pill.style.outline = '';
|
|
730
|
+
pill.style.outlineOffset = '';
|
|
731
|
+
pill.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
|
|
732
|
+
pill.style.zIndex = '';
|
|
733
|
+
pill.style.filter = '';
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
// Also update full card highlights
|
|
737
|
+
const { updateSelectionHighlights } = require('./cards');
|
|
738
|
+
updateSelectionHighlights(ctx);
|
|
739
|
+
}
|
|
740
|
+
|