Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions apps/web/src/components/app/media-lightbox-actions.tsx
Original file line number Diff line number Diff line change
@@ -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<number | null>(null);
const cachedTextRef = useRef<string | null>(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 (
<div
className="flex flex-none items-center gap-1"
onClick={(event) => event.stopPropagation()}
>
<a
href={src}
download={displayName}
className="inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground"
title="Download"
>
<Download className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Download</span>
</a>
{showCopy && (
<Button
size="sm"
variant={showCopied ? "default" : "ghost"}
className={
showCopied
? "h-7 gap-1.5 px-2 text-xs"
: "h-7 gap-1.5 px-2 text-xs text-muted-foreground hover:text-foreground"
}
onClick={handleCopy}
title={copyLabel}
aria-label={copyLabel}
>
{showCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span className="hidden sm:inline">
{showCopied ? "Copied!" : copyLabel}
</span>
</Button>
)}
</div>
);
}
119 changes: 119 additions & 0 deletions apps/web/src/components/app/media-lightbox-image.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
ref={viewportRef}
data-testid="media-lightbox-image-viewport"
className={cn(
"absolute inset-0 flex select-none items-center justify-center overflow-hidden overscroll-none touch-none",
transform.scale > 1
? "cursor-grab active:cursor-grabbing"
: "cursor-zoom-in"
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onWheel={handleWheel}
onDoubleClick={handleDoubleClick}
>
<img
ref={imageRef}
src={src}
alt={alt}
draggable={false}
className="max-h-full max-w-full object-contain will-change-transform"
style={{
transform: `translate3d(${transform.x}px, ${transform.y}px, 0) scale(${transform.scale})`,
}}
/>
<div className="pointer-events-none absolute inset-x-0 bottom-[max(1rem,env(safe-area-inset-bottom))] flex justify-center">
<div
className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/15 bg-black/65 p-1 text-white shadow-lg backdrop-blur"
onPointerDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<Button
aria-label="Zoom out"
data-testid="media-lightbox-zoom-out"
disabled={transform.scale <= MIN_IMAGE_SCALE}
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-white hover:bg-white/15 hover:text-white"
onClick={() => zoomAt(transform.scale / 1.5)}
>
<ZoomOut className="h-5 w-5" />
</Button>
<Button
aria-label="Reset zoom"
data-testid="media-lightbox-zoom-reset"
size="sm"
variant="ghost"
className="h-10 min-w-16 rounded-full px-2 text-xs tabular-nums text-white hover:bg-white/15 hover:text-white"
onClick={reset}
>
{Math.round(transform.scale * 100)}%
</Button>
<Button
aria-label="Zoom in"
data-testid="media-lightbox-zoom-in"
disabled={transform.scale >= MAX_IMAGE_SCALE}
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-white hover:bg-white/15 hover:text-white"
onClick={() => zoomAt(transform.scale * 1.5)}
>
<ZoomIn className="h-5 w-5" />
</Button>
<Button
aria-label="Fit image to screen"
size="icon"
variant="ghost"
className="h-10 w-10 rounded-full text-white hover:bg-white/15 hover:text-white"
onClick={reset}
>
<Maximize2 className="h-5 w-5" />
</Button>
</div>
</div>
<span className="sr-only">
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.
</span>
</div>
);
}
129 changes: 129 additions & 0 deletions apps/web/src/components/app/media-lightbox-syntax.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
".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;
}
}
Loading
Loading