Enhance canvas functionality by adding media preview capabilities and image upload handling. Introduce compressed image previews during uploads, improve media library integration, and implement retry logic for bridge edge creation. Update dashboard to display media previews and optimize image node handling.
This commit is contained in:
740
components/canvas/nodes/crop-node.tsx
Normal file
740
components/canvas/nodes/crop-node.tsx
Normal file
@@ -0,0 +1,740 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user