diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 5b615d82e..af553ef2f 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -64,6 +64,19 @@ import { ImageNodeEmptyState, ImageNodeProcessingOverlay, } from "@/components/canvas/nodes/image-node/image-node-empty-state"; +import { + BoxCutoutPoint, + createCenteredCropRectForAspect, + createCenteredCropRectFromSize, + createNormalizedRect, + CROP_PRESET_GROUPS, + type CropInteractionHandle, + type CropPanelGroupKey, + DEFAULT_CROP_PRESET_ID, + getCropPresetById, + getNormalizedMinCropSize, + updateCropRectWithPointer, +} from "@/components/canvas/nodes/image-node/image-node-crop"; import { PromptRegenerateDialog, ThreeDGenerationDialog, @@ -194,35 +207,6 @@ const AI_CLOUD_IMAGE_ACTIONS = new Set([ type AngleEditPanelMode = "subject" | "camera"; type FlipRotateEditAction = "horizontal" | "vertical" | "reset"; -type CropPanelGroupKey = - | "common" - | "instagram" - | "facebook" - | "tiktok" - | "youtube"; -type CropInteractionHandle = - | "move" - | "n" - | "s" - | "e" - | "w" - | "nw" - | "ne" - | "sw" - | "se"; - -type CropPresetOption = { - id: string; - label: string; - ratio: number; -}; - -type CropPresetGroup = { - key: CropPanelGroupKey; - label: string; - options: CropPresetOption[]; -}; - type CropInteractionState = { handle: CropInteractionHandle; pointerId: number; @@ -252,11 +236,6 @@ type AnglePreviewDragState = { startVerticalAngle: number; }; -interface BoxCutoutPoint { - x: number; - y: number; -} - interface LocalEditReferenceImage { id: string; url: string; @@ -314,53 +293,6 @@ const ANGLE_PREVIEW_HORIZONTAL_RING_ROTATIONS = [ -78, -56, -34, -14, 0, 14, 34, 56, 78, ]; const ANGLE_PREVIEW_DIAGONAL_RING_ROTATIONS = [-62, -40, -18, 8, 34, 58]; -const DEFAULT_CROP_PRESET_ID = "1:1"; -const CROP_PRESET_GROUPS: CropPresetGroup[] = [ - { - key: "common", - label: "通用", - options: [ - { id: "1:1", label: "1:1", ratio: 1 }, - { id: "3:4", label: "3:4", ratio: 3 / 4 }, - { id: "2:3", label: "2:3", ratio: 2 / 3 }, - { id: "9:16", label: "9:16", ratio: 9 / 16 }, - { id: "4:3", label: "4:3", ratio: 4 / 3 }, - { id: "3:2", label: "3:2", ratio: 3 / 2 }, - { id: "16:9", label: "16:9", ratio: 16 / 9 }, - ], - }, - { - key: "instagram", - label: "Instagram", - options: [ - { id: "ig-square", label: "1:1", ratio: 1 }, - { id: "ig-portrait", label: "4:5", ratio: 4 / 5 }, - { id: "ig-story", label: "9:16", ratio: 9 / 16 }, - ], - }, - { - key: "facebook", - label: "Facebook", - options: [ - { id: "fb-feed", label: "4:5", ratio: 4 / 5 }, - { id: "fb-cover", label: "16:9", ratio: 16 / 9 }, - ], - }, - { - key: "tiktok", - label: "TikTok", - options: [{ id: "tt-video", label: "9:16", ratio: 9 / 16 }], - }, - { - key: "youtube", - label: "YouTube", - options: [ - { id: "yt-cover", label: "16:9", ratio: 16 / 9 }, - { id: "yt-short", label: "9:16", ratio: 9 / 16 }, - ], - }, -]; - const normalizeAnglePanelHorizontalAngle = (value: number) => { if (!Number.isFinite(value)) return 0; return value < 0 ? 360 + value : value; @@ -482,249 +414,6 @@ const fitSizeWithinBox = ({ }; }; -const createNormalizedRect = ( - start: BoxCutoutPoint, - end: BoxCutoutPoint -): NormalizedCropRect => { - const x = Math.min(start.x, end.x); - const y = Math.min(start.y, end.y); - const right = Math.max(start.x, end.x); - const bottom = Math.max(start.y, end.y); - - return { - x, - y, - width: clampNumber(right - x, 0, 1), - height: clampNumber(bottom - y, 0, 1), - }; -}; - -const getCropPresetById = (presetId: string) => { - for (const group of CROP_PRESET_GROUPS) { - const matched = group.options.find((option) => option.id === presetId); - if (matched) return matched; - } - return null; -}; - -const getNormalizedMinCropSize = ( - naturalWidth: number, - naturalHeight: number -) => ({ - width: naturalWidth > 0 ? Math.min(1, BOX_CUTOUT_MIN_EDGE_PX / naturalWidth) : 0.02, - height: - naturalHeight > 0 ? Math.min(1, BOX_CUTOUT_MIN_EDGE_PX / naturalHeight) : 0.02, -}); - -const createCenteredCropRectForAspect = ( - ratio: number, - centerX = 0.5, - centerY = 0.5 -): NormalizedCropRect => { - const safeRatio = Number.isFinite(ratio) && ratio > 0 ? ratio : 1; - const maxHalfWidth = Math.min(centerX, 1 - centerX); - const maxHalfHeight = Math.min(centerY, 1 - centerY); - - let width = maxHalfHeight * 2 * safeRatio; - let height = width / safeRatio; - - if (width > maxHalfWidth * 2) { - width = maxHalfWidth * 2; - height = width / safeRatio; - } - - if (height > maxHalfHeight * 2) { - height = maxHalfHeight * 2; - width = height * safeRatio; - } - - return { - x: clampNumber(centerX - width / 2, 0, 1 - width), - y: clampNumber(centerY - height / 2, 0, 1 - height), - width: clampNumber(width, 0, 1), - height: clampNumber(height, 0, 1), - }; -}; - -const createCenteredCropRectFromSize = ( - centerX: number, - centerY: number, - width: number, - height: number -): NormalizedCropRect => { - const safeWidth = clampNumber(width, 0.0001, 1); - const safeHeight = clampNumber(height, 0.0001, 1); - let x = centerX - safeWidth / 2; - let y = centerY - safeHeight / 2; - - x = clampNumber(x, 0, 1 - safeWidth); - y = clampNumber(y, 0, 1 - safeHeight); - - return { - x, - y, - width: safeWidth, - height: safeHeight, - }; -}; - -const updateCropRectWithPointer = ({ - aspectRatio, - handle, - minHeight, - minWidth, - pointer, - startPoint, - startRect, -}: { - aspectRatio: number | null; - handle: CropInteractionHandle; - minHeight: number; - minWidth: number; - pointer: BoxCutoutPoint; - startPoint: BoxCutoutPoint; - startRect: NormalizedCropRect; -}): NormalizedCropRect => { - const startRight = startRect.x + startRect.width; - const startBottom = startRect.y + startRect.height; - const currentAspect = - aspectRatio && Number.isFinite(aspectRatio) && aspectRatio > 0 - ? aspectRatio - : null; - - if (handle === "move") { - const nextX = clampNumber( - startRect.x + (pointer.x - startPoint.x), - 0, - 1 - startRect.width - ); - const nextY = clampNumber( - startRect.y + (pointer.y - startPoint.y), - 0, - 1 - startRect.height - ); - return { - x: nextX, - y: nextY, - width: startRect.width, - height: startRect.height, - }; - } - - if (!currentAspect) { - let x = startRect.x; - let y = startRect.y; - let width = startRect.width; - let height = startRect.height; - - if (handle.includes("w")) { - x = clampNumber(pointer.x, 0, startRight - minWidth); - width = startRight - x; - } - if (handle.includes("e")) { - width = clampNumber(pointer.x - startRect.x, minWidth, 1 - startRect.x); - } - if (handle.includes("n")) { - y = clampNumber(pointer.y, 0, startBottom - minHeight); - height = startBottom - y; - } - if (handle.includes("s")) { - height = clampNumber(pointer.y - startRect.y, minHeight, 1 - startRect.y); - } - - return { - x, - y, - width, - height, - }; - } - - if (handle === "n" || handle === "s") { - const anchorY = handle === "n" ? startBottom : startRect.y; - let height = Math.abs(anchorY - pointer.y); - height = clampNumber( - height, - minHeight, - handle === "n" ? anchorY : 1 - anchorY - ); - let width = height * currentAspect; - const centerX = startRect.x + startRect.width / 2; - const maxWidth = Math.min(centerX, 1 - centerX) * 2; - if (width > maxWidth) { - width = maxWidth; - height = width / currentAspect; - } - return { - x: clampNumber(centerX - width / 2, 0, 1 - width), - y: handle === "n" ? anchorY - height : anchorY, - width, - height, - }; - } - - if (handle === "w" || handle === "e") { - const anchorX = handle === "w" ? startRight : startRect.x; - let width = Math.abs(anchorX - pointer.x); - width = clampNumber(width, minWidth, handle === "w" ? anchorX : 1 - anchorX); - let height = width / currentAspect; - const centerY = startRect.y + startRect.height / 2; - const maxHeight = Math.min(centerY, 1 - centerY) * 2; - if (height > maxHeight) { - height = maxHeight; - width = height * currentAspect; - } - return { - x: handle === "w" ? anchorX - width : anchorX, - y: clampNumber(centerY - height / 2, 0, 1 - height), - width, - height, - }; - } - - const isWest = handle.includes("w"); - const isNorth = handle.includes("n"); - const anchorX = isWest ? startRight : startRect.x; - const anchorY = isNorth ? startBottom : startRect.y; - const maxWidth = isWest ? anchorX : 1 - anchorX; - const maxHeight = isNorth ? anchorY : 1 - anchorY; - - let width = Math.abs(anchorX - pointer.x); - let height = width / currentAspect; - - if (height > maxHeight) { - height = maxHeight; - width = height * currentAspect; - } - if (width > maxWidth) { - width = maxWidth; - height = width / currentAspect; - } - if (width < minWidth) { - width = minWidth; - height = width / currentAspect; - } - if (height < minHeight) { - height = minHeight; - width = height * currentAspect; - } - if (height > maxHeight) { - height = maxHeight; - width = height * currentAspect; - } - if (width > maxWidth) { - width = maxWidth; - height = width / currentAspect; - } - - return { - x: isWest ? anchorX - width : anchorX, - y: isNorth ? anchorY - height : anchorY, - width, - height, - }; -}; - const normalizeTextEditItems = ( items: ImageTextEditItem[] | undefined ): ImageTextEditItem[] => { diff --git a/components/canvas/nodes/image-node/image-node-crop.ts b/components/canvas/nodes/image-node/image-node-crop.ts new file mode 100644 index 000000000..a70f13457 --- /dev/null +++ b/components/canvas/nodes/image-node/image-node-crop.ts @@ -0,0 +1,329 @@ +import type { NormalizedCropRect } from "@/services/canvas"; + +export type CropPanelGroupKey = + | "common" + | "instagram" + | "facebook" + | "tiktok" + | "youtube"; + +export type CropInteractionHandle = + | "move" + | "n" + | "s" + | "e" + | "w" + | "nw" + | "ne" + | "sw" + | "se"; + +export type CropPresetOption = { + id: string; + label: string; + ratio: number; +}; + +export type CropPresetGroup = { + key: CropPanelGroupKey; + label: string; + options: CropPresetOption[]; +}; + +export interface BoxCutoutPoint { + x: number; + y: number; +} + +export const DEFAULT_CROP_PRESET_ID = "1:1"; + +export const CROP_PRESET_GROUPS: CropPresetGroup[] = [ + { + key: "common", + label: "通用", + options: [ + { id: "1:1", label: "1:1", ratio: 1 }, + { id: "3:4", label: "3:4", ratio: 3 / 4 }, + { id: "2:3", label: "2:3", ratio: 2 / 3 }, + { id: "9:16", label: "9:16", ratio: 9 / 16 }, + { id: "4:3", label: "4:3", ratio: 4 / 3 }, + { id: "3:2", label: "3:2", ratio: 3 / 2 }, + { id: "16:9", label: "16:9", ratio: 16 / 9 }, + ], + }, + { + key: "instagram", + label: "Instagram", + options: [ + { id: "ig-square", label: "1:1", ratio: 1 }, + { id: "ig-portrait", label: "4:5", ratio: 4 / 5 }, + { id: "ig-story", label: "9:16", ratio: 9 / 16 }, + ], + }, + { + key: "facebook", + label: "Facebook", + options: [ + { id: "fb-feed", label: "4:5", ratio: 4 / 5 }, + { id: "fb-cover", label: "16:9", ratio: 16 / 9 }, + ], + }, + { + key: "tiktok", + label: "TikTok", + options: [{ id: "tt-video", label: "9:16", ratio: 9 / 16 }], + }, + { + key: "youtube", + label: "YouTube", + options: [ + { id: "yt-cover", label: "16:9", ratio: 16 / 9 }, + { id: "yt-short", label: "9:16", ratio: 9 / 16 }, + ], + }, +]; + +const clampCropNumber = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, value)); + +export const createNormalizedRect = ( + start: BoxCutoutPoint, + end: BoxCutoutPoint +): NormalizedCropRect => { + const x = Math.min(start.x, end.x); + const y = Math.min(start.y, end.y); + const right = Math.max(start.x, end.x); + const bottom = Math.max(start.y, end.y); + + return { + x, + y, + width: clampCropNumber(right - x, 0, 1), + height: clampCropNumber(bottom - y, 0, 1), + }; +}; + +export const getCropPresetById = (presetId: string) => { + for (const group of CROP_PRESET_GROUPS) { + const matched = group.options.find((option) => option.id === presetId); + if (matched) return matched; + } + return null; +}; + +export const getNormalizedMinCropSize = ( + naturalWidth: number, + naturalHeight: number +) => ({ + width: naturalWidth > 0 ? Math.min(1, 24 / naturalWidth) : 0.02, + height: naturalHeight > 0 ? Math.min(1, 24 / naturalHeight) : 0.02, +}); + +export const createCenteredCropRectForAspect = ( + ratio: number, + centerX = 0.5, + centerY = 0.5 +): NormalizedCropRect => { + const safeRatio = Number.isFinite(ratio) && ratio > 0 ? ratio : 1; + const maxHalfWidth = Math.min(centerX, 1 - centerX); + const maxHalfHeight = Math.min(centerY, 1 - centerY); + + let width = maxHalfHeight * 2 * safeRatio; + let height = width / safeRatio; + + if (width > maxHalfWidth * 2) { + width = maxHalfWidth * 2; + height = width / safeRatio; + } + + if (height > maxHalfHeight * 2) { + height = maxHalfHeight * 2; + width = height * safeRatio; + } + + return { + x: clampCropNumber(centerX - width / 2, 0, 1 - width), + y: clampCropNumber(centerY - height / 2, 0, 1 - height), + width: clampCropNumber(width, 0, 1), + height: clampCropNumber(height, 0, 1), + }; +}; + +export const createCenteredCropRectFromSize = ( + centerX: number, + centerY: number, + width: number, + height: number +): NormalizedCropRect => { + const safeWidth = clampCropNumber(width, 0.0001, 1); + const safeHeight = clampCropNumber(height, 0.0001, 1); + let x = centerX - safeWidth / 2; + let y = centerY - safeHeight / 2; + + x = clampCropNumber(x, 0, 1 - safeWidth); + y = clampCropNumber(y, 0, 1 - safeHeight); + + return { + x, + y, + width: safeWidth, + height: safeHeight, + }; +}; + +export const updateCropRectWithPointer = ({ + aspectRatio, + handle, + minHeight, + minWidth, + pointer, + startPoint, + startRect, +}: { + aspectRatio: number | null; + handle: CropInteractionHandle; + minHeight: number; + minWidth: number; + pointer: BoxCutoutPoint; + startPoint: BoxCutoutPoint; + startRect: NormalizedCropRect; +}): NormalizedCropRect => { + const startRight = startRect.x + startRect.width; + const startBottom = startRect.y + startRect.height; + const currentAspect = + aspectRatio && Number.isFinite(aspectRatio) && aspectRatio > 0 + ? aspectRatio + : null; + + if (handle === "move") { + const nextX = clampCropNumber( + startRect.x + (pointer.x - startPoint.x), + 0, + 1 - startRect.width + ); + const nextY = clampCropNumber( + startRect.y + (pointer.y - startPoint.y), + 0, + 1 - startRect.height + ); + return { + x: nextX, + y: nextY, + width: startRect.width, + height: startRect.height, + }; + } + + if (!currentAspect) { + let x = startRect.x; + let y = startRect.y; + let width = startRect.width; + let height = startRect.height; + + if (handle.includes("w")) { + x = clampCropNumber(pointer.x, 0, startRight - minWidth); + width = startRight - x; + } + if (handle.includes("e")) { + width = clampCropNumber(pointer.x - startRect.x, minWidth, 1 - startRect.x); + } + if (handle.includes("n")) { + y = clampCropNumber(pointer.y, 0, startBottom - minHeight); + height = startBottom - y; + } + if (handle.includes("s")) { + height = clampCropNumber(pointer.y - startRect.y, minHeight, 1 - startRect.y); + } + + return { + x, + y, + width, + height, + }; + } + + if (handle === "n" || handle === "s") { + const anchorY = handle === "n" ? startBottom : startRect.y; + let height = Math.abs(anchorY - pointer.y); + height = clampCropNumber( + height, + minHeight, + handle === "n" ? anchorY : 1 - anchorY + ); + let width = height * currentAspect; + const centerX = startRect.x + startRect.width / 2; + const maxWidth = Math.min(centerX, 1 - centerX) * 2; + if (width > maxWidth) { + width = maxWidth; + height = width / currentAspect; + } + return { + x: clampCropNumber(centerX - width / 2, 0, 1 - width), + y: handle === "n" ? anchorY - height : anchorY, + width, + height, + }; + } + + if (handle === "w" || handle === "e") { + const anchorX = handle === "w" ? startRight : startRect.x; + let width = Math.abs(anchorX - pointer.x); + width = clampCropNumber(width, minWidth, handle === "w" ? anchorX : 1 - anchorX); + let height = width / currentAspect; + const centerY = startRect.y + startRect.height / 2; + const maxHeight = Math.min(centerY, 1 - centerY) * 2; + if (height > maxHeight) { + height = maxHeight; + width = height * currentAspect; + } + return { + x: handle === "w" ? anchorX - width : anchorX, + y: clampCropNumber(centerY - height / 2, 0, 1 - height), + width, + height, + }; + } + + const isWest = handle.includes("w"); + const isNorth = handle.includes("n"); + const anchorX = isWest ? startRight : startRect.x; + const anchorY = isNorth ? startBottom : startRect.y; + const maxWidth = isWest ? anchorX : 1 - anchorX; + const maxHeight = isNorth ? anchorY : 1 - anchorY; + + let width = Math.abs(anchorX - pointer.x); + let height = width / currentAspect; + + if (height > maxHeight) { + height = maxHeight; + width = height * currentAspect; + } + if (width > maxWidth) { + width = maxWidth; + height = width / currentAspect; + } + if (width < minWidth) { + width = minWidth; + height = width / currentAspect; + } + if (height < minHeight) { + height = minHeight; + width = height * currentAspect; + } + if (height > maxHeight) { + height = maxHeight; + width = height * currentAspect; + } + if (width > maxWidth) { + width = maxWidth; + height = width / currentAspect; + } + + return { + x: isWest ? anchorX - width : anchorX, + y: isNorth ? anchorY - height : anchorY, + width, + height, + }; +};