| 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">−</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">×</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> |