gitmaps 1.1.5 → 1.1.7
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/app/analytics.db +0 -0
- package/app/globals.css +55 -1
- package/app/layout.tsx +7 -0
- package/app/lib/events.tsx +23 -1
- package/app/lib/low-zoom-preview.test.ts +19 -1
- package/app/lib/low-zoom-preview.ts +179 -5
- package/app/lib/viewport-culling.ts +61 -92
- package/package.json +3 -1
package/app/analytics.db
CHANGED
|
Binary file
|
package/app/globals.css
CHANGED
|
@@ -1145,10 +1145,64 @@ body {
|
|
|
1145
1145
|
|
|
1146
1146
|
/* ── Sticky Zoom Controls ── */
|
|
1147
1147
|
body.repo-loading .sticky-zoom-pill,
|
|
1148
|
-
body.
|
|
1148
|
+
body.repo-loading .detail-mode-switch,
|
|
1149
|
+
body.landing-placeholder-visible .sticky-zoom-pill,
|
|
1150
|
+
body.landing-placeholder-visible .detail-mode-switch {
|
|
1149
1151
|
display: none;
|
|
1150
1152
|
}
|
|
1151
1153
|
|
|
1154
|
+
.detail-mode-switch {
|
|
1155
|
+
position: fixed;
|
|
1156
|
+
top: 12px;
|
|
1157
|
+
right: 144px;
|
|
1158
|
+
z-index: 10001;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
.detail-mode-btn {
|
|
1162
|
+
display: inline-flex;
|
|
1163
|
+
align-items: center;
|
|
1164
|
+
gap: 8px;
|
|
1165
|
+
padding: 6px 10px;
|
|
1166
|
+
background: rgba(15, 23, 42, 0.88);
|
|
1167
|
+
border: 1px solid rgba(124, 58, 237, 0.24);
|
|
1168
|
+
border-radius: 10px;
|
|
1169
|
+
color: #d8ccff;
|
|
1170
|
+
font-family: 'JetBrains Mono', monospace;
|
|
1171
|
+
font-size: 10px;
|
|
1172
|
+
cursor: pointer;
|
|
1173
|
+
backdrop-filter: blur(8px);
|
|
1174
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
|
1175
|
+
transition:
|
|
1176
|
+
background 0.2s ease,
|
|
1177
|
+
border-color 0.2s ease,
|
|
1178
|
+
transform 0.12s ease;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
.detail-mode-btn:hover {
|
|
1182
|
+
background: rgba(15, 23, 42, 0.96);
|
|
1183
|
+
border-color: rgba(124, 58, 237, 0.45);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
.detail-mode-btn:active {
|
|
1187
|
+
transform: scale(0.98);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
.detail-mode-btn.active {
|
|
1191
|
+
border-color: rgba(96, 165, 250, 0.6);
|
|
1192
|
+
box-shadow: 0 10px 28px rgba(59, 130, 246, 0.16);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
.detail-mode-label {
|
|
1196
|
+
opacity: 0.72;
|
|
1197
|
+
text-transform: uppercase;
|
|
1198
|
+
letter-spacing: 0.06em;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
.detail-mode-state {
|
|
1202
|
+
color: #f8fafc;
|
|
1203
|
+
font-weight: 700;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1152
1206
|
.sticky-zoom-pill {
|
|
1153
1207
|
position: absolute;
|
|
1154
1208
|
bottom: 16px;
|
package/app/layout.tsx
CHANGED
|
@@ -875,6 +875,13 @@ export default function RootLayout({ children }: { children: any }) {
|
|
|
875
875
|
</div>
|
|
876
876
|
|
|
877
877
|
{/* Sticky Zoom Controls — floating pill, bottom-right */}
|
|
878
|
+
<div id="detailModeSwitch" className="detail-mode-switch" title="Toggle preview-focused detail mode">
|
|
879
|
+
<button id="toggleDetailMode" type="button" className="detail-mode-btn">
|
|
880
|
+
<span className="detail-mode-label">Preview mode</span>
|
|
881
|
+
<span id="detailModeState" className="detail-mode-state">Auto</span>
|
|
882
|
+
</button>
|
|
883
|
+
</div>
|
|
884
|
+
|
|
878
885
|
<div id="stickyZoomControls" className="sticky-zoom-pill">
|
|
879
886
|
<button id="stickyZoomOut" className="sz-btn" title="Zoom out">
|
|
880
887
|
<svg
|
package/app/lib/events.tsx
CHANGED
|
@@ -27,7 +27,7 @@ import { createLayer, getActiveLayer, addSectionToLayer } from './layers';
|
|
|
27
27
|
import { updateCanvasTransform, updateZoomUI, updateMinimap, fitAllFiles, setupMinimapClick } from './canvas';
|
|
28
28
|
import { zoomTowardScreen, panByDelta, screenToWorld, getCardManager } from './xydraw-bridge';
|
|
29
29
|
import { hideSelectedFiles, showHiddenFilesModal as showHiddenModal } from './hidden-files';
|
|
30
|
-
import { updatePillSelectionHighlights } from './viewport-culling';
|
|
30
|
+
import { getDetailMode, toggleDetailMode, 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
33
|
import { handoffRepoLoad, syncRepoSelection } from './repo-handoff';
|
|
@@ -451,6 +451,28 @@ export function setupEventListeners(ctx: CanvasContext) {
|
|
|
451
451
|
});
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
+
const detailModeToggle = document.getElementById('toggleDetailMode');
|
|
455
|
+
if (detailModeToggle) {
|
|
456
|
+
const stateEl = document.getElementById('detailModeState');
|
|
457
|
+
const updateDetailModeUi = () => {
|
|
458
|
+
const mode = getDetailMode();
|
|
459
|
+
detailModeToggle.classList.toggle('active', mode === 'preview');
|
|
460
|
+
detailModeToggle.setAttribute('title', mode === 'preview'
|
|
461
|
+
? 'Preview mode forced on — click to restore auto detail switching'
|
|
462
|
+
: 'Auto detail switching — click to keep preview mode on at every zoom');
|
|
463
|
+
if (stateEl) stateEl.textContent = mode === 'preview' ? 'Preview' : 'Auto';
|
|
464
|
+
};
|
|
465
|
+
updateDetailModeUi();
|
|
466
|
+
detailModeToggle.addEventListener('click', () => {
|
|
467
|
+
const next = toggleDetailMode();
|
|
468
|
+
updateDetailModeUi();
|
|
469
|
+
rerenderCurrentView(ctx);
|
|
470
|
+
showToast(next === 'preview'
|
|
471
|
+
? 'Preview mode forced on'
|
|
472
|
+
: 'Auto detail switching restored', 'info');
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
454
476
|
// Control mode toggle (Simple vs Advanced)
|
|
455
477
|
const modeToggle = document.getElementById('toggleControlMode');
|
|
456
478
|
if (modeToggle) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { getLowZoomPreviewText, getLowZoomScale } from './low-zoom-preview';
|
|
2
|
+
import { estimatePreviewCharsPerLine, estimatePreviewLineCapacity, estimateTitleCharsPerLine, getLowZoomPreviewText, getLowZoomScale, wrapPreviewText } from './low-zoom-preview';
|
|
3
3
|
|
|
4
4
|
describe('low zoom preview helpers', () => {
|
|
5
5
|
test('anchors preview text to approximate saved scroll position', () => {
|
|
@@ -24,4 +24,22 @@ describe('low zoom preview helpers', () => {
|
|
|
24
24
|
expect(far.titleFont).toBeGreaterThan(near.titleFont);
|
|
25
25
|
expect(far.bodyFont).toBeGreaterThan(near.bodyFont);
|
|
26
26
|
});
|
|
27
|
+
|
|
28
|
+
test('wraps preview text into bounded lines with ellipsis', () => {
|
|
29
|
+
const lines = wrapPreviewText('alpha beta gamma delta epsilon zeta eta theta', 10, 3);
|
|
30
|
+
expect(lines.length).toBeLessThanOrEqual(3);
|
|
31
|
+
expect(lines[lines.length - 1]?.endsWith('…')).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('preview capacity estimates stay positive', () => {
|
|
35
|
+
expect(estimatePreviewCharsPerLine(580, 0.25)).toBeGreaterThan(8);
|
|
36
|
+
expect(estimateTitleCharsPerLine(580, 0.25)).toBeGreaterThan(8);
|
|
37
|
+
expect(estimatePreviewLineCapacity(700, 0.25)).toBeGreaterThanOrEqual(2);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('title typography is larger than body typography for readability', () => {
|
|
41
|
+
const scale = getLowZoomScale(0.18);
|
|
42
|
+
expect(scale.titleFont).toBeGreaterThan(scale.bodyFont);
|
|
43
|
+
expect(scale.titleLineHeight).toBeGreaterThan(scale.bodyLineHeight * 0.7);
|
|
44
|
+
});
|
|
27
45
|
});
|
|
@@ -5,15 +5,16 @@ const PREVIEWABLE_EXTS = new Set([
|
|
|
5
5
|
export function getLowZoomScale(zoom: number) {
|
|
6
6
|
const clampedZoom = Math.max(0.08, Math.min(0.25, zoom));
|
|
7
7
|
const progress = (0.25 - clampedZoom) / (0.25 - 0.08);
|
|
8
|
-
const desiredScreenTitle =
|
|
9
|
-
const desiredScreenBody =
|
|
8
|
+
const desiredScreenTitle = 14 + progress * 6;
|
|
9
|
+
const desiredScreenBody = 9 + progress * 4;
|
|
10
10
|
return {
|
|
11
11
|
titleFont: desiredScreenTitle / clampedZoom,
|
|
12
|
+
titleLineHeight: (desiredScreenTitle * 1.08) / clampedZoom,
|
|
12
13
|
bodyFont: desiredScreenBody / clampedZoom,
|
|
13
14
|
bodyLineHeight: (desiredScreenBody * 1.45) / clampedZoom,
|
|
14
|
-
padding: (
|
|
15
|
-
gap: (
|
|
16
|
-
radius:
|
|
15
|
+
padding: (12 + progress * 6) / clampedZoom,
|
|
16
|
+
gap: (7 + progress * 3) / clampedZoom,
|
|
17
|
+
radius: 10 / clampedZoom,
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -29,3 +30,176 @@ export function getLowZoomPreviewText(file: any, scrollTop: number): string {
|
|
|
29
30
|
const startLine = Math.max(0, Math.floor(scrollTop / approxLineHeight));
|
|
30
31
|
return lines.slice(startLine, startLine + 60).join('\n').trim();
|
|
31
32
|
}
|
|
33
|
+
|
|
34
|
+
export function wrapPreviewText(text: string, maxCharsPerLine: number, maxLines: number): string[] {
|
|
35
|
+
const safeMaxChars = Math.max(8, Math.floor(maxCharsPerLine));
|
|
36
|
+
const safeMaxLines = Math.max(1, Math.floor(maxLines));
|
|
37
|
+
const sourceLines = String(text || '').split('\n');
|
|
38
|
+
const out: string[] = [];
|
|
39
|
+
|
|
40
|
+
for (const sourceLine of sourceLines) {
|
|
41
|
+
const words = sourceLine.length === 0 ? [''] : sourceLine.split(/(\s+)/).filter(Boolean);
|
|
42
|
+
let current = '';
|
|
43
|
+
|
|
44
|
+
for (const part of words) {
|
|
45
|
+
if (part.length > safeMaxChars) {
|
|
46
|
+
if (current.trim().length > 0) {
|
|
47
|
+
out.push(current.trimEnd());
|
|
48
|
+
if (out.length >= safeMaxLines) return ellipsizeWrappedLines(out, safeMaxLines);
|
|
49
|
+
current = '';
|
|
50
|
+
}
|
|
51
|
+
for (let i = 0; i < part.length; i += safeMaxChars) {
|
|
52
|
+
out.push(part.slice(i, i + safeMaxChars));
|
|
53
|
+
if (out.length >= safeMaxLines) return ellipsizeWrappedLines(out, safeMaxLines);
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if ((current + part).length > safeMaxChars && current.length > 0) {
|
|
59
|
+
out.push(current.trimEnd());
|
|
60
|
+
if (out.length >= safeMaxLines) return ellipsizeWrappedLines(out, safeMaxLines);
|
|
61
|
+
current = part.trimStart();
|
|
62
|
+
} else {
|
|
63
|
+
current += part;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (current.length > 0 || sourceLine.length === 0) {
|
|
68
|
+
out.push(current.trimEnd());
|
|
69
|
+
if (out.length >= safeMaxLines) return ellipsizeWrappedLines(out, safeMaxLines);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return out.slice(0, safeMaxLines);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ellipsizeWrappedLines(lines: string[], maxLines: number) {
|
|
77
|
+
const sliced = lines.slice(0, maxLines);
|
|
78
|
+
if (sliced.length === 0) return sliced;
|
|
79
|
+
const last = sliced[sliced.length - 1].replace(/[\s.…]+$/g, '');
|
|
80
|
+
sliced[sliced.length - 1] = `${last}…`;
|
|
81
|
+
return sliced;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function estimatePreviewLineCapacity(height: number, zoom: number): number {
|
|
85
|
+
const scale = getLowZoomScale(zoom);
|
|
86
|
+
const available = Math.max(scale.bodyLineHeight, height - scale.padding * 2 - scale.titleLineHeight * 2 - scale.bodyFont - scale.gap * 4);
|
|
87
|
+
return Math.max(2, Math.floor(available / scale.bodyLineHeight));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function estimateTitleCharsPerLine(width: number, zoom: number): number {
|
|
91
|
+
const scale = getLowZoomScale(zoom);
|
|
92
|
+
const available = Math.max(80, width - scale.padding * 2 - Math.max(14, width * 0.02));
|
|
93
|
+
const avgCharWidth = Math.max(7, scale.titleFont * 0.58);
|
|
94
|
+
return Math.max(8, Math.floor(available / avgCharWidth));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function estimatePreviewCharsPerLine(width: number, zoom: number): number {
|
|
98
|
+
const scale = getLowZoomScale(zoom);
|
|
99
|
+
const available = Math.max(60, width - scale.padding * 2 - Math.max(14, width * 0.02));
|
|
100
|
+
const avgCharWidth = Math.max(6, scale.bodyFont * 0.6);
|
|
101
|
+
return Math.max(8, Math.floor(available / avgCharWidth));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function renderLowZoomPreviewCanvas(
|
|
105
|
+
canvas: HTMLCanvasElement,
|
|
106
|
+
params: {
|
|
107
|
+
path: string;
|
|
108
|
+
file: any;
|
|
109
|
+
width: number;
|
|
110
|
+
height: number;
|
|
111
|
+
zoom: number;
|
|
112
|
+
scrollTop: number;
|
|
113
|
+
accentColor: string;
|
|
114
|
+
isChanged: boolean;
|
|
115
|
+
},
|
|
116
|
+
) {
|
|
117
|
+
const { path, file, width, height, zoom, scrollTop, accentColor } = params;
|
|
118
|
+
const dpr = (globalThis.devicePixelRatio || 1);
|
|
119
|
+
const scale = getLowZoomScale(zoom);
|
|
120
|
+
const ctx = canvas.getContext('2d');
|
|
121
|
+
if (!ctx) return;
|
|
122
|
+
|
|
123
|
+
canvas.width = Math.max(1, Math.floor(width * dpr));
|
|
124
|
+
canvas.height = Math.max(1, Math.floor(height * dpr));
|
|
125
|
+
canvas.style.width = `${width}px`;
|
|
126
|
+
canvas.style.height = `${height}px`;
|
|
127
|
+
|
|
128
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
129
|
+
ctx.clearRect(0, 0, width, height);
|
|
130
|
+
|
|
131
|
+
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
|
132
|
+
gradient.addColorStop(0, 'rgba(15,23,42,0.96)');
|
|
133
|
+
gradient.addColorStop(1, 'rgba(2,6,23,0.96)');
|
|
134
|
+
ctx.fillStyle = gradient;
|
|
135
|
+
roundRect(ctx, 0, 0, width, height, Math.max(6, scale.radius));
|
|
136
|
+
ctx.fill();
|
|
137
|
+
|
|
138
|
+
ctx.fillStyle = accentColor;
|
|
139
|
+
ctx.fillRect(0, 0, Math.max(10, width * 0.02), height);
|
|
140
|
+
|
|
141
|
+
const leftInset = scale.padding + Math.max(14, width * 0.02);
|
|
142
|
+
const topInset = scale.padding;
|
|
143
|
+
const maxTextWidth = Math.max(40, width - leftInset - scale.padding);
|
|
144
|
+
|
|
145
|
+
ctx.textBaseline = 'top';
|
|
146
|
+
ctx.font = `700 ${scale.titleFont}px "JetBrains Mono", monospace`;
|
|
147
|
+
ctx.fillStyle = '#f8fafc';
|
|
148
|
+
const title = path.split('/').pop() || path;
|
|
149
|
+
const titleLines = wrapPreviewText(title, estimateTitleCharsPerLine(width, zoom), 2);
|
|
150
|
+
titleLines.forEach((line, index) => {
|
|
151
|
+
ctx.fillText(trimToWidth(ctx, line, maxTextWidth), leftInset, topInset + index * scale.titleLineHeight);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const subtitleY = topInset + titleLines.length * scale.titleLineHeight + scale.gap * 0.8;
|
|
155
|
+
ctx.font = `${Math.max(scale.bodyFont * 0.8, 9 / Math.max(zoom, 0.08))}px "JetBrains Mono", monospace`;
|
|
156
|
+
ctx.fillStyle = 'rgba(226,232,240,0.72)';
|
|
157
|
+
const pathParts = path.split('/');
|
|
158
|
+
const subtitle = pathParts.length > 1 ? pathParts.slice(Math.max(0, pathParts.length - 3), -1).join(' / ') : 'root';
|
|
159
|
+
ctx.fillText(trimToWidth(ctx, subtitle, maxTextWidth), leftInset, subtitleY);
|
|
160
|
+
|
|
161
|
+
const previewY = subtitleY + Math.max(scale.bodyFont * 0.8, 9 / Math.max(zoom, 0.08)) + scale.gap * 1.35;
|
|
162
|
+
const rawPreview = getLowZoomPreviewText(file, scrollTop) || 'Preview unavailable';
|
|
163
|
+
const wrapped = wrapPreviewText(
|
|
164
|
+
rawPreview,
|
|
165
|
+
estimatePreviewCharsPerLine(width, zoom),
|
|
166
|
+
estimatePreviewLineCapacity(height, zoom),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
ctx.font = `${scale.bodyFont}px "JetBrains Mono", monospace`;
|
|
170
|
+
ctx.fillStyle = 'rgba(226,232,240,0.92)';
|
|
171
|
+
|
|
172
|
+
const fadeStart = Math.max(previewY, height - scale.bodyLineHeight * 2.2);
|
|
173
|
+
const bodyHeight = Math.max(scale.bodyLineHeight * 2, height - previewY - scale.padding);
|
|
174
|
+
const mask = ctx.createLinearGradient(0, previewY, 0, previewY + bodyHeight);
|
|
175
|
+
mask.addColorStop(0, 'rgba(226,232,240,0.92)');
|
|
176
|
+
mask.addColorStop(Math.max(0, (fadeStart - previewY) / Math.max(1, bodyHeight)), 'rgba(226,232,240,0.92)');
|
|
177
|
+
mask.addColorStop(1, 'rgba(226,232,240,0)');
|
|
178
|
+
ctx.fillStyle = mask;
|
|
179
|
+
|
|
180
|
+
wrapped.forEach((line, index) => {
|
|
181
|
+
const y = previewY + index * scale.bodyLineHeight;
|
|
182
|
+
if (y > height - scale.padding) return;
|
|
183
|
+
ctx.fillText(line, leftInset, y);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function trimToWidth(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
|
|
188
|
+
if (ctx.measureText(text).width <= maxWidth) return text;
|
|
189
|
+
let out = text;
|
|
190
|
+
while (out.length > 1 && ctx.measureText(`${out}…`).width > maxWidth) {
|
|
191
|
+
out = out.slice(0, -1);
|
|
192
|
+
}
|
|
193
|
+
return `${out}…`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) {
|
|
197
|
+
const r = Math.min(radius, width / 2, height / 2);
|
|
198
|
+
ctx.beginPath();
|
|
199
|
+
ctx.moveTo(x + r, y);
|
|
200
|
+
ctx.arcTo(x + width, y, x + width, y + height, r);
|
|
201
|
+
ctx.arcTo(x + width, y + height, x, y + height, r);
|
|
202
|
+
ctx.arcTo(x, y + height, x, y, r);
|
|
203
|
+
ctx.arcTo(x, y, x + width, y, r);
|
|
204
|
+
ctx.closePath();
|
|
205
|
+
}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { measure } from 'measure-fn';
|
|
25
25
|
import type { CanvasContext } from './context';
|
|
26
|
-
import {
|
|
26
|
+
import { getLowZoomScale, renderLowZoomPreviewCanvas } from './low-zoom-preview';
|
|
27
27
|
import { materializeViewport } from './xydraw-bridge';
|
|
28
28
|
|
|
29
29
|
// ── Culling state ──────────────────────────────────────────
|
|
@@ -36,6 +36,7 @@ const VIEWPORT_MARGIN = 500;
|
|
|
36
36
|
|
|
37
37
|
// LOD threshold: below this zoom level, use lightweight pill placeholders
|
|
38
38
|
const LOD_ZOOM_THRESHOLD = 0.25;
|
|
39
|
+
const LOW_ZOOM_MODE_STORAGE_KEY = 'gitmaps:detailMode';
|
|
39
40
|
|
|
40
41
|
// Maximum deferred cards to materialize per animation frame
|
|
41
42
|
// Prevents frame drops when zooming out then back in on huge repos
|
|
@@ -52,6 +53,14 @@ export function markTransformActive() {
|
|
|
52
53
|
|
|
53
54
|
// Track current LOD mode so we can detect transitions
|
|
54
55
|
let _currentLodMode: 'full' | 'pill' = 'full';
|
|
56
|
+
let _detailMode: 'auto' | 'preview' = (() => {
|
|
57
|
+
try {
|
|
58
|
+
const stored = localStorage.getItem(LOW_ZOOM_MODE_STORAGE_KEY);
|
|
59
|
+
return stored === 'preview' ? 'preview' : 'auto';
|
|
60
|
+
} catch {
|
|
61
|
+
return 'auto';
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
55
64
|
|
|
56
65
|
// Track pill elements for cleanup
|
|
57
66
|
const pillCards = new Map<string, HTMLElement>();
|
|
@@ -90,6 +99,27 @@ export function getPinnedCards(): Set<string> {
|
|
|
90
99
|
return _pinnedCards;
|
|
91
100
|
}
|
|
92
101
|
|
|
102
|
+
export function getDetailMode(): 'auto' | 'preview' {
|
|
103
|
+
return _detailMode;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function setDetailMode(mode: 'auto' | 'preview') {
|
|
107
|
+
_detailMode = mode;
|
|
108
|
+
try {
|
|
109
|
+
localStorage.setItem(LOW_ZOOM_MODE_STORAGE_KEY, mode);
|
|
110
|
+
} catch { }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function toggleDetailMode(): 'auto' | 'preview' {
|
|
114
|
+
const next = _detailMode === 'preview' ? 'auto' : 'preview';
|
|
115
|
+
setDetailMode(next);
|
|
116
|
+
return next;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function isPreviewModeForced() {
|
|
120
|
+
return _detailMode === 'preview';
|
|
121
|
+
}
|
|
122
|
+
|
|
93
123
|
// ── Status colors for low-zoom cards
|
|
94
124
|
const PILL_COLORS: Record<string, string> = {
|
|
95
125
|
'ts': '#3178c6',
|
|
@@ -133,14 +163,13 @@ function createPillCard(ctx: CanvasContext, file: any, path: string, x: number,
|
|
|
133
163
|
const pill = document.createElement('div');
|
|
134
164
|
pill.className = 'file-pill';
|
|
135
165
|
pill.dataset.path = path;
|
|
136
|
-
pill.dataset.previewMode = '
|
|
166
|
+
pill.dataset.previewMode = 'canvas';
|
|
137
167
|
pill.style.cssText = `
|
|
138
168
|
position: absolute;
|
|
139
169
|
left: ${x}px;
|
|
140
170
|
top: ${y}px;
|
|
141
171
|
width: ${w}px;
|
|
142
172
|
height: ${h}px;
|
|
143
|
-
background: linear-gradient(180deg, rgba(15,23,42,0.96) 0%, rgba(2,6,23,0.96) 100%);
|
|
144
173
|
border-radius: 6px;
|
|
145
174
|
opacity: ${animate ? '0' : '0.94'};
|
|
146
175
|
contain: layout style paint;
|
|
@@ -151,45 +180,17 @@ function createPillCard(ctx: CanvasContext, file: any, path: string, x: number,
|
|
|
151
180
|
user-select: none;
|
|
152
181
|
transition: opacity 0.25s ease, box-shadow 0.2s ease, transform 0.25s ease;
|
|
153
182
|
transform: ${animate ? 'scale(0.92)' : 'scale(1)'};
|
|
183
|
+
background: transparent;
|
|
154
184
|
`;
|
|
155
185
|
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
top:0;
|
|
161
|
-
left:0;
|
|
162
|
-
width:${Math.max(10, w * 0.02)}px;
|
|
163
|
-
height:100%;
|
|
164
|
-
background:${getPillColor(path, isChanged)};
|
|
165
|
-
opacity:0.95;
|
|
166
|
-
pointer-events:none;
|
|
167
|
-
`;
|
|
168
|
-
pill.appendChild(accent);
|
|
169
|
-
|
|
170
|
-
const inner = document.createElement('div');
|
|
171
|
-
inner.className = 'file-pill-inner';
|
|
172
|
-
inner.style.cssText = 'position:absolute; inset:0; transform-origin:top left; pointer-events:none;';
|
|
173
|
-
pill.appendChild(inner);
|
|
186
|
+
const canvas = document.createElement('canvas');
|
|
187
|
+
canvas.className = 'file-pill-canvas';
|
|
188
|
+
canvas.style.cssText = 'display:block;width:100%;height:100%;pointer-events:none;';
|
|
189
|
+
pill.appendChild(canvas);
|
|
174
190
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
title.textContent = path.split('/').pop() || path;
|
|
178
|
-
inner.appendChild(title);
|
|
191
|
+
(pill as any)._fileData = file;
|
|
192
|
+
updatePillCardLayout(ctx, pill, zoom, isChanged);
|
|
179
193
|
|
|
180
|
-
const sub = document.createElement('div');
|
|
181
|
-
sub.className = 'file-pill-subtitle';
|
|
182
|
-
sub.textContent = path.includes('/') ? path.split('/').slice(0, -1).join('/') : 'root';
|
|
183
|
-
inner.appendChild(sub);
|
|
184
|
-
|
|
185
|
-
const preview = document.createElement('div');
|
|
186
|
-
preview.className = 'file-pill-preview';
|
|
187
|
-
preview.textContent = getLowZoomPreviewText(file, getSavedScrollTop(ctx, path)) || 'Preview unavailable';
|
|
188
|
-
inner.appendChild(preview);
|
|
189
|
-
|
|
190
|
-
updatePillCardLayout(pill, zoom);
|
|
191
|
-
|
|
192
|
-
// Animate pill entrance
|
|
193
194
|
if (animate) {
|
|
194
195
|
requestAnimationFrame(() => {
|
|
195
196
|
pill.style.opacity = '0.94';
|
|
@@ -200,62 +201,28 @@ function createPillCard(ctx: CanvasContext, file: any, path: string, x: number,
|
|
|
200
201
|
return pill;
|
|
201
202
|
}
|
|
202
203
|
|
|
203
|
-
function updatePillCardLayout(pill: HTMLElement, zoom: number) {
|
|
204
|
-
const x = parseFloat(pill.style.left) || 0;
|
|
205
|
-
const y = parseFloat(pill.style.top) || 0;
|
|
204
|
+
function updatePillCardLayout(ctx: CanvasContext, pill: HTMLElement, zoom: number, isChanged?: boolean) {
|
|
206
205
|
const w = parseFloat(pill.style.width) || 580;
|
|
207
206
|
const h = parseFloat(pill.style.height) || 700;
|
|
208
207
|
const scale = getLowZoomScale(zoom);
|
|
208
|
+
const path = pill.dataset.path || '';
|
|
209
|
+
const canvas = pill.querySelector('.file-pill-canvas') as HTMLCanvasElement | null;
|
|
210
|
+
const file = (pill as any)._fileData || ctx.allFilesData?.find(f => f.path === path) || ctx.commitFilesData?.find(f => f.path === path) || null;
|
|
211
|
+
const changed = isChanged ?? pill.dataset.changed === 'true';
|
|
209
212
|
pill.dataset.zoomBucket = zoom.toFixed(3);
|
|
210
213
|
pill.style.borderRadius = `${Math.max(6, scale.radius)}px`;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
title.style.cssText = `
|
|
224
|
-
color:#f8fafc;
|
|
225
|
-
font-family:'JetBrains Mono', monospace;
|
|
226
|
-
font-size:${scale.titleFont}px;
|
|
227
|
-
font-weight:700;
|
|
228
|
-
line-height:1.05;
|
|
229
|
-
white-space:nowrap;
|
|
230
|
-
overflow:hidden;
|
|
231
|
-
text-overflow:ellipsis;
|
|
232
|
-
text-shadow:0 2px 8px rgba(0,0,0,0.45);
|
|
233
|
-
`;
|
|
234
|
-
|
|
235
|
-
sub.style.cssText = `
|
|
236
|
-
color:rgba(226,232,240,0.72);
|
|
237
|
-
font-family:'JetBrains Mono', monospace;
|
|
238
|
-
font-size:${Math.max(scale.bodyFont * 0.78, 8 / Math.max(zoom, 0.08))}px;
|
|
239
|
-
line-height:1.05;
|
|
240
|
-
white-space:nowrap;
|
|
241
|
-
overflow:hidden;
|
|
242
|
-
text-overflow:ellipsis;
|
|
243
|
-
`;
|
|
244
|
-
|
|
245
|
-
preview.style.cssText = `
|
|
246
|
-
color:rgba(226,232,240,0.92);
|
|
247
|
-
font-family:'JetBrains Mono', monospace;
|
|
248
|
-
font-size:${scale.bodyFont}px;
|
|
249
|
-
line-height:${scale.bodyLineHeight}px;
|
|
250
|
-
white-space:pre-wrap;
|
|
251
|
-
overflow-wrap:anywhere;
|
|
252
|
-
word-break:break-word;
|
|
253
|
-
overflow:hidden;
|
|
254
|
-
flex:1 1 auto;
|
|
255
|
-
text-shadow:0 1px 4px rgba(0,0,0,0.3);
|
|
256
|
-
mask-image:linear-gradient(to bottom, black 0%, black 84%, transparent 100%);
|
|
257
|
-
-webkit-mask-image:linear-gradient(to bottom, black 0%, black 84%, transparent 100%);
|
|
258
|
-
`;
|
|
214
|
+
if (!canvas) return;
|
|
215
|
+
|
|
216
|
+
renderLowZoomPreviewCanvas(canvas, {
|
|
217
|
+
path,
|
|
218
|
+
file,
|
|
219
|
+
width: w,
|
|
220
|
+
height: h,
|
|
221
|
+
zoom,
|
|
222
|
+
scrollTop: getSavedScrollTop(ctx, path),
|
|
223
|
+
accentColor: getPillColor(path, changed),
|
|
224
|
+
isChanged: changed,
|
|
225
|
+
});
|
|
259
226
|
}
|
|
260
227
|
|
|
261
228
|
/**
|
|
@@ -356,7 +323,7 @@ export function performViewportCulling(ctx: CanvasContext) {
|
|
|
356
323
|
// Phase 4c: also materialize deferred CardManager cards
|
|
357
324
|
// Reuse zoom from worldRect (already snapped) — avoids redundant ctx.snap()
|
|
358
325
|
const zoom = worldRect.zoom;
|
|
359
|
-
const isLowZoom = zoom <= LOD_ZOOM_THRESHOLD;
|
|
326
|
+
const isLowZoom = _detailMode === 'preview' || zoom <= LOD_ZOOM_THRESHOLD;
|
|
360
327
|
|
|
361
328
|
// Important: never materialize full cards while in low-zoom pill mode.
|
|
362
329
|
// Otherwise CardManager keeps mounting heavyweight cards right when the
|
|
@@ -471,10 +438,11 @@ export function performViewportCulling(ctx: CanvasContext) {
|
|
|
471
438
|
|
|
472
439
|
if (inView && !pillCards.has(path)) {
|
|
473
440
|
const pill = createPillCard(ctx, file, path, x, y, cardW, cardH, !!isChanged, zoom, true);
|
|
441
|
+
if (isChanged) pill.dataset.changed = 'true';
|
|
474
442
|
ctx.canvas.appendChild(pill);
|
|
475
443
|
pillCards.set(path, pill);
|
|
476
444
|
} else if (inView && pillCards.has(path)) {
|
|
477
|
-
updatePillCardLayout(pillCards.get(path)!, zoom);
|
|
445
|
+
updatePillCardLayout(ctx, pillCards.get(path)!, zoom, !!isChanged);
|
|
478
446
|
} else if (!inView && pillCards.has(path)) {
|
|
479
447
|
removePillForPath(path);
|
|
480
448
|
}
|
|
@@ -499,11 +467,12 @@ export function performViewportCulling(ctx: CanvasContext) {
|
|
|
499
467
|
if (inView) {
|
|
500
468
|
const file = ctx.allFilesData?.find(f => f.path === path) || ctx.commitFilesData?.find(f => f.path === path) || null;
|
|
501
469
|
const pill = createPillCard(ctx, file, path, x, y, w, h, isChanged, zoom, true);
|
|
470
|
+
if (isChanged) pill.dataset.changed = 'true';
|
|
502
471
|
ctx.canvas.appendChild(pill);
|
|
503
472
|
pillCards.set(path, pill);
|
|
504
473
|
}
|
|
505
474
|
} else {
|
|
506
|
-
updatePillCardLayout(pillCards.get(path)!, zoom);
|
|
475
|
+
updatePillCardLayout(ctx, pillCards.get(path)!, zoom, card.dataset.changed === 'true');
|
|
507
476
|
}
|
|
508
477
|
}
|
|
509
478
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gitmaps",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"gitmaps": "cli.ts"
|
|
@@ -20,10 +20,12 @@
|
|
|
20
20
|
"smoke:browser": "bun scripts/browser-smoke-local.ts",
|
|
21
21
|
"smoke:browser-tools": "bash scripts/browser-smoke-local.sh",
|
|
22
22
|
"smoke:browser-tools:load": "bash scripts/browser-repo-load-smoke.sh",
|
|
23
|
+
"smoke:browser-tools:low-zoom": "bash scripts/browser-low-zoom-perf-smoke.sh",
|
|
23
24
|
"smoke:browser-tools:guard": "bash scripts/browser-smoke-guard.sh",
|
|
24
25
|
"smoke:browser-tools:self-check": "bash scripts/browser-smoke-self-check.sh",
|
|
25
26
|
"smoke:browser-tools:check": "bash scripts/browser-smoke-check.sh",
|
|
26
27
|
"smoke:docker-image": "bash scripts/docker-image-smoke.sh",
|
|
28
|
+
"bench:low-zoom": "bun scripts/low-zoom-preview-bench.ts",
|
|
27
29
|
"prepublishOnly": "echo 'Publishing gitmaps to npm'"
|
|
28
30
|
},
|
|
29
31
|
"keywords": [
|