150 lines
5.6 KiB
TypeScript
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'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'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));
|
|
}
|