gitmaps 1.1.5 → 1.1.6
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.
|
@@ -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, 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,15 @@ 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(estimatePreviewLineCapacity(700, 0.25)).toBeGreaterThanOrEqual(2);
|
|
37
|
+
});
|
|
27
38
|
});
|
|
@@ -29,3 +29,165 @@ export function getLowZoomPreviewText(file: any, scrollTop: number): string {
|
|
|
29
29
|
const startLine = Math.max(0, Math.floor(scrollTop / approxLineHeight));
|
|
30
30
|
return lines.slice(startLine, startLine + 60).join('\n').trim();
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
export function wrapPreviewText(text: string, maxCharsPerLine: number, maxLines: number): string[] {
|
|
34
|
+
const safeMaxChars = Math.max(8, Math.floor(maxCharsPerLine));
|
|
35
|
+
const safeMaxLines = Math.max(1, Math.floor(maxLines));
|
|
36
|
+
const sourceLines = String(text || '').split('\n');
|
|
37
|
+
const out: string[] = [];
|
|
38
|
+
|
|
39
|
+
for (const sourceLine of sourceLines) {
|
|
40
|
+
const words = sourceLine.length === 0 ? [''] : sourceLine.split(/(\s+)/).filter(Boolean);
|
|
41
|
+
let current = '';
|
|
42
|
+
|
|
43
|
+
for (const part of words) {
|
|
44
|
+
if (part.length > safeMaxChars) {
|
|
45
|
+
if (current.trim().length > 0) {
|
|
46
|
+
out.push(current.trimEnd());
|
|
47
|
+
if (out.length >= safeMaxLines) return ellipsizeWrappedLines(out, safeMaxLines);
|
|
48
|
+
current = '';
|
|
49
|
+
}
|
|
50
|
+
for (let i = 0; i < part.length; i += safeMaxChars) {
|
|
51
|
+
out.push(part.slice(i, i + safeMaxChars));
|
|
52
|
+
if (out.length >= safeMaxLines) return ellipsizeWrappedLines(out, safeMaxLines);
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if ((current + part).length > safeMaxChars && current.length > 0) {
|
|
58
|
+
out.push(current.trimEnd());
|
|
59
|
+
if (out.length >= safeMaxLines) return ellipsizeWrappedLines(out, safeMaxLines);
|
|
60
|
+
current = part.trimStart();
|
|
61
|
+
} else {
|
|
62
|
+
current += part;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (current.length > 0 || sourceLine.length === 0) {
|
|
67
|
+
out.push(current.trimEnd());
|
|
68
|
+
if (out.length >= safeMaxLines) return ellipsizeWrappedLines(out, safeMaxLines);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return out.slice(0, safeMaxLines);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ellipsizeWrappedLines(lines: string[], maxLines: number) {
|
|
76
|
+
const sliced = lines.slice(0, maxLines);
|
|
77
|
+
if (sliced.length === 0) return sliced;
|
|
78
|
+
const last = sliced[sliced.length - 1].replace(/[\s.…]+$/g, '');
|
|
79
|
+
sliced[sliced.length - 1] = `${last}…`;
|
|
80
|
+
return sliced;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function estimatePreviewLineCapacity(height: number, zoom: number): number {
|
|
84
|
+
const scale = getLowZoomScale(zoom);
|
|
85
|
+
const available = Math.max(scale.bodyLineHeight, height - scale.padding * 2 - scale.titleFont - scale.bodyFont - scale.gap * 3);
|
|
86
|
+
return Math.max(2, Math.floor(available / scale.bodyLineHeight));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function estimatePreviewCharsPerLine(width: number, zoom: number): number {
|
|
90
|
+
const scale = getLowZoomScale(zoom);
|
|
91
|
+
const available = Math.max(60, width - scale.padding * 2 - Math.max(14, width * 0.02));
|
|
92
|
+
const avgCharWidth = Math.max(6, scale.bodyFont * 0.6);
|
|
93
|
+
return Math.max(8, Math.floor(available / avgCharWidth));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function renderLowZoomPreviewCanvas(
|
|
97
|
+
canvas: HTMLCanvasElement,
|
|
98
|
+
params: {
|
|
99
|
+
path: string;
|
|
100
|
+
file: any;
|
|
101
|
+
width: number;
|
|
102
|
+
height: number;
|
|
103
|
+
zoom: number;
|
|
104
|
+
scrollTop: number;
|
|
105
|
+
accentColor: string;
|
|
106
|
+
isChanged: boolean;
|
|
107
|
+
},
|
|
108
|
+
) {
|
|
109
|
+
const { path, file, width, height, zoom, scrollTop, accentColor } = params;
|
|
110
|
+
const dpr = (globalThis.devicePixelRatio || 1);
|
|
111
|
+
const scale = getLowZoomScale(zoom);
|
|
112
|
+
const ctx = canvas.getContext('2d');
|
|
113
|
+
if (!ctx) return;
|
|
114
|
+
|
|
115
|
+
canvas.width = Math.max(1, Math.floor(width * dpr));
|
|
116
|
+
canvas.height = Math.max(1, Math.floor(height * dpr));
|
|
117
|
+
canvas.style.width = `${width}px`;
|
|
118
|
+
canvas.style.height = `${height}px`;
|
|
119
|
+
|
|
120
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
121
|
+
ctx.clearRect(0, 0, width, height);
|
|
122
|
+
|
|
123
|
+
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
|
124
|
+
gradient.addColorStop(0, 'rgba(15,23,42,0.96)');
|
|
125
|
+
gradient.addColorStop(1, 'rgba(2,6,23,0.96)');
|
|
126
|
+
ctx.fillStyle = gradient;
|
|
127
|
+
roundRect(ctx, 0, 0, width, height, Math.max(6, scale.radius));
|
|
128
|
+
ctx.fill();
|
|
129
|
+
|
|
130
|
+
ctx.fillStyle = accentColor;
|
|
131
|
+
ctx.fillRect(0, 0, Math.max(10, width * 0.02), height);
|
|
132
|
+
|
|
133
|
+
const leftInset = scale.padding + Math.max(14, width * 0.02);
|
|
134
|
+
const topInset = scale.padding;
|
|
135
|
+
const maxTextWidth = Math.max(40, width - leftInset - scale.padding);
|
|
136
|
+
|
|
137
|
+
ctx.textBaseline = 'top';
|
|
138
|
+
ctx.font = `700 ${scale.titleFont}px "JetBrains Mono", monospace`;
|
|
139
|
+
ctx.fillStyle = '#f8fafc';
|
|
140
|
+
const title = path.split('/').pop() || path;
|
|
141
|
+
ctx.fillText(trimToWidth(ctx, title, maxTextWidth), leftInset, topInset);
|
|
142
|
+
|
|
143
|
+
const subtitleY = topInset + scale.titleFont + scale.gap;
|
|
144
|
+
ctx.font = `${Math.max(scale.bodyFont * 0.78, 8 / Math.max(zoom, 0.08))}px "JetBrains Mono", monospace`;
|
|
145
|
+
ctx.fillStyle = 'rgba(226,232,240,0.72)';
|
|
146
|
+
const subtitle = path.includes('/') ? path.split('/').slice(0, -1).join('/') : 'root';
|
|
147
|
+
ctx.fillText(trimToWidth(ctx, subtitle, maxTextWidth), leftInset, subtitleY);
|
|
148
|
+
|
|
149
|
+
const previewY = subtitleY + Math.max(scale.bodyFont * 0.78, 8 / Math.max(zoom, 0.08)) + scale.gap * 1.5;
|
|
150
|
+
const rawPreview = getLowZoomPreviewText(file, scrollTop) || 'Preview unavailable';
|
|
151
|
+
const wrapped = wrapPreviewText(
|
|
152
|
+
rawPreview,
|
|
153
|
+
estimatePreviewCharsPerLine(width, zoom),
|
|
154
|
+
estimatePreviewLineCapacity(height, zoom),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
ctx.font = `${scale.bodyFont}px "JetBrains Mono", monospace`;
|
|
158
|
+
ctx.fillStyle = 'rgba(226,232,240,0.92)';
|
|
159
|
+
|
|
160
|
+
const fadeStart = Math.max(previewY, height - scale.bodyLineHeight * 2.2);
|
|
161
|
+
const bodyHeight = Math.max(scale.bodyLineHeight * 2, height - previewY - scale.padding);
|
|
162
|
+
const mask = ctx.createLinearGradient(0, previewY, 0, previewY + bodyHeight);
|
|
163
|
+
mask.addColorStop(0, 'rgba(226,232,240,0.92)');
|
|
164
|
+
mask.addColorStop(Math.max(0, (fadeStart - previewY) / Math.max(1, bodyHeight)), 'rgba(226,232,240,0.92)');
|
|
165
|
+
mask.addColorStop(1, 'rgba(226,232,240,0)');
|
|
166
|
+
ctx.fillStyle = mask;
|
|
167
|
+
|
|
168
|
+
wrapped.forEach((line, index) => {
|
|
169
|
+
const y = previewY + index * scale.bodyLineHeight;
|
|
170
|
+
if (y > height - scale.padding) return;
|
|
171
|
+
ctx.fillText(line, leftInset, y);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function trimToWidth(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
|
|
176
|
+
if (ctx.measureText(text).width <= maxWidth) return text;
|
|
177
|
+
let out = text;
|
|
178
|
+
while (out.length > 1 && ctx.measureText(`${out}…`).width > maxWidth) {
|
|
179
|
+
out = out.slice(0, -1);
|
|
180
|
+
}
|
|
181
|
+
return `${out}…`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) {
|
|
185
|
+
const r = Math.min(radius, width / 2, height / 2);
|
|
186
|
+
ctx.beginPath();
|
|
187
|
+
ctx.moveTo(x + r, y);
|
|
188
|
+
ctx.arcTo(x + width, y, x + width, y + height, r);
|
|
189
|
+
ctx.arcTo(x + width, y + height, x, y + height, r);
|
|
190
|
+
ctx.arcTo(x, y + height, x, y, r);
|
|
191
|
+
ctx.arcTo(x, y, x + width, y, r);
|
|
192
|
+
ctx.closePath();
|
|
193
|
+
}
|
|
@@ -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 ──────────────────────────────────────────
|
|
@@ -133,14 +133,13 @@ function createPillCard(ctx: CanvasContext, file: any, path: string, x: number,
|
|
|
133
133
|
const pill = document.createElement('div');
|
|
134
134
|
pill.className = 'file-pill';
|
|
135
135
|
pill.dataset.path = path;
|
|
136
|
-
pill.dataset.previewMode = '
|
|
136
|
+
pill.dataset.previewMode = 'canvas';
|
|
137
137
|
pill.style.cssText = `
|
|
138
138
|
position: absolute;
|
|
139
139
|
left: ${x}px;
|
|
140
140
|
top: ${y}px;
|
|
141
141
|
width: ${w}px;
|
|
142
142
|
height: ${h}px;
|
|
143
|
-
background: linear-gradient(180deg, rgba(15,23,42,0.96) 0%, rgba(2,6,23,0.96) 100%);
|
|
144
143
|
border-radius: 6px;
|
|
145
144
|
opacity: ${animate ? '0' : '0.94'};
|
|
146
145
|
contain: layout style paint;
|
|
@@ -151,45 +150,17 @@ function createPillCard(ctx: CanvasContext, file: any, path: string, x: number,
|
|
|
151
150
|
user-select: none;
|
|
152
151
|
transition: opacity 0.25s ease, box-shadow 0.2s ease, transform 0.25s ease;
|
|
153
152
|
transform: ${animate ? 'scale(0.92)' : 'scale(1)'};
|
|
153
|
+
background: transparent;
|
|
154
154
|
`;
|
|
155
155
|
|
|
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);
|
|
174
|
-
|
|
175
|
-
const title = document.createElement('div');
|
|
176
|
-
title.className = 'file-pill-title';
|
|
177
|
-
title.textContent = path.split('/').pop() || path;
|
|
178
|
-
inner.appendChild(title);
|
|
156
|
+
const canvas = document.createElement('canvas');
|
|
157
|
+
canvas.className = 'file-pill-canvas';
|
|
158
|
+
canvas.style.cssText = 'display:block;width:100%;height:100%;pointer-events:none;';
|
|
159
|
+
pill.appendChild(canvas);
|
|
179
160
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
sub.textContent = path.includes('/') ? path.split('/').slice(0, -1).join('/') : 'root';
|
|
183
|
-
inner.appendChild(sub);
|
|
161
|
+
(pill as any)._fileData = file;
|
|
162
|
+
updatePillCardLayout(ctx, pill, zoom, isChanged);
|
|
184
163
|
|
|
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
164
|
if (animate) {
|
|
194
165
|
requestAnimationFrame(() => {
|
|
195
166
|
pill.style.opacity = '0.94';
|
|
@@ -200,62 +171,28 @@ function createPillCard(ctx: CanvasContext, file: any, path: string, x: number,
|
|
|
200
171
|
return pill;
|
|
201
172
|
}
|
|
202
173
|
|
|
203
|
-
function updatePillCardLayout(pill: HTMLElement, zoom: number) {
|
|
204
|
-
const x = parseFloat(pill.style.left) || 0;
|
|
205
|
-
const y = parseFloat(pill.style.top) || 0;
|
|
174
|
+
function updatePillCardLayout(ctx: CanvasContext, pill: HTMLElement, zoom: number, isChanged?: boolean) {
|
|
206
175
|
const w = parseFloat(pill.style.width) || 580;
|
|
207
176
|
const h = parseFloat(pill.style.height) || 700;
|
|
208
177
|
const scale = getLowZoomScale(zoom);
|
|
178
|
+
const path = pill.dataset.path || '';
|
|
179
|
+
const canvas = pill.querySelector('.file-pill-canvas') as HTMLCanvasElement | null;
|
|
180
|
+
const file = (pill as any)._fileData || ctx.allFilesData?.find(f => f.path === path) || ctx.commitFilesData?.find(f => f.path === path) || null;
|
|
181
|
+
const changed = isChanged ?? pill.dataset.changed === 'true';
|
|
209
182
|
pill.dataset.zoomBucket = zoom.toFixed(3);
|
|
210
183
|
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
|
-
`;
|
|
184
|
+
if (!canvas) return;
|
|
185
|
+
|
|
186
|
+
renderLowZoomPreviewCanvas(canvas, {
|
|
187
|
+
path,
|
|
188
|
+
file,
|
|
189
|
+
width: w,
|
|
190
|
+
height: h,
|
|
191
|
+
zoom,
|
|
192
|
+
scrollTop: getSavedScrollTop(ctx, path),
|
|
193
|
+
accentColor: getPillColor(path, changed),
|
|
194
|
+
isChanged: changed,
|
|
195
|
+
});
|
|
259
196
|
}
|
|
260
197
|
|
|
261
198
|
/**
|
|
@@ -471,10 +408,11 @@ export function performViewportCulling(ctx: CanvasContext) {
|
|
|
471
408
|
|
|
472
409
|
if (inView && !pillCards.has(path)) {
|
|
473
410
|
const pill = createPillCard(ctx, file, path, x, y, cardW, cardH, !!isChanged, zoom, true);
|
|
411
|
+
if (isChanged) pill.dataset.changed = 'true';
|
|
474
412
|
ctx.canvas.appendChild(pill);
|
|
475
413
|
pillCards.set(path, pill);
|
|
476
414
|
} else if (inView && pillCards.has(path)) {
|
|
477
|
-
updatePillCardLayout(pillCards.get(path)!, zoom);
|
|
415
|
+
updatePillCardLayout(ctx, pillCards.get(path)!, zoom, !!isChanged);
|
|
478
416
|
} else if (!inView && pillCards.has(path)) {
|
|
479
417
|
removePillForPath(path);
|
|
480
418
|
}
|
|
@@ -499,11 +437,12 @@ export function performViewportCulling(ctx: CanvasContext) {
|
|
|
499
437
|
if (inView) {
|
|
500
438
|
const file = ctx.allFilesData?.find(f => f.path === path) || ctx.commitFilesData?.find(f => f.path === path) || null;
|
|
501
439
|
const pill = createPillCard(ctx, file, path, x, y, w, h, isChanged, zoom, true);
|
|
440
|
+
if (isChanged) pill.dataset.changed = 'true';
|
|
502
441
|
ctx.canvas.appendChild(pill);
|
|
503
442
|
pillCards.set(path, pill);
|
|
504
443
|
}
|
|
505
444
|
} else {
|
|
506
|
-
updatePillCardLayout(pillCards.get(path)!, zoom);
|
|
445
|
+
updatePillCardLayout(ctx, pillCards.get(path)!, zoom, card.dataset.changed === 'true');
|
|
507
446
|
}
|
|
508
447
|
}
|
|
509
448
|
|