fix(passport-photo): fix dropdown overlap, add common bg colors, fix zoom behavior

- Country dropdown now uses position:fixed with z-index:9999 to render above all siblings
- Added 5 common passport background color presets (white, off-white, light gray, light blue, red)
- Zoom now crops into the image center rather than scaling the canvas element
- Compliance overlay lines are zoom-aware
This commit is contained in:
stirling-image
2026-04-14 13:04:35 +08:00
parent 831ef3f2de
commit e17d5d08cb
@@ -182,6 +182,16 @@ const FILE_SIZE_PRESETS = [
{ label: "500 KB", value: 500 }, { label: "500 KB", value: 500 },
]; ];
// ── Common background colors for passport photos ──────────────────
const COMMON_BG_COLORS = [
{ color: "#FFFFFF", label: "White" },
{ color: "#F0F0F0", label: "Off-white" },
{ color: "#D4D4D4", label: "Light gray (UK/DE)" },
{ color: "#BFDBFE", label: "Light blue (FR)" },
{ color: "#EF4444", label: "Red (ID)" },
];
// ── Canvas helpers ───────────────────────────────────────────────── // ── Canvas helpers ─────────────────────────────────────────────────
function computeCropRegion( function computeCropRegion(
@@ -302,6 +312,12 @@ export function PassportPhotoSettings() {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; width: number }>({
top: 0,
left: 0,
width: 0,
});
// Custom file size input // Custom file size input
const [customSizeInput, setCustomSizeInput] = useState(""); const [customSizeInput, setCustomSizeInput] = useState("");
@@ -462,10 +478,17 @@ export function PassportPhotoSettings() {
<div className="space-y-4"> <div className="space-y-4">
{/* Country selector */} {/* Country selector */}
<SectionLabel>Country</SectionLabel> <SectionLabel>Country</SectionLabel>
<div ref={dropdownRef} className="relative" style={{ zIndex: 40 }}> <div ref={dropdownRef} className="relative">
<button <button
ref={buttonRef}
type="button" type="button"
onClick={() => setDropdownOpen(!dropdownOpen)} onClick={() => {
if (!dropdownOpen && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
}
setDropdownOpen(!dropdownOpen);
}}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground hover:border-primary/50 transition-colors" className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground hover:border-primary/50 transition-colors"
> >
<span>{selectedSpec.flag}</span> <span>{selectedSpec.flag}</span>
@@ -482,10 +505,13 @@ export function PassportPhotoSettings() {
{dropdownOpen && ( {dropdownOpen && (
<div <div
className="absolute mt-1 w-full max-h-64 overflow-auto rounded-lg border border-border shadow-lg" className="fixed max-h-64 overflow-auto rounded-lg border border-border shadow-xl"
style={{ style={{
zIndex: 50, zIndex: 9999,
backgroundColor: "var(--popover)", backgroundColor: "var(--popover)",
top: dropdownPos.top,
left: dropdownPos.left,
width: dropdownPos.width,
}} }}
> >
{/* Search input */} {/* Search input */}
@@ -582,7 +608,7 @@ export function PassportPhotoSettings() {
<SectionLabel>Background Color</SectionLabel> <SectionLabel>Background Color</SectionLabel>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex gap-1.5 flex-wrap"> <div className="flex gap-1.5 flex-wrap">
{docSpec.bgColors.map((color) => ( {COMMON_BG_COLORS.map(({ color, label }) => (
<button <button
key={color} key={color}
type="button" type="button"
@@ -591,7 +617,7 @@ export function PassportPhotoSettings() {
bgColor === color ? "border-primary scale-110" : "border-border" bgColor === color ? "border-primary scale-110" : "border-border"
}`} }`}
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
title={color} title={label}
/> />
))} ))}
</div> </div>
@@ -777,10 +803,7 @@ export function PassportPhotoPreview() {
canvasDisplayHeight = Math.round(canvasDisplayWidth / aspectRatio); canvasDisplayHeight = Math.round(canvasDisplayWidth / aspectRatio);
} }
// Apply zoom // Canvas stays the same size; zoom crops into a sub-region of the passport photo
canvasDisplayWidth = Math.round(canvasDisplayWidth * zoom);
canvasDisplayHeight = Math.round(canvasDisplayHeight * zoom);
canvas.width = canvasDisplayWidth; canvas.width = canvasDisplayWidth;
canvas.height = canvasDisplayHeight; canvas.height = canvasDisplayHeight;
@@ -794,10 +817,17 @@ export function PassportPhotoPreview() {
// Map crop region from original image coords to preview image coords // Map crop region from original image coords to preview image coords
const scaleX = img.naturalWidth / imageWidth; const scaleX = img.naturalWidth / imageWidth;
const scaleY = img.naturalHeight / imageHeight; const scaleY = img.naturalHeight / imageHeight;
const srcX = crop.leftX * scaleX;
const srcY = crop.topY * scaleY; // When zoom > 1, show a sub-region centered on the face
const srcW = crop.photoWidthPx * scaleX; const zoomedW = crop.photoWidthPx / zoom;
const srcH = crop.photoHeightPx * scaleY; const zoomedH = crop.photoHeightPx / zoom;
const zoomedLeft = crop.leftX + (crop.photoWidthPx - zoomedW) / 2;
const zoomedTop = crop.topY + (crop.photoHeightPx - zoomedH) / 2;
const srcX = zoomedLeft * scaleX;
const srcY = zoomedTop * scaleY;
const srcW = zoomedW * scaleX;
const srcH = zoomedH * scaleY;
// Draw preview image onto full canvas // Draw preview image onto full canvas
ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, canvasDisplayWidth, canvasDisplayHeight); ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, canvasDisplayWidth, canvasDisplayHeight);
@@ -811,10 +841,12 @@ export function PassportPhotoPreview() {
ctx.setLineDash([6, 4]); ctx.setLineDash([6, 4]);
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
// Helper: convert original-image Y coord to canvas Y coord (zoom-aware)
const toCanvasY = (origY: number) => ((origY - zoomedTop) / zoomedH) * canvasDisplayHeight;
const toCanvasX = (origX: number) => ((origX - zoomedLeft) / zoomedW) * canvasDisplayWidth;
// Crown line // Crown line
const crownYCanvas = const crownYCanvas = toCanvasY((landmarks.crown.y + adjustY) * imageHeight);
((landmarks.crown.y + adjustY) * imageHeight - crop.topY) *
(canvasDisplayHeight / crop.photoHeightPx);
ctx.strokeStyle = headOk ? "#22c55e" : "#ef4444"; ctx.strokeStyle = headOk ? "#22c55e" : "#ef4444";
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, crownYCanvas); ctx.moveTo(0, crownYCanvas);
@@ -822,9 +854,7 @@ export function PassportPhotoPreview() {
ctx.stroke(); ctx.stroke();
// Chin line // Chin line
const chinYCanvas = const chinYCanvas = toCanvasY((landmarks.chin.y + adjustY) * imageHeight);
((landmarks.chin.y + adjustY) * imageHeight - crop.topY) *
(canvasDisplayHeight / crop.photoHeightPx);
ctx.strokeStyle = headOk ? "#22c55e" : "#ef4444"; ctx.strokeStyle = headOk ? "#22c55e" : "#ef4444";
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, chinYCanvas); ctx.moveTo(0, chinYCanvas);
@@ -832,9 +862,7 @@ export function PassportPhotoPreview() {
ctx.stroke(); ctx.stroke();
// Eye line // Eye line
const eyeYCanvas = const eyeYCanvas = toCanvasY((landmarks.eyeCenter.y + adjustY) * imageHeight);
((landmarks.eyeCenter.y + adjustY) * imageHeight - crop.topY) *
(canvasDisplayHeight / crop.photoHeightPx);
ctx.strokeStyle = eyeOk ? "#3b82f6" : "#ef4444"; ctx.strokeStyle = eyeOk ? "#3b82f6" : "#ef4444";
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, eyeYCanvas); ctx.moveTo(0, eyeYCanvas);
@@ -842,9 +870,7 @@ export function PassportPhotoPreview() {
ctx.stroke(); ctx.stroke();
// Center line (vertical) // Center line (vertical)
const centerXCanvas = const centerXCanvas = toCanvasX((landmarks.faceCenterX + adjustX) * imageWidth);
((landmarks.faceCenterX + adjustX) * imageWidth - crop.leftX) *
(canvasDisplayWidth / crop.photoWidthPx);
ctx.strokeStyle = centerOk ? "#f59e0b" : "#ef4444"; ctx.strokeStyle = centerOk ? "#f59e0b" : "#ef4444";
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(centerXCanvas, 0); ctx.moveTo(centerXCanvas, 0);