mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(pdf-to-image): redesign with thumbnails, page selection, color mode, and expanded formats
- Add preview endpoint for page thumbnails with clickable selection - Add 8 output formats (PNG, JPEG, WebP, AVIF, TIFF, GIF, HEIC, HEIF) - Add quality slider for lossy formats, color mode (Color/Grayscale/B&W) - Add custom DPI input (36-1200) alongside preset buttons - Replace reply.hijack() ZIP streaming with JSON response + server-side ZIP - Add dedicated Zustand store with bidirectional page range sync - Add ResultsPanel with per-page download, format/size badges - Update integration tests (16 cases) and e2e tests (3 cases)
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
import { Check, Download, FileOutput, Loader2 } from "lucide-react";
|
||||
import { usePdfToImageStore } from "@/stores/pdf-to-image-store";
|
||||
|
||||
const PREVIEWABLE_FORMATS = new Set(["png", "jpg", "webp", "gif", "avif"]);
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function PdfToImagePreview() {
|
||||
const store = usePdfToImageStore();
|
||||
|
||||
// No file uploaded
|
||||
if (!store.file) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full w-full gap-3 text-center">
|
||||
<FileOutput className="h-12 w-12 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">Upload a PDF to get started</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading preview thumbnails
|
||||
if (store.loadingPreview) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full w-full gap-3">
|
||||
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">Generating page previews...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Converting - show thumbnails with progress overlay
|
||||
if (store.processing) {
|
||||
return (
|
||||
<div className="h-full w-full overflow-y-auto p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Loader2 className="h-4 w-4 text-primary animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Converting {store.selectedPages.size} page
|
||||
{store.selectedPages.size !== 1 ? "s" : ""}...
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{store.thumbnails.map((thumb) => {
|
||||
const isSelected = store.selectedPages.has(thumb.page);
|
||||
return (
|
||||
<div
|
||||
key={thumb.page}
|
||||
className={`relative rounded-lg border overflow-hidden ${
|
||||
isSelected ? "border-primary/50 opacity-100" : "border-border opacity-30"
|
||||
}`}
|
||||
>
|
||||
<img src={thumb.dataUrl} alt={`Page ${thumb.page}`} className="w-full h-auto" />
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
<Loader2 className="h-6 w-6 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-1.5 left-1.5 bg-background/80 backdrop-blur-sm border border-border px-1.5 py-0.5 rounded text-xs text-muted-foreground tabular-nums">
|
||||
{thumb.page}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Results ready - show converted images
|
||||
if (store.results && store.results.length > 0) {
|
||||
const isPreviewable = PREVIEWABLE_FORMATS.has(store.format);
|
||||
const totalSize = store.results.reduce((sum, r) => sum + (r.size ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-y-auto p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{store.results.length} page
|
||||
{store.results.length !== 1 ? "s" : ""} converted
|
||||
{totalSize > 0 && (
|
||||
<span className="text-muted-foreground font-normal ml-1">
|
||||
({formatSize(totalSize)})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground uppercase font-mono">{store.format}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{store.results.map((result) => {
|
||||
const thumb = store.thumbnails.find((t) => t.page === result.page);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.page}
|
||||
className="group relative rounded-lg border border-border overflow-hidden bg-muted/30"
|
||||
>
|
||||
{isPreviewable ? (
|
||||
<img
|
||||
src={result.downloadUrl}
|
||||
alt={`Page ${result.page}`}
|
||||
className="w-full h-auto"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : thumb?.dataUrl ? (
|
||||
<img src={thumb.dataUrl} alt={`Page ${result.page}`} className="w-full h-auto" />
|
||||
) : (
|
||||
<div className="w-full aspect-[3/4] flex flex-col items-center justify-center gap-1">
|
||||
<FileOutput className="h-8 w-8 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Page number badge */}
|
||||
<div className="absolute top-1.5 left-1.5 bg-background/80 backdrop-blur-sm border border-border px-1.5 py-0.5 rounded text-xs text-muted-foreground tabular-nums">
|
||||
{result.page}
|
||||
</div>
|
||||
|
||||
{/* Format badge */}
|
||||
<div className="absolute top-1.5 right-1.5 bg-background/80 backdrop-blur-sm border border-border px-1.5 py-0.5 rounded text-xs text-muted-foreground uppercase font-mono">
|
||||
{store.format}
|
||||
</div>
|
||||
|
||||
{/* File size badge */}
|
||||
{result.size > 0 && (
|
||||
<div className="absolute bottom-1.5 right-1.5 bg-background/80 backdrop-blur-sm border border-border px-1.5 py-0.5 rounded text-xs text-muted-foreground tabular-nums">
|
||||
{formatSize(result.size)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download overlay */}
|
||||
<a
|
||||
href={result.downloadUrl}
|
||||
download={`page-${result.page}.${store.format}`}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-colors"
|
||||
>
|
||||
<Download className="h-5 w-5 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Thumbnails loaded - show selectable page previews
|
||||
if (store.thumbnails.length > 0) {
|
||||
const allSelected = store.pageCount !== null && store.selectedPages.size === store.pageCount;
|
||||
const noneSelected = store.selectedPages.size === 0;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-y-auto p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{store.selectedPages.size} of {store.pageCount} page
|
||||
{store.pageCount !== 1 ? "s" : ""} selected
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{!allSelected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => store.selectAllPages()}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
)}
|
||||
{!noneSelected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => store.deselectAllPages()}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Deselect All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{store.thumbnails.map((thumb) => {
|
||||
const isSelected = store.selectedPages.has(thumb.page);
|
||||
return (
|
||||
<button
|
||||
key={thumb.page}
|
||||
type="button"
|
||||
onClick={() => store.togglePage(thumb.page)}
|
||||
className={`relative rounded-lg border overflow-hidden text-left transition-all ${
|
||||
isSelected
|
||||
? "border-primary ring-1 ring-primary/30"
|
||||
: "border-border opacity-50 hover:opacity-75"
|
||||
}`}
|
||||
>
|
||||
<img src={thumb.dataUrl} alt={`Page ${thumb.page}`} className="w-full h-auto" />
|
||||
|
||||
{/* Page number badge */}
|
||||
<div className="absolute top-1.5 left-1.5 bg-background/80 backdrop-blur-sm border border-border px-1.5 py-0.5 rounded text-xs text-muted-foreground tabular-nums">
|
||||
{thumb.page}
|
||||
</div>
|
||||
|
||||
{/* Selection checkbox */}
|
||||
<div
|
||||
className={`absolute top-1.5 right-1.5 w-5 h-5 rounded border flex items-center justify-center transition-colors ${
|
||||
isSelected ? "bg-primary border-primary" : "bg-background/80 border-border"
|
||||
}`}
|
||||
>
|
||||
{isSelected && <Check className="h-3 w-3 text-primary-foreground" />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full w-full gap-3 text-center">
|
||||
<FileOutput className="h-12 w-12 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">Upload a PDF to get started</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,6 @@
|
||||
import { Download, FileUp, Loader2, X } from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
|
||||
const DPI_OPTIONS = [
|
||||
{ value: 72, label: "72 (Screen)" },
|
||||
{ value: 150, label: "150 (Standard)" },
|
||||
{ value: 300, label: "300 (Print)" },
|
||||
{ value: 600, label: "600 (High Quality)" },
|
||||
];
|
||||
import { useCallback, useRef } from "react";
|
||||
import { usePdfToImageStore } from "@/stores/pdf-to-image-store";
|
||||
|
||||
const FORMAT_OPTIONS = [
|
||||
{ value: "png", label: "PNG" },
|
||||
@@ -15,58 +8,47 @@ const FORMAT_OPTIONS = [
|
||||
{ value: "webp", label: "WebP" },
|
||||
{ value: "avif", label: "AVIF" },
|
||||
{ value: "tiff", label: "TIFF" },
|
||||
{ value: "gif", label: "GIF" },
|
||||
{ value: "heic", label: "HEIC" },
|
||||
{ value: "heif", label: "HEIF" },
|
||||
];
|
||||
|
||||
const DPI_PRESETS = [
|
||||
{ value: 72, label: "72" },
|
||||
{ value: 150, label: "150" },
|
||||
{ value: 300, label: "300" },
|
||||
{ value: 600, label: "600" },
|
||||
];
|
||||
|
||||
const DPI_LABELS: Record<number, string> = {
|
||||
72: "Screen",
|
||||
150: "Standard",
|
||||
300: "Print",
|
||||
600: "High Quality",
|
||||
};
|
||||
|
||||
const COLOR_MODE_OPTIONS = [
|
||||
{ value: "color", label: "Color" },
|
||||
{ value: "grayscale", label: "Grayscale" },
|
||||
{ value: "bw", label: "B&W" },
|
||||
] as const;
|
||||
|
||||
const LOSSY_FORMATS = ["jpg", "webp", "avif", "heic", "heif"];
|
||||
|
||||
export function PdfToImageSettings() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [pageCount, setPageCount] = useState<number | null>(null);
|
||||
const [format, setFormat] = useState("png");
|
||||
const [dpi, setDpi] = useState(150);
|
||||
const [pages, setPages] = useState("");
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [loadingInfo, setLoadingInfo] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [downloadName, setDownloadName] = useState<string>("");
|
||||
const store = usePdfToImageStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchPageCount = useCallback(async (pdfFile: File) => {
|
||||
setLoadingInfo(true);
|
||||
setError(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", pdfFile);
|
||||
const res = await fetch("/api/v1/tools/pdf-to-image/info", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Failed: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setPageCount(data.pageCount);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to read PDF");
|
||||
setFile(null);
|
||||
setPageCount(null);
|
||||
} finally {
|
||||
setLoadingInfo(false);
|
||||
}
|
||||
}, []);
|
||||
const isLossy = LOSSY_FORMATS.includes(store.format);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(files: FileList | null) => {
|
||||
const pdfFile = files?.[0];
|
||||
if (!pdfFile) return;
|
||||
setFile(pdfFile);
|
||||
setPageCount(null);
|
||||
setDownloadUrl(null);
|
||||
setError(null);
|
||||
fetchPageCount(pdfFile);
|
||||
store.setFile(pdfFile);
|
||||
store.loadPreview(pdfFile);
|
||||
},
|
||||
[fetchPageCount],
|
||||
[store],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
@@ -78,82 +60,16 @@ export function PdfToImageSettings() {
|
||||
);
|
||||
|
||||
const handleRemoveFile = useCallback(() => {
|
||||
setFile(null);
|
||||
setPageCount(null);
|
||||
setDownloadUrl(null);
|
||||
setError(null);
|
||||
store.setFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}, []);
|
||||
}, [store]);
|
||||
|
||||
const getSelectedPageCount = (): number => {
|
||||
if (!pageCount) return 0;
|
||||
const trimmed = pages.trim();
|
||||
if (trimmed === "" || trimmed.toLowerCase() === "all") return pageCount;
|
||||
try {
|
||||
const nums = new Set<number>();
|
||||
for (const seg of trimmed.split(",")) {
|
||||
const s = seg.trim();
|
||||
if (s.includes("-")) {
|
||||
const [a, b] = s.split("-").map((x) => Number(x.trim()));
|
||||
for (let i = a; i <= b; i++) nums.add(i);
|
||||
} else {
|
||||
nums.add(Number(s));
|
||||
}
|
||||
}
|
||||
return nums.size;
|
||||
} catch {
|
||||
return pageCount;
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (!file) return;
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
setDownloadUrl(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("settings", JSON.stringify({ format, dpi, pages: pages || "all" }));
|
||||
|
||||
const res = await fetch("/api/v1/tools/pdf-to-image", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Conversion failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const selectedCount = getSelectedPageCount();
|
||||
|
||||
if (selectedCount === 1) {
|
||||
const data = await res.json();
|
||||
setDownloadUrl(data.downloadUrl);
|
||||
setDownloadName(`page.${format === "jpg" ? "jpg" : format}`);
|
||||
} else {
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
setDownloadUrl(url);
|
||||
setDownloadName("pdf-pages.zip");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Conversion failed");
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedCount = getSelectedPageCount();
|
||||
const isMultiPage = selectedCount > 1;
|
||||
const selectedCount = store.selectedPages.size;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* PDF upload area */}
|
||||
{!file ? (
|
||||
{!store.file ? (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -166,7 +82,7 @@ export function PdfToImageSettings() {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
className="border-2 border-dashed border-border rounded-lg p-8 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||
className="border-2 border-dashed border-border rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<FileUp className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Drop a PDF here or click to select</p>
|
||||
@@ -181,15 +97,15 @@ export function PdfToImageSettings() {
|
||||
) : (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{file.name}</p>
|
||||
<p className="text-sm font-medium truncate">{store.file.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{loadingInfo ? (
|
||||
{store.loadingPreview ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Reading PDF...
|
||||
</span>
|
||||
) : pageCount !== null ? (
|
||||
`${pageCount} page${pageCount !== 1 ? "s" : ""}`
|
||||
) : store.pageCount !== null ? (
|
||||
`${store.pageCount} page${store.pageCount !== 1 ? "s" : ""}`
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
@@ -203,42 +119,114 @@ export function PdfToImageSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Format dropdown */}
|
||||
{/* Output Format - grid buttons */}
|
||||
<div>
|
||||
<label htmlFor="pdf-format" className="text-xs text-muted-foreground">
|
||||
Output Format
|
||||
</label>
|
||||
<select
|
||||
id="pdf-format"
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Output Format</p>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{FORMAT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => store.setFormat(opt.value)}
|
||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||
store.format === opt.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</option>
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DPI dropdown */}
|
||||
{/* Quality slider (lossy formats only) */}
|
||||
{isLossy && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-xs text-muted-foreground">Quality</p>
|
||||
<span className="text-xs font-mono text-foreground">{store.quality}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
value={store.quality}
|
||||
onChange={(e) => store.setQuality(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DPI presets + custom */}
|
||||
<div>
|
||||
<label htmlFor="pdf-dpi" className="text-xs text-muted-foreground">
|
||||
Resolution (DPI)
|
||||
</label>
|
||||
<select
|
||||
id="pdf-dpi"
|
||||
value={dpi}
|
||||
onChange={(e) => setDpi(Number(e.target.value))}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
{DPI_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Resolution (DPI)</p>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{DPI_PRESETS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
store.setDpi(opt.value);
|
||||
store.setCustomDpi(false);
|
||||
}}
|
||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||
store.dpi === opt.value && !store.customDpi
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</option>
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => store.setCustomDpi(true)}
|
||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||
store.customDpi
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
{store.customDpi ? (
|
||||
<input
|
||||
type="number"
|
||||
min={36}
|
||||
max={1200}
|
||||
value={store.dpi}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (v >= 36 && v <= 1200) store.setDpi(v);
|
||||
}}
|
||||
className="w-full mt-1.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">{DPI_LABELS[store.dpi] ?? ""}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color Mode */}
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Color Mode</p>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{COLOR_MODE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => store.setColorMode(opt.value)}
|
||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||
store.colorMode === opt.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page range input */}
|
||||
@@ -249,45 +237,52 @@ export function PdfToImageSettings() {
|
||||
<input
|
||||
id="pdf-pages"
|
||||
type="text"
|
||||
value={pages}
|
||||
onChange={(e) => setPages(e.target.value)}
|
||||
value={store.pages}
|
||||
onChange={(e) => store.setPages(e.target.value)}
|
||||
placeholder="All pages"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
{pageCount !== null && (
|
||||
{store.pageCount !== null && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
e.g. 1-3, 5, 8-10 (document has {pageCount} pages)
|
||||
e.g. 1-3, 5, 8-10 (document has {store.pageCount} pages)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
{store.error && <p className="text-xs text-red-500">{store.error}</p>}
|
||||
|
||||
{/* Convert button */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pdf-to-image-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!file || !pageCount || processing}
|
||||
onClick={() => store.convert()}
|
||||
disabled={!store.file || !store.pageCount || store.processing || selectedCount === 0}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing
|
||||
{store.processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{store.processing
|
||||
? "Converting..."
|
||||
: `Convert${pageCount ? ` ${selectedCount} page${selectedCount !== 1 ? "s" : ""}` : ""}`}
|
||||
: `Convert ${selectedCount} page${selectedCount !== 1 ? "s" : ""}`}
|
||||
</button>
|
||||
|
||||
{/* Download link */}
|
||||
{downloadUrl && (
|
||||
{/* Download ZIP */}
|
||||
{store.zipUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download={downloadName}
|
||||
href={store.zipUrl}
|
||||
download="pdf-pages.zip"
|
||||
data-testid="pdf-to-image-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{isMultiPage ? `Download ZIP (${selectedCount} pages)` : "Download Image"}
|
||||
Download All (ZIP)
|
||||
{store.zipSize != null && (
|
||||
<span className="text-xs opacity-70">
|
||||
{store.zipSize < 1024 * 1024
|
||||
? `${(store.zipSize / 1024).toFixed(0)} KB`
|
||||
: `${(store.zipSize / (1024 * 1024)).toFixed(1)} MB`}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -187,6 +187,11 @@ const PdfToImageSettings = lazy(() =>
|
||||
default: m.PdfToImageSettings,
|
||||
})),
|
||||
);
|
||||
const PdfToImagePreview = lazy(() =>
|
||||
import("@/components/tools/pdf-to-image-preview").then((m) => ({
|
||||
default: m.PdfToImagePreview,
|
||||
})),
|
||||
);
|
||||
const ReplaceColorSettings = lazy(() =>
|
||||
import("@/components/tools/replace-color-settings").then((m) => ({
|
||||
default: m.ReplaceColorSettings,
|
||||
@@ -304,7 +309,10 @@ export const toolRegistry = new Map<string, ToolRegistryEntry>([
|
||||
["bulk-rename", { displayMode: "before-after", Settings: BulkRenameSettings }],
|
||||
["favicon", { displayMode: "before-after", Settings: FaviconSettings }],
|
||||
["image-to-pdf", { displayMode: "before-after", Settings: ImageToPdfSettings }],
|
||||
["pdf-to-image", { displayMode: "no-dropzone", Settings: PdfToImageSettings }],
|
||||
[
|
||||
"pdf-to-image",
|
||||
{ displayMode: "no-dropzone", Settings: PdfToImageSettings, ResultsPanel: PdfToImagePreview },
|
||||
],
|
||||
|
||||
// Adjustments extra
|
||||
["replace-color", { displayMode: "before-after", Settings: ReplaceColorSettings }],
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { create } from "zustand";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
|
||||
interface PageResult {
|
||||
page: number;
|
||||
downloadUrl: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface Thumbnail {
|
||||
page: number;
|
||||
dataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type ColorMode = "color" | "grayscale" | "bw";
|
||||
|
||||
interface PdfToImageState {
|
||||
file: File | null;
|
||||
pageCount: number | null;
|
||||
thumbnails: Thumbnail[];
|
||||
format: string;
|
||||
dpi: number;
|
||||
customDpi: boolean;
|
||||
quality: number;
|
||||
colorMode: ColorMode;
|
||||
pages: string;
|
||||
selectedPages: Set<number>;
|
||||
processing: boolean;
|
||||
loadingPreview: boolean;
|
||||
error: string | null;
|
||||
results: PageResult[] | null;
|
||||
zipUrl: string | null;
|
||||
zipSize: number | null;
|
||||
setFormat: (format: string) => void;
|
||||
setDpi: (dpi: number) => void;
|
||||
setCustomDpi: (custom: boolean) => void;
|
||||
setQuality: (quality: number) => void;
|
||||
setColorMode: (mode: ColorMode) => void;
|
||||
setPages: (pages: string) => void;
|
||||
setFile: (file: File | null) => void;
|
||||
togglePage: (page: number) => void;
|
||||
selectAllPages: () => void;
|
||||
deselectAllPages: () => void;
|
||||
loadPreview: (file: File) => Promise<void>;
|
||||
convert: () => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress a set of page numbers into a compact range string.
|
||||
* e.g. {1,2,3,5,7,8,9} -> "1-3, 5, 7-9"
|
||||
*/
|
||||
function compressPageRange(pages: Set<number>, totalPages: number): string {
|
||||
if (pages.size === 0 || pages.size === totalPages) return "";
|
||||
const sorted = [...pages].sort((a, b) => a - b);
|
||||
const ranges: string[] = [];
|
||||
let start = sorted[0];
|
||||
let end = sorted[0];
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (sorted[i] === end + 1) {
|
||||
end = sorted[i];
|
||||
} else {
|
||||
ranges.push(start === end ? `${start}` : `${start}-${end}`);
|
||||
start = sorted[i];
|
||||
end = sorted[i];
|
||||
}
|
||||
}
|
||||
ranges.push(start === end ? `${start}` : `${start}-${end}`);
|
||||
return ranges.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a page range string into a Set of page numbers.
|
||||
* Returns null if the string is invalid.
|
||||
*/
|
||||
function parsePageRangeToSet(input: string, totalPages: number): Set<number> | null {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === "" || trimmed.toLowerCase() === "all") {
|
||||
return new Set(Array.from({ length: totalPages }, (_, i) => i + 1));
|
||||
}
|
||||
try {
|
||||
const pages = new Set<number>();
|
||||
for (const seg of trimmed.split(",")) {
|
||||
const s = seg.trim();
|
||||
if (s === "") continue;
|
||||
if (s.includes("-")) {
|
||||
const [a, b] = s.split("-").map((x) => Number(x.trim()));
|
||||
if (Number.isNaN(a) || Number.isNaN(b) || a < 1 || b < 1 || a > b) return null;
|
||||
for (let i = a; i <= Math.min(b, totalPages); i++) pages.add(i);
|
||||
} else {
|
||||
const n = Number(s);
|
||||
if (Number.isNaN(n) || n < 1 || n > totalPages) return null;
|
||||
pages.add(n);
|
||||
}
|
||||
}
|
||||
return pages.size > 0 ? pages : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
file: null as File | null,
|
||||
pageCount: null as number | null,
|
||||
thumbnails: [] as Thumbnail[],
|
||||
format: "png",
|
||||
dpi: 150,
|
||||
customDpi: false,
|
||||
quality: 85,
|
||||
colorMode: "color" as ColorMode,
|
||||
pages: "",
|
||||
selectedPages: new Set<number>(),
|
||||
processing: false,
|
||||
loadingPreview: false,
|
||||
error: null as string | null,
|
||||
results: null as PageResult[] | null,
|
||||
zipUrl: null as string | null,
|
||||
zipSize: null as number | null,
|
||||
};
|
||||
|
||||
export const usePdfToImageStore = create<PdfToImageState>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setFormat: (format) => set({ format }),
|
||||
setDpi: (dpi) => set({ dpi }),
|
||||
setCustomDpi: (customDpi) => set({ customDpi }),
|
||||
setQuality: (quality) => set({ quality }),
|
||||
setColorMode: (colorMode) => set({ colorMode }),
|
||||
|
||||
setPages: (pages) => {
|
||||
const { pageCount } = get();
|
||||
const parsed = pageCount ? parsePageRangeToSet(pages, pageCount) : null;
|
||||
set({
|
||||
pages,
|
||||
selectedPages: parsed ?? new Set(Array.from({ length: pageCount ?? 0 }, (_, i) => i + 1)),
|
||||
});
|
||||
},
|
||||
|
||||
setFile: (file) => {
|
||||
if (!file) {
|
||||
set({ ...initialState });
|
||||
return;
|
||||
}
|
||||
set({
|
||||
file,
|
||||
pageCount: null,
|
||||
thumbnails: [],
|
||||
results: null,
|
||||
zipUrl: null,
|
||||
zipSize: null,
|
||||
error: null,
|
||||
selectedPages: new Set<number>(),
|
||||
pages: "",
|
||||
});
|
||||
},
|
||||
|
||||
togglePage: (page) => {
|
||||
const { selectedPages, pageCount } = get();
|
||||
const next = new Set(selectedPages);
|
||||
if (next.has(page)) {
|
||||
next.delete(page);
|
||||
} else {
|
||||
next.add(page);
|
||||
}
|
||||
set({
|
||||
selectedPages: next,
|
||||
pages: compressPageRange(next, pageCount ?? 0),
|
||||
});
|
||||
},
|
||||
|
||||
selectAllPages: () => {
|
||||
const { pageCount } = get();
|
||||
if (!pageCount) return;
|
||||
set({
|
||||
selectedPages: new Set(Array.from({ length: pageCount }, (_, i) => i + 1)),
|
||||
pages: "",
|
||||
});
|
||||
},
|
||||
|
||||
deselectAllPages: () => {
|
||||
set({ selectedPages: new Set<number>(), pages: "none" });
|
||||
},
|
||||
|
||||
loadPreview: async (file) => {
|
||||
set({ loadingPreview: true, error: null });
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await fetch("/api/v1/tools/pdf-to-image/preview", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Failed: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
set({
|
||||
pageCount: data.pageCount,
|
||||
thumbnails: data.thumbnails,
|
||||
selectedPages: new Set(Array.from({ length: data.pageCount }, (_, i) => i + 1)),
|
||||
});
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : "Failed to read PDF",
|
||||
file: null,
|
||||
pageCount: null,
|
||||
thumbnails: [],
|
||||
});
|
||||
} finally {
|
||||
set({ loadingPreview: false });
|
||||
}
|
||||
},
|
||||
|
||||
convert: async () => {
|
||||
const { file, format, dpi, quality, colorMode, pages, selectedPages } = get();
|
||||
if (!file) return;
|
||||
set({ processing: true, error: null, results: null, zipUrl: null, zipSize: null });
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const pagesValue =
|
||||
pages.trim() === "" || pages.trim().toLowerCase() === "all"
|
||||
? "all"
|
||||
: compressPageRange(selectedPages, get().pageCount ?? 0) || "all";
|
||||
formData.append(
|
||||
"settings",
|
||||
JSON.stringify({ format, dpi, quality, colorMode, pages: pagesValue }),
|
||||
);
|
||||
const res = await fetch("/api/v1/tools/pdf-to-image", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Conversion failed: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
set({ results: data.pages, zipUrl: data.zipUrl, zipSize: data.zipSize });
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : "Conversion failed",
|
||||
});
|
||||
} finally {
|
||||
set({ processing: false });
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => set({ ...initialState }),
|
||||
}));
|
||||
Reference in New Issue
Block a user