feat: add reference images to local edit regions
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-20 11:01:36 +08:00
parent e415de06d8
commit 4c86e01956
3 changed files with 474 additions and 158 deletions

View File

@ -30,6 +30,12 @@ type LocalEditRegionInput = {
color?: string;
prompt?: string;
rect?: LocalEditRect;
referenceImages?: Array<{
id?: string;
url?: string;
name?: string;
source?: string;
}>;
};
type LocalEditRequest = {
@ -47,6 +53,12 @@ type NormalizedLocalEditRegion = {
color: string;
colorName: string;
prompt: string;
referenceImages: Array<{
id: string;
url: string;
name?: string;
source: "upload" | "canvas";
}>;
rect: {
x: number;
y: number;
@ -62,6 +74,7 @@ type NormalizedLocalEditRegion = {
};
const MAX_LOCAL_EDIT_REGIONS = 8;
const MAX_REFERENCE_IMAGES_PER_REGION = 3;
const MIN_REGION_EDGE_PX = 24;
const DEFAULT_REGION_COLORS = [
"#ff3b30",
@ -100,6 +113,41 @@ function normalizeHexColor(input: unknown, fallback: string) {
return /^#[0-9a-f]{6}$/i.test(expanded) ? expanded.toLowerCase() : fallback;
}
function normalizeHttpImageUrl(input: unknown) {
const raw = String(input || "").trim();
if (!/^https?:\/\//i.test(raw)) return "";
return raw;
}
function normalizeRegionReferenceImages(
region: LocalEditRegionInput,
itemIndex: number
): NormalizedLocalEditRegion["referenceImages"] {
if (!Array.isArray(region.referenceImages)) return [];
return region.referenceImages
.map((reference, referenceIndex) => {
const url = normalizeHttpImageUrl(reference?.url);
if (!url) return null;
const name =
typeof reference?.name === "string" && reference.name.trim()
? reference.name.trim().slice(0, 80)
: "";
return {
id:
typeof reference?.id === "string" && reference.id.trim()
? reference.id.trim()
: `region-${itemIndex + 1}-reference-${referenceIndex + 1}`,
url,
...(name ? { name } : {}),
source: reference?.source === "canvas" ? "canvas" as const : "upload" as const,
};
})
.filter((item): item is NormalizedLocalEditRegion["referenceImages"][number] =>
Boolean(item)
)
.slice(0, MAX_REFERENCE_IMAGES_PER_REGION);
}
function getColorName(hex: string) {
const normalized = hex.toLowerCase();
const names: Record<string, string> = {
@ -203,6 +251,7 @@ function normalizeRegions(
color,
colorName: getColorName(color),
prompt: prompt.slice(0, 300),
referenceImages: normalizeRegionReferenceImages(region, itemIndex),
rect,
pixelRect,
};
@ -278,18 +327,32 @@ function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) {
const top = Math.round(region.rect.y * 1000) / 10;
const width = Math.round(region.rect.width * 1000) / 10;
const height = Math.round(region.rect.height * 1000) / 10;
return `区域 ${region.index}(${region.colorName},编号 ${region.index},原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%):${region.prompt}`;
const referenceNote =
region.referenceImages.length > 0
? `;该区域有 ${region.referenceImages.length} 张专属参考图,依次为后续输入图片中的“区域 ${region.index} 参考图”,只可用于该区域`
: "";
return `区域 ${region.index}(${region.colorName},编号 ${region.index},原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%${referenceNote}):${region.prompt}`;
})
.join("\n");
const referenceLines = regions
.flatMap((region) =>
region.referenceImages.map(
(reference, referenceIndex) =>
`区域 ${region.index} 参考图 ${referenceIndex + 1}:${reference.name || "未命名参考图"},只能影响区域 ${region.index}`
)
)
.join("\n");
return [
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。",
"输入图片2是同一张图的彩色标注说明图,只用于理解编辑区域位置和区域提示词,不是画面参考。",
"输入图片3及之后如存在,是各区域专属参考图;专属参考图只服务于对应区域,不得改变其它区域或整张图的主体身份。",
"请不要复刻、保留、描摹或生成输入图片2里的任何彩色框、编号圆点、提示词气泡、边框线、半透明色块或标注文字。",
"如果输入图片2的标注元素与输入图片1冲突,必须完全忽略标注元素,只保留输入图片1的真实画面内容。",
"",
"请严格根据每个区域下方的提示词,只在对应彩色框附近完成局部编辑:",
regionLines,
referenceLines ? `\n区域专属参考图说明:\n${referenceLines}` : "",
"",
"最终输出必须是干净照片:不得出现任何彩色框、蓝框、绿框、红框、矩形边框、编号、提示词气泡、标注文字或 UI 元素。",
"未被彩色框标注的区域必须尽量保持原图不变。",
@ -368,6 +431,9 @@ export async function POST(request: NextRequest) {
toArrayBuffer(annotatedBuffer)
);
const prompt = buildLocalEditPrompt(regions);
const regionReferenceImageUrls = regions.flatMap((region) =>
region.referenceImages.map((reference) => reference.url)
);
const imageRequest: CanvasGenerateImageRequest = {
aspectRatio:
@ -378,6 +444,7 @@ export async function POST(request: NextRequest) {
debugLabel: "canvas-local-edit",
debugMeta: {
annotatedReferenceUrl: annotatedUpload.url,
regionReferenceImageCount: regionReferenceImageUrls.length,
regionCount: regions.length,
sourceNodeId: body.sourceNodeId || null,
},
@ -394,7 +461,11 @@ export async function POST(request: NextRequest) {
"彩色框、蓝色边框、绿色边框、红色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框",
prompt,
promptExtend: false,
referenceImages: [sourceImageUrl, annotatedUpload.url],
referenceImages: [
sourceImageUrl,
annotatedUpload.url,
...regionReferenceImageUrls,
],
ownerKey: session.user.id,
};

View File

@ -248,12 +248,21 @@ interface BoxCutoutPoint {
y: number;
}
interface LocalEditReferenceImage {
id: string;
url: string;
name?: string;
source?: "upload" | "canvas";
createdAt: number;
}
interface LocalEditRegion {
id: string;
index: number;
color: string;
prompt: string;
rect: NormalizedCropRect;
referenceImages?: LocalEditReferenceImage[];
createdAt: number;
}
@ -261,6 +270,7 @@ const BOX_CUTOUT_DEFAULT_PADDING_RATIO = 0.08;
const BOX_CUTOUT_MIN_EDGE_PX = 24;
const LOCAL_EDIT_METADATA_KEY = "localEditRegions";
const LOCAL_EDIT_MAX_REGIONS = 8;
const LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION = 3;
const LOCAL_EDIT_MIN_EDGE_PX = BOX_CUTOUT_MIN_EDGE_PX;
const LOCAL_EDIT_POLL_INTERVAL_MS = 2500;
const LOCAL_EDIT_MAX_POLL_ATTEMPTS = 180;
@ -875,6 +885,8 @@ const normalizeTextEditItems = (
const createLocalEditRegionId = () =>
`local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const createLocalEditReferenceImageId = () =>
`local-edit-ref-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const normalizeLocalEditRect = (value: unknown): NormalizedCropRect | null => {
if (!value || typeof value !== "object") return null;
@ -896,6 +908,40 @@ const normalizeLocalEditRect = (value: unknown): NormalizedCropRect | null => {
return { x, y, width, height };
};
const normalizeLocalEditReferenceImages = (
value: unknown
): LocalEditReferenceImage[] => {
if (!Array.isArray(value)) return [];
const references: LocalEditReferenceImage[] = [];
for (const item of value) {
if (!item || typeof item !== "object") continue;
const record = item as Record<string, unknown>;
const url = typeof record.url === "string" ? record.url.trim() : "";
if (!isHttpImageUrl(url)) continue;
const name =
typeof record.name === "string" && record.name.trim()
? record.name.trim().slice(0, 80)
: "";
references.push({
id:
typeof record.id === "string" && record.id.trim()
? record.id.trim()
: createLocalEditReferenceImageId(),
url,
...(name ? { name } : {}),
source: record.source === "canvas" ? "canvas" : "upload",
createdAt:
typeof record.createdAt === "number" && Number.isFinite(record.createdAt)
? record.createdAt
: Date.now(),
});
if (references.length >= LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION) {
break;
}
}
return references;
};
const reindexLocalEditRegions = (
regions: LocalEditRegion[]
): LocalEditRegion[] =>
@ -907,38 +953,36 @@ const reindexLocalEditRegions = (
const normalizeLocalEditRegions = (value: unknown): LocalEditRegion[] => {
if (!Array.isArray(value)) return [];
return reindexLocalEditRegions(
value
.map((item, index) => {
if (!item || typeof item !== "object") return null;
const record = item as Record<string, unknown>;
const rect = normalizeLocalEditRect(record.rect);
if (!rect) return null;
return {
id:
typeof record.id === "string" && record.id.trim()
? record.id.trim()
: createLocalEditRegionId(),
index:
typeof record.index === "number" && Number.isFinite(record.index)
? record.index
: index + 1,
color:
typeof record.color === "string" && record.color.trim()
? record.color.trim()
: LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length],
prompt:
typeof record.prompt === "string" ? record.prompt.trim() : "",
rect,
createdAt:
typeof record.createdAt === "number" && Number.isFinite(record.createdAt)
? record.createdAt
: Date.now(),
} satisfies LocalEditRegion;
})
.filter((item): item is LocalEditRegion => !!item)
.slice(0, LOCAL_EDIT_MAX_REGIONS)
);
const regions: LocalEditRegion[] = [];
value.forEach((item, index) => {
if (!item || typeof item !== "object") return;
const record = item as Record<string, unknown>;
const rect = normalizeLocalEditRect(record.rect);
if (!rect) return;
regions.push({
id:
typeof record.id === "string" && record.id.trim()
? record.id.trim()
: createLocalEditRegionId(),
index:
typeof record.index === "number" && Number.isFinite(record.index)
? record.index
: index + 1,
color:
typeof record.color === "string" && record.color.trim()
? record.color.trim()
: LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length],
prompt:
typeof record.prompt === "string" ? record.prompt.trim() : "",
rect,
referenceImages: normalizeLocalEditReferenceImages(record.referenceImages),
createdAt:
typeof record.createdAt === "number" && Number.isFinite(record.createdAt)
? record.createdAt
: Date.now(),
});
});
return reindexLocalEditRegions(regions.slice(0, LOCAL_EDIT_MAX_REGIONS));
};
const getLocalEditRegionsFromMetadata = (
@ -1838,6 +1882,10 @@ export function ImageNode({
const [editingLocalEditRegionId, setEditingLocalEditRegionId] =
useState<string | null>(null);
const [isSubmittingLocalEdit, setIsSubmittingLocalEdit] = useState(false);
const [
uploadingLocalEditReferenceRegionId,
setUploadingLocalEditReferenceRegionId,
] = useState<string | null>(null);
const anglePreviewDragRef = useRef<AnglePreviewDragState | null>(null);
const activeImageAdjustments = imageAdjustPanelOpen
? imageAdjustmentsDraft
@ -2384,9 +2432,13 @@ export function ImageNode({
const width = Number(style.width || 0);
const height = Number(style.height || 0);
const imageBottom = boxCutoutMetrics.offsetY + boxCutoutMetrics.renderedHeight;
const placeAbove = top + height + 58 > imageBottom;
const promptTop = placeAbove ? top - 46 : top + height + 8;
const promptWidth = Math.min(Math.max(width, 176), 300);
const referenceCount = region.referenceImages?.length || 0;
const promptStackHeight = referenceCount > 0 ? 82 : 42;
const placeAbove = top + height + promptStackHeight + 14 > imageBottom;
const promptTop = placeAbove
? top - promptStackHeight - 8
: top + height + 8;
const promptWidth = Math.min(Math.max(width, 236), 360);
return {
region,
@ -2467,6 +2519,7 @@ export function ImageNode({
setLocalEditDragEnd(null);
setLocalEditRegionInteraction(null);
setEditingLocalEditRegionId(null);
setUploadingLocalEditReferenceRegionId(null);
}, []);
const focusLocalEditPromptInput = useCallback((regionId: string) => {
@ -3402,6 +3455,7 @@ export function ImageNode({
LOCAL_EDIT_COLORS[localEditRegions.length % LOCAL_EDIT_COLORS.length],
prompt: "",
rect: nextRect,
referenceImages: [],
createdAt: Date.now(),
};
commitLocalEditRegions([...localEditRegions, nextRegion]);
@ -3618,6 +3672,95 @@ export function ImageNode({
[commitLocalEditRegions, editingLocalEditRegionId]
);
const handleLocalEditReferenceUpload = useCallback(
async (regionId: string, fileList: FileList | null) => {
const files = Array.from(fileList || []).filter((file) =>
file.type.startsWith("image/")
);
if (files.length === 0) return;
const currentRegion = localEditRegionsRef.current.find(
(region) => region.id === regionId
);
if (!currentRegion) return;
const currentReferences = currentRegion.referenceImages || [];
const remainingSlots =
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION - currentReferences.length;
if (remainingSlots <= 0) {
toast.info(
`每个局部编辑区域最多添加 ${LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图`
);
return;
}
const selectedFiles = files.slice(0, remainingSlots);
if (files.length > selectedFiles.length) {
toast.info(
`已自动保留前 ${LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图`
);
}
setUploadingLocalEditReferenceRegionId(regionId);
try {
const uploadedReferences = await Promise.all(
selectedFiles.map(async (file) => {
const uploaded = await uploadChatAttachmentBlob(file, file.name);
return {
id: createLocalEditReferenceImageId(),
url: uploaded.url,
name: file.name,
source: "upload" as const,
createdAt: Date.now(),
} satisfies LocalEditReferenceImage;
})
);
commitLocalEditRegions(
localEditRegionsRef.current.map((region) =>
region.id === regionId
? {
...region,
referenceImages: [
...(region.referenceImages || []),
...uploadedReferences,
].slice(0, LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION),
}
: region
)
);
toast.success(
uploadedReferences.length > 1
? `已添加 ${uploadedReferences.length} 张区域参考图`
: "已添加区域参考图"
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "参考图上传失败");
} finally {
setUploadingLocalEditReferenceRegionId(null);
}
},
[commitLocalEditRegions]
);
const removeLocalEditReferenceImage = useCallback(
(regionId: string, referenceId: string) => {
commitLocalEditRegions(
localEditRegionsRef.current.map((region) =>
region.id === regionId
? {
...region,
referenceImages: (region.referenceImages || []).filter(
(reference) => reference.id !== referenceId
),
}
: region
)
);
},
[commitLocalEditRegions]
);
const clearLocalEditRegions = useCallback(() => {
localEditPromptDraftRefs.current = {};
commitLocalEditRegions([]);
@ -3696,6 +3839,12 @@ export function ImageNode({
color: region.color,
prompt: region.prompt,
rect: region.rect,
referenceImages: (region.referenceImages || []).map((reference) => ({
id: reference.id,
url: reference.url,
name: reference.name,
source: reference.source,
})),
})),
});
@ -8366,7 +8515,7 @@ export function ImageNode({
localEditPromptPortalOverlays.map(({ region, isEditing, style }) =>
createPortal(
<div
className="nodrag nopan nowheel fixed z-[10000] flex items-center gap-1.5"
className="nodrag nopan nowheel fixed z-[10000] flex flex-col items-stretch gap-1.5"
data-canvas-no-drag="true"
data-local-edit-control="true"
key={region.id}
@ -8378,110 +8527,190 @@ export function ImageNode({
onPointerDownCapture={(event) => event.stopPropagation()}
style={style}
>
{isEditing ? (
<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 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"
data-canvas-no-drag="true"
data-local-edit-control="true"
defaultValue={
localEditPromptDraftRefs.current[region.id] ?? region.prompt
}
onBlur={(event) => {
const nextPrompt = event.currentTarget.value;
localEditPromptDraftRefs.current[region.id] = nextPrompt;
persistLocalEditRegionPrompt(region.id, nextPrompt);
if (isLocalEditControlTarget(event.relatedTarget)) {
return;
<div className="flex items-center gap-1.5">
{isEditing ? (
<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 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"
data-canvas-no-drag="true"
data-local-edit-control="true"
defaultValue={
localEditPromptDraftRefs.current[region.id] ?? region.prompt
}
window.setTimeout(() => {
if (isLocalEditControlTarget(document.activeElement)) {
onBlur={(event) => {
const nextPrompt = event.currentTarget.value;
localEditPromptDraftRefs.current[region.id] = nextPrompt;
persistLocalEditRegionPrompt(region.id, nextPrompt);
if (isLocalEditControlTarget(event.relatedTarget)) {
return;
}
setEditingLocalEditRegionId((current) => {
if (current === region.id) {
focusLocalEditPromptInput(region.id);
window.setTimeout(() => {
if (isLocalEditControlTarget(document.activeElement)) {
return;
}
return current;
});
}, 80);
}}
onClick={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
onFocus={(event) => {
event.stopPropagation();
setEditingLocalEditRegionId((current) =>
current === region.id ? current : region.id
);
}}
onInput={(event) => {
localEditPromptDraftRefs.current[region.id] =
event.currentTarget.value;
}}
setEditingLocalEditRegionId((current) => {
if (current === region.id) {
focusLocalEditPromptInput(region.id);
}
return current;
});
}, 80);
}}
onClick={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
onFocus={(event) => {
event.stopPropagation();
setEditingLocalEditRegionId((current) =>
current === region.id ? current : region.id
);
}}
onInput={(event) => {
localEditPromptDraftRefs.current[region.id] =
event.currentTarget.value;
}}
onChange={(event) => {
localEditPromptDraftRefs.current[region.id] =
event.currentTarget.value;
}}
onKeyDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
if (event.key === "Escape") {
event.preventDefault();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
}}
onKeyUp={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onMouseDownCapture={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
onPointerDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDownCapture={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
placeholder={`区域 ${region.index} 提示词`}
ref={(input) => {
localEditPromptInputRefs.current[region.id] = input;
}}
/>
) : (
<button
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);
}}
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>
)}
<input
accept="image/jpeg,image/png,image/webp"
className="hidden"
data-canvas-no-drag="true"
data-local-edit-control="true"
id={`local-edit-reference-upload-${region.id}`}
multiple
onChange={(event) => {
localEditPromptDraftRefs.current[region.id] =
event.currentTarget.value;
}}
onKeyDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
if (event.key === "Escape") {
event.preventDefault();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
}}
onKeyUp={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onMouseDownCapture={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
onPointerDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDownCapture={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
placeholder={`区域 ${region.index} 提示词`}
ref={(input) => {
localEditPromptInputRefs.current[region.id] = input;
void handleLocalEditReferenceUpload(
region.id,
event.currentTarget.files
);
event.currentTarget.value = "";
}}
type="file"
/>
) : (
<button
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"
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"
data-canvas-no-drag="true"
data-local-edit-control="true"
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
setEditingLocalEditRegionId(region.id);
document
.getElementById(`local-edit-reference-upload-${region.id}`)
?.click();
}}
disabled={
uploadingLocalEditReferenceRegionId === region.id ||
(region.referenceImages?.length || 0) >=
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION
}
onMouseDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
title="添加区域参考图"
type="button"
>
{uploadingLocalEditReferenceRegionId === region.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlus className="h-3.5 w-3.5" />
)}
</button>
<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-500 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-red-500 hover:text-white dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)]"
data-canvas-no-drag="true"
data-local-edit-control="true"
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
deleteLocalEditRegion(region.id);
}}
onMouseDown={(event) => {
event.preventDefault();
@ -8492,40 +8721,50 @@ export function ImageNode({
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
setEditingLocalEditRegionId(region.id);
deleteLocalEditRegion(region.id);
}}
title={region.prompt}
title="删除区域"
type="button"
>
{region.index}. {region.prompt}
<X className="h-3.5 w-3.5" />
</button>
)}
<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-500 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-red-500 hover:text-white dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)]"
data-canvas-no-drag="true"
data-local-edit-control="true"
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
deleteLocalEditRegion(region.id);
}}
onMouseDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
deleteLocalEditRegion(region.id);
}}
title="删除区域"
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{region.referenceImages?.length ? (
<div className="flex max-w-full items-center gap-1 overflow-hidden rounded-2xl border border-slate-200 bg-white/92 px-1.5 py-1 shadow-[0_8px_20px_rgba(15,23,42,0.1)] dark:border-white/12 dark:bg-slate-950/80">
<span className="shrink-0 px-1 text-[10px] font-semibold text-slate-500 dark:text-white/52">
参考
</span>
{region.referenceImages.map((reference) => (
<div
className="group relative h-8 w-8 shrink-0 overflow-hidden rounded-lg border border-slate-200 bg-slate-100 dark:border-white/12 dark:bg-slate-800"
key={reference.id}
title={reference.name || "区域参考图"}
>
<img
alt={reference.name || "区域参考图"}
className="h-full w-full object-cover"
draggable={false}
src={reference.url}
/>
<button
className="absolute inset-0 hidden items-center justify-center bg-black/52 text-white group-hover:flex"
data-canvas-no-drag="true"
data-local-edit-control="true"
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
removeLocalEditReferenceImage(region.id, reference.id);
}}
title="移除参考图"
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
) : null}
</div>,
document.body
)

View File

@ -307,6 +307,12 @@ export const imageGenerationService = {
color: string;
prompt: string;
rect: NormalizedCropRect;
referenceImages?: Array<{
id?: string;
url: string;
name?: string;
source?: "upload" | "canvas";
}>;
}>;
model?: string;
imageSize?: string;