src/components/blog/Diagram.astro 9.9 K raw
1
---
2
interface Props {
3
	src: string;
4
	alt: string;
5
	caption?: string;
6
}
7
8
const { src, alt, caption } = Astro.props;
9
const uniqueId = crypto.randomUUID();
10
---
11
12
<div class="diagram-container" data-diagram-id={uniqueId}>
13
	<div class="diagram-preview">
14
		<img src={src} alt={alt} loading="lazy" draggable="false" />
15
		<span class="diagram-hint">
16
			<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
17
				<polyline points="15 3 21 3 21 9"></polyline>
18
				<polyline points="9 21 3 21 3 15"></polyline>
19
				<line x1="21" y1="3" x2="14" y2="10"></line>
20
				<line x1="3" y1="21" x2="10" y2="14"></line>
21
			</svg>
22
		</span>
23
	</div>
24
	{caption && <p class="diagram-caption">{caption}</p>}
25
26
	<div class="diagram-overlay" data-diagram-overlay={uniqueId}>
27
		<div class="diagram-controls">
28
			<button data-action="zoom-in" title="Zoom in">+</button>
29
			<button data-action="zoom-out" title="Zoom out">&minus;</button>
30
      <button data-action="reset" title="Reset view">
31
        <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 256 256"><path fill="currentColor" d="M222 128a94 94 0 0 1-92.74 94H128a93.43 93.43 0 0 1-64.5-25.65a6 6 0 1 1 8.24-8.72A82 82 0 1 0 70 70l-.19.19L39.44 98H72a6 6 0 0 1 0 12H24a6 6 0 0 1-6-6V56a6 6 0 0 1 12 0v34.34L61.63 61.4A94 94 0 0 1 222 128"/></svg>
32
      </button>
33
			<button data-action="close" title="Close">&times;</button>
34
		</div>
35
		<div class="diagram-viewport">
36
			<img src={src} alt={alt} draggable="false" />
37
		</div>
38
	</div>
39
</div>
40
41
<style>
42
	.diagram-container {
43
		position: relative;
44
		width: 100%;
45
		margin: 1.5rem 0;
46
	}
47
48
	.diagram-preview {
49
		position: relative;
50
		cursor: zoom-in;
51
		border: 1px dashed #333;
52
		border-radius: 4px;
53
		padding: 1rem;
54
		transition: border-color 0.2s ease;
55
	}
56
57
	.diagram-preview:hover {
58
		border-color: #555;
59
	}
60
61
	.diagram-preview img {
62
		width: 100%;
63
		height: auto;
64
		display: block;
65
	}
66
67
	.diagram-hint {
68
		position: absolute;
69
		bottom: 0.5rem;
70
		right: 0.5rem;
71
		color: #888;
72
		opacity: 0;
73
		transition: opacity 0.2s ease;
74
		line-height: 1;
75
	}
76
77
	.diagram-preview:hover .diagram-hint {
78
		opacity: 0.8;
79
	}
80
81
	.diagram-caption {
82
		margin-top: 0.5rem;
83
		font-size: 0.875rem;
84
		color: #888;
85
		text-align: center;
86
	}
87
88
	.diagram-overlay {
89
		position: fixed;
90
		top: 0;
91
		left: 0;
92
		width: 100vw;
93
		height: 100dvh;
94
		background-color: #121113;
95
		display: flex;
96
		align-items: center;
97
		justify-content: center;
98
		opacity: 0;
99
		pointer-events: none;
100
		transition: all 0.3s ease;
101
		z-index: 9999;
102
	}
103
104
	.diagram-overlay.active {
105
		background-color: #121113;
106
		opacity: 1;
107
		pointer-events: all;
108
	}
109
110
	.diagram-controls {
111
		position: absolute;
112
		top: 1rem;
113
		right: 1rem;
114
		display: flex;
115
		gap: 0.5rem;
116
		z-index: 10000;
117
	}
118
119
	.diagram-controls button {
120
		background: rgba(255, 255, 255, 0.1);
121
		border: 1px solid rgba(255, 255, 255, 0.2);
122
		color: white;
123
		width: 2.5rem;
124
		height: 2.5rem;
125
		border-radius: 4px;
126
		cursor: pointer;
127
		font-family: 'Commit Mono', monospace;
128
		font-size: 1rem;
129
		display: flex;
130
		align-items: center;
131
		justify-content: center;
132
		transition: background 0.15s ease;
133
	}
134
135
	.diagram-controls button:hover:not(:disabled) {
136
		background: rgba(255, 255, 255, 0.2);
137
	}
138
139
	.diagram-controls button:disabled {
140
		opacity: 0.3;
141
		cursor: default;
142
	}
143
144
	.diagram-viewport {
145
		width: 100%;
146
		height: 100%;
147
		overflow: hidden;
148
		display: flex;
149
		align-items: center;
150
		justify-content: center;
151
		touch-action: none;
152
	}
153
154
	.diagram-viewport img {
155
		max-width: 90%;
156
		max-height: 90vh;
157
		object-fit: contain;
158
		transform-origin: 0 0;
159
		user-select: none;
160
		-webkit-user-drag: none;
161
	}
162
</style>
163
164
<script>
165
	const MIN_SCALE = 1;
166
	const MAX_SCALE = 5;
167
	const ZOOM_STEP = 0.15;
168
	const WHEEL_FACTOR = 0.001;
169
	const DRAG_THRESHOLD = 5;
170
171
	document.querySelectorAll<HTMLElement>('[data-diagram-id]').forEach((container) => {
172
		const id = container.dataset.diagramId!;
173
		const overlay = document.querySelector<HTMLElement>(`[data-diagram-overlay="${id}"]`);
174
		if (!overlay) return;
175
176
		const preview = container.querySelector<HTMLElement>('.diagram-preview')!;
177
		const viewport = overlay.querySelector<HTMLElement>('.diagram-viewport')!;
178
		const viewportImg = viewport.querySelector<HTMLImageElement>('img')!;
179
		const zoomOutBtn = overlay.querySelector<HTMLButtonElement>('[data-action="zoom-out"]')!;
180
181
		const state = {
182
			scale: 1,
183
			translateX: 0,
184
			translateY: 0,
185
			isDragging: false,
186
			dragMoved: false,
187
			startX: 0,
188
			startY: 0,
189
			initialPinchDistance: 0,
190
			initialScale: 1,
191
		};
192
193
		function applyTransform() {
194
			viewportImg.style.transform = `translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale})`;
195
			viewport.style.cursor = state.scale >= 1.001
196
				? (state.isDragging ? 'grabbing' : 'grab')
197
				: 'default';
198
			zoomOutBtn.disabled = state.scale <= MIN_SCALE;
199
		}
200
201
		function reset() {
202
			state.scale = 1;
203
			state.translateX = 0;
204
			state.translateY = 0;
205
			applyTransform();
206
		}
207
208
		function open() {
209
			overlay.classList.add('active');
210
			document.body.style.overflow = 'hidden';
211
		}
212
213
		function close() {
214
			overlay.classList.remove('active');
215
			document.body.style.overflow = '';
216
			setTimeout(() => reset(), 300);
217
		}
218
219
		function zoomAt(x: number, y: number, delta: number) {
220
			const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.scale + delta));
221
			const ratio = newScale / state.scale;
222
			state.translateX = x - (x - state.translateX) * ratio;
223
			state.translateY = y - (y - state.translateY) * ratio;
224
			state.scale = newScale;
225
			applyTransform();
226
		}
227
228
		function zoomCenter(delta: number) {
229
			const rect = viewport.getBoundingClientRect();
230
			zoomAt(rect.width / 2, rect.height / 2, delta);
231
		}
232
233
		// Store functions on overlay for keyboard handler access
234
		(overlay as any)._diagramClose = close;
235
		(overlay as any)._diagramZoomCenter = zoomCenter;
236
237
		// Open
238
		preview.addEventListener('click', open);
239
240
		// Close button
241
		overlay.querySelector('[data-action="close"]')!.addEventListener('click', close);
242
243
		// Click outside to close (but not after dragging)
244
		overlay.addEventListener('click', (e) => {
245
			if (state.dragMoved) return;
246
			if (e.target === overlay || e.target === viewport) close();
247
		});
248
249
		// Control buttons
250
		overlay.querySelector('[data-action="zoom-in"]')!.addEventListener('click', (e) => {
251
			e.stopPropagation();
252
			zoomCenter(ZOOM_STEP);
253
		});
254
		overlay.querySelector('[data-action="zoom-out"]')!.addEventListener('click', (e) => {
255
			e.stopPropagation();
256
			zoomCenter(-ZOOM_STEP);
257
		});
258
		overlay.querySelector('[data-action="reset"]')!.addEventListener('click', (e) => {
259
			e.stopPropagation();
260
			reset();
261
		});
262
263
		// Wheel zoom — scale by actual deltaY magnitude for smooth trackpad/scroll
264
		viewport.addEventListener('wheel', (e) => {
265
			e.preventDefault();
266
			const rect = viewport.getBoundingClientRect();
267
			const x = e.clientX - rect.left;
268
			const y = e.clientY - rect.top;
269
			const delta = -e.deltaY * WHEEL_FACTOR * state.scale;
270
			zoomAt(x, y, delta);
271
		}, { passive: false });
272
273
		// Mouse pan
274
		viewport.addEventListener('mousedown', (e) => {
275
			if (state.scale < 1.001) return;
276
			state.isDragging = true;
277
			state.dragMoved = false;
278
			state.startX = e.clientX - state.translateX;
279
			state.startY = e.clientY - state.translateY;
280
			viewport.style.cursor = 'grabbing';
281
		});
282
283
		window.addEventListener('mousemove', (e) => {
284
			if (!state.isDragging) return;
285
			const dx = e.clientX - state.startX;
286
			const dy = e.clientY - state.startY;
287
			if (Math.abs(dx - state.translateX) > DRAG_THRESHOLD || Math.abs(dy - state.translateY) > DRAG_THRESHOLD) {
288
				state.dragMoved = true;
289
			}
290
			state.translateX = dx;
291
			state.translateY = dy;
292
			applyTransform();
293
		});
294
295
		window.addEventListener('mouseup', () => {
296
			if (!state.isDragging) return;
297
			state.isDragging = false;
298
			applyTransform();
299
			setTimeout(() => { state.dragMoved = false; }, 0);
300
		});
301
302
		// Touch support
303
		function getTouchDistance(t1: Touch, t2: Touch) {
304
			return Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
305
		}
306
307
		viewport.addEventListener('touchstart', (e) => {
308
			if (e.touches.length === 2) {
309
				e.preventDefault();
310
				state.initialPinchDistance = getTouchDistance(e.touches[0], e.touches[1]);
311
				state.initialScale = state.scale;
312
			} else if (e.touches.length === 1 && state.scale >= 1.001) {
313
				state.isDragging = true;
314
				state.dragMoved = false;
315
				state.startX = e.touches[0].clientX - state.translateX;
316
				state.startY = e.touches[0].clientY - state.translateY;
317
			}
318
		}, { passive: false });
319
320
		viewport.addEventListener('touchmove', (e) => {
321
			if (e.touches.length === 2) {
322
				e.preventDefault();
323
				const dist = getTouchDistance(e.touches[0], e.touches[1]);
324
				const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.initialScale * (dist / state.initialPinchDistance)));
325
				const rect = viewport.getBoundingClientRect();
326
				const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
327
				const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
328
				const ratio = newScale / state.scale;
329
				state.translateX = midX - (midX - state.translateX) * ratio;
330
				state.translateY = midY - (midY - state.translateY) * ratio;
331
				state.scale = newScale;
332
				applyTransform();
333
			} else if (e.touches.length === 1 && state.isDragging) {
334
				e.preventDefault();
335
				state.dragMoved = true;
336
				state.translateX = e.touches[0].clientX - state.startX;
337
				state.translateY = e.touches[0].clientY - state.startY;
338
				applyTransform();
339
			}
340
		}, { passive: false });
341
342
		viewport.addEventListener('touchend', () => {
343
			state.isDragging = false;
344
			setTimeout(() => { state.dragMoved = false; }, 0);
345
		});
346
	});
347
348
	// Global keyboard handler
349
	document.addEventListener('keydown', (e) => {
350
		const active = document.querySelector<HTMLElement>('.diagram-overlay.active') as any;
351
		if (!active) return;
352
353
		if (e.key === 'Escape') {
354
			active._diagramClose();
355
		} else if (e.key === '+' || e.key === '=') {
356
			active._diagramZoomCenter(ZOOM_STEP);
357
		} else if (e.key === '-') {
358
			active._diagramZoomCenter(-ZOOM_STEP);
359
		}
360
	});
361
</script>