Files
lemonspace_app/components/canvas/nodes/crop-node.tsx

741 lines
28 KiB
TypeScript

"use client";
import { useCallback, useMemo, useRef, type PointerEvent as ReactPointerEvent } from "react";
import { Handle, 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 type { Id } from "@/convex/_generated/dataModel";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
type CropNodeViewData = CropNodeData & {
_status?: string;
_statusMessage?: string;
};
export type CropNodeType = Node<CropNodeViewData, "crop">;
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<CropNodeType>) {
const tNodes = useTranslations("nodes");
const { queueNodeDataUpdate } = useCanvasSync();
const graph = useCanvasGraph();
const normalizeData = useCallback((value: unknown) => normalizeCropNodeData(value), []);
const previewAreaRef = useRef<HTMLDivElement | null>(null);
const interactionRef = useRef<CropInteractionState | null>(null);
const { localData, updateLocalData } = useNodeLocalData<CropNodeData>({
nodeId: id,
data,
normalize: normalizeData,
saveDelayMs: 40,
onSave: (next) =>
queueNodeDataUpdate({
nodeId: id as Id<"nodes">,
data: next,
}),
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<string, unknown>;
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<CropNodeData["resize"]>) => {
updateLocalData((current) =>
normalizeCropNodeData({
...current,
resize: {
...current.resize,
...next,
},
}),
);
};
const beginCropInteraction = useCallback(
(event: ReactPointerEvent<HTMLElement>, 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<HTMLElement>) => {
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<HTMLElement>) => {
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 (
<BaseNodeWrapper
nodeType="crop"
selected={selected}
status={data._status}
statusMessage={data._statusMessage}
className="min-w-[320px] border-violet-500/30"
>
<Handle
type="target"
position={Position.Left}
className="!h-3 !w-3 !border-2 !border-background !bg-violet-500"
/>
<div className="space-y-3 p-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-violet-700 dark:text-violet-400">
<Crop className="h-3.5 w-3.5" />
{tNodes("adjustments.crop.title")}
</div>
<div className="space-y-2">
<div
ref={previewAreaRef}
data-testid="crop-preview-area"
className="relative overflow-hidden rounded-md border border-border bg-muted/30"
style={{ aspectRatio: `${Math.max(0.25, previewAspectRatio)}` }}
>
{!hasSource ? (
<div className="absolute inset-0 flex items-center justify-center px-3 text-center text-[11px] text-muted-foreground">
{tNodes("adjustments.crop.previewHint")}
</div>
) : null}
{hasSource ? <canvas ref={canvasRef} className="h-full w-full" /> : null}
{hasSource ? (
<div className="pointer-events-none absolute inset-0">
<div
data-testid="crop-overlay"
className="nodrag pointer-events-auto absolute cursor-move border border-violet-300 bg-violet-500/10"
style={{
left: `${localData.crop.x * 100}%`,
top: `${localData.crop.y * 100}%`,
width: `${localData.crop.width * 100}%`,
height: `${localData.crop.height * 100}%`,
}}
onPointerDown={(event) => beginCropInteraction(event, "move")}
onPointerMove={updateCropInteraction}
onPointerUp={endCropInteraction}
onPointerCancel={endCropInteraction}
>
<div className="pointer-events-none absolute left-1/3 top-0 h-full w-px bg-violet-200/70" />
<div className="pointer-events-none absolute left-2/3 top-0 h-full w-px bg-violet-200/70" />
<div className="pointer-events-none absolute left-0 top-1/3 h-px w-full bg-violet-200/70" />
<div className="pointer-events-none absolute left-0 top-2/3 h-px w-full bg-violet-200/70" />
</div>
<button
type="button"
data-testid="crop-handle-nw"
className="nodrag pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize rounded-full border border-background bg-violet-500"
style={{ left: `${localData.crop.x * 100}%`, top: `${localData.crop.y * 100}%` }}
onPointerDown={(event) => beginCropInteraction(event, "resize", "nw")}
onPointerMove={updateCropInteraction}
onPointerUp={endCropInteraction}
onPointerCancel={endCropInteraction}
/>
<button
type="button"
data-testid="crop-handle-n"
className="nodrag pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize rounded-full border border-background bg-violet-500"
style={{
left: `${(localData.crop.x + localData.crop.width / 2) * 100}%`,
top: `${localData.crop.y * 100}%`,
}}
onPointerDown={(event) => beginCropInteraction(event, "resize", "n")}
onPointerMove={updateCropInteraction}
onPointerUp={endCropInteraction}
onPointerCancel={endCropInteraction}
/>
<button
type="button"
data-testid="crop-handle-ne"
className="nodrag pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-nesw-resize rounded-full border border-background bg-violet-500"
style={{
left: `${(localData.crop.x + localData.crop.width) * 100}%`,
top: `${localData.crop.y * 100}%`,
}}
onPointerDown={(event) => beginCropInteraction(event, "resize", "ne")}
onPointerMove={updateCropInteraction}
onPointerUp={endCropInteraction}
onPointerCancel={endCropInteraction}
/>
<button
type="button"
data-testid="crop-handle-e"
className="nodrag pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize rounded-full border border-background bg-violet-500"
style={{
left: `${(localData.crop.x + localData.crop.width) * 100}%`,
top: `${(localData.crop.y + localData.crop.height / 2) * 100}%`,
}}
onPointerDown={(event) => beginCropInteraction(event, "resize", "e")}
onPointerMove={updateCropInteraction}
onPointerUp={endCropInteraction}
onPointerCancel={endCropInteraction}
/>
<button
type="button"
data-testid="crop-handle-se"
className="nodrag pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize rounded-full border border-background bg-violet-500"
style={{
left: `${(localData.crop.x + localData.crop.width) * 100}%`,
top: `${(localData.crop.y + localData.crop.height) * 100}%`,
}}
onPointerDown={(event) => beginCropInteraction(event, "resize", "se")}
onPointerMove={updateCropInteraction}
onPointerUp={endCropInteraction}
onPointerCancel={endCropInteraction}
/>
<button
type="button"
data-testid="crop-handle-s"
className="nodrag pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize rounded-full border border-background bg-violet-500"
style={{
left: `${(localData.crop.x + localData.crop.width / 2) * 100}%`,
top: `${(localData.crop.y + localData.crop.height) * 100}%`,
}}
onPointerDown={(event) => beginCropInteraction(event, "resize", "s")}
onPointerMove={updateCropInteraction}
onPointerUp={endCropInteraction}
onPointerCancel={endCropInteraction}
/>
<button
type="button"
data-testid="crop-handle-sw"
className="nodrag pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-nesw-resize rounded-full border border-background bg-violet-500"
style={{
left: `${localData.crop.x * 100}%`,
top: `${(localData.crop.y + localData.crop.height) * 100}%`,
}}
onPointerDown={(event) => beginCropInteraction(event, "resize", "sw")}
onPointerMove={updateCropInteraction}
onPointerUp={endCropInteraction}
onPointerCancel={endCropInteraction}
/>
<button
type="button"
data-testid="crop-handle-w"
className="nodrag pointer-events-auto absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize rounded-full border border-background bg-violet-500"
style={{
left: `${localData.crop.x * 100}%`,
top: `${(localData.crop.y + localData.crop.height / 2) * 100}%`,
}}
onPointerDown={(event) => beginCropInteraction(event, "resize", "w")}
onPointerMove={updateCropInteraction}
onPointerUp={endCropInteraction}
onPointerCancel={endCropInteraction}
/>
</div>
) : null}
{isRendering ? (
<div className="absolute right-1 top-1 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-muted-foreground">
{tNodes("adjustments.crop.previewRendering")}
</div>
) : null}
</div>
<div className="flex items-center justify-between rounded-md border border-border/70 bg-muted/30 px-2 py-1 text-[11px] text-muted-foreground">
<span>{tNodes("adjustments.crop.outputResolutionLabel")}</span>
<span className="font-medium text-foreground">{outputResolutionLabel}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<label className="space-y-1 text-[11px] text-muted-foreground">
<span>{tNodes("adjustments.crop.fields.x")}</span>
<input
type="number"
min={0}
max={1}
step={0.01}
value={localData.crop.x}
onChange={(event) => {
const parsed = parseNumberInput(event.target.value);
if (parsed === null) return;
updateCropField("x", clamp(parsed, 0, 1));
}}
className="nodrag nowheel h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
/>
</label>
<label className="space-y-1 text-[11px] text-muted-foreground">
<span>{tNodes("adjustments.crop.fields.y")}</span>
<input
type="number"
min={0}
max={1}
step={0.01}
value={localData.crop.y}
onChange={(event) => {
const parsed = parseNumberInput(event.target.value);
if (parsed === null) return;
updateCropField("y", clamp(parsed, 0, 1));
}}
className="nodrag nowheel h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
/>
</label>
<label className="space-y-1 text-[11px] text-muted-foreground">
<span>{tNodes("adjustments.crop.fields.width")}</span>
<input
type="number"
min={0.01}
max={1}
step={0.01}
value={localData.crop.width}
onChange={(event) => {
const parsed = parseNumberInput(event.target.value);
if (parsed === null) return;
updateCropField("width", clamp(parsed, 0.01, 1));
}}
className="nodrag nowheel h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
/>
</label>
<label className="space-y-1 text-[11px] text-muted-foreground">
<span>{tNodes("adjustments.crop.fields.height")}</span>
<input
type="number"
min={0.01}
max={1}
step={0.01}
value={localData.crop.height}
onChange={(event) => {
const parsed = parseNumberInput(event.target.value);
if (parsed === null) return;
updateCropField("height", clamp(parsed, 0.01, 1));
}}
className="nodrag nowheel h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
/>
</label>
</div>
<div className="grid grid-cols-2 gap-2 text-[11px]">
<div className="space-y-1">
<div className="text-muted-foreground">{tNodes("adjustments.crop.resizeMode")}</div>
<Select
value={localData.resize.mode}
onValueChange={(value: CropResizeMode) => {
updateResize({ mode: value });
}}
>
<SelectTrigger className="nodrag h-8 text-xs" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent className="nodrag">
<SelectItem value="source">{tNodes("adjustments.crop.resizeModes.source")}</SelectItem>
<SelectItem value="custom">{tNodes("adjustments.crop.resizeModes.custom")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">{tNodes("adjustments.crop.fitMode")}</div>
<Select
value={localData.resize.fit}
onValueChange={(value: CropFitMode) => {
updateResize({ fit: value });
}}
>
<SelectTrigger className="nodrag h-8 text-xs" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent className="nodrag">
<SelectItem value="cover">{tNodes("adjustments.crop.fitModes.cover")}</SelectItem>
<SelectItem value="contain">{tNodes("adjustments.crop.fitModes.contain")}</SelectItem>
<SelectItem value="fill">{tNodes("adjustments.crop.fitModes.fill")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{localData.resize.mode === "custom" ? (
<div className="grid grid-cols-2 gap-2">
<label className="space-y-1 text-[11px] text-muted-foreground">
<span>{tNodes("adjustments.crop.fields.outputWidth")}</span>
<input
type="number"
min={1}
max={16384}
step={1}
value={localData.resize.width ?? CUSTOM_DIMENSION_FALLBACK}
onChange={(event) => {
const parsed = parseNumberInput(event.target.value);
if (parsed === null) return;
updateResize({ width: Math.round(clamp(parsed, 1, 16384)) });
}}
className="nodrag nowheel h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
/>
</label>
<label className="space-y-1 text-[11px] text-muted-foreground">
<span>{tNodes("adjustments.crop.fields.outputHeight")}</span>
<input
type="number"
min={1}
max={16384}
step={1}
value={localData.resize.height ?? CUSTOM_DIMENSION_FALLBACK}
onChange={(event) => {
const parsed = parseNumberInput(event.target.value);
if (parsed === null) return;
updateResize({ height: Math.round(clamp(parsed, 1, 16384)) });
}}
className="nodrag nowheel h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
/>
</label>
</div>
) : null}
<label className="flex items-center gap-2 text-[11px] text-muted-foreground">
<input
type="checkbox"
checked={localData.resize.keepAspect}
onChange={(event) => updateResize({ keepAspect: event.target.checked })}
className="nodrag h-3.5 w-3.5 rounded border-input"
/>
{tNodes("adjustments.crop.keepAspect")}
</label>
<div className="text-[10px] text-muted-foreground">
{tNodes("adjustments.crop.cropSummary", {
x: formatPercent(localData.crop.x),
y: formatPercent(localData.crop.y),
width: formatPercent(localData.crop.width),
height: formatPercent(localData.crop.height),
})}
</div>
{error ? <p className="text-[11px] text-destructive">{error}</p> : null}
</div>
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-background !bg-violet-500"
/>
</BaseNodeWrapper>
);
}