Skip to content

Crop photo v2 #25

Description

@twaenny

@claude this is what gpt gave me: use this as an example!

"use client";

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { applySmartCrop } from "@/app/actions/smart-crop";
import {
computeCropRect,
getAdjustmentRange,
POSTCARD_ASPECT_RATIO,
type CropHints,
type CropRect,
type CropOptions,
} from "@/lib/smart-crop";

/**

  • SmartCropPreview — robust, accessible, and pointer-friendly crop previewer
  • Improvements vs. original:
    • Pointer Events (mouse + touch + pen) with setPointerCapture for smooth drag
    • No global window keydown listener; focusable container handles arrows
    • Stable drag math based on initial values (no cumulative drift)
    • ResizeObserver to keep scale in sync when the container resizes
    • Abort stale async crop analyses; ignores late responses
    • Defensive clamps + NaN guards; fewer re-renders via useMemo/useCallback
    • Optional thirds grid overlay + double-click to reset adjustments
    • ARIA: reduced-motion, roles, labels, better error messaging
      */

interface SmartCropPreviewProps {
imageUrl: string;
onCropChange?: (crop: CropRect) => void;
aspectRatio?: number; // default: POSTCARD_ASPECT_RATIO (3:2)
className?: string;
}

export default function SmartCropPreview({
imageUrl,
onCropChange,
aspectRatio = POSTCARD_ASPECT_RATIO,
className,
}: SmartCropPreviewProps) {
const [hints, setHints] = useState<CropHints | null>(null);
const [cropRect, setCropRect] = useState<CropRect | null>(null);
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAIOverlay, setShowAIOverlay] = useState(false);
const [showThirds, setShowThirds] = useState(true);
const [manualAdjust, setManualAdjust] = useState({ manualAdjustX: 0, manualAdjustY: 0 });

// Drag state
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const manualStartRef = useRef({ manualAdjustX: 0, manualAdjustY: 0 });

// DOM refs
const imgRef = useRef(null);
const containerRef = useRef(null);

// Keep an AbortController / request token for stale async protection
const requestIdRef = useRef(0);

// Track container size for scale calculation
const [containerWidth, setContainerWidth] = useState(0);

// ResizeObserver hook
useEffect(() => {
if (!containerRef.current) return;
const el = containerRef.current;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const cr = entry.contentRect;
if (cr.width > 0) setContainerWidth(cr.width);
}
});
ro.observe(el);
// Initial width
setContainerWidth(el.getBoundingClientRect().width);
return () => ro.disconnect();
}, []);

// Load image dimensions when URL changes
useEffect(() => {
setHints(null);
setCropRect(null);
setError(null);
setManualAdjust({ manualAdjustX: 0, manualAdjustY: 0 });

if (!imageUrl) return;

const img = new Image();
img.decoding = "async";
img.onload = () => setImageDimensions({ width: img.width, height: img.height });
img.onerror = () => setError("Failed to load image.");
img.src = imageUrl;

}, [imageUrl]);

// Compute new crop when manual adjustment OR hints changes
useEffect(() => {
if (!hints) return;
if (imageDimensions.width <= 0 || imageDimensions.height <= 0) return;

const next = computeCropRect(
  imageDimensions.width,
  imageDimensions.height,
  hints.focalPoint,
  manualAdjust,
  aspectRatio
);
setCropRect(next);
onCropChange?.(next);

}, [manualAdjust, hints, imageDimensions, aspectRatio, onCropChange]);

// Analyze (server action) once we know dimensions
useEffect(() => {
if (!imageUrl) return;
if (imageDimensions.width <= 0) return;

const myId = ++requestIdRef.current;
const run = async () => {
  setLoading(true);
  setError(null);
  try {
    const result = await applySmartCrop(
      imageUrl,
      imageDimensions.width,
      imageDimensions.height,
      { ...manualAdjust },
      aspectRatio
    );
    if (requestIdRef.current !== myId) return; // stale response
    setHints(result.hints);
    setCropRect(result.cropRect);
    onCropChange?.(result.cropRect);
  } catch (err: unknown) {
    if (requestIdRef.current !== myId) return; // ignore late error
    const msg = err instanceof Error ? err.message : "Failed to analyze image.";
    setError(msg);
  } finally {
    if (requestIdRef.current === myId) setLoading(false);
  }
};
run();
// eslint-disable-next-line react-hooks/exhaustive-deps

}, [imageUrl, imageDimensions.width, imageDimensions.height, aspectRatio]);

// Handy values
const adjustmentRange = useMemo(
() => getAdjustmentRange(imageDimensions.width, imageDimensions.height, aspectRatio),
[imageDimensions, aspectRatio]
);

// Scale to render original pixels so that cropRect fits container
const scale = useMemo(() => {
if (!cropRect || containerWidth <= 0) return 1;
return containerWidth / cropRect.w;
}, [cropRect, containerWidth]);

// --- Pointer handlers (mouse/touch/pen) ---
const onPointerDown = useCallback((e: React.PointerEvent) => {
if (!containerRef.current) return;
(e.currentTarget as Element).setPointerCapture?.(e.pointerId);
setIsDragging(true);

const rect = containerRef.current.getBoundingClientRect();
dragStartRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
manualStartRef.current = { ...manualAdjust };

}, [manualAdjust]);

const onPointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isDragging || !containerRef.current || !dragStartRef.current) return;

  const rect = containerRef.current.getBoundingClientRect();
  const currentX = e.clientX - rect.left;
  const currentY = e.clientY - rect.top;

  const dx = currentX - dragStartRef.current.x;
  const dy = currentY - dragStartRef.current.y;

  // Convert to normalized [-1, 1] offsets relative to container
  const nx = (dx / rect.width) * 2;
  const ny = (dy / rect.height) * 2;

  setManualAdjust((prev) => {
    const base = manualStartRef.current;
    let nextX = base.manualAdjustX ?? 0;
    let nextY = base.manualAdjustY ?? 0;

    if (adjustmentRange.horizontal) nextX = clamp((base.manualAdjustX ?? 0) + nx, -1, 1);
    if (adjustmentRange.vertical) nextY = clamp((base.manualAdjustY ?? 0) + ny, -1, 1);

    if (nextX === (prev.manualAdjustX ?? 0) && nextY === (prev.manualAdjustY ?? 0)) return prev;
    return { manualAdjustX: nextX, manualAdjustY: nextY };
  });
},
[isDragging, adjustmentRange]

);

const endDrag = useCallback((e?: React.PointerEvent) => {
setIsDragging(false);
if (e) (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
dragStartRef.current = null;
}, []);

// Keyboard arrows on focused container
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const step = 0.05; // 5% per keypress
const key = e.key;
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "0"].includes(key)) return;
e.preventDefault();

  setManualAdjust((prev) => {
    let x = prev.manualAdjustX ?? 0;
    let y = prev.manualAdjustY ?? 0;
    if (key === "ArrowLeft" && adjustmentRange.horizontal) x = clamp(x - step, -1, 1);
    if (key === "ArrowRight" && adjustmentRange.horizontal) x = clamp(x + step, -1, 1);
    if (key === "ArrowUp" && adjustmentRange.vertical) y = clamp(y - step, -1, 1);
    if (key === "ArrowDown" && adjustmentRange.vertical) y = clamp(y + step, -1, 1);
    if (key === "Home" || key === "0") { x = 0; y = 0; }
    if (key === "End") { x = adjustmentRange.horizontal ? Math.sign(x) : 0; y = adjustmentRange.vertical ? Math.sign(y) : 0; }
    return { manualAdjustX: x, manualAdjustY: y };
  });
},
[adjustmentRange]

);

const resetAdjustments = useCallback(() => setManualAdjust({ manualAdjustX: 0, manualAdjustY: 0 }), []);

const onDoubleClick = useCallback(() => resetAdjustments(), [resetAdjustments]);

// Derived object-position and transforms for the img element
const imgStyle = useMemo<React.CSSProperties>(() => {
if (!cropRect || imageDimensions.width <= 0) return {};
return {
objectFit: "none",
objectPosition: ${-cropRect.x}px ${-cropRect.y}px,
width: ${imageDimensions.width}px,
height: ${imageDimensions.height}px,
maxWidth: "none",
transform: translateZ(0) scale(${Number.isFinite(scale) && scale > 0 ? scale : 1}),
willChange: isDragging ? ("transform, object-position" as any) : undefined,
} as React.CSSProperties;
}, [cropRect, imageDimensions, scale, isDragging]);

// Utility values for UI
const offsetLabel = useMemo(() => {
const x = (manualAdjust.manualAdjustX ?? 0) * 100;
const y = (manualAdjust.manualAdjustY ?? 0) * 100;
return X: ${x.toFixed(0)}%, Y: ${y.toFixed(0)}%;
}, [manualAdjust]);

return (
<div className={"space-y-4 " + (className ?? "")}
aria-busy={loading}
aria-live="polite"
>


Smart Crop Preview ({aspectRatio.toFixed(2)} aspect)



{(adjustmentRange.horizontal || adjustmentRange.vertical) && (

Reset Position

)}
<button
onClick={() => setShowThirds((v) => !v)}
className="px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300 rounded-md"
>
{showThirds ? "Hide" : "Show"} Thirds Grid

<button
onClick={() => setShowAIOverlay((v) => !v)}
className="px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300 rounded-md"
>
{showAIOverlay ? "Hide" : "Show"} AI Detection

<button
onClick={() => {
// Re-run analysis with current adjustments (useful after manual nudges)
const id = ++requestIdRef.current;
(async () => {
setLoading(true);
setError(null);
try {
const result = await applySmartCrop(
imageUrl,
imageDimensions.width,
imageDimensions.height,
{ ...manualAdjust },
aspectRatio
);
if (requestIdRef.current !== id) return;
setHints(result.hints);
setCropRect(result.cropRect);
onCropChange?.(result.cropRect);
} catch (err: unknown) {
if (requestIdRef.current !== id) return;
const msg = err instanceof Error ? err.message : "Failed to analyze image.";
setError(msg);
} finally {
if (requestIdRef.current === id) setLoading(false);
}
})();
}}
disabled={loading}
className="px-3 py-1 text-sm bg-blue-500 text-white hover:bg-blue-600 rounded-md disabled:opacity-50"
>
{loading ? "Analyzing…" : "Re-analyze"}


  {error && (
    <div role="alert" className="p-3 bg-red-50 border border-red-200 rounded-md text-red-700 text-sm">
      {error}
    </div>
  )}

  <div
    ref={containerRef}
    className={`relative overflow-hidden rounded-lg border-2 border-gray-300 select-none ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
    style={{ aspectRatio: `${aspectRatio}` }}
    onPointerDown={onPointerDown}
    onPointerMove={onPointerMove}
    onPointerUp={endDrag}
    onPointerCancel={endDrag}
    onDoubleClick={onDoubleClick}
    // Keyboard support
    tabIndex={0}
    onKeyDown={onKeyDown}
    role="application"
    aria-label="Smart crop interactive area"
  >
    {/* Render the original image scaled so that cropRect fills the frame */}
    <img
      ref={imgRef}
      src={imageUrl}
      alt="Crop preview"
      draggable={false}
      className="absolute inset-0 w-full h-full object-cover"
      style={imgStyle}
    />

    {/* Thirds grid overlay */}
    {showThirds && cropRect && (
      <svg className="absolute inset-0 w-full h-full pointer-events-none" viewBox={`0 0 ${cropRect.w} ${cropRect.h}`}>
        {[1, 2].map((i) => (
          <line key={`v${i}`} x1={(cropRect.w * i) / 3} y1={0} x2={(cropRect.w * i) / 3} y2={cropRect.h} stroke="white" strokeOpacity="0.5" strokeWidth={1} />
        ))}
        {[1, 2].map((i) => (
          <line key={`h${i}`} x1={0} y1={(cropRect.h * i) / 3} x2={cropRect.w} y2={(cropRect.h * i) / 3} stroke="white" strokeOpacity="0.5" strokeWidth={1} />
        ))}
      </svg>
    )}

    {/* AI overlay */}
    {showAIOverlay && hints && cropRect && (
      <svg className="absolute inset-0 w-full h-full pointer-events-none" viewBox={`0 0 ${cropRect.w} ${cropRect.h}`}>
        {/* Focal point */}
        <circle
          cx={hints.focalPoint.x * imageDimensions.width - cropRect.x}
          cy={hints.focalPoint.y * imageDimensions.height - cropRect.y}
          r={8}
          fill="red"
          fillOpacity={0.7}
          stroke="white"
          strokeWidth={2}
        />

        {/* Primary subject */}
        <rect
          x={hints.primarySubject.box.x * imageDimensions.width - cropRect.x}
          y={hints.primarySubject.box.y * imageDimensions.height - cropRect.y}
          width={hints.primarySubject.box.w * imageDimensions.width}
          height={hints.primarySubject.box.h * imageDimensions.height}
          fill="none"
          stroke="yellow"
          strokeWidth={3}
          strokeDasharray="5,5"
        />

        {/* Other regions */}
        {hints.regions.map((region, idx) => (
          <g key={idx}>
            <rect
              x={region.box.x * imageDimensions.width - cropRect.x}
              y={region.box.y * imageDimensions.height - cropRect.y}
              width={region.box.w * imageDimensions.width}
              height={region.box.h * imageDimensions.height}
              fill="none"
              stroke={region.importance > 7 ? "lime" : region.importance > 4 ? "cyan" : "gray"}
              strokeWidth={2}
              opacity={0.7}
            />
            <text
              x={region.box.x * imageDimensions.width - cropRect.x + 5}
              y={region.box.y * imageDimensions.height - cropRect.y + 15}
              fill="white"
              fontSize={12}
              fontWeight="bold"
              style={{ textShadow: "0 0 3px rgba(0,0,0,0.8)" }}
            >
              {region.type} ({region.importance})
            </text>
          </g>
        ))}
      </svg>
    )}

    {/* Loading shimmer */}
    {loading && (
      <div className="absolute inset-0 animate-pulse bg-gradient-to-r from-transparent via-white/20 to-transparent" aria-hidden />
    )}
  </div>

  {(adjustmentRange.horizontal || adjustmentRange.vertical) && (
    <div className="text-sm text-gray-600 space-y-1">
      <p className="font-semibold">Manual Adjustment</p>
      {adjustmentRange.horizontal && <p>• Drag or use ← → to pan horizontally</p>}
      {adjustmentRange.vertical && <p>• Drag or use ↑ ↓ to pan vertically</p>}
      <p className="text-xs text-gray-500 mt-2">Current offset: {offsetLabel}</p>
    </div>
  )}

  {hints && (
    <div className="text-sm text-gray-700 space-y-1 border-t pt-3">
      <p className="font-semibold">AI Detection</p>
      <p>• Primary subject: {hints.primarySubject.type} (confidence: {(hints.primarySubject.confidence * 100).toFixed(0)}%)</p>
      <p>• Detected {hints.regions.length} regions</p>
      <div className="flex flex-wrap gap-2 mt-2">
        {hints.regions.slice(0, 6).map((r, i) => (
          <span key={i} className={`px-2 py-1 text-xs rounded-full ${r.importance > 7 ? "bg-green-100 text-green-700" : r.importance > 4 ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"}`}>
            {r.label}
          </span>
        ))}
      </div>
    </div>
  )}
</div>

);
}

function clamp(n: number, min: number, max: number) {
if (Number.isNaN(n)) return 0;
return Math.min(max, Math.max(min, n));
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions