diff --git a/apps/web/src/components/app/media-lightbox-actions.tsx b/apps/web/src/components/app/media-lightbox-actions.tsx new file mode 100644 index 00000000..88929e84 --- /dev/null +++ b/apps/web/src/components/app/media-lightbox-actions.tsx @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Check, Copy, Download } from "lucide-react"; + +import { stripTimestamp } from "@/components/app/media-file-utils"; +import { Button } from "@/components/ui/button"; +import { useCopyText } from "@/hooks/use-copy"; + +const HAS_CLIPBOARD_WRITE = + typeof ClipboardItem !== "undefined" && !!navigator.clipboard?.write; + +export function MediaActions({ + src, + fileName, + isText, + isMarkdown, +}: { + src: string; + fileName: string; + isText?: boolean; + isMarkdown?: boolean; +}): JSX.Element { + const [copied, copyText] = useCopyText(); + const [imageCopied, setImageCopied] = useState(false); + const imageCopiedTimerRef = useRef(null); + const cachedTextRef = useRef(null); + + const displayName = stripTimestamp(fileName); + + // Pre-fetch text content so it's available synchronously for execCommand copy. + useEffect(() => { + cachedTextRef.current = null; + if (!isText) return; + const controller = new AbortController(); + void fetch(src, { signal: controller.signal }) + .then((r) => r.text()) + .then((t) => { + cachedTextRef.current = t; + }) + .catch(() => {}); + return () => controller.abort(); + }, [src, isText]); + + useEffect( + () => () => { + if (imageCopiedTimerRef.current) + window.clearTimeout(imageCopiedTimerRef.current); + }, + [] + ); + + const markImageCopied = useCallback(() => { + setImageCopied(true); + if (imageCopiedTimerRef.current) + window.clearTimeout(imageCopiedTimerRef.current); + imageCopiedTimerRef.current = window.setTimeout( + () => setImageCopied(false), + 2000 + ); + }, []); + + const handleCopy = useCallback(() => { + if (isText) { + if (cachedTextRef.current) copyText(cachedTextRef.current); + } else if (HAS_CLIPBOARD_WRITE) { + const blobPromise = fetch(src).then((r) => r.blob()); + void navigator.clipboard + .write([new ClipboardItem({ "image/png": blobPromise })]) + .then(markImageCopied) + .catch(() => {}); + } + }, [src, isText, copyText, markImageCopied]); + + const showCopied = isText ? copied : imageCopied; + const showCopy = isText || HAS_CLIPBOARD_WRITE; + const copyLabel = isMarkdown ? "Copy source" : "Copy"; + + return ( +
event.stopPropagation()} + > + + + Download + + {showCopy && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/app/media-lightbox-image.tsx b/apps/web/src/components/app/media-lightbox-image.tsx new file mode 100644 index 00000000..67a3e503 --- /dev/null +++ b/apps/web/src/components/app/media-lightbox-image.tsx @@ -0,0 +1,119 @@ +import { Maximize2, ZoomIn, ZoomOut } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + MAX_IMAGE_SCALE, + MIN_IMAGE_SCALE, + useZoomableImage, +} from "@/hooks/use-zoomable-image"; +import { cn } from "@/lib/utils"; + +export function ZoomableImage({ + src, + alt, + onPrevious, + onNext, +}: { + src: string; + alt: string; + onPrevious?: () => void; + onNext?: () => void; +}): JSX.Element { + const { + viewportRef, + imageRef, + transform, + reset, + zoomAt, + handlePointerDown, + handlePointerMove, + handlePointerEnd, + handleWheel, + handleDoubleClick, + } = useZoomableImage({ src, onPrevious, onNext }); + + return ( +
1 + ? "cursor-grab active:cursor-grabbing" + : "cursor-zoom-in" + )} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerEnd} + onPointerCancel={handlePointerEnd} + onWheel={handleWheel} + onDoubleClick={handleDoubleClick} + > + {alt} +
+
event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + > + + + + +
+
+ + Pinch or use the zoom controls to zoom. Drag to pan. Double tap to + toggle zoom. Swipe left or right at one hundred percent zoom to change + items. + +
+ ); +} diff --git a/apps/web/src/components/app/media-lightbox-syntax.ts b/apps/web/src/components/app/media-lightbox-syntax.ts new file mode 100644 index 00000000..5d134d69 --- /dev/null +++ b/apps/web/src/components/app/media-lightbox-syntax.ts @@ -0,0 +1,129 @@ +import hljs from "highlight.js/lib/core"; +import bash from "highlight.js/lib/languages/bash"; +import c from "highlight.js/lib/languages/c"; +import cpp from "highlight.js/lib/languages/cpp"; +import css from "highlight.js/lib/languages/css"; +import diff from "highlight.js/lib/languages/diff"; +import elixir from "highlight.js/lib/languages/elixir"; +import erlang from "highlight.js/lib/languages/erlang"; +import go from "highlight.js/lib/languages/go"; +import haskell from "highlight.js/lib/languages/haskell"; +import ini from "highlight.js/lib/languages/ini"; +import java from "highlight.js/lib/languages/java"; +import javascript from "highlight.js/lib/languages/javascript"; +import json from "highlight.js/lib/languages/json"; +import kotlin from "highlight.js/lib/languages/kotlin"; +import lua from "highlight.js/lib/languages/lua"; +import markdown from "highlight.js/lib/languages/markdown"; +import nim from "highlight.js/lib/languages/nim"; +import objectivec from "highlight.js/lib/languages/objectivec"; +import php from "highlight.js/lib/languages/php"; +import python from "highlight.js/lib/languages/python"; +import r from "highlight.js/lib/languages/r"; +import ruby from "highlight.js/lib/languages/ruby"; +import rust from "highlight.js/lib/languages/rust"; +import sql from "highlight.js/lib/languages/sql"; +import swift from "highlight.js/lib/languages/swift"; +import typescript from "highlight.js/lib/languages/typescript"; +import xml from "highlight.js/lib/languages/xml"; +import yaml from "highlight.js/lib/languages/yaml"; + +import { fileExtension } from "@/components/app/media-file-utils"; + +const LANGUAGES = { + bash, + c, + cpp, + css, + diff, + elixir, + erlang, + go, + haskell, + ini, + java, + javascript, + json, + kotlin, + lua, + markdown, + nim, + objectivec, + php, + python, + r, + ruby, + rust, + sql, + swift, + typescript, + xml, + yaml, +}; + +for (const [name, language] of Object.entries(LANGUAGES)) { + hljs.registerLanguage(name, language); +} + +const EXT_TO_LANG: Record = { + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".jsx": "javascript", + ".py": "python", + ".go": "go", + ".rs": "rust", + ".sh": "bash", + ".bash": "bash", + ".json": "json", + ".yaml": "yaml", + ".yml": "yaml", + ".toml": "ini", + ".html": "xml", + ".xml": "xml", + ".css": "css", + ".sql": "sql", + ".md": "markdown", + ".swift": "swift", + ".kt": "kotlin", + ".java": "java", + ".c": "c", + ".cpp": "cpp", + ".h": "c", + ".hpp": "cpp", + ".rb": "ruby", + ".php": "php", + ".lua": "lua", + ".r": "r", + ".ex": "elixir", + ".exs": "elixir", + ".erl": "erlang", + ".hs": "haskell", + ".diff": "diff", + ".patch": "diff", + ".ini": "ini", + ".cfg": "ini", + ".conf": "ini", + ".nim": "nim", + ".m": "objectivec", +}; + +export function highlightCode( + content: string, + fileName: string +): string | null { + const language = EXT_TO_LANG[fileExtension(fileName)]; + if (language) { + try { + return hljs.highlight(content, { language }).value; + } catch { + // Fall through to auto-detection. + } + } + + try { + return hljs.highlightAuto(content).value; + } catch { + return null; + } +} diff --git a/apps/web/src/components/app/media-lightbox-text.tsx b/apps/web/src/components/app/media-lightbox-text.tsx new file mode 100644 index 00000000..6e4cb05e --- /dev/null +++ b/apps/web/src/components/app/media-lightbox-text.tsx @@ -0,0 +1,110 @@ +import { useEffect, useMemo, useState } from "react"; + +import { highlightCode } from "@/components/app/media-lightbox-syntax"; +import { LogStream } from "@/components/ui/log-stream"; +import { Markdown } from "@/components/ui/markdown"; + +function useFetchedText(src: string): { + content: string | null; + error: string | null; +} { + const [content, setContent] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + setContent(null); + setError(null); + let cancelled = false; + fetch(src) + .then((response) => { + if (!response.ok) + throw new Error(`Failed to load (${response.status})`); + return response.text(); + }) + .then((text) => { + if (!cancelled) setContent(text); + }) + .catch((fetchError) => { + if (!cancelled) setError(String(fetchError)); + }); + return () => { + cancelled = true; + }; + }, [src]); + + return { content, error }; +} + +function LoadingText(): JSX.Element { + return ( +
+ Loading... +
+ ); +} + +function TextError({ error }: { error: string }): JSX.Element { + return ( +
+ {error} +
+ ); +} + +function TextViewer({ + content, + fileName, +}: { + content: string; + fileName: string; +}): JSX.Element { + const highlightedHtml = useMemo( + () => highlightCode(content, fileName), + [content, fileName] + ); + + return ( + + {highlightedHtml ? ( +
+          
+        
+ ) : ( +
+          {content}
+        
+ )} +
+ ); +} + +export function MarkdownViewer({ src }: { src: string }): JSX.Element { + const { content, error } = useFetchedText(src); + + if (error) return ; + if (content === null) return ; + + return ( +
+ {content} +
+ ); +} + +export function TextFileViewer({ + src, + fileName, +}: { + src: string; + fileName: string; +}): JSX.Element { + const { content, error } = useFetchedText(src); + + if (error) return ; + if (content === null) return ; + + return ; +} diff --git a/apps/web/src/components/app/media-lightbox.tsx b/apps/web/src/components/app/media-lightbox.tsx index 1988eb3a..62387f3c 100644 --- a/apps/web/src/components/app/media-lightbox.tsx +++ b/apps/web/src/components/app/media-lightbox.tsx @@ -1,133 +1,20 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - Check, - ChevronLeft, - ChevronRight, - Copy, - Download, - X, -} from "lucide-react"; -import hljs from "highlight.js/lib/core"; -import typescript from "highlight.js/lib/languages/typescript"; -import javascript from "highlight.js/lib/languages/javascript"; -import python from "highlight.js/lib/languages/python"; -import go from "highlight.js/lib/languages/go"; -import rust from "highlight.js/lib/languages/rust"; -import bash from "highlight.js/lib/languages/bash"; -import json from "highlight.js/lib/languages/json"; -import yaml from "highlight.js/lib/languages/yaml"; -import xml from "highlight.js/lib/languages/xml"; -import css from "highlight.js/lib/languages/css"; -import sql from "highlight.js/lib/languages/sql"; -import markdown from "highlight.js/lib/languages/markdown"; -import swift from "highlight.js/lib/languages/swift"; -import kotlin from "highlight.js/lib/languages/kotlin"; -import java from "highlight.js/lib/languages/java"; -import c from "highlight.js/lib/languages/c"; -import cpp from "highlight.js/lib/languages/cpp"; -import ruby from "highlight.js/lib/languages/ruby"; -import php from "highlight.js/lib/languages/php"; -import lua from "highlight.js/lib/languages/lua"; -import r from "highlight.js/lib/languages/r"; -import elixir from "highlight.js/lib/languages/elixir"; -import erlang from "highlight.js/lib/languages/erlang"; -import haskell from "highlight.js/lib/languages/haskell"; -import diff from "highlight.js/lib/languages/diff"; -import ini from "highlight.js/lib/languages/ini"; -import objectivec from "highlight.js/lib/languages/objectivec"; -import nim from "highlight.js/lib/languages/nim"; - -hljs.registerLanguage("typescript", typescript); -hljs.registerLanguage("javascript", javascript); -hljs.registerLanguage("python", python); -hljs.registerLanguage("go", go); -hljs.registerLanguage("rust", rust); -hljs.registerLanguage("bash", bash); -hljs.registerLanguage("json", json); -hljs.registerLanguage("yaml", yaml); -hljs.registerLanguage("xml", xml); -hljs.registerLanguage("css", css); -hljs.registerLanguage("sql", sql); -hljs.registerLanguage("markdown", markdown); -hljs.registerLanguage("swift", swift); -hljs.registerLanguage("kotlin", kotlin); -hljs.registerLanguage("java", java); -hljs.registerLanguage("c", c); -hljs.registerLanguage("cpp", cpp); -hljs.registerLanguage("ruby", ruby); -hljs.registerLanguage("php", php); -hljs.registerLanguage("lua", lua); -hljs.registerLanguage("r", r); -hljs.registerLanguage("elixir", elixir); -hljs.registerLanguage("erlang", erlang); -hljs.registerLanguage("haskell", haskell); -hljs.registerLanguage("diff", diff); -hljs.registerLanguage("ini", ini); -hljs.registerLanguage("objectivec", objectivec); -hljs.registerLanguage("nim", nim); +import { useEffect } from "react"; +import { ChevronLeft, ChevronRight, X } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { LogStream } from "@/components/ui/log-stream"; -import { Markdown } from "@/components/ui/markdown"; +import { MediaActions } from "@/components/app/media-lightbox-actions"; +import { ZoomableImage } from "@/components/app/media-lightbox-image"; +import { + MarkdownViewer, + TextFileViewer, +} from "@/components/app/media-lightbox-text"; import { fileExtension, isTextFile, stripTimestamp, } from "@/components/app/media-file-utils"; -import { useCopyText } from "@/hooks/use-copy"; +import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -const EXT_TO_LANG: Record = { - ".ts": "typescript", - ".tsx": "typescript", - ".js": "javascript", - ".jsx": "javascript", - ".py": "python", - ".go": "go", - ".rs": "rust", - ".sh": "bash", - ".bash": "bash", - ".json": "json", - ".yaml": "yaml", - ".yml": "yaml", - ".toml": "ini", - ".html": "xml", - ".xml": "xml", - ".css": "css", - ".sql": "sql", - ".md": "markdown", - ".swift": "swift", - ".kt": "kotlin", - ".java": "java", - ".c": "c", - ".cpp": "cpp", - ".h": "c", - ".hpp": "cpp", - ".rb": "ruby", - ".php": "php", - ".lua": "lua", - ".r": "r", - ".ex": "elixir", - ".exs": "elixir", - ".erl": "erlang", - ".hs": "haskell", - ".diff": "diff", - ".patch": "diff", - ".ini": "ini", - ".cfg": "ini", - ".conf": "ini", - ".zig": "zig", - ".nim": "nim", - ".m": "objectivec", -}; - -function isMarkdownFile(name: string): boolean { - return fileExtension(name) === ".md"; -} - -const HAS_CLIPBOARD_WRITE = - typeof ClipboardItem !== "undefined" && !!navigator.clipboard?.write; - type MediaLightboxItem = { src: string; caption: string; @@ -146,246 +33,11 @@ type MediaLightboxProps = { setLightboxIndex: (nextIndex: number | null) => void; }; -function TextViewer({ - content, - fileName, -}: { - content: string; - fileName: string; -}): JSX.Element { - const highlightedHtml = useMemo(() => { - const ext = fileExtension(fileName); - const lang = EXT_TO_LANG[ext]; - if (lang) { - try { - return hljs.highlight(content, { language: lang }).value; - } catch { - // fall through to auto-detect - } - } - try { - return hljs.highlightAuto(content).value; - } catch { - return null; - } - }, [content, fileName]); - - return ( - - {highlightedHtml ? ( -
-          
-        
- ) : ( -
-          {content}
-        
- )} -
- ); -} - -function useFetchedText(src: string): { - content: string | null; - error: string | null; -} { - const [content, setContent] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - setContent(null); - setError(null); - let cancelled = false; - fetch(src) - .then((res) => { - if (!res.ok) throw new Error(`Failed to load (${res.status})`); - return res.text(); - }) - .then((text) => { - if (!cancelled) setContent(text); - }) - .catch((err) => { - if (!cancelled) setError(String(err)); - }); - return () => { - cancelled = true; - }; - }, [src]); - - return { content, error }; -} - -function MarkdownViewer({ src }: { src: string }): JSX.Element { - const { content, error } = useFetchedText(src); - - if (error) { - return ( -
- {error} -
- ); - } - - if (content === null) { - return ( -
- Loading... -
- ); - } - - return ( -
- {content} -
- ); -} - -function TextFileViewer({ - src, - fileName, -}: { - src: string; - fileName: string; -}): JSX.Element { - const { content, error } = useFetchedText(src); - - if (error) { - return ( -
- {error} -
- ); - } - - if (content === null) { - return ( -
- Loading... -
- ); - } - - return ; -} - -function MediaActions({ - src, - fileName, - isText, - isMarkdown, -}: { - src: string; - fileName: string; - isText?: boolean; - isMarkdown?: boolean; -}): JSX.Element { - const [copied, copyText] = useCopyText(); - const [imageCopied, setImageCopied] = useState(false); - const imageCopiedTimerRef = useRef(null); - const cachedTextRef = useRef(null); - - const displayName = stripTimestamp(fileName); - - // Pre-fetch text content so it's available synchronously for execCommand copy. - useEffect(() => { - cachedTextRef.current = null; - if (!isText) return; - const controller = new AbortController(); - void fetch(src, { signal: controller.signal }) - .then((r) => r.text()) - .then((t) => { - cachedTextRef.current = t; - }) - .catch(() => {}); - return () => controller.abort(); - }, [src, isText]); - - // Clean up the image-copied timer on unmount. - useEffect( - () => () => { - if (imageCopiedTimerRef.current) - window.clearTimeout(imageCopiedTimerRef.current); - }, - [] - ); - - const markImageCopied = useCallback(() => { - setImageCopied(true); - if (imageCopiedTimerRef.current) - window.clearTimeout(imageCopiedTimerRef.current); - imageCopiedTimerRef.current = window.setTimeout( - () => setImageCopied(false), - 2000 - ); - }, []); - - const handleCopy = useCallback(() => { - if (isText) { - if (cachedTextRef.current) copyText(cachedTextRef.current); - } else { - // Images: use Clipboard API (only works on secure contexts / desktop). - // On mobile non-secure contexts, users can long-press to copy images. - if (HAS_CLIPBOARD_WRITE) { - const blobPromise = fetch(src).then((r) => r.blob()); - void navigator.clipboard - .write([new ClipboardItem({ "image/png": blobPromise })]) - .then(markImageCopied) - .catch(() => {}); - } - } - }, [src, isText, copyText, markImageCopied]); - - const showCopied = isText ? copied : imageCopied; - - const showCopy = isText || HAS_CLIPBOARD_WRITE; - const copyLabel = isMarkdown ? "Copy source" : "Copy"; - - return ( -
e.stopPropagation()} - > - - - Download - - {showCopy && ( - - )} -
- ); +function isMarkdownFile(name: string): boolean { + return fileExtension(name) === ".md"; } -export { MediaActions }; +export { MediaActions } from "@/components/app/media-lightbox-actions"; export function MediaLightbox({ item, @@ -396,22 +48,19 @@ export function MediaLightbox({ const canGoPrev = currentIndex > 0; const canGoNext = currentIndex >= 0 && currentIndex < totalItems - 1; - // Allow pinch-to-zoom while lightbox is open by relaxing the viewport meta tag + // Keep the page fixed behind the viewer. Image gestures are handled locally, + // so the browser viewport never needs to zoom or scroll. useEffect(() => { if (!item) return; - const meta = document.querySelector("meta[name=viewport]"); - if (!meta) return; - const original = meta.content; - meta.content = "width=device-width, initial-scale=1.0"; + const originalOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; return () => { - meta.content = original; + document.body.style.overflow = originalOverflow; }; }, [item]); useEffect(() => { - if (!item) { - return; - } + if (!item) return; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { @@ -436,21 +85,17 @@ export function MediaLightbox({ }; window.addEventListener("keydown", onKeyDown); - return () => { - window.removeEventListener("keydown", onKeyDown); - }; + return () => window.removeEventListener("keydown", onKeyDown); }, [canGoNext, canGoPrev, currentIndex, item, setLightboxIndex]); - if (!item) { - return null; - } + if (!item) return null; const isText = item.file.source === "text" || isTextFile(item.file.name); const isMarkdown = isMarkdownFile(item.file.name); const isDocument = /\.pdf$/i.test(item.file.name); const isVideo = /\.mp4/i.test(item.src); + const isImage = !isDocument && !isText && !isVideo; const displayName = stripTimestamp(item.file.name); - const sizeLabel = item.file.size >= 1024 * 1024 ? `${(item.file.size / (1024 * 1024)).toFixed(1)} MB` @@ -458,12 +103,30 @@ export function MediaLightbox({ return (
-
- - {displayName} +
+ + {isImage && item.caption ? item.caption : displayName}
setLightboxIndex(currentIndex - 1)} > - + {totalItems > 0 ? `${currentIndex + 1}/${totalItems}` : ""}
) : ( - {item.caption} setLightboxIndex(currentIndex - 1) : undefined + } + onNext={ + canGoNext ? () => setLightboxIndex(currentIndex + 1) : undefined + } /> )}
-
- {item.caption ? ( - {item.caption} - ) : null} - {item.file.source ? ( - - {item.file.source === "user" ? "your upload" : item.file.source} + {!isImage && ( +
+ {item.caption ? ( + {item.caption} + ) : null} + {item.file.source ? ( + + {item.file.source === "user" ? "your upload" : item.file.source} + + ) : null} + {sizeLabel} + + {new Date(item.file.updatedAt).toLocaleString()} - ) : null} - {sizeLabel} - - {new Date(item.file.updatedAt).toLocaleString()} - -
+
+ )}
); } diff --git a/apps/web/src/hooks/use-zoomable-image.ts b/apps/web/src/hooks/use-zoomable-image.ts new file mode 100644 index 00000000..5f1eb34a --- /dev/null +++ b/apps/web/src/hooks/use-zoomable-image.ts @@ -0,0 +1,327 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, + type WheelEvent as ReactWheelEvent, +} from "react"; + +type Point = { x: number; y: number }; + +type ImageTransform = Point & { + scale: number; +}; + +export const MIN_IMAGE_SCALE = 1; +export const MAX_IMAGE_SCALE = 8; +const DOUBLE_TAP_ZOOM = 2.5; + +function distanceBetween(first: Point, second: Point): number { + return Math.hypot(second.x - first.x, second.y - first.y); +} + +function midpointBetween(first: Point, second: Point): Point { + return { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 }; +} + +export function useZoomableImage({ + src, + onPrevious, + onNext, +}: { + src: string; + onPrevious?: () => void; + onNext?: () => void; +}) { + const viewportRef = useRef(null); + const imageRef = useRef(null); + const pointersRef = useRef(new Map()); + const dragRef = useRef<{ + pointerX: number; + pointerY: number; + transform: ImageTransform; + } | null>(null); + const pinchRef = useRef<{ + distance: number; + midpoint: Point; + transform: ImageTransform; + } | null>(null); + const swipeRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + currentX: number; + currentY: number; + } | null>(null); + const lastTapRef = useRef<{ time: number; x: number; y: number } | null>( + null + ); + const movedRef = useRef(false); + const transformRef = useRef({ scale: 1, x: 0, y: 0 }); + const [transform, setTransform] = useState( + transformRef.current + ); + + const clampTransform = useCallback((next: ImageTransform): ImageTransform => { + const viewport = viewportRef.current; + const image = imageRef.current; + const scale = Math.min( + MAX_IMAGE_SCALE, + Math.max(MIN_IMAGE_SCALE, next.scale) + ); + + if (!viewport || !image || scale === MIN_IMAGE_SCALE) { + return { scale, x: 0, y: 0 }; + } + + const maxX = Math.max( + 0, + (image.offsetWidth * scale - viewport.clientWidth) / 2 + ); + const maxY = Math.max( + 0, + (image.offsetHeight * scale - viewport.clientHeight) / 2 + ); + + return { + scale, + x: Math.min(maxX, Math.max(-maxX, next.x)), + y: Math.min(maxY, Math.max(-maxY, next.y)), + }; + }, []); + + const updateTransform = useCallback( + (next: ImageTransform) => { + const clamped = clampTransform(next); + transformRef.current = clamped; + setTransform(clamped); + }, + [clampTransform] + ); + + const reset = useCallback(() => { + updateTransform({ scale: 1, x: 0, y: 0 }); + }, [updateTransform]); + + useEffect(() => { + reset(); + pointersRef.current.clear(); + }, [reset, src]); + + useEffect(() => { + const onResize = () => updateTransform(transformRef.current); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, [updateTransform]); + + const zoomAt = useCallback( + (nextScale: number, clientX?: number, clientY?: number) => { + const viewport = viewportRef.current; + const previous = transformRef.current; + if (!viewport) return; + + const rect = viewport.getBoundingClientRect(); + const focalX = (clientX ?? rect.left + rect.width / 2) - rect.left; + const focalY = (clientY ?? rect.top + rect.height / 2) - rect.top; + const centerX = rect.width / 2; + const centerY = rect.height / 2; + const scale = Math.min( + MAX_IMAGE_SCALE, + Math.max(MIN_IMAGE_SCALE, nextScale) + ); + const ratio = scale / previous.scale; + + updateTransform({ + scale, + x: focalX - centerX - (focalX - centerX - previous.x) * ratio, + y: focalY - centerY - (focalY - centerY - previous.y) * ratio, + }); + }, + [updateTransform] + ); + + const handlePointerDown = (event: ReactPointerEvent) => { + if (event.pointerType === "mouse" && event.button !== 0) return; + event.currentTarget.setPointerCapture(event.pointerId); + pointersRef.current.set(event.pointerId, { + x: event.clientX, + y: event.clientY, + }); + movedRef.current = false; + + const pointers = [...pointersRef.current.values()]; + if (pointers.length === 2) { + swipeRef.current = null; + pinchRef.current = { + distance: distanceBetween(pointers[0], pointers[1]), + midpoint: midpointBetween(pointers[0], pointers[1]), + transform: transformRef.current, + }; + dragRef.current = null; + } else if (pointers.length === 1) { + if ( + event.pointerType !== "mouse" && + transformRef.current.scale === MIN_IMAGE_SCALE + ) { + swipeRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + currentX: event.clientX, + currentY: event.clientY, + }; + } + dragRef.current = { + pointerX: event.clientX, + pointerY: event.clientY, + transform: transformRef.current, + }; + } + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + if (!pointersRef.current.has(event.pointerId)) return; + const previousPointer = pointersRef.current.get(event.pointerId); + if ( + previousPointer && + Math.hypot( + event.clientX - previousPointer.x, + event.clientY - previousPointer.y + ) > 2 + ) { + movedRef.current = true; + } + pointersRef.current.set(event.pointerId, { + x: event.clientX, + y: event.clientY, + }); + if (swipeRef.current?.pointerId === event.pointerId) { + swipeRef.current.currentX = event.clientX; + swipeRef.current.currentY = event.clientY; + } + + const pointers = [...pointersRef.current.values()]; + if (pointers.length === 2 && pinchRef.current) { + const viewport = viewportRef.current; + if (!viewport) return; + const pinch = pinchRef.current; + const currentMidpoint = midpointBetween(pointers[0], pointers[1]); + const rect = viewport.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const scale = Math.min( + MAX_IMAGE_SCALE, + Math.max( + MIN_IMAGE_SCALE, + pinch.transform.scale * + (distanceBetween(pointers[0], pointers[1]) / pinch.distance) + ) + ); + const ratio = scale / pinch.transform.scale; + + updateTransform({ + scale, + x: + currentMidpoint.x - + centerX - + (pinch.midpoint.x - centerX - pinch.transform.x) * ratio, + y: + currentMidpoint.y - + centerY - + (pinch.midpoint.y - centerY - pinch.transform.y) * ratio, + }); + } else if (pointers.length === 1 && dragRef.current) { + const drag = dragRef.current; + updateTransform({ + ...drag.transform, + x: drag.transform.x + event.clientX - drag.pointerX, + y: drag.transform.y + event.clientY - drag.pointerY, + }); + } + }; + + const handlePointerEnd = (event: ReactPointerEvent) => { + const swipe = + swipeRef.current?.pointerId === event.pointerId ? swipeRef.current : null; + pointersRef.current.delete(event.pointerId); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + const remaining = [...pointersRef.current.values()]; + pinchRef.current = null; + if (remaining.length === 1) { + dragRef.current = { + pointerX: remaining[0].x, + pointerY: remaining[0].y, + transform: transformRef.current, + }; + return; + } + dragRef.current = null; + + if (swipe && transformRef.current.scale === MIN_IMAGE_SCALE) { + const deltaX = swipe.currentX - swipe.startX; + const deltaY = swipe.currentY - swipe.startY; + swipeRef.current = null; + if (Math.abs(deltaX) >= 60 && Math.abs(deltaX) > Math.abs(deltaY) * 1.5) { + if (deltaX > 0) onPrevious?.(); + else onNext?.(); + lastTapRef.current = null; + return; + } + } + + if (!movedRef.current && event.pointerType !== "mouse") { + const now = Date.now(); + const lastTap = lastTapRef.current; + if ( + lastTap && + now - lastTap.time < 300 && + Math.hypot(event.clientX - lastTap.x, event.clientY - lastTap.y) < 30 + ) { + zoomAt( + transformRef.current.scale > 1 ? 1 : DOUBLE_TAP_ZOOM, + event.clientX, + event.clientY + ); + lastTapRef.current = null; + } else { + lastTapRef.current = { + time: now, + x: event.clientX, + y: event.clientY, + }; + } + } + }; + + const handleWheel = (event: ReactWheelEvent) => { + event.preventDefault(); + const factor = Math.exp(-event.deltaY * 0.002); + zoomAt(transformRef.current.scale * factor, event.clientX, event.clientY); + }; + + const handleDoubleClick = (event: ReactMouseEvent) => { + zoomAt( + transformRef.current.scale > 1 ? 1 : DOUBLE_TAP_ZOOM, + event.clientX, + event.clientY + ); + }; + + return { + viewportRef, + imageRef, + transform, + reset, + zoomAt, + handlePointerDown, + handlePointerMove, + handlePointerEnd, + handleWheel, + handleDoubleClick, + }; +} diff --git a/e2e/media-sidebar.spec.ts b/e2e/media-sidebar.spec.ts index 5b8addb0..3c82e657 100644 --- a/e2e/media-sidebar.spec.ts +++ b/e2e/media-sidebar.spec.ts @@ -148,7 +148,49 @@ test.describe("Media sidebar", () => { await expect(lightbox).toContainText("1/2"); await expect(lightbox).toContainText("Second image"); - await page.getByTestId("media-lightbox-next").click(); + const imageViewport = page.getByTestId("media-lightbox-image-viewport"); + await expect(imageViewport).toBeVisible(); + await expect(imageViewport).toHaveCSS("touch-action", "none"); + await expect(page.getByTestId("media-lightbox-zoom-reset")).toHaveText( + "100%" + ); + + await page.getByTestId("media-lightbox-zoom-in").click(); + await expect(page.getByTestId("media-lightbox-zoom-reset")).toHaveText( + "150%" + ); + await page.getByTestId("media-lightbox-zoom-reset").click(); + + await page.getByTestId("media-lightbox-zoom-in").dblclick(); + await expect(page.getByTestId("media-lightbox-zoom-reset")).toHaveText( + "225%" + ); + await page.getByTestId("media-lightbox-zoom-reset").dblclick(); + await expect(page.getByTestId("media-lightbox-zoom-reset")).toHaveText( + "100%" + ); + + await imageViewport.dispatchEvent("pointerdown", { + pointerId: 1, + pointerType: "touch", + isPrimary: true, + clientX: 280, + clientY: 300, + }); + await imageViewport.dispatchEvent("pointermove", { + pointerId: 1, + pointerType: "touch", + isPrimary: true, + clientX: 120, + clientY: 305, + }); + await imageViewport.dispatchEvent("pointerup", { + pointerId: 1, + pointerType: "touch", + isPrimary: true, + clientX: 120, + clientY: 305, + }); await expect(lightbox).toContainText("2/2"); await expect(lightbox).toContainText("First image");