feat: add reference images to local edit regions
This commit is contained in:
parent
e415de06d8
commit
4c86e01956
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user