feat: improve canvas local edit scale adjustment

This commit is contained in:
134 2026-06-18 18:41:17 +08:00
parent 07854d7c6a
commit f29a26b43f
2 changed files with 815 additions and 220 deletions

View File

@ -256,6 +256,16 @@ type DetailResultLocalEditRect = {
height: number;
};
type DetailResultLocalEditRegionHandle = "move" | "nw" | "ne" | "sw" | "se";
type DetailResultLocalEditRegionInteraction = {
handle: DetailResultLocalEditRegionHandle;
pointerId: number;
regionId: string;
startPoint: { x: number; y: number };
startRect: DetailResultLocalEditRect;
};
type DetailResultLocalEditSubmitParams = {
imageUrl: string;
mode: DetailResultLocalEditMode;
@ -264,6 +274,16 @@ type DetailResultLocalEditSubmitParams = {
regions: LocalEditRegion[];
};
type DetailResultLocalEditPreviewResult = {
cardId: string;
resultModeType: "detail_generation" | "detail_replacement";
sourceImageUrl: string;
resultImageUrl: string;
mode: DetailResultLocalEditMode;
title: string;
scalePercent?: DetailResultScalePercent;
};
const MAX_CHAT_TEXT_PART_LENGTH = 2000;
const MAX_CHAT_HIDDEN_PROMPT_LENGTH = 4000;
const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100;
@ -278,6 +298,7 @@ const DETAIL_SCALE_ADJUST_OPTIONS: DetailResultScalePercent[] = [
20,
30,
];
const DETAIL_LOCAL_EDIT_MIN_RECT_RATIO = 0.025;
const DIRECT_MODEL_UPLOAD_SLOT_COUNT = 4;
const PRODUCT_DETAIL_CHANNEL_OPTIONS: Array<{
value: AgentWorkflowProductDetailChannel;
@ -846,31 +867,46 @@ function getDetailScaleAdjustRegionPrompt(scalePercent: DetailResultScalePercent
}
function DetailResultLocalEditDialog({
previewResult,
submitting,
target,
onClose,
onConfirmResult,
onResetResult,
onSubmit,
}: {
previewResult: DetailResultLocalEditPreviewResult | null;
submitting: boolean;
target: DetailResultLocalEditTarget | null;
onClose: () => void;
onConfirmResult: () => void;
onResetResult: () => void;
onSubmit: (params: DetailResultLocalEditSubmitParams) => void;
}) {
const [regions, setRegions] = useState<LocalEditRegion[]>([]);
const [draftRect, setDraftRect] = useState<DetailResultLocalEditRect | null>(null);
const [activeRegionId, setActiveRegionId] = useState<string | null>(null);
const [regionInteraction, setRegionInteraction] =
useState<DetailResultLocalEditRegionInteraction | null>(null);
const [uploadingRegionId, setUploadingRegionId] = useState<string | null>(null);
const [selectedScalePercent, setSelectedScalePercent] =
useState<DetailResultScalePercent | null>(null);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const editSurfaceRef = useRef<HTMLDivElement | null>(null);
const mode: DetailResultLocalEditMode =
target?.mode === "scale_adjust" ? "scale_adjust" : "local_edit";
const isScaleAdjustMode = mode === "scale_adjust";
const activePreviewResult =
previewResult && target && previewResult.cardId === target.cardId
? previewResult
: null;
const isBusy = submitting || Boolean(uploadingRegionId);
useEffect(() => {
setRegions([]);
setDraftRect(null);
setActiveRegionId(null);
setRegionInteraction(null);
setUploadingRegionId(null);
setSelectedScalePercent(null);
dragStartRef.current = null;
@ -886,8 +922,9 @@ function DetailResultLocalEditDialog({
}
}, [activeRegionId, regions]);
const getPoint = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const bounds = event.currentTarget.getBoundingClientRect();
const getPoint = useCallback((event: React.PointerEvent<HTMLElement>) => {
const pointSource = editSurfaceRef.current || event.currentTarget;
const bounds = pointSource.getBoundingClientRect();
const x = bounds.width > 0 ? (event.clientX - bounds.left) / bounds.width : 0;
const y = bounds.height > 0 ? (event.clientY - bounds.top) / bounds.height : 0;
return {
@ -903,13 +940,89 @@ function DetailResultLocalEditDialog({
return {
x: left,
y: top,
width: Math.min(1 - left, Math.max(0.01, Math.abs(point.x - start.x))),
height: Math.min(1 - top, Math.max(0.01, Math.abs(point.y - start.y))),
width: Math.min(
1 - left,
Math.max(DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, Math.abs(point.x - start.x))
),
height: Math.min(
1 - top,
Math.max(DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, Math.abs(point.y - start.y))
),
};
},
[]
);
const clampRegionRect = useCallback((rect: DetailResultLocalEditRect) => {
const width = Math.min(1, Math.max(DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, rect.width));
const height = Math.min(1, Math.max(DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, rect.height));
return {
x: Math.min(1 - width, Math.max(0, rect.x)),
y: Math.min(1 - height, Math.max(0, rect.y)),
width,
height,
};
}, []);
const updateRegionRect = useCallback(
(regionId: string, rect: DetailResultLocalEditRect) => {
const nextRect = clampRegionRect(rect);
setRegions((current) =>
current.map((region) =>
region.id === regionId ? { ...region, rect: nextRect } : region
)
);
},
[clampRegionRect]
);
const calculateInteractionRect = useCallback(
(
interaction: DetailResultLocalEditRegionInteraction,
point: { x: number; y: number }
) => {
const dx = point.x - interaction.startPoint.x;
const dy = point.y - interaction.startPoint.y;
const start = interaction.startRect;
if (interaction.handle === "move") {
return clampRegionRect({
...start,
x: start.x + dx,
y: start.y + dy,
});
}
const leftHandle = interaction.handle === "nw" || interaction.handle === "sw";
const rightHandle = interaction.handle === "ne" || interaction.handle === "se";
const topHandle = interaction.handle === "nw" || interaction.handle === "ne";
const bottomHandle = interaction.handle === "sw" || interaction.handle === "se";
let left = start.x;
let right = start.x + start.width;
let top = start.y;
let bottom = start.y + start.height;
if (leftHandle) left = Math.min(right - DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, start.x + dx);
if (rightHandle) right = Math.max(left + DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, right + dx);
if (topHandle) top = Math.min(bottom - DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, start.y + dy);
if (bottomHandle) bottom = Math.max(top + DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, bottom + dy);
left = Math.max(0, left);
top = Math.max(0, top);
right = Math.min(1, right);
bottom = Math.min(1, bottom);
return clampRegionRect({
x: left,
y: top,
width: right - left,
height: bottom - top,
});
},
[clampRegionRect]
);
const updateDraftRect = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const start = dragStartRef.current;
@ -1077,7 +1190,10 @@ function DetailResultLocalEditDialog({
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (nextRect.width < 0.025 || nextRect.height < 0.025) {
if (
nextRect.width < DETAIL_LOCAL_EDIT_MIN_RECT_RATIO ||
nextRect.height < DETAIL_LOCAL_EDIT_MIN_RECT_RATIO
) {
toast.info("请拖拽框选更明确的编辑区域");
return;
}
@ -1102,11 +1218,73 @@ function DetailResultLocalEditDialog({
[createRectFromPoints, getPoint, isScaleAdjustMode, regions.length, selectedScalePercent]
);
const handleRegionInteractionStart = useCallback(
(
event: React.PointerEvent<HTMLButtonElement>,
region: LocalEditRegion,
fallbackHandle: DetailResultLocalEditRegionHandle = "move"
) => {
if (isBusy) return;
const targetElement = event.target as HTMLElement | null;
const requestedHandle = targetElement?.dataset
?.detailRegionHandle as DetailResultLocalEditRegionHandle | undefined;
const handle = requestedHandle || fallbackHandle;
event.preventDefault();
event.stopPropagation();
setActiveRegionId(region.id);
dragStartRef.current = null;
setDraftRect(null);
event.currentTarget.setPointerCapture(event.pointerId);
setRegionInteraction({
handle,
pointerId: event.pointerId,
regionId: region.id,
startPoint: getPoint(event),
startRect: region.rect,
});
},
[getPoint, isBusy]
);
const handleRegionInteractionMove = useCallback(
(event: React.PointerEvent<HTMLButtonElement>, regionId: string) => {
if (
!regionInteraction ||
regionInteraction.pointerId !== event.pointerId ||
regionInteraction.regionId !== regionId
) {
return;
}
event.preventDefault();
event.stopPropagation();
updateRegionRect(regionId, calculateInteractionRect(regionInteraction, getPoint(event)));
},
[calculateInteractionRect, getPoint, regionInteraction, updateRegionRect]
);
const handleRegionInteractionEnd = useCallback(
(event: React.PointerEvent<HTMLButtonElement>, regionId: string) => {
if (
regionInteraction &&
regionInteraction.pointerId === event.pointerId &&
regionInteraction.regionId === regionId
) {
event.preventDefault();
event.stopPropagation();
updateRegionRect(regionId, calculateInteractionRect(regionInteraction, getPoint(event)));
setRegionInteraction(null);
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
},
[calculateInteractionRect, getPoint, regionInteraction, updateRegionRect]
);
const completionCopy =
target?.resultModeType === "detail_replacement"
? "处理完成后会更新当前套详情结果图。"
: "处理完成后会加入 AI详情结果区。";
const isBusy = submitting || Boolean(uploadingRegionId);
const dialogTitle = isScaleAdjustMode ? "比例调整详情图" : "局部编辑详情图";
const dialogDescription = isScaleAdjustMode
? "框选要调整比例的物体,选择固定放大或缩小幅度后生成。"
@ -1123,10 +1301,26 @@ function DetailResultLocalEditDialog({
</DialogHeader>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_340px]">
<div className="flex min-h-[520px] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/35 p-3">
{target?.imageUrl ? (
<div
className="relative max-h-[70vh] max-w-full touch-none select-none overflow-hidden rounded-xl"
onPointerCancel={(event) => {
{activePreviewResult?.resultImageUrl ? (
<div className="relative max-h-[70vh] max-w-full overflow-hidden rounded-xl">
<DetailResultCompareImage
alt={`${activePreviewResult.title || target?.title || "详情图"} 处理结果`}
compareSourceUrl={activePreviewResult.sourceImageUrl}
imageClassName="block max-h-[70vh] max-w-full object-contain"
previewQuality={88}
previewWidth={1200}
resultUrl={activePreviewResult.resultImageUrl}
/>
<div className="pointer-events-none absolute left-3 top-3 z-30 inline-flex items-center gap-1.5 rounded-full border border-emerald-200/45 bg-zinc-950/72 px-3 py-1.5 text-xs font-semibold text-emerald-50 shadow-[0_12px_30px_rgba(0,0,0,0.38)] backdrop-blur-md">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-300" />
处理完成
</div>
</div>
) : target?.imageUrl ? (
<div
className="relative max-h-[70vh] max-w-full touch-none select-none overflow-hidden rounded-xl"
ref={editSurfaceRef}
onPointerCancel={(event) => {
updateDraftRect(event);
dragStartRef.current = null;
setDraftRect(null);
@ -1163,24 +1357,36 @@ function DetailResultLocalEditDialog({
draggable={false}
src={buildImageProxyUrl(target.imageUrl, { width: 1200, quality: 88 })}
/>
{regions.map((region) => {
const isActive = region.id === activeRegionId;
return (
<button
className="absolute rounded-sm border-2 bg-black/0 text-left transition"
key={region.id}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setActiveRegionId(region.id);
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
setActiveRegionId(region.id);
}}
style={{
borderColor: region.color,
{regions.map((region) => {
const isActive = region.id === activeRegionId;
return (
<button
className={cn(
"absolute rounded-sm border-2 bg-black/0 text-left transition",
regionInteraction?.regionId === region.id
? "cursor-grabbing"
: "cursor-grab"
)}
key={region.id}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setActiveRegionId(region.id);
}}
onPointerCancel={(event) =>
handleRegionInteractionEnd(event, region.id)
}
onPointerDown={(event) =>
handleRegionInteractionStart(event, region)
}
onPointerMove={(event) =>
handleRegionInteractionMove(event, region.id)
}
onPointerUp={(event) =>
handleRegionInteractionEnd(event, region.id)
}
style={{
borderColor: region.color,
boxShadow: isActive
? `0 0 0 9999px rgba(0,0,0,0.20), 0 0 0 3px ${region.color}55`
: "0 0 0 9999px rgba(0,0,0,0.08)",
@ -1191,16 +1397,52 @@ function DetailResultLocalEditDialog({
}}
title={`区域 ${region.index}`}
type="button"
>
<span
className="absolute -left-2 -top-2 flex h-6 min-w-6 items-center justify-center rounded-full px-1.5 text-[11px] font-bold text-white shadow-lg"
style={{ backgroundColor: region.color }}
>
{region.index}
</span>
</button>
);
})}
>
<span
className="absolute -left-2 -top-2 flex h-6 min-w-6 items-center justify-center rounded-full px-1.5 text-[11px] font-bold text-white shadow-lg"
style={{ backgroundColor: region.color }}
>
{region.index}
</span>
{isActive
? (
[
{
handle: "nw",
className:
"-left-[6px] -top-[6px] cursor-nwse-resize",
},
{
handle: "ne",
className:
"-right-[6px] -top-[6px] cursor-nesw-resize",
},
{
handle: "sw",
className:
"-bottom-[6px] -left-[6px] cursor-nesw-resize",
},
{
handle: "se",
className:
"-bottom-[6px] -right-[6px] cursor-nwse-resize",
},
] as const
).map((item) => (
<span
className={cn(
"absolute h-3 w-3 rounded-[2px] border-2 bg-white shadow-[0_3px_10px_rgba(0,0,0,0.28)]",
item.className
)}
data-detail-region-handle={item.handle}
key={item.handle}
style={{ borderColor: region.color }}
/>
))
: null}
</button>
);
})}
{draftRect ? (
<div
className="pointer-events-none absolute border-2 border-sky-300 bg-sky-400/20 shadow-[0_0_0_9999px_rgba(0,0,0,0.24)]"
@ -1227,7 +1469,7 @@ function DetailResultLocalEditDialog({
? `拖拽框选物体,建议留出少量周边背景,最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`
: `拖拽图片添加区域。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域,每个区域最多 ${LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图。${completionCopy}`}
</div>
{isScaleAdjustMode ? (
{!activePreviewResult && isScaleAdjustMode ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.035] p-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
Scale
@ -1257,7 +1499,17 @@ function DetailResultLocalEditDialog({
</div>
</div>
) : null}
{regions.length === 0 ? (
{activePreviewResult ? (
<div className="flex min-h-[260px] flex-col justify-center rounded-2xl border border-emerald-200/18 bg-emerald-400/[0.07] p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-emerald-50">
<CheckCircle2 className="h-4 w-4 text-emerald-300" />
处理结果已生成
</div>
<div className="text-sm leading-6 text-white/58">
左侧展示的是本次处理后的图片。可以开启划线对比检查原图与新图差异;不满意可重新处理,满意后再确认应用到结果区。
</div>
</div>
) : regions.length === 0 ? (
<div className="flex min-h-[260px] items-center justify-center rounded-2xl border border-dashed border-white/12 bg-white/[0.02] px-5 text-center text-sm text-white/38">
{isScaleAdjustMode
? "在左侧图片上拖拽框选物体后,选择一个比例即可生成。"
@ -1413,29 +1665,54 @@ function DetailResultLocalEditDialog({
</div>
)}
<div className="mt-auto flex justify-end gap-2">
<Button
className="border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
disabled={submitting}
onClick={onClose}
type="button"
variant="outline"
>
取消
</Button>
<Button
className="bg-white text-black hover:bg-white/90"
disabled={
submitting ||
Boolean(uploadingRegionId) ||
regions.length === 0 ||
(isScaleAdjustMode && !selectedScalePercent)
}
onClick={handleSubmit}
type="button"
>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{isScaleAdjustMode ? "生成比例调整" : "生成局部编辑"}
</Button>
{activePreviewResult ? (
<>
<Button
className="border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
disabled={submitting}
onClick={onResetResult}
type="button"
variant="outline"
>
<RefreshCw className="mr-2 h-4 w-4" />
重新处理
</Button>
<Button
className="bg-white text-black hover:bg-white/90"
disabled={submitting}
onClick={onConfirmResult}
type="button"
>
确认应用结果
</Button>
</>
) : (
<>
<Button
className="border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
disabled={submitting}
onClick={onClose}
type="button"
variant="outline"
>
取消
</Button>
<Button
className="bg-white text-black hover:bg-white/90"
disabled={
submitting ||
Boolean(uploadingRegionId) ||
regions.length === 0 ||
(isScaleAdjustMode && !selectedScalePercent)
}
onClick={handleSubmit}
type="button"
>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{isScaleAdjustMode ? "生成比例调整" : "生成局部编辑"}
</Button>
</>
)}
</div>
</div>
</div>
@ -2499,6 +2776,8 @@ export function AgentGenerationWorkspaceDarkV2({
const [sceneResizeEditorTargetUrl, setSceneResizeEditorTargetUrl] = useState<string | null>(null);
const [detailLocalEditTarget, setDetailLocalEditTarget] =
useState<DetailResultLocalEditTarget | null>(null);
const [detailLocalEditPreviewResult, setDetailLocalEditPreviewResult] =
useState<DetailResultLocalEditPreviewResult | null>(null);
const [detailLocalEditSubmittingId, setDetailLocalEditSubmittingId] =
useState<string | null>(null);
const [detailLocalEditSubmittingMode, setDetailLocalEditSubmittingMode] =
@ -3506,6 +3785,15 @@ export function AgentGenerationWorkspaceDarkV2({
};
}, []);
useEffect(() => {
setDetailLocalEditPreviewResult(null);
}, [
detailLocalEditTarget?.cardId,
detailLocalEditTarget?.imageUrl,
detailLocalEditTarget?.mode,
detailLocalEditTarget?.resultModeType,
]);
useEffect(() => {
if (isWorkflowModelLocked) {
setSelectedModelId((current) => (current === "auto" ? current : "auto"));
@ -3957,6 +4245,17 @@ export function AgentGenerationWorkspaceDarkV2({
),
[detailGenerationRunResults, normalizedDeletedResultCollections.detail_generation]
);
const detailGenerationCompareSourceByUrl = useMemo(() => {
const entries: Record<string, string> = {};
detailGenerationRunResults.forEach((item) => {
const resultUrl = String(item.originalUrl || item.thumbnailUrl || "").trim();
const sourceUrl = normalizeResultCompareSourceUrl(resultUrl, item.compareSourceUrl);
if (resultUrl && sourceUrl) {
entries[resultUrl] = sourceUrl;
}
});
return entries;
}, [detailGenerationRunResults]);
const currentLiveResultUrls =
currentResultModeType === "detail_generation"
? detailGenerationRunResultUrls
@ -4332,7 +4631,8 @@ export function AgentGenerationWorkspaceDarkV2({
index,
url,
compareSourceUrl: url
? normalizeResultCompareSourceUrl(url, resultCompareSourceByUrl[url])
? normalizeResultCompareSourceUrl(url, resultCompareSourceByUrl[url]) ||
normalizeResultCompareSourceUrl(url, detailGenerationCompareSourceByUrl[url])
: null,
status: url
? ("completed" as const)
@ -4351,6 +4651,7 @@ export function AgentGenerationWorkspaceDarkV2({
[
activeResultModeType,
activeResultModeUrls,
detailGenerationCompareSourceByUrl,
detailGenerationProgress.status,
gridPendingRemainingCount,
gridResultCardCount,
@ -5607,10 +5908,10 @@ export function AgentGenerationWorkspaceDarkV2({
registerResultCompareSource,
]
);
const showProcessedResultHint = useCallback((imageUrl: string, label: string) => {
const normalizedUrl = String(imageUrl || "").trim();
const normalizedLabel = String(label || "").trim();
if (!/^https?:\/\//i.test(normalizedUrl) || !normalizedLabel) return;
const showProcessedResultHint = useCallback((imageUrl: string, label: string) => {
const normalizedUrl = String(imageUrl || "").trim();
const normalizedLabel = String(label || "").trim();
if (!/^https?:\/\//i.test(normalizedUrl) || !normalizedLabel) return;
const existingTimer = processedResultHintTimerRef.current[normalizedUrl];
if (existingTimer) {
@ -5629,9 +5930,51 @@ export function AgentGenerationWorkspaceDarkV2({
delete next[normalizedUrl];
return next;
});
delete processedResultHintTimerRef.current[normalizedUrl];
}, 2800);
}, []);
delete processedResultHintTimerRef.current[normalizedUrl];
}, 2800);
}, []);
const updateDetailGenerationProcessedResult = useCallback(
(params: {
cardId?: string | null;
previousUrl: string;
nextUrl: string;
compareSourceUrl?: string | null;
}) => {
const cardId = String(params.cardId || "").trim();
const previousUrl = String(params.previousUrl || "").trim();
const nextUrl = String(params.nextUrl || "").trim();
if (!/^https?:\/\//i.test(nextUrl)) return;
const sourceUrl = normalizeResultCompareSourceUrl(
nextUrl,
params.compareSourceUrl || previousUrl
);
registerResultCompareSource(nextUrl, sourceUrl || previousUrl);
setDetailGenerationRunResults((current) => {
let changed = false;
const nextItems = current.map((item) => {
const originalUrl = String(item.originalUrl || "").trim();
const thumbnailUrl = String(item.thumbnailUrl || "").trim();
const isTarget =
originalUrl === previousUrl ||
thumbnailUrl === previousUrl ||
(Boolean(cardId) && (originalUrl === cardId || thumbnailUrl === cardId));
if (!isTarget) return item;
changed = true;
return {
...item,
originalUrl: nextUrl,
thumbnailUrl: nextUrl,
compareSourceUrl: sourceUrl || previousUrl || null,
};
});
return changed ? nextItems : current;
});
},
[registerResultCompareSource]
);
const handleEnhanceDetailGenerationResult = useCallback(
async (params: {
cardId: string;
@ -5673,16 +6016,22 @@ export function AgentGenerationWorkspaceDarkV2({
setActiveDetailReferenceCardId(cardId);
setSelectedResultUrl(nextUrl);
} else {
if (
!appendResultUrlToCollection("detail_generation", nextUrl, {
compareSourceUrl: imageUrl,
})
) {
throw new Error("当前项目未接入结果保存能力");
}
setActiveResultModeType("detail_generation");
setSelectedResultUrl(nextUrl);
}
if (
!appendResultUrlToCollection("detail_generation", nextUrl, {
compareSourceUrl: imageUrl,
})
) {
throw new Error("当前项目未接入结果保存能力");
}
updateDetailGenerationProcessedResult({
cardId,
previousUrl: imageUrl,
nextUrl,
compareSourceUrl: imageUrl,
});
setActiveResultModeType("detail_generation");
setSelectedResultUrl(nextUrl);
}
toast.success(
resultModeType === "detail_replacement"
? "高清处理完成,已更新当前套详情结果图"
@ -5700,13 +6049,14 @@ export function AgentGenerationWorkspaceDarkV2({
}
},
[
appendResultUrlToCollection,
detailEnhancingCardIds,
handleUpdateReplacementResultRecord,
]
);
const handleSubmitDetailLocalEdit = useCallback(
async (params: DetailResultLocalEditSubmitParams) => {
appendResultUrlToCollection,
detailEnhancingCardIds,
handleUpdateReplacementResultRecord,
updateDetailGenerationProcessedResult,
]
);
const handleSubmitDetailLocalEdit = useCallback(
async (params: DetailResultLocalEditSubmitParams) => {
const target = detailLocalEditTarget;
const cardId = String(target?.cardId || "").trim();
const imageUrl = String(params.imageUrl || "").trim();
@ -5766,48 +6116,27 @@ export function AgentGenerationWorkspaceDarkV2({
continue;
}
if (status === "succeeded" || status === "completed") {
const generatedImage = task.result?.images?.[0];
const nextUrl =
generatedImage?.originalUrl || generatedImage?.thumbnailUrl || "";
if (!/^https?:\/\//i.test(nextUrl)) {
throw new Error(`${isScaleAdjust ? "比例调整" : "局部编辑"}完成但结果地址无效`);
}
if (resultModeType === "detail_replacement") {
handleUpdateReplacementResultRecord({
modeType: "detail_replacement",
if (status === "succeeded" || status === "completed") {
const generatedImage = task.result?.images?.[0];
const nextUrl =
generatedImage?.originalUrl || generatedImage?.thumbnailUrl || "";
if (!/^https?:\/\//i.test(nextUrl)) {
throw new Error(`${isScaleAdjust ? "比例调整" : "局部编辑"}完成但结果地址无效`);
}
setDetailLocalEditPreviewResult({
cardId,
url: nextUrl,
text: isScaleAdjust ? "已完成比例调整处理。" : "已完成局部编辑处理。",
status: "completed",
compareSourceUrl: imageUrl,
resultModeType,
sourceImageUrl: imageUrl,
resultImageUrl: nextUrl,
mode: editMode,
scalePercent: params.scalePercent,
title: params.title || target.title,
});
setActiveResultModeType("detail_replacement");
setActiveDetailReferenceCardId(cardId);
setSelectedResultUrl(nextUrl);
} else {
if (
!appendResultUrlToCollection("detail_generation", nextUrl, {
compareSourceUrl: imageUrl,
})
) {
throw new Error("当前项目未接入结果保存能力");
}
setActiveResultModeType("detail_generation");
setSelectedResultUrl(nextUrl);
}
showProcessedResultHint(
nextUrl,
isScaleAdjust ? "比例调整完成" : "局部编辑完成"
);
setDetailLocalEditTarget(null);
toast.success(
resultModeType === "detail_replacement"
? `${isScaleAdjust ? "比例调整" : "局部编辑"}完成,已更新当前套详情结果图`
: `${isScaleAdjust ? "比例调整" : "局部编辑"}完成,已加入 AI详情结果区`
);
return;
}
toast.success(
`${isScaleAdjust ? "比例调整" : "局部编辑"}完成,请在弹窗中确认结果`
);
return;
}
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
throw new Error(task.error || `${isScaleAdjust ? "比例调整" : "局部编辑"}生成失败`);
@ -5832,16 +6161,77 @@ export function AgentGenerationWorkspaceDarkV2({
setDetailLocalEditSubmittingMode(null);
}
},
[
appendResultUrlToCollection,
detailLocalEditSubmittingId,
detailLocalEditTarget,
handleUpdateReplacementResultRecord,
showProcessedResultHint,
]
);
const handlePsdDerivedImageCreated = useCallback(
(imageUrl: string) => {
[
detailLocalEditSubmittingId,
detailLocalEditTarget,
]
);
const handleConfirmDetailLocalEditResult = useCallback(() => {
const preview = detailLocalEditPreviewResult;
if (!preview) return;
const cardId = String(preview.cardId || "").trim();
const sourceImageUrl = String(preview.sourceImageUrl || "").trim();
const resultImageUrl = String(preview.resultImageUrl || "").trim();
const isScaleAdjust = preview.mode === "scale_adjust";
if (!cardId || !/^https?:\/\//i.test(sourceImageUrl) || !/^https?:\/\//i.test(resultImageUrl)) {
toast.error("处理结果地址无效,无法应用");
return;
}
if (preview.resultModeType === "detail_replacement") {
handleUpdateReplacementResultRecord({
modeType: "detail_replacement",
cardId,
url: resultImageUrl,
text: isScaleAdjust ? "已完成比例调整处理。" : "已完成局部编辑处理。",
status: "completed",
compareSourceUrl: sourceImageUrl,
});
setActiveResultModeType("detail_replacement");
setActiveDetailReferenceCardId(cardId);
setSelectedResultUrl(resultImageUrl);
} else {
if (
!appendResultUrlToCollection("detail_generation", resultImageUrl, {
compareSourceUrl: sourceImageUrl,
})
) {
toast.error("当前项目未接入结果保存能力");
return;
}
updateDetailGenerationProcessedResult({
cardId,
previousUrl: sourceImageUrl,
nextUrl: resultImageUrl,
compareSourceUrl: sourceImageUrl,
});
setActiveResultModeType("detail_generation");
setSelectedResultUrl(resultImageUrl);
}
showProcessedResultHint(resultImageUrl, isScaleAdjust ? "比例调整完成" : "局部编辑完成");
setDetailLocalEditPreviewResult(null);
setDetailLocalEditTarget(null);
toast.success(
preview.resultModeType === "detail_replacement"
? `${isScaleAdjust ? "比例调整" : "局部编辑"}结果已更新当前套详情图`
: `${isScaleAdjust ? "比例调整" : "局部编辑"}结果已加入 AI详情结果区`
);
}, [
appendResultUrlToCollection,
detailLocalEditPreviewResult,
handleUpdateReplacementResultRecord,
showProcessedResultHint,
updateDetailGenerationProcessedResult,
]);
const handleResetDetailLocalEditPreview = useCallback(() => {
setDetailLocalEditPreviewResult(null);
}, []);
const handlePsdDerivedImageCreated = useCallback(
(imageUrl: string) => {
appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl, {
compareSourceUrl: psdTemplateDialogSourceUrl,
});
@ -10170,22 +10560,26 @@ export function AgentGenerationWorkspaceDarkV2({
</ClientErrorBoundary>
) : null}
<DetailResultLocalEditDialog
submitting={Boolean(detailLocalEditSubmittingId)}
target={detailLocalEditTarget}
onClose={() => {
if (detailLocalEditSubmittingId) {
<DetailResultLocalEditDialog
previewResult={detailLocalEditPreviewResult}
submitting={Boolean(detailLocalEditSubmittingId)}
target={detailLocalEditTarget}
onClose={() => {
if (detailLocalEditSubmittingId) {
toast.info(
detailLocalEditSubmittingMode === "scale_adjust"
? "比例调整正在处理中,请稍候"
: "局部编辑正在处理中,请稍候"
);
return;
}
setDetailLocalEditTarget(null);
}}
onSubmit={handleSubmitDetailLocalEdit}
/>
return;
}
setDetailLocalEditPreviewResult(null);
setDetailLocalEditTarget(null);
}}
onConfirmResult={handleConfirmDetailLocalEditResult}
onResetResult={handleResetDetailLocalEditPreview}
onSubmit={handleSubmitDetailLocalEdit}
/>
{/* Fullscreen Preview */}
<AnimatePresence>

View File

@ -22,6 +22,7 @@ import {
Link2,
Maximize2,
MessageSquarePlus,
Minimize2,
MoreHorizontal,
MoreVertical,
Pencil,
@ -264,6 +265,7 @@ const AI_CLOUD_IMAGE_ACTIONS = new Set([
"layer-split",
"box-cutout",
"local-edit",
"scale-adjust",
"text-edit",
"prompt-regenerate",
"generate-3d-model",
@ -392,6 +394,40 @@ type LocalEditRegionInteractionState = {
startRect: NormalizedCropRect;
};
type CanvasLocalEditOperationMode = "local_edit" | "scale_adjust";
type CanvasScaleAdjustPercent = -30 | -20 | -10 | 10 | 20 | 30;
const CANVAS_SCALE_ADJUST_OPTIONS: CanvasScaleAdjustPercent[] = [
-30,
-20,
-10,
10,
20,
30,
];
function getCanvasScaleAdjustLabel(scalePercent: CanvasScaleAdjustPercent) {
return scalePercent > 0 ? `放大 ${scalePercent}%` : `缩小 ${Math.abs(scalePercent)}%`;
}
function getCanvasScaleAdjustTargetLabel(scalePercent: CanvasScaleAdjustPercent) {
return `${Math.round((1 + scalePercent / 100) * 100)}%`;
}
function getCanvasScaleAdjustRegionPrompt(scalePercent: CanvasScaleAdjustPercent | null) {
if (!scalePercent) return "比例调整";
const actionLabel = getCanvasScaleAdjustLabel(scalePercent);
const targetLabel = getCanvasScaleAdjustTargetLabel(scalePercent);
const scaleVerb = scalePercent > 0 ? "放大" : "缩小";
return [
`对框选范围内的主物体执行等比${actionLabel},调整后主体视觉尺寸必须约为原来的 ${targetLabel},需要肉眼明显看出已经${scaleVerb}。`,
"只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
scalePercent < 0
? "主体缩小后露出的背景、桌面、墙面、阴影和遮挡关系必须自然补全。"
: "主体放大后边缘、遮挡和透视必须自然衔接。",
].join(" ");
}
type LocalEditPromptCompositionSnapshot = {
end: number;
start: number;
@ -1507,6 +1543,10 @@ export function ImageNode({
const [boxCutoutDragEnd, setBoxCutoutDragEnd] =
useState<BoxCutoutPoint | null>(null);
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)
);
@ -2429,6 +2469,8 @@ export function ImageNode({
const resetLocalEditState = useCallback(() => {
setLocalEditMode(false);
setLocalEditOperationMode("local_edit");
setSelectedLocalEditScalePercent(null);
setIsLocalEditDrawing(false);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
@ -3363,7 +3405,10 @@ export function ImageNode({
);
const handleEnterLocalEditMode = useCallback(
(event?: React.MouseEvent | React.PointerEvent) => {
(
event?: React.MouseEvent | React.PointerEvent,
operationMode: CanvasLocalEditOperationMode = "local_edit"
) => {
event?.stopPropagation();
if (!data.src) {
toast.error("请先上传图片");
@ -3391,6 +3436,8 @@ export function ImageNode({
setNotePanelOpen(false);
resetBoxCutoutState();
setLocalEditRegions(getLocalEditRegionsFromMetadata(data.metadata));
setLocalEditOperationMode(operationMode);
setSelectedLocalEditScalePercent(operationMode === "scale_adjust" ? -30 : null);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setIsLocalEditDrawing(false);
@ -3398,7 +3445,11 @@ export function ImageNode({
window.requestAnimationFrame(() => {
refreshBoxCutoutMetrics();
});
toast.info("拖拽框选要局部编辑的位置,并在框下方填写提示词");
toast.info(
operationMode === "scale_adjust"
? "拖拽框选要调整比例的主体,并选择缩小或放大幅度"
: "拖拽框选要局部编辑的位置,并在框下方填写提示词"
);
},
[
data.metadata,
@ -3490,18 +3541,21 @@ export function ImageNode({
nextWidthPx >= LOCAL_EDIT_MIN_EDGE_PX &&
nextHeightPx >= LOCAL_EDIT_MIN_EDGE_PX
) {
const isScaleAdjustMode = localEditOperationMode === "scale_adjust";
const nextRegion: LocalEditRegion = {
id: createLocalEditRegionId(),
index: localEditRegions.length + 1,
color:
LOCAL_EDIT_COLORS[localEditRegions.length % LOCAL_EDIT_COLORS.length],
prompt: "",
prompt: isScaleAdjustMode
? getCanvasScaleAdjustRegionPrompt(selectedLocalEditScalePercent)
: "",
rect: nextRect,
referenceImages: [],
createdAt: Date.now(),
};
commitLocalEditRegions([...localEditRegions, nextRegion]);
setEditingLocalEditRegionId(nextRegion.id);
setEditingLocalEditRegionId(isScaleAdjustMode ? null : nextRegion.id);
} else {
toast.info("框选区域太小,请至少拖出 24px 以上的区域");
}
@ -3515,7 +3569,9 @@ export function ImageNode({
getBoxCutoutPointerPoint,
localEditDragStart,
localEditMode,
localEditOperationMode,
localEditRegions,
selectedLocalEditScalePercent,
]
);
@ -3832,12 +3888,15 @@ export function ImageNode({
return;
}
const isScaleAdjustMode = localEditOperationMode === "scale_adjust";
const validRegions = reindexLocalEditRegions(
localEditRegions.map((region) => ({
...region,
prompt: (
localEditPromptDraftRefs.current[region.id] ?? region.prompt
).trim(),
prompt: isScaleAdjustMode
? getCanvasScaleAdjustRegionPrompt(selectedLocalEditScalePercent)
: (
localEditPromptDraftRefs.current[region.id] ?? region.prompt
).trim(),
}))
);
@ -3845,18 +3904,29 @@ export function ImageNode({
toast.error("请先框选至少一个局部编辑区域");
return;
}
const emptyRegion = validRegions.find((region) => !region.prompt);
if (emptyRegion) {
setEditingLocalEditRegionId(emptyRegion.id);
toast.error(`请填写区域 ${emptyRegion.index} 的提示词`);
return;
if (isScaleAdjustMode) {
if (!selectedLocalEditScalePercent) {
toast.error("请选择放大或缩小比例");
return;
}
} else {
const emptyRegion = validRegions.find((region) => !region.prompt);
if (emptyRegion) {
setEditingLocalEditRegionId(emptyRegion.id);
toast.error(`请填写区域 ${emptyRegion.index} 的提示词`);
return;
}
}
setIsSubmittingLocalEdit(true);
setIsProcessing(true);
commitLocalEditRegions(validRegions);
const actionName = "局部编辑";
const actionName = isScaleAdjustMode ? "比例调整" : "局部编辑";
const editMode = isScaleAdjustMode ? "scale-adjust" : "local-edit";
const scalePercent = isScaleAdjustMode
? selectedLocalEditScalePercent || undefined
: undefined;
const newNodeId = addNode({
type: "image",
position: {
@ -3871,7 +3941,8 @@ export function ImageNode({
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
editMode: "local-edit",
editMode,
scalePercent,
sourceNodeId: node.id,
localEditMode: false,
sourceLocalEditRegions: validRegions,
@ -3885,6 +3956,8 @@ export function ImageNode({
const submitResult = await imageGenerationService.localEdit({
sourceImageUrl: data.src,
sourceNodeId: node.id,
operation: isScaleAdjustMode ? "scale-adjust" : undefined,
scalePercent,
regions: validRegions.map((region) => ({
id: region.id,
index: region.index,
@ -3929,8 +4002,9 @@ export function ImageNode({
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
editMode: "local-edit",
editMode,
sourceNodeId: node.id,
scalePercent,
aspectRatio: submitResult.data.aspectRatio,
taskId,
annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl,
@ -3972,8 +4046,9 @@ export function ImageNode({
originalSrc: data.src,
model: task.result?.model || task.result?.modelRef || undefined,
metadata: {
editMode: "local-edit",
editMode,
sourceNodeId: node.id,
scalePercent,
aspectRatio: submitResult.data.aspectRatio,
taskId,
annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl,
@ -3986,7 +4061,7 @@ export function ImageNode({
},
});
resetLocalEditState();
toast.success("局部编辑结果已生成");
toast.success(`${actionName}结果已生成`);
return;
}
@ -4007,8 +4082,9 @@ export function ImageNode({
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
editMode: "local-edit",
editMode,
sourceNodeId: node.id,
scalePercent,
error: message,
localEditMode: false,
sourceLocalEditRegions: validRegions,
@ -4027,6 +4103,7 @@ export function ImageNode({
displayTitle,
isProcessing,
isSubmittingLocalEdit,
localEditOperationMode,
localEditRegions,
node.id,
node.position.x,
@ -4035,6 +4112,7 @@ export function ImageNode({
node.size.width,
resetLocalEditState,
selectNode,
selectedLocalEditScalePercent,
updateNode,
]);
@ -6013,7 +6091,7 @@ export function ImageNode({
return;
}
setAiWashQuickPanelOpen(false);
if (action !== "local-edit") {
if (action !== "local-edit" && action !== "scale-adjust") {
resetLocalEditState();
}
@ -6126,6 +6204,10 @@ export function ImageNode({
handleEnterLocalEditMode();
return;
}
if (action === "scale-adjust") {
handleEnterLocalEditMode(undefined, "scale_adjust");
return;
}
if (action === "annotate") {
setCropPanelOpen(false);
setCropInteraction(null);
@ -8147,7 +8229,7 @@ export function ImageNode({
style={style}
>
<div className="flex items-center gap-1.5">
{isEditing ? (
{isEditing && localEditOperationMode !== "scale_adjust" ? (
<input
autoFocus={editingLocalEditRegionId === region.id}
className="nodrag nopan nowheel h-9 min-w-0 flex-1 rounded-full border border-slate-300 bg-white/95 px-3 text-[12px] font-medium leading-5 text-slate-950 caret-slate-950 shadow-[0_10px_24px_rgba(15,23,42,0.14)] outline-none ring-2 ring-transparent transition placeholder:text-slate-400 focus:border-slate-950 focus:ring-slate-950/15 dark:border-white/20 dark:bg-slate-950/88 dark:text-white dark:caret-white dark:placeholder:text-white/42 dark:focus:border-white/40 dark:focus:ring-white/18"
@ -8262,29 +8344,47 @@ export function ImageNode({
className="nodrag nopan nowheel h-9 min-w-0 flex-1 truncate rounded-full border border-slate-200 bg-white/95 px-3 text-left text-[12px] font-semibold text-slate-900 shadow-[0_10px_24px_rgba(15,23,42,0.14)] hover:bg-slate-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-slate-900"
data-canvas-no-drag="true"
data-local-edit-control="true"
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
setEditingLocalEditRegionId(region.id);
}}
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
if (localEditOperationMode !== "scale_adjust") {
setEditingLocalEditRegionId(region.id);
}
}}
onMouseDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
setEditingLocalEditRegionId(region.id);
}}
title={region.prompt}
type="button"
>
{region.index}. {region.prompt}
</button>
)}
onPointerDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
if (localEditOperationMode !== "scale_adjust") {
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}`}
</button>
)}
<input
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden"
@ -8301,8 +8401,11 @@ export function ImageNode({
}}
type="file"
/>
<button
className="nodrag nopan nowheel flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white/95 text-slate-600 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-slate-950 hover:text-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-white dark:hover:text-slate-950"
<button
className={cn(
"nodrag nopan nowheel flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white/95 text-slate-600 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-slate-950 hover:text-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-white dark:hover:text-slate-950",
localEditOperationMode === "scale_adjust" && "hidden"
)}
data-canvas-no-drag="true"
data-local-edit-control="true"
disabled={
@ -8861,27 +8964,101 @@ export function ImageNode({
scrollbarWidth: "none",
}}
>
<div className="mr-1 inline-flex h-10 shrink-0 items-center gap-2 rounded-xl bg-slate-100 px-3 font-medium text-[13px] text-slate-700 dark:bg-zinc-800 dark:text-slate-100">
<Wand2 className="h-4 w-4 text-slate-950 dark:text-white" />
<span className="whitespace-nowrap">局部编辑</span>
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-slate-300">
{localEditRegions.length}/{LOCAL_EDIT_MAX_REGIONS}
</span>
</div>
<div className="mr-1 inline-flex h-10 shrink-0 items-center gap-2 rounded-xl bg-slate-100 px-3 font-medium text-[13px] text-slate-700 dark:bg-zinc-800 dark:text-slate-100">
<Wand2 className="h-4 w-4 text-slate-950 dark:text-white" />
<span className="whitespace-nowrap">
{localEditOperationMode === "scale_adjust" ? "比例调整" : "局部编辑"}
</span>
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-slate-300">
{localEditRegions.length}/{LOCAL_EDIT_MAX_REGIONS}
</span>
</div>
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
<div className="inline-flex shrink-0 items-center gap-1 rounded-xl bg-slate-100 p-1 dark:bg-zinc-800">
<Button
className={cn(
"h-8 rounded-lg px-2.5 text-[12px] font-semibold",
localEditOperationMode === "local_edit"
? "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);
}}
type="button"
variant="ghost"
>
局编
</Button>
<Button
className={cn(
"h-8 rounded-lg px-2.5 text-[12px] font-semibold",
localEditOperationMode === "scale_adjust"
? "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");
setSelectedLocalEditScalePercent((current) => current || -30);
setEditingLocalEditRegionId(null);
}}
type="button"
variant="ghost"
>
比例
</Button>
</div>
<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
}
onClick={() => void handleSubmitLocalEdit()}
title="生成局部编辑结果"
type="button"
variant="ghost"
>
<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">
{CANVAS_SCALE_ADJUST_OPTIONS.map((option) => {
const active = selectedLocalEditScalePercent === option;
const Icon = option > 0 ? Maximize2 : Minimize2;
return (
<Button
className={cn(
"h-9 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={() => setSelectedLocalEditScalePercent(option)}
title={getCanvasScaleAdjustLabel(option)}
type="button"
variant="ghost"
>
<Icon className="mr-1 h-3.5 w-3.5" />
{option > 0 ? `+${option}%` : `${option}%`}
</Button>
);
})}
</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 ||
(localEditOperationMode === "scale_adjust" &&
!selectedLocalEditScalePercent)
}
onClick={() => void handleSubmitLocalEdit()}
title={
localEditOperationMode === "scale_adjust"
? "生成比例调整结果"
: "生成局部编辑结果"
}
type="button"
variant="ghost"
>
{isSubmittingLocalEdit ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
@ -9340,14 +9517,31 @@ export function ImageNode({
item={imageLocalEditItem}
/>
{imageLocalEditItem.label}
<ImageActionCreditBadge
compact
label={localEditCostShortLabel}
/>
</Button>
) : null}
<ImageActionCreditBadge
compact
label={localEditCostShortLabel}
/>
</Button>
) : null}
{isSelectedImageActionInline("removeWatermark") ? (
{isSelectedImageActionInline("localEdit") ? (
<Button
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing || isSubmittingLocalEdit}
onClick={() => handleAction("scale-adjust")}
title="比例调整"
variant="ghost"
>
<Minimize2 className="mr-1.5 h-4 w-4" />
比例调整
<ImageActionCreditBadge
compact
label={localEditCostShortLabel}
/>
</Button>
) : null}
{isSelectedImageActionInline("removeWatermark") ? (
<Button
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
@ -9617,11 +9811,18 @@ export function ImageNode({
fallback={Wand2}
item={imageLocalEditItem}
/>
<span>{imageLocalEditItem.label}</span>
<ImageActionCreditBadge label={localEditCostShortLabel} />
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("removeWatermark") ? (
<span>{imageLocalEditItem.label}</span>
<ImageActionCreditBadge label={localEditCostShortLabel} />
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("localEdit") ? (
<DropdownMenuItem onClick={() => handleAction("scale-adjust")}>
<Minimize2 className="mr-2 h-4 w-4" />
<span>比例调整</span>
<ImageActionCreditBadge label={localEditCostShortLabel} />
</DropdownMenuItem>
) : null}
{isSelectedImageActionInMore("removeWatermark") ? (
<DropdownMenuItem onClick={() => handleAction("watermark")}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"