Files
craft2prints/src/components/PhotoPrintAreaField.tsx
T

150 lines
5.6 KiB
TypeScript

'use client';
import { useRef, useState } from 'react';
type Box = { xPct: number; yPct: number; wPct: number; hPct: number }; // 0-100
const DEFAULT_BOX: Box = { xPct: 30, yPct: 25, wPct: 40, hPct: 35 };
const MIN_SIZE = 6; // percent
export default function PhotoPrintAreaField({ variant = 'front' }: { variant?: 'front' | 'back' }) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [box, setBox] = useState<Box>(DEFAULT_BOX);
const containerRef = useRef<HTMLDivElement>(null);
const dragState = useRef<{
mode: 'move' | 'resize';
startX: number;
startY: number;
startBox: Box;
} | null>(null);
const fieldName = variant === 'front' ? 'image' : 'imageBack';
const suffix = variant === 'front' ? '' : 'Back';
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
setPreviewUrl(null);
return;
}
setPreviewUrl(URL.createObjectURL(file));
setBox(DEFAULT_BOX);
};
const onBoxMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
dragState.current = { mode: 'move', startX: e.clientX, startY: e.clientY, startBox: box };
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
const onResizeMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
dragState.current = { mode: 'resize', startX: e.clientX, startY: e.clientY, startBox: box };
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
const state = dragState.current;
const rect = containerRef.current?.getBoundingClientRect();
if (!state || !rect) return;
const dxPct = ((e.clientX - state.startX) / rect.width) * 100;
const dyPct = ((e.clientY - state.startY) / rect.height) * 100;
if (state.mode === 'move') {
const xPct = clamp(state.startBox.xPct + dxPct, 0, 100 - state.startBox.wPct);
const yPct = clamp(state.startBox.yPct + dyPct, 0, 100 - state.startBox.hPct);
setBox({ ...state.startBox, xPct, yPct });
} else {
const wPct = clamp(state.startBox.wPct + dxPct, MIN_SIZE, 100 - state.startBox.xPct);
const hPct = clamp(state.startBox.hPct + dyPct, MIN_SIZE, 100 - state.startBox.yPct);
setBox({ ...state.startBox, wPct, hPct });
}
};
const onMouseUp = () => {
dragState.current = null;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
return (
<div>
<input
id={fieldName}
name={fieldName}
type="file"
accept="image/*"
onChange={handleFileChange}
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
{variant === 'front' ? (
<p className="mt-1 text-xs text-muted">
Uploading a real photo replaces the illustration everywhere this product is shown. One
tradeoff: the color swatches below can no longer recolor a photo the way they recolor the
illustration they&apos;ll still be saved with each order, but the picture itself stays
fixed. Leave this blank to keep using the illustration (which does recolor live).
</p>
) : (
<p className="mt-1 text-xs text-muted">
If set, customers get a Front/Back toggle and can add a separate design to the back
drag a print-area box below just like the front.
</p>
)}
{previewUrl && (
<div className="mt-4">
<p className="tag-label mb-2">Drag the box to mark the print area</p>
<p className="mb-2 text-xs text-muted">
Cropped to a square here that&apos;s the same crop the design tool uses, so the box
lines up correctly.
</p>
<div
ref={containerRef}
className="relative aspect-square w-full max-w-sm select-none border border-line"
style={{ touchAction: 'none' }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt="Product preview"
className="absolute inset-0 h-full w-full object-cover"
draggable={false}
/>
<div
onMouseDown={onBoxMouseDown}
className="absolute cursor-move border-2 border-dashed bg-splash-pink/10"
style={{
left: `${box.xPct}%`,
top: `${box.yPct}%`,
width: `${box.wPct}%`,
height: `${box.hPct}%`,
borderColor: '#E5227E',
}}
>
<div
onMouseDown={onResizeMouseDown}
className="absolute -bottom-1.5 -right-1.5 h-4 w-4 cursor-se-resize rounded-full border border-paper bg-splash-pink"
/>
</div>
</div>
<p className="mt-1 text-xs text-muted">Drag the pink handle to resize.</p>
</div>
)}
<input type="hidden" name={`hasCustomPrintArea${suffix}`} value={previewUrl ? '1' : ''} />
<input type="hidden" name={`printX${suffix}`} value={box.xPct / 100} />
<input type="hidden" name={`printY${suffix}`} value={box.yPct / 100} />
<input type="hidden" name={`printW${suffix}`} value={box.wPct / 100} />
<input type="hidden" name={`printH${suffix}`} value={box.hPct / 100} />
</div>
);
}
function clamp(n: number, min: number, max: number) {
return Math.min(Math.max(n, min), Math.max(min, max));
}