mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(image-to-pdf): add live PDF page preview with margin visualization
Add a miniature page preview in the settings panel that shows how the image will look in the PDF. The preview updates in real-time as the user changes page size, orientation, or margin. Uses the same scaling logic as the backend to accurately represent image placement. Preview tracks the currently selected image when multiple files are uploaded.
This commit is contained in:
@@ -1,13 +1,120 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const PAGE_SIZES: Record<string, [number, number]> = {
|
||||
A4: [595.28, 841.89],
|
||||
Letter: [612, 792],
|
||||
A3: [841.89, 1190.55],
|
||||
A5: [419.53, 595.28],
|
||||
};
|
||||
|
||||
const PREVIEW_HEIGHT = 220;
|
||||
|
||||
function PdfPagePreview({
|
||||
pageSize,
|
||||
orientation,
|
||||
margin,
|
||||
file,
|
||||
}: {
|
||||
pageSize: string;
|
||||
orientation: "portrait" | "landscape";
|
||||
margin: number;
|
||||
file: File | null;
|
||||
}) {
|
||||
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null);
|
||||
const imgUrl = useMemo(() => (file ? URL.createObjectURL(file) : null), [file]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imgUrl) URL.revokeObjectURL(imgUrl);
|
||||
};
|
||||
}, [imgUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imgUrl) {
|
||||
setImgSize(null);
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.onload = () => setImgSize({ w: img.naturalWidth, h: img.naturalHeight });
|
||||
img.src = imgUrl;
|
||||
}, [imgUrl]);
|
||||
|
||||
let [pageW, pageH] = PAGE_SIZES[pageSize] ?? PAGE_SIZES.A4;
|
||||
if (orientation === "landscape") [pageW, pageH] = [pageH, pageW];
|
||||
|
||||
const scale = PREVIEW_HEIGHT / pageH;
|
||||
const previewW = pageW * scale;
|
||||
const previewH = PREVIEW_HEIGHT;
|
||||
const marginScaled = margin * scale;
|
||||
|
||||
const contentW = previewW - marginScaled * 2;
|
||||
const contentH = previewH - marginScaled * 2;
|
||||
|
||||
let imgStyle: React.CSSProperties | null = null;
|
||||
if (imgSize && imgUrl && contentW > 0 && contentH > 0) {
|
||||
const imgScale = Math.min(contentW / imgSize.w, contentH / imgSize.h, 1);
|
||||
const scaledW = imgSize.w * imgScale;
|
||||
const scaledH = imgSize.h * imgScale;
|
||||
imgStyle = {
|
||||
width: scaledW,
|
||||
height: scaledH,
|
||||
position: "absolute" as const,
|
||||
left: marginScaled + (contentW - scaledW) / 2,
|
||||
top: marginScaled + (contentH - scaledH) / 2,
|
||||
objectFit: "contain" as const,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-2">Preview</p>
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="relative bg-white border border-border"
|
||||
style={{
|
||||
width: previewW,
|
||||
height: previewH,
|
||||
boxShadow: "2px 3px 8px rgba(0,0,0,0.10)",
|
||||
transition: "width 0.2s ease, height 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{/* Margin area — dashed inner border */}
|
||||
{margin > 0 && (
|
||||
<div
|
||||
className="absolute border border-dashed border-blue-300/60"
|
||||
style={{
|
||||
left: marginScaled,
|
||||
top: marginScaled,
|
||||
width: contentW,
|
||||
height: contentH,
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Image thumbnail */}
|
||||
{imgStyle && imgUrl && (
|
||||
<img src={imgUrl} alt="Preview" style={{ ...imgStyle, transition: "all 0.2s ease" }} />
|
||||
)}
|
||||
{/* Empty state placeholder */}
|
||||
{!imgUrl && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-[10px] text-muted-foreground/40">No image</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function ImageToPdfSettings() {
|
||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||
const { files, selectedIndex, processing, error, setProcessing, setError } = useFileStore();
|
||||
const [pageSize, setPageSize] = useState<"A4" | "Letter" | "A3" | "A5">("A4");
|
||||
const [orientation, setOrientation] = useState<"portrait" | "landscape">("portrait");
|
||||
const [margin, setMargin] = useState(20);
|
||||
@@ -111,6 +218,13 @@ export function ImageToPdfSettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PdfPagePreview
|
||||
pageSize={pageSize}
|
||||
orientation={orientation}
|
||||
margin={margin}
|
||||
file={files.length > 0 ? (files[selectedIndex] ?? files[0]) : null}
|
||||
/>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user