refactor: extract image node crop geometry

This commit is contained in:
Your Name 2026-05-27 09:57:27 +08:00
parent f0a7cbe2ac
commit 0950e6827b
2 changed files with 342 additions and 324 deletions

View File

@ -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[] => {

View File

@ -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,
};
};