"use client"; import { useCallback, useMemo, useRef, type PointerEvent as ReactPointerEvent } from "react"; import { Position, type Node, type NodeProps } from "@xyflow/react"; import { Crop } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCanvasGraph } from "@/components/canvas/canvas-graph-context"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper"; import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data"; import { usePipelinePreview } from "@/hooks/use-pipeline-preview"; import { collectPipelineFromGraph, getSourceImageFromGraph, shouldFastPathPreviewPipeline, } from "@/lib/canvas-render-preview"; import { normalizeCropNodeData, type CropFitMode, type CropNodeData, type CropResizeMode, } from "@/lib/image-pipeline/crop-node-data"; import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; import type { Id } from "@/convex/_generated/dataModel"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import CanvasHandle from "@/components/canvas/canvas-handle"; type CropNodeViewData = CropNodeData & { _status?: string; _statusMessage?: string; }; export type CropNodeType = Node; const PREVIEW_PIPELINE_TYPES = new Set([ "curves", "color-adjust", "light-adjust", "detail-adjust", "crop", ]); const CUSTOM_DIMENSION_FALLBACK = 1024; const CROP_MIN_SIZE = 0.01; type CropHandle = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; type CropInteractionState = { pointerId: number; mode: "move" | "resize"; handle?: CropHandle; startX: number; startY: number; previewWidth: number; previewHeight: number; startCrop: CropNodeData["crop"]; keepAspect: boolean; aspectRatio: number; }; function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } function parseNumberInput(value: string): number | null { const parsed = Number(value); if (!Number.isFinite(parsed)) { return null; } return parsed; } function formatPercent(value: number): string { return `${Math.round(value * 100)}%`; } function clampCropRect(rect: CropNodeData["crop"]): CropNodeData["crop"] { const width = clamp(rect.width, CROP_MIN_SIZE, 1); const height = clamp(rect.height, CROP_MIN_SIZE, 1); const x = clamp(rect.x, 0, Math.max(0, 1 - width)); const y = clamp(rect.y, 0, Math.max(0, 1 - height)); return { x, y, width, height, }; } function resizeCropRect( start: CropNodeData["crop"], handle: CropHandle, deltaX: number, deltaY: number, keepAspect: boolean, aspectRatio: number, ): CropNodeData["crop"] { const startRight = start.x + start.width; const startBottom = start.y + start.height; if (!keepAspect) { let left = start.x; let top = start.y; let right = startRight; let bottom = startBottom; if (handle.includes("w")) { left = clamp(start.x + deltaX, 0, startRight - CROP_MIN_SIZE); } if (handle.includes("e")) { right = clamp(startRight + deltaX, start.x + CROP_MIN_SIZE, 1); } if (handle.includes("n")) { top = clamp(start.y + deltaY, 0, startBottom - CROP_MIN_SIZE); } if (handle.includes("s")) { bottom = clamp(startBottom + deltaY, start.y + CROP_MIN_SIZE, 1); } return clampCropRect({ x: left, y: top, width: right - left, height: bottom - top, }); } const aspect = Math.max(CROP_MIN_SIZE, aspectRatio); if (handle === "e" || handle === "w") { const centerY = start.y + start.height / 2; const maxWidth = handle === "e" ? 1 - start.x : startRight; const minWidth = Math.max(CROP_MIN_SIZE, CROP_MIN_SIZE * aspect); const rawWidth = handle === "e" ? start.width + deltaX : start.width - deltaX; const width = clamp(rawWidth, minWidth, Math.max(minWidth, maxWidth)); const height = width / aspect; const y = clamp(centerY - height / 2, 0, Math.max(0, 1 - height)); const x = handle === "e" ? start.x : startRight - width; return clampCropRect({ x, y, width, height }); } if (handle === "n" || handle === "s") { const centerX = start.x + start.width / 2; const maxHeight = handle === "s" ? 1 - start.y : startBottom; const minHeight = Math.max(CROP_MIN_SIZE, CROP_MIN_SIZE / aspect); const rawHeight = handle === "s" ? start.height + deltaY : start.height - deltaY; const height = clamp(rawHeight, minHeight, Math.max(minHeight, maxHeight)); const width = height * aspect; const x = clamp(centerX - width / 2, 0, Math.max(0, 1 - width)); const y = handle === "s" ? start.y : startBottom - height; return clampCropRect({ x, y, width, height }); } const movesRight = handle.includes("e"); const movesDown = handle.includes("s"); const rawWidth = start.width + (movesRight ? deltaX : -deltaX); const rawHeight = start.height + (movesDown ? deltaY : -deltaY); const widthByHeight = rawHeight * aspect; const heightByWidth = rawWidth / aspect; const useWidth = Math.abs(rawWidth - start.width) >= Math.abs(rawHeight - start.height); let width = useWidth ? rawWidth : widthByHeight; let height = useWidth ? heightByWidth : rawHeight; const anchorX = movesRight ? start.x : startRight; const anchorY = movesDown ? start.y : startBottom; const maxWidth = movesRight ? 1 - anchorX : anchorX; const maxHeight = movesDown ? 1 - anchorY : anchorY; const maxScaleByWidth = maxWidth / Math.max(CROP_MIN_SIZE, width); const maxScaleByHeight = maxHeight / Math.max(CROP_MIN_SIZE, height); const maxScale = Math.min(1, maxScaleByWidth, maxScaleByHeight); width *= maxScale; height *= maxScale; const minScaleByWidth = Math.max(1, CROP_MIN_SIZE / Math.max(CROP_MIN_SIZE, width)); const minScaleByHeight = Math.max(1, CROP_MIN_SIZE / Math.max(CROP_MIN_SIZE, height)); const minScale = Math.max(minScaleByWidth, minScaleByHeight); width *= minScale; height *= minScale; const x = movesRight ? anchorX : anchorX - width; const y = movesDown ? anchorY : anchorY - height; return clampCropRect({ x, y, width, height }); } export default function CropNode({ id, data, selected, width }: NodeProps) { const tNodes = useTranslations("nodes"); const { queueNodeDataUpdate } = useCanvasSync(); const graph = useCanvasGraph(); const normalizeData = useCallback( (value: unknown) => preserveNodeFavorite(normalizeCropNodeData(value), value) as CropNodeData, [], ); const previewAreaRef = useRef(null); const interactionRef = useRef(null); const { localData, updateLocalData } = useNodeLocalData({ nodeId: id, data, normalize: normalizeData, saveDelayMs: 40, onSave: (next) => queueNodeDataUpdate({ nodeId: id as Id<"nodes">, data: preserveNodeFavorite(next, data), }), debugLabel: "crop", }); const sourceUrl = useMemo( () => getSourceImageFromGraph(graph, { nodeId: id, isSourceNode: (node) => node.type === "image" || node.type === "ai-image" || node.type === "asset" || node.type === "video" || node.type === "ai-video", getSourceImageFromNode: (node) => { const sourceData = (node.data ?? {}) as Record; const directUrl = typeof sourceData.url === "string" ? sourceData.url : null; if (directUrl && directUrl.length > 0) { return directUrl; } const previewUrl = typeof sourceData.previewUrl === "string" ? sourceData.previewUrl : null; return previewUrl && previewUrl.length > 0 ? previewUrl : null; }, }), [graph, id], ); const steps = useMemo(() => { const collected = collectPipelineFromGraph(graph, { nodeId: id, isPipelineNode: (node) => PREVIEW_PIPELINE_TYPES.has(node.type ?? ""), }); return collected.map((step) => { if (step.nodeId === id && step.type === "crop") { return { ...step, params: localData, }; } return step; }); }, [graph, id, localData]); const previewDebounceMs = shouldFastPathPreviewPipeline(steps, graph.previewNodeDataOverrides) ? 16 : undefined; const { canvasRef, hasSource, isRendering, previewAspectRatio, error } = usePipelinePreview({ sourceUrl, steps, nodeWidth: Math.max(250, Math.round(width ?? 300)), includeHistogram: false, debounceMs: previewDebounceMs, previewScale: 0.5, maxPreviewWidth: 720, maxDevicePixelRatio: 1.25, }); const outputResolutionLabel = localData.resize.mode === "custom" ? `${localData.resize.width ?? CUSTOM_DIMENSION_FALLBACK} x ${localData.resize.height ?? CUSTOM_DIMENSION_FALLBACK}` : tNodes("adjustments.crop.sourceResolution"); const updateCropField = (field: keyof CropNodeData["crop"], value: number) => { updateLocalData((current) => normalizeCropNodeData({ ...current, crop: { ...current.crop, [field]: value, }, }), ); }; const updateResize = (next: Partial) => { updateLocalData((current) => normalizeCropNodeData({ ...current, resize: { ...current.resize, ...next, }, }), ); }; const beginCropInteraction = useCallback( (event: ReactPointerEvent, mode: "move" | "resize", handle?: CropHandle) => { if (!hasSource) { return; } const previewElement = previewAreaRef.current; if (!previewElement) { return; } const bounds = previewElement.getBoundingClientRect(); if (bounds.width <= 0 || bounds.height <= 0) { return; } event.preventDefault(); event.stopPropagation(); const pointerId = Number.isFinite(event.pointerId) ? event.pointerId : 1; event.currentTarget.setPointerCapture?.(pointerId); interactionRef.current = { pointerId, mode, handle, startX: event.clientX, startY: event.clientY, previewWidth: bounds.width, previewHeight: bounds.height, startCrop: localData.crop, keepAspect: localData.resize.keepAspect, aspectRatio: localData.crop.width / Math.max(CROP_MIN_SIZE, localData.crop.height), }; }, [hasSource, localData.crop, localData.resize.keepAspect], ); const updateCropInteraction = useCallback( (event: ReactPointerEvent) => { const activeInteraction = interactionRef.current; if (!activeInteraction) { return; } const pointerId = Number.isFinite(event.pointerId) ? event.pointerId : 1; if (pointerId !== activeInteraction.pointerId) { return; } event.preventDefault(); event.stopPropagation(); const deltaX = (event.clientX - activeInteraction.startX) / activeInteraction.previewWidth; const deltaY = (event.clientY - activeInteraction.startY) / activeInteraction.previewHeight; const nextCrop = activeInteraction.mode === "move" ? clampCropRect({ ...activeInteraction.startCrop, x: activeInteraction.startCrop.x + deltaX, y: activeInteraction.startCrop.y + deltaY, }) : resizeCropRect( activeInteraction.startCrop, activeInteraction.handle ?? "se", deltaX, deltaY, activeInteraction.keepAspect, activeInteraction.aspectRatio, ); updateLocalData((current) => normalizeCropNodeData({ ...current, crop: nextCrop, }), ); }, [updateLocalData], ); const endCropInteraction = useCallback((event: ReactPointerEvent) => { const activeInteraction = interactionRef.current; if (!activeInteraction) { return; } const pointerId = Number.isFinite(event.pointerId) ? event.pointerId : 1; if (pointerId !== activeInteraction.pointerId) { return; } event.preventDefault(); event.stopPropagation(); event.currentTarget.releasePointerCapture?.(pointerId); interactionRef.current = null; }, []); return (
{tNodes("adjustments.crop.title")}
{!hasSource ? (
{tNodes("adjustments.crop.previewHint")}
) : null} {hasSource ? : null} {hasSource ? (
beginCropInteraction(event, "move")} onPointerMove={updateCropInteraction} onPointerUp={endCropInteraction} onPointerCancel={endCropInteraction} >
) : null} {isRendering ? (
{tNodes("adjustments.crop.previewRendering")}
) : null}
{tNodes("adjustments.crop.outputResolutionLabel")} {outputResolutionLabel}
{tNodes("adjustments.crop.resizeMode")}
{tNodes("adjustments.crop.fitMode")}
{localData.resize.mode === "custom" ? (
) : null}
{tNodes("adjustments.crop.cropSummary", { x: formatPercent(localData.crop.x), y: formatPercent(localData.crop.y), width: formatPercent(localData.crop.width), height: formatPercent(localData.crop.height), })}
{error ?

{error}

: null}
); }