EthanHealy01 30004b656b linter
2025-09-12 18:01:08 +01:00

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>
);
}