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:
Siddharth Kumar Sah
2026-03-26 16:01:00 +08:00
parent bf62820064
commit ddde152545
@@ -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