mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-18 01:19:24 +00:00
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { AddStampParameters } from './useAddStampParameters';
|
|
import { pdfWorkerManager } from '../../../services/pdfWorkerManager';
|
|
import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration';
|
|
import { A4_ASPECT_RATIO, getFirstSelectedPage, getFontFamily, computeStampPreviewStyle } from './StampPreviewUtils';
|
|
import FitText from '../../shared/FitText';
|
|
import styles from './StampPreview.module.css';
|
|
|
|
type Props = {
|
|
parameters: AddStampParameters;
|
|
onParameterChange: <K extends keyof AddStampParameters>(key: K, value: AddStampParameters[K]) => void;
|
|
file?: File | null;
|
|
showQuickGrid?: boolean;
|
|
};
|
|
|
|
export default function StampPreview({ parameters, onParameterChange, file, showQuickGrid }: Props) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [containerSize, setContainerSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
|
|
const [imageMeta, setImageMeta] = useState<{ url: string; width: number; height: number } | null>(null);
|
|
const [pageSize, setPageSize] = useState<{ widthPts: number; heightPts: number } | null>(null);
|
|
const [pageThumbnail, setPageThumbnail] = useState<string | null>(null);
|
|
const { requestThumbnail } = useThumbnailGeneration();
|
|
const [hoverTile, setHoverTile] = useState<number | null>(null);
|
|
|
|
// Load image URL and meta for aspect ratio if an image is selected
|
|
useEffect(() => {
|
|
if (parameters.stampType === 'image' && parameters.stampImage) {
|
|
const url = URL.createObjectURL(parameters.stampImage);
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
setImageMeta({ url, width: img.width, height: img.height });
|
|
};
|
|
img.src = url;
|
|
return () => URL.revokeObjectURL(url);
|
|
} else {
|
|
setImageMeta(null);
|
|
}
|
|
}, [parameters.stampType, parameters.stampImage]);
|
|
|
|
// Observe container size for responsive positioning
|
|
useEffect(() => {
|
|
const node = containerRef.current;
|
|
if (!node) return;
|
|
const resize = () => {
|
|
const aspect = pageSize ? (pageSize.widthPts / pageSize.heightPts) : A4_ASPECT_RATIO;
|
|
setContainerSize({ width: node.clientWidth, height: node.clientWidth / aspect });
|
|
};
|
|
resize();
|
|
const ro = new ResizeObserver(resize);
|
|
ro.observe(node);
|
|
return () => ro.disconnect();
|
|
}, [pageSize]);
|
|
|
|
// Load first PDF page size in points for accurate scaling
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const load = async () => {
|
|
if (!file || file.type !== 'application/pdf') {
|
|
setPageSize(null);
|
|
return;
|
|
}
|
|
try {
|
|
const buffer = await file.arrayBuffer();
|
|
const pdf = await pdfWorkerManager.createDocument(buffer, { disableAutoFetch: true, disableStream: true });
|
|
const page = await pdf.getPage(1);
|
|
const viewport = page.getViewport({ scale: 1 });
|
|
if (!cancelled) {
|
|
setPageSize({ widthPts: viewport.width, heightPts: viewport.height });
|
|
}
|
|
pdfWorkerManager.destroyDocument(pdf);
|
|
} catch {
|
|
// Fallback to A4 if we cannot read page
|
|
if (!cancelled) setPageSize(null);
|
|
}
|
|
};
|
|
load();
|
|
return () => { cancelled = true; };
|
|
}, [file]);
|
|
|
|
// Load first-page thumbnail for background preview so users see the content
|
|
useEffect(() => {
|
|
let isActive = true;
|
|
const loadThumb = async () => {
|
|
if (!file || file.type !== 'application/pdf') {
|
|
setPageThumbnail(null);
|
|
return;
|
|
}
|
|
try {
|
|
const pageNumber = Math.max(1, getFirstSelectedPage(parameters.pageNumbers));
|
|
const pageId = `${file.name}:page:${pageNumber}`;
|
|
const thumb = await requestThumbnail(pageId, file, pageNumber);
|
|
if (isActive) setPageThumbnail(thumb || null);
|
|
} catch {
|
|
if (isActive) setPageThumbnail(null);
|
|
}
|
|
};
|
|
loadThumb();
|
|
return () => { isActive = false; };
|
|
}, [file, parameters.pageNumbers, requestThumbnail]);
|
|
|
|
const style = useMemo(() => (
|
|
computeStampPreviewStyle(
|
|
parameters,
|
|
imageMeta,
|
|
pageSize,
|
|
containerSize,
|
|
showQuickGrid,
|
|
hoverTile,
|
|
!!pageThumbnail
|
|
)
|
|
), [containerSize, parameters, imageMeta, pageSize, showQuickGrid, hoverTile, pageThumbnail]);
|
|
|
|
// Drag/resize/rotate interactions
|
|
const draggingRef = useRef<{ type: 'move' | 'resize' | 'rotate'; startX: number; startY: number; initLeft: number; initBottom: number; initHeight: number; centerX: number; centerY: number } | null>(null);
|
|
|
|
const ensureOverrides = () => {
|
|
const pageWidth = containerSize.width;
|
|
const pageHeight = containerSize.height;
|
|
if (pageWidth <= 0 || pageHeight <= 0) return;
|
|
|
|
// Recompute current x,y from style (so that we start from visual position)
|
|
const itemStyle = style.item as any;
|
|
const leftPx = parseFloat(String(itemStyle.left).replace('px', '')) || 0;
|
|
const bottomPx = parseFloat(String(itemStyle.bottom).replace('px', '')) || 0;
|
|
const widthPts = pageSize?.widthPts ?? 595.28;
|
|
const heightPts = pageSize?.heightPts ?? 841.89;
|
|
const scaleX = containerSize.width / widthPts;
|
|
const scaleY = containerSize.height / heightPts;
|
|
if (parameters.overrideX < 0 || parameters.overrideY < 0) {
|
|
onParameterChange('overrideX', Math.max(0, Math.min(pageWidth, leftPx)) / scaleX as any);
|
|
onParameterChange('overrideY', Math.max(0, Math.min(pageHeight, bottomPx)) / scaleY as any);
|
|
}
|
|
};
|
|
|
|
const handlePointerDown = (e: React.PointerEvent, type: 'move' | 'resize' | 'rotate') => {
|
|
e.preventDefault();
|
|
ensureOverrides();
|
|
|
|
const item = style.item as any;
|
|
const left = parseFloat(String(item.left).replace('px', '')) || 0;
|
|
const bottom = parseFloat(String(item.bottom).replace('px', '')) || 0;
|
|
const width = parseFloat(String(item.width).replace('px', '')) || parameters.fontSize;
|
|
const height = parseFloat(String(item.height).replace('px', '')) || parameters.fontSize;
|
|
|
|
const rect = (e.currentTarget.parentElement as HTMLElement)?.getBoundingClientRect();
|
|
const centerX = left + width / 2;
|
|
const centerY = bottom + height / 2;
|
|
|
|
draggingRef.current = {
|
|
type,
|
|
startX: e.clientX - (rect?.left || 0),
|
|
startY: (rect ? rect.bottom - e.clientY : 0), // convert to bottom-based coords
|
|
initLeft: left,
|
|
initBottom: bottom,
|
|
initHeight: height,
|
|
centerX,
|
|
centerY,
|
|
};
|
|
|
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
};
|
|
|
|
const handlePointerMove = (e: React.PointerEvent) => {
|
|
if (!draggingRef.current) return;
|
|
const node = containerRef.current;
|
|
if (!node) return;
|
|
const rect = node.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = rect.bottom - e.clientY; // bottom-based
|
|
|
|
const drag = draggingRef.current;
|
|
|
|
if (drag.type === 'move') {
|
|
const dx = x - drag.startX;
|
|
const dy = y - drag.startY;
|
|
const newLeftPx = Math.max(0, Math.min(containerSize.width, drag.initLeft + dx));
|
|
const newBottomPx = Math.max(0, Math.min(containerSize.height, drag.initBottom + dy));
|
|
const widthPts = pageSize?.widthPts ?? 595.28;
|
|
const heightPts = pageSize?.heightPts ?? 841.89;
|
|
const scaleX = containerSize.width / widthPts;
|
|
const scaleY = containerSize.height / heightPts;
|
|
const newLeftPts = newLeftPx / scaleX;
|
|
const newBottomPts = newBottomPx / scaleY;
|
|
onParameterChange('overrideX', newLeftPts as any);
|
|
onParameterChange('overrideY', newBottomPts as any);
|
|
}
|
|
|
|
if (drag.type === 'resize') {
|
|
// Height is our canonical size (fontSize)
|
|
const heightPts = pageSize?.heightPts ?? 841.89;
|
|
const scaleY = containerSize.height / heightPts;
|
|
const newHeightPx = Math.max(1, drag.initHeight + (y - drag.startY));
|
|
const newHeightPts = newHeightPx / scaleY;
|
|
onParameterChange('fontSize', newHeightPts as any);
|
|
}
|
|
|
|
if (drag.type === 'rotate') {
|
|
const angle = Math.atan2(y - drag.centerY, x - drag.centerX) * (180 / Math.PI);
|
|
onParameterChange('rotation', angle as any);
|
|
}
|
|
};
|
|
|
|
const handlePointerUp = (e: React.PointerEvent) => {
|
|
if (!draggingRef.current) return;
|
|
draggingRef.current = null;
|
|
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
|
};
|
|
|
|
const itemHandles = null; // Drag-only per request
|
|
|
|
return (
|
|
<div>
|
|
<div className={styles.previewHeader}>
|
|
<div className={styles.divider} />
|
|
<div className={styles.previewLabel}>Preview Stamp</div>
|
|
</div>
|
|
<div
|
|
ref={containerRef}
|
|
className={`${styles.container} ${styles.containerBorder} ${pageThumbnail ? styles.containerWithThumbnail : styles.containerWithoutThumbnail}`}
|
|
style={style.container as React.CSSProperties}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
>
|
|
{pageThumbnail && (
|
|
<img
|
|
src={pageThumbnail}
|
|
alt="page preview"
|
|
className={styles.pageThumbnail}
|
|
draggable={false}
|
|
/>
|
|
)}
|
|
{parameters.stampType === 'text' && (
|
|
<div
|
|
className={`${styles.stampItem} ${styles.stampItemGridMode}`}
|
|
style={style.item as React.CSSProperties}
|
|
>
|
|
{(parameters.stampText || '').split('\n').map((line, idx) => (
|
|
<FitText
|
|
key={idx}
|
|
text={line || '\u00A0'}
|
|
lines={1}
|
|
minimumFontScale={0.5}
|
|
fontSize={parameters.fontSize}
|
|
className={styles.textLine}
|
|
style={{
|
|
fontFamily: getFontFamily(parameters.alphabet),
|
|
}}
|
|
as="span"
|
|
/>
|
|
))}
|
|
{itemHandles}
|
|
</div>
|
|
)}
|
|
{parameters.stampType === 'image' && imageMeta && (
|
|
<div
|
|
className={`${styles.stampItem} ${showQuickGrid ? styles.stampItemGridMode : styles.stampItemDraggable}`}
|
|
style={style.item as React.CSSProperties}
|
|
onPointerDown={(e) => handlePointerDown(e, 'move')}
|
|
>
|
|
<img
|
|
src={imageMeta.url}
|
|
alt="stamp preview"
|
|
className={styles.stampImage}
|
|
/>
|
|
{itemHandles}
|
|
</div>
|
|
)}
|
|
|
|
{/* Quick position overlay grid */}
|
|
{showQuickGrid && (
|
|
<div className={styles.quickGrid}>
|
|
{Array.from({ length: 9 }).map((_, i) => {
|
|
const idx = (i + 1) as 1|2|3|4|5|6|7|8|9;
|
|
const selected = parameters.position === idx && (parameters.overrideX < 0 || parameters.overrideY < 0);
|
|
return (
|
|
<button
|
|
key={idx}
|
|
type="button"
|
|
className={`${styles.gridTile} ${selected || hoverTile === idx ? styles.gridTileSelected : ''} ${hoverTile === idx ? styles.gridTileHovered : ''}`}
|
|
onClick={() => {
|
|
// Clear overrides to use grid positioning and set position
|
|
onParameterChange('overrideX', -1 as any);
|
|
onParameterChange('overrideY', -1 as any);
|
|
onParameterChange('position', idx as any);
|
|
}}
|
|
onMouseEnter={() => setHoverTile(idx)}
|
|
onMouseLeave={() => setHoverTile(null)}
|
|
>
|
|
{idx}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className={styles.previewDisclaimer}>
|
|
Preview is approximate. Final output may vary due to PDF font metrics.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|