diff options
| -rw-r--r-- | index.html | 2 | ||||
| -rw-r--r-- | src/App.svelte | 10 | ||||
| -rw-r--r-- | src/app.css | 5 | ||||
| -rw-r--r-- | src/lib/components/ImageList.svelte | 11 | ||||
| -rw-r--r-- | src/lib/components/ImagePreview.svelte | 191 |
5 files changed, 204 insertions, 15 deletions
@@ -2,7 +2,7 @@ <html lang="en" data-theme="cmyk"> <head> <meta charset="UTF-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <title>SpeedSync</title> </head> <body> diff --git a/src/App.svelte b/src/App.svelte index b9050dc..a9a2506 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -62,7 +62,7 @@ </div> <!-- Right: Image list --> - <div class="w-72 lg:w-80 shrink-0 border-l border-base-300 flex flex-col"> +<div class="w-36 lg:w-40 shrink-0 border-l border-base-300 flex flex-col"> <div class="px-3 py-2 bg-base-100 border-b border-base-300 shrink-0"> <span class="text-sm font-semibold">Photos ({images.length})</span> </div> @@ -74,16 +74,16 @@ <!-- FAB Flower: bottom-right --> <div class="fab fab-flower"> - <div tabindex="0" class="btn btn-lg btn-circle btn-neutral"> + <div tabindex="0" class="btn btn-lg btn-circle btn-primary"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"> <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> </svg> </div> <div class="fab-close"> - <span class="btn btn-circle btn-lg btn-neutral">✕</span> + <span class="btn btn-circle btn-lg btn-secondary">✕</span> </div> <!-- Dark mode toggle --> - <button class="btn btn-lg btn-circle btn-neutral" onclick={() => (isDark = !isDark)}> + <button class="btn btn-lg btn-circle btn-secondary" onclick={() => (isDark = !isDark)}> {#if isDark} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" /> @@ -95,7 +95,7 @@ {/if} </button> <!-- Refresh --> - <button class="btn btn-lg btn-circle btn-neutral" onclick={() => loadAllImages()}> + <button class="btn btn-lg btn-circle btn-secondary" onclick={() => loadAllImages()}> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12a7.5 7.5 0 0 1 12.57-5.55L19.5 8.87" /> <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 4.5v4.37h-4.37" /> diff --git a/src/app.css b/src/app.css index 147bcf3..9a009fc 100644 --- a/src/app.css +++ b/src/app.css @@ -2,3 +2,8 @@ @plugin "daisyui" { themes: cmyk --default, black; } + +html, body { + touch-action: pan-x pan-y; + overscroll-behavior: none; +} diff --git a/src/lib/components/ImageList.svelte b/src/lib/components/ImageList.svelte index 7e6f836..f338670 100644 --- a/src/lib/components/ImageList.svelte +++ b/src/lib/components/ImageList.svelte @@ -23,7 +23,7 @@ {@const isSelected = file.path === selectedPath} <li> <button - class="flex items-center gap-2 w-full rounded-lg p-1.5 text-left transition-colors {isSelected + class="flex flex-col w-full rounded-lg p-1.5 text-left transition-colors {isSelected ? 'bg-primary text-primary-content' : 'hover:bg-base-200'}" onclick={() => onSelect(file)} @@ -32,17 +32,18 @@ <img src={thumbUrl} alt={file.filename} - class="w-12 h-12 rounded object-cover shrink-0" + class="w-full aspect-square rounded object-cover" loading="lazy" /> {:else} - <div class="w-12 h-12 rounded bg-base-300 flex items-center justify-center shrink-0"> + <div class="w-full aspect-square rounded bg-base-300 flex items-center justify-center"> <span class="text-lg">🖼️</span> </div> {/if} - <div class="min-w-0 flex-1"> + <div class="min-w-0 w-full mt-1"> <p class="text-xs font-medium truncate">{file.filename}</p> - <p class="text-xs opacity-60">{file.date.toLocaleString()}</p> + <p class="text-xs opacity-60">{file.date.toLocaleDateString()}</p> + <p class="text-xs opacity-60">{file.date.toLocaleTimeString()}</p> </div> </button> </li> diff --git a/src/lib/components/ImagePreview.svelte b/src/lib/components/ImagePreview.svelte index 8ef9395..2f41ea3 100644 --- a/src/lib/components/ImagePreview.svelte +++ b/src/lib/components/ImagePreview.svelte @@ -26,6 +26,173 @@ let rawObjectUrl: string | undefined; let rawThumbnailUrl: string | undefined; + // --- Zoom & pan state --- + let zoomLevel = $state(1); // user zoom: 1 = fit, >1 = zoomed in + let panX = $state(0); + let panY = $state(0); + let containerEl: HTMLDivElement | undefined; + let containerW = $state(0); + let containerH = $state(0); + + // Touch tracking for pinch-to-zoom and pan + let lastTouchDist = 0; + let lastTouchMidX = 0; + let lastTouchMidY = 0; + let isPinching = false; + let isPanning = false; + let lastPanX = 0; + let lastPanY = 0; + + const MIN_ZOOM = 1; + const MAX_ZOOM = 10; + + function resetZoom() { + zoomLevel = 1; + panX = 0; + panY = 0; + } + + function clampPan() { + if (zoomLevel <= 1) { + panX = 0; + panY = 0; + return; + } + if (containerW === 0 || containerH === 0) return; + const maxPanX = (containerW * (zoomLevel - 1)) / 2; + const maxPanY = (containerH * (zoomLevel - 1)) / 2; + panX = Math.max(-maxPanX, Math.min(maxPanX, panX)); + panY = Math.max(-maxPanY, Math.min(maxPanY, panY)); + } + + function handleWheel(e: WheelEvent) { + e.preventDefault(); + const delta = e.deltaY > 0 ? 0.9 : 1.1; + const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoomLevel * delta)); + + if (containerEl !== undefined) { + const rect = containerEl.getBoundingClientRect(); + const cursorX = e.clientX - rect.left - rect.width / 2; + const cursorY = e.clientY - rect.top - rect.height / 2; + const factor = newZoom / zoomLevel; + panX = cursorX - factor * (cursorX - panX); + panY = cursorY - factor * (cursorY - panY); + } + + zoomLevel = newZoom; + clampPan(); + } + + function touchDist(t1: Touch, t2: Touch): number { + const dx = t1.clientX - t2.clientX; + const dy = t1.clientY - t2.clientY; + return Math.sqrt(dx * dx + dy * dy); + } + + function handleTouchStart(e: TouchEvent) { + if (e.touches.length === 2) { + e.preventDefault(); + isPinching = true; + isPanning = false; + const t0 = e.touches[0] as Touch; + const t1 = e.touches[1] as Touch; + lastTouchDist = touchDist(t0, t1); + lastTouchMidX = (t0.clientX + t1.clientX) / 2; + lastTouchMidY = (t0.clientY + t1.clientY) / 2; + } else if (e.touches.length === 1 && zoomLevel > 1) { + isPanning = true; + isPinching = false; + const t = e.touches[0] as Touch; + lastPanX = t.clientX; + lastPanY = t.clientY; + } + } + + function handleTouchMove(e: TouchEvent) { + if (isPinching && e.touches.length === 2) { + e.preventDefault(); + const t0 = e.touches[0] as Touch; + const t1 = e.touches[1] as Touch; + const dist = touchDist(t0, t1); + const midX = (t0.clientX + t1.clientX) / 2; + const midY = (t0.clientY + t1.clientY) / 2; + + const factor = dist / lastTouchDist; + const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoomLevel * factor)); + + if (containerEl !== undefined) { + const rect = containerEl.getBoundingClientRect(); + const cx = midX - rect.left - rect.width / 2; + const cy = midY - rect.top - rect.height / 2; + const sf = newZoom / zoomLevel; + panX = cx - sf * (cx - panX) + (midX - lastTouchMidX); + panY = cy - sf * (cy - panY) + (midY - lastTouchMidY); + } + + zoomLevel = newZoom; + clampPan(); + + lastTouchDist = dist; + lastTouchMidX = midX; + lastTouchMidY = midY; + } else if (isPanning && e.touches.length === 1 && zoomLevel > 1) { + e.preventDefault(); + const t = e.touches[0] as Touch; + panX += t.clientX - lastPanX; + panY += t.clientY - lastPanY; + clampPan(); + lastPanX = t.clientX; + lastPanY = t.clientY; + } + } + + function handleTouchEnd(e: TouchEvent) { + if (e.touches.length < 2) { + isPinching = false; + } + if (e.touches.length === 0) { + isPanning = false; + } + if (e.touches.length === 1 && zoomLevel > 1) { + isPanning = true; + const t = e.touches[0] as Touch; + lastPanX = t.clientX; + lastPanY = t.clientY; + } + } + + // Track container size via ResizeObserver + $effect(() => { + const el = containerEl; + if (el === undefined) return; + + const ro = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry !== undefined) { + containerW = entry.contentRect.width; + containerH = entry.contentRect.height; + } + }); + ro.observe(el); + return () => ro.disconnect(); + }); + + // Bind touch listeners with { passive: false } so we can preventDefault + $effect(() => { + const el = containerEl; + if (el === undefined) return; + + el.addEventListener('touchstart', handleTouchStart, { passive: false }); + el.addEventListener('touchmove', handleTouchMove, { passive: false }); + el.addEventListener('touchend', handleTouchEnd, { passive: true }); + + return () => { + el.removeEventListener('touchstart', handleTouchStart); + el.removeEventListener('touchmove', handleTouchMove); + el.removeEventListener('touchend', handleTouchEnd); + }; + }); + $effect(() => { const currentFile = file; if (currentFile === undefined) { @@ -33,6 +200,9 @@ return; } + // Reset zoom on file change + resetZoom(); + // Clear stale state synchronously before async loads if (rawThumbnailUrl !== undefined) { URL.revokeObjectURL(rawThumbnailUrl); @@ -67,6 +237,7 @@ progress = 0; downloading = false; loadError = undefined; + resetZoom(); } async function loadThumbnail(entry: FlashAirFileEntry) { @@ -157,9 +328,17 @@ let progressPercent = $derived(Math.round(progress * 100)); let showThumbnail = $derived(fullObjectUrl === undefined && thumbnailBlobUrl !== undefined); + let imageTransform = $derived( + `translate(${String(panX)}px, ${String(panY)}px) scale(${String(zoomLevel)})` + ); </script> -<div class="h-full flex items-center justify-center bg-base-300 relative"> +<!-- svelte-ignore a11y_no_static_element_interactions --> +<div + class="h-full flex items-center justify-center bg-base-300 relative overflow-hidden touch-none" + bind:this={containerEl} + onwheel={handleWheel} +> {#if file === undefined} <div class="text-base-content/40 text-center p-8"> <p class="text-lg">Select a photo to preview</p> @@ -171,8 +350,8 @@ </div> {:else} {#if downloading && progress < 1} - <div class="absolute inset-x-0 top-0 z-10 p-3"> - <div class="max-w-xs mx-auto"> + <div class="absolute inset-0 z-10 flex items-center justify-center bg-black/50"> + <div class="bg-base-100 rounded-box p-6 shadow-xl max-w-xs w-full mx-4"> <div class="flex items-center gap-2"> <progress class="progress progress-primary flex-1" value={progressPercent} max="100"></progress> <span class="text-xs font-mono text-base-content/70 w-10 text-right">{progressPercent}%</span> @@ -185,7 +364,10 @@ <img src={fullObjectUrl} alt={file.filename} - class="max-w-full max-h-full object-contain" + class="max-w-full max-h-full object-contain will-change-transform" + style:transform={imageTransform} + style:transform-origin="center center" + draggable="false" /> {/key} {:else if showThumbnail} @@ -197,6 +379,7 @@ src={thumbnailBlobUrl} alt={file.filename} class="w-full h-full object-cover blur-lg" + draggable="false" /> </div> {:else} |
