fix: use target box flow for canvas scale adjust

This commit is contained in:
134 2026-06-22 19:52:55 +08:00
parent aaf3e08dd8
commit bf8410a6d4

View File

@ -396,22 +396,9 @@ type LocalEditRegionInteractionState = {
};
type CanvasLocalEditOperationMode = "local_edit" | "scale_adjust";
type CanvasScaleAdjustPercent = -30 | -20 | -10 | 10 | 20 | 30 | 50 | 100;
const CANVAS_SCALE_TARGET_DEFAULT_PERCENT: CanvasScaleAdjustPercent = 50;
const CANVAS_SCALE_TARGET_MIN_PERCENT = -80;
const CANVAS_SCALE_TARGET_MAX_PERCENT = 200;
const CANVAS_SCALE_ADJUST_OPTIONS: CanvasScaleAdjustPercent[] = [
-30,
-20,
-10,
10,
20,
30,
50,
100,
];
function getCanvasScaleAdjustLabel(scalePercent: number) {
return scalePercent > 0 ? `放大 ${scalePercent}%` : `缩小 ${Math.abs(scalePercent)}%`;
}
@ -440,36 +427,6 @@ function getCanvasScaleAdjustRegionPrompt(
].join(" ");
}
function scaleCanvasRectAroundCenter(
rect: NormalizedCropRect,
scalePercent: number
): NormalizedCropRect {
const requestedScale = Math.max(0.05, 1 + scalePercent / 100);
const maxUniformScale = Math.max(
0.05,
Math.min(
rect.width > 0 ? 1 / rect.width : requestedScale,
rect.height > 0 ? 1 / rect.height : requestedScale
)
);
const scale = Math.min(requestedScale, maxUniformScale);
const width = Math.min(1, rect.width * scale);
const height = Math.min(1, rect.height * scale);
const centerX = rect.x + rect.width / 2;
const centerY = rect.y + rect.height / 2;
const x = Math.min(Math.max(centerX - width / 2, 0), Math.max(0, 1 - width));
const y = Math.min(Math.max(centerY - height / 2, 0), Math.max(0, 1 - height));
return { x, y, width, height };
}
function getCanvasScaleTargetRect(
region: Pick<LocalEditRegion, "rect" | "targetRect">,
fallbackPercent: number
): NormalizedCropRect {
return region.targetRect || scaleCanvasRectAroundCenter(region.rect, fallbackPercent);
}
function deriveCanvasScalePercentFromRects(
sourceRect: NormalizedCropRect,
targetRect: NormalizedCropRect | null | undefined
@ -1612,8 +1569,6 @@ export function ImageNode({
const [localEditMode, setLocalEditMode] = useState(false);
const [localEditOperationMode, setLocalEditOperationMode] =
useState<CanvasLocalEditOperationMode>("local_edit");
const [selectedLocalEditScalePercent, setSelectedLocalEditScalePercent] =
useState<CanvasScaleAdjustPercent | null>(null);
const [localEditRegions, setLocalEditRegions] = useState<LocalEditRegion[]>(
() => getLocalEditRegionsFromMetadata(data.metadata)
);
@ -1625,6 +1580,8 @@ export function ImageNode({
useState<BoxCutoutPoint | null>(null);
const [localEditDragEnd, setLocalEditDragEnd] =
useState<BoxCutoutPoint | null>(null);
const [localEditDrawingTargetRegionId, setLocalEditDrawingTargetRegionId] =
useState<string | null>(null);
const [localEditRegionInteraction, setLocalEditRegionInteraction] =
useState<LocalEditRegionInteractionState | null>(null);
const [editingLocalEditRegionId, setEditingLocalEditRegionId] =
@ -2380,23 +2337,18 @@ export function ImageNode({
if (!boxCutoutMetrics || localEditOperationMode !== "scale_adjust") {
return [];
}
const fallbackPercent =
selectedLocalEditScalePercent || CANVAS_SCALE_TARGET_DEFAULT_PERCENT;
return localEditRegions.map((region) => {
const targetRect = getCanvasScaleTargetRect(region, fallbackPercent);
return {
region,
targetRect,
scalePercent: deriveCanvasScalePercentFromRects(region.rect, targetRect),
style: getNormalizedRectOverlayStyle(targetRect, boxCutoutMetrics),
};
});
}, [
boxCutoutMetrics,
localEditOperationMode,
localEditRegions,
selectedLocalEditScalePercent,
]);
return localEditRegions
.filter((region) => region.targetRect)
.map((region) => {
const targetRect = region.targetRect as NormalizedCropRect;
return {
region,
targetRect,
scalePercent: deriveCanvasScalePercentFromRects(region.rect, targetRect),
style: getNormalizedRectOverlayStyle(targetRect, boxCutoutMetrics),
};
});
}, [boxCutoutMetrics, localEditOperationMode, localEditRegions]);
const localEditPromptInlineOverlays = useMemo(() => {
if (!localEditMode || !boxCutoutMetrics) {
return [];
@ -2558,10 +2510,10 @@ export function ImageNode({
const resetLocalEditState = useCallback(() => {
setLocalEditMode(false);
setLocalEditOperationMode("local_edit");
setSelectedLocalEditScalePercent(null);
setIsLocalEditDrawing(false);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
setLocalEditRegionInteraction(null);
setEditingLocalEditRegionId(null);
setUploadingLocalEditReferenceRegionId(null);
@ -3525,11 +3477,9 @@ export function ImageNode({
resetBoxCutoutState();
setLocalEditRegions(getLocalEditRegionsFromMetadata(data.metadata));
setLocalEditOperationMode(operationMode);
setSelectedLocalEditScalePercent(
operationMode === "scale_adjust" ? CANVAS_SCALE_TARGET_DEFAULT_PERCENT : null
);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
setIsLocalEditDrawing(false);
setLocalEditMode(true);
window.requestAnimationFrame(() => {
@ -3537,7 +3487,7 @@ export function ImageNode({
});
toast.info(
operationMode === "scale_adjust"
? "先框选要调整比例的主体,再拖动青色目标框调整放大后的大小和位置"
? "先框选要调整比例的主体,再框选青色目标大小和位置"
: "拖拽框选要局部编辑的位置,并在框下方填写提示词"
);
},
@ -3560,7 +3510,11 @@ export function ImageNode({
event.preventDefault();
event.stopPropagation();
if (localEditRegions.length >= LOCAL_EDIT_MAX_REGIONS) {
const pendingTargetRegion =
localEditOperationMode === "scale_adjust"
? localEditRegions.find((region) => !region.targetRect)
: null;
if (!pendingTargetRegion && localEditRegions.length >= LOCAL_EDIT_MAX_REGIONS) {
toast.info(`局部编辑最多支持 ${LOCAL_EDIT_MAX_REGIONS} 个区域`);
return;
}
@ -3572,6 +3526,7 @@ export function ImageNode({
if (!pointerResult?.insideImage) return;
setEditingLocalEditRegionId(null);
setLocalEditDrawingTargetRegionId(pendingTargetRegion?.id || null);
setLocalEditDragStart(pointerResult.point);
setLocalEditDragEnd(pointerResult.point);
setIsLocalEditDrawing(true);
@ -3582,6 +3537,8 @@ export function ImageNode({
isProcessing,
isSubmittingLocalEdit,
localEditMode,
localEditOperationMode,
localEditRegions,
localEditRegions.length,
]
);
@ -3616,6 +3573,7 @@ export function ImageNode({
setIsLocalEditDrawing(false);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
return;
}
@ -3631,29 +3589,48 @@ export function ImageNode({
nextWidthPx >= LOCAL_EDIT_MIN_EDGE_PX &&
nextHeightPx >= LOCAL_EDIT_MIN_EDGE_PX
) {
if (localEditDrawingTargetRegionId) {
const nextRegions = reindexLocalEditRegions(
localEditRegions.map((region) => {
if (region.id !== localEditDrawingTargetRegionId) return region;
const scalePercent = deriveCanvasScalePercentFromRects(
region.rect,
nextRect
);
return {
...region,
targetRect: nextRect,
prompt: getCanvasScaleAdjustRegionPrompt(scalePercent, true),
};
})
);
commitLocalEditRegions(nextRegions);
toast.info("已设置青色目标框,可拖动它继续调整大小和位置");
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
setIsLocalEditDrawing(false);
return;
}
const isScaleAdjustMode = localEditOperationMode === "scale_adjust";
const scalePercent =
selectedLocalEditScalePercent || CANVAS_SCALE_TARGET_DEFAULT_PERCENT;
const targetRect = isScaleAdjustMode
? scaleCanvasRectAroundCenter(nextRect, scalePercent)
: undefined;
const nextRegion: LocalEditRegion = {
id: createLocalEditRegionId(),
index: localEditRegions.length + 1,
color:
LOCAL_EDIT_COLORS[localEditRegions.length % LOCAL_EDIT_COLORS.length],
prompt: isScaleAdjustMode
? getCanvasScaleAdjustRegionPrompt(scalePercent, true)
? "比例调整"
: "",
rect: nextRect,
...(targetRect ? { targetRect } : {}),
referenceImages: [],
createdAt: Date.now(),
};
commitLocalEditRegions([...localEditRegions, nextRegion]);
setEditingLocalEditRegionId(isScaleAdjustMode ? null : nextRegion.id);
if (isScaleAdjustMode) {
toast.info("已生成青色目标框,可拖动它调整放大后的大小和位置");
setLocalEditDrawingTargetRegionId(nextRegion.id);
toast.info("继续拖拽框选青色目标大小和位置");
}
} else {
toast.info("框选区域太小,请至少拖出 24px 以上的区域");
@ -3661,16 +3638,17 @@ export function ImageNode({
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
setIsLocalEditDrawing(false);
},
[
commitLocalEditRegions,
getBoxCutoutPointerPoint,
localEditDragStart,
localEditDrawingTargetRegionId,
localEditMode,
localEditOperationMode,
localEditRegions,
selectedLocalEditScalePercent,
]
);
@ -3695,6 +3673,7 @@ export function ImageNode({
setIsLocalEditDrawing(false);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
event.currentTarget.releasePointerCapture?.(event.pointerId);
},
[localEditMode]
@ -3711,7 +3690,6 @@ export function ImageNode({
localEditRegionsRef.current.map((region) => {
if (region.id !== regionId) return region;
if (target === "scaleTarget") {
setSelectedLocalEditScalePercent(null);
const scalePercent = deriveCanvasScalePercentFromRects(
region.rect,
rect
@ -4001,23 +3979,6 @@ export function ImageNode({
setEditingLocalEditRegionId(null);
}, [commitLocalEditRegions]);
const applyLocalEditScalePercent = useCallback(
(scalePercent: CanvasScaleAdjustPercent) => {
setSelectedLocalEditScalePercent(scalePercent);
const nextRegions = reindexLocalEditRegions(
localEditRegionsRef.current.map((region) => ({
...region,
targetRect: scaleCanvasRectAroundCenter(region.rect, scalePercent),
prompt: getCanvasScaleAdjustRegionPrompt(scalePercent, true),
}))
);
localEditRegionsRef.current = nextRegions;
setLocalEditRegions(nextRegions);
commitLocalEditRegions(nextRegions);
},
[commitLocalEditRegions]
);
const handleSubmitLocalEdit = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
@ -4033,19 +3994,15 @@ export function ImageNode({
}
const isScaleAdjustMode = localEditOperationMode === "scale_adjust";
const scaleFallbackPercent =
selectedLocalEditScalePercent || CANVAS_SCALE_TARGET_DEFAULT_PERCENT;
const validRegions = reindexLocalEditRegions(
localEditRegions.map((region) => {
if (isScaleAdjustMode) {
const targetRect = getCanvasScaleTargetRect(region, scaleFallbackPercent);
const regionScalePercent = deriveCanvasScalePercentFromRects(
region.rect,
targetRect
region.targetRect
);
return {
...region,
targetRect,
prompt: getCanvasScaleAdjustRegionPrompt(regionScalePercent, true),
};
}
@ -4063,6 +4020,11 @@ export function ImageNode({
return;
}
if (isScaleAdjustMode) {
const missingTargetRegion = validRegions.find((region) => !region.targetRect);
if (missingTargetRegion) {
toast.error(`请为区域 ${missingTargetRegion.index} 框选青色目标框`);
return;
}
const unchangedRegion = validRegions.find((region) => {
const regionScalePercent = deriveCanvasScalePercentFromRects(
region.rect,
@ -4282,7 +4244,6 @@ export function ImageNode({
node.size.width,
resetLocalEditState,
selectNode,
selectedLocalEditScalePercent,
updateNode,
]);
@ -8675,24 +8636,32 @@ export function ImageNode({
setEditingLocalEditRegionId(region.id);
}
}}
title={
localEditOperationMode === "scale_adjust"
? selectedLocalEditScalePercent
? getCanvasScaleAdjustLabel(selectedLocalEditScalePercent)
: "请选择比例"
: region.prompt
}
type="button"
>
{localEditOperationMode === "scale_adjust"
? `${region.index}. ${
selectedLocalEditScalePercent
? getCanvasScaleAdjustLabel(
selectedLocalEditScalePercent
)
: "请选择比例"
}`
: `${region.index}. ${region.prompt}`}
title={
localEditOperationMode === "scale_adjust"
? region.targetRect
? getCanvasScaleAdjustLabel(
deriveCanvasScalePercentFromRects(
region.rect,
region.targetRect
)
)
: "请框选青色目标框"
: region.prompt
}
type="button"
>
{localEditOperationMode === "scale_adjust"
? `${region.index}. ${
region.targetRect
? getCanvasScaleAdjustLabel(
deriveCanvasScalePercentFromRects(
region.rect,
region.targetRect
)
)
: "请框选目标框"
}`
: `${region.index}. ${region.prompt}`}
</button>
)}
<input
@ -8836,7 +8805,12 @@ export function ImageNode({
: null}
{localEditDraftRectStyle ? (
<div
className="pointer-events-none absolute z-[162] rounded-none border-[1.5px] border-[#27a8ff] bg-transparent shadow-none"
className={cn(
"pointer-events-none absolute z-[162] rounded-none border-[1.5px] bg-transparent shadow-none",
localEditDrawingTargetRegionId
? "border-dashed border-cyan-300"
: "border-[#27a8ff]"
)}
style={{
left: localEditDraftRectStyle.left,
top: localEditDraftRectStyle.top,
@ -9294,11 +9268,10 @@ export function ImageNode({
? "bg-white text-slate-950 shadow-sm dark:bg-white dark:text-slate-950"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-white"
)}
disabled={isSubmittingLocalEdit}
onClick={() => {
setLocalEditOperationMode("local_edit");
setSelectedLocalEditScalePercent(null);
}}
disabled={isSubmittingLocalEdit}
onClick={() => {
setLocalEditOperationMode("local_edit");
}}
type="button"
variant="ghost"
>
@ -9311,14 +9284,11 @@ export function ImageNode({
? "bg-white text-slate-950 shadow-sm dark:bg-white dark:text-slate-950"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-white"
)}
disabled={isSubmittingLocalEdit}
onClick={() => {
setLocalEditOperationMode("scale_adjust");
if (!selectedLocalEditScalePercent) {
applyLocalEditScalePercent(CANVAS_SCALE_TARGET_DEFAULT_PERCENT);
}
setEditingLocalEditRegionId(null);
}}
disabled={isSubmittingLocalEdit}
onClick={() => {
setLocalEditOperationMode("scale_adjust");
setEditingLocalEditRegionId(null);
}}
type="button"
variant="ghost"
>
@ -9328,43 +9298,23 @@ export function ImageNode({
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
{localEditOperationMode === "scale_adjust" ? (
<div className="inline-flex shrink-0 items-center gap-1">
<span className="hidden whitespace-nowrap px-1 text-[12px] font-medium text-slate-500 dark:text-slate-300 lg:inline">
拖动青色目标框
</span>
{CANVAS_SCALE_ADJUST_OPTIONS.map((option) => {
const active = selectedLocalEditScalePercent === option;
const Icon = option > 0 ? Maximize2 : Minimize2;
return (
<Button
className={cn(
"inline-flex h-9 items-center justify-center rounded-xl border px-2 text-[12px] font-semibold",
active
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-300/45 dark:bg-emerald-400/16 dark:text-emerald-100"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50 hover:text-slate-950 dark:border-white/12 dark:bg-zinc-900 dark:text-slate-200 dark:hover:bg-white/10"
)}
disabled={isSubmittingLocalEdit}
key={option}
onClick={() => applyLocalEditScalePercent(option)}
title={getCanvasScaleAdjustLabel(option)}
type="button"
variant="ghost"
>
<Icon className="mr-1 h-3.5 w-3.5 shrink-0" />
{option > 0 ? `+${option}%` : `${option}%`}
</Button>
);
})}
</div>
) : null}
{localEditOperationMode === "scale_adjust" ? (
<div className="inline-flex h-10 shrink-0 items-center gap-2 rounded-xl border border-cyan-300/35 bg-cyan-50 px-3 text-[12px] font-semibold text-cyan-800 dark:border-cyan-300/25 dark:bg-cyan-400/10 dark:text-cyan-100">
<Maximize2 className="h-3.5 w-3.5 shrink-0" />
<span className="whitespace-nowrap">
先框主体,再框目标大小和位置
</span>
</div>
) : null}
<Button
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-950 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white disabled:bg-slate-300 disabled:text-slate-500 dark:border dark:border-white/14 dark:bg-black dark:text-white dark:disabled:border-transparent dark:disabled:bg-slate-700 dark:disabled:text-slate-400 dark:hover:bg-zinc-900"
disabled={
isSubmittingLocalEdit ||
localEditRegions.length === 0
}
disabled={
isSubmittingLocalEdit ||
localEditRegions.length === 0 ||
(localEditOperationMode === "scale_adjust" &&
localEditRegions.some((region) => !region.targetRect))
}
onClick={() => void handleSubmitLocalEdit()}
title={
localEditOperationMode === "scale_adjust"