feat: support canvas text color editing

This commit is contained in:
134 2026-06-17 10:26:40 +08:00
parent c88cf6146b
commit 8ec27aa225
7 changed files with 282 additions and 13 deletions

View File

@ -16,6 +16,7 @@ import { createRunway } from "@/lib/ai/providers/runway";
import { createKrea } from "@/lib/ai/providers/krea";
import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision";
import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
import { createLogger } from "@/lib/observability/logger";
import { uploadFile } from "@/lib/storage/service";
@ -30,6 +31,7 @@ const textItemSchema = z.object({
id: z.string().min(1),
originalText: z.string().min(1),
editedText: z.string().optional(),
textColor: z.string().optional(),
action: z.enum(["replace", "delete"]).optional(),
});
@ -371,22 +373,53 @@ function parseImageFromGenerationText(
}
function buildTextEditPrompt(
replacements: Array<{ originalText: string; editedText: string }>,
replacements: Array<{
originalText: string;
editedText: string;
textColor?: string;
}>,
deletions: Array<{ originalText: string }>,
) {
const sections: string[] = [
"请基于输入图片执行精确文字替换。",
"硬性要求:",
"1. 仅替换下方列出的文字内容,不要修改其他主体、配色、材质、光影与构图。",
"2. 必须保持原图排版、字重、笔画风格与字体形状尽可能一致,仅替换指定文字内容,其他视觉元素不变。",
"1. 仅编辑下方列出的目标文字区域,不要修改其他主体、产品、背景、材质、配色、光影、图标、装饰元素与构图。",
"2. 必须保持原图排版、字重、笔画风格、字体形状、字号关系与文字所在位置尽可能一致,仅替换指定文字内容或指定文字颜色。",
"3. 输出图严格保持原图宽高比例,不裁切、不拉伸、不加边框。",
"4. 若目标文案更长,优先微调字距或字号,确保自然贴合原位置。",
"5. 必须输出一张新生成的图片,不要直接返回输入原图或源图链接;如果无法完成替换,请明确失败,不要用原图冒充结果。",
"5. 如果指定了文字颜色,只允许修改该段文字本身的颜色,绝对禁止把产品、包装、背景、阴影、高光、边框或其他非文字区域一起染色。",
"6. 如未指定文字颜色,则尽量保持原图该文字本来的颜色与质感。",
"7. 删除文字时请仅移除对应文字并自然修复背景,不要改动周围产品外观。",
"8. 必须输出一张新生成的图片,不要直接返回输入原图或源图链接;如果无法完成替换,请明确失败,不要用原图冒充结果。",
];
if (replacements.length > 0) {
const replacementLines = replacements
.map((item, index) => `${index + 1}. 原文: ${item.originalText}\n 替换为: ${item.editedText}`)
.map((item, index) => {
if (item.textColor && item.originalText === item.editedText) {
return [
`${index + 1}. 原文: ${item.originalText}`,
" 文字内容保持不变",
` 仅将这段文字颜色改为: ${item.textColor.toUpperCase()}`,
" 注意: 只能改文字颜色,不要让产品、背景或其他元素变色。",
].join("\n");
}
if (item.textColor) {
return [
`${index + 1}. 原文: ${item.originalText}`,
` 替换为: ${item.editedText}`,
` 新文字颜色: ${item.textColor.toUpperCase()}`,
" 注意: 只对这段文字应用该颜色,其它区域颜色保持原样。",
].join("\n");
}
return [
`${index + 1}. 原文: ${item.originalText}`,
` 替换为: ${item.editedText}`,
" 文字颜色要求: 保持原图该文字原有颜色不变",
].join("\n");
})
.join("\n");
sections.push("替换清单:", replacementLines);
}
@ -398,7 +431,7 @@ function buildTextEditPrompt(
sections.push(
"删除清单:",
deletionLines,
"删除时请清除对应文字并自然修复背景,不保留残影或笔画痕迹。",
"删除时请清除对应文字并自然修复背景,不保留残影或笔画痕迹,也不要影响产品主体颜色。",
);
}
@ -497,6 +530,7 @@ export async function POST(request: Request) {
id: item.id,
originalText: item.originalText.trim(),
editedText: (item.editedText || "").trim(),
textColor: normalizeOptionalHexColor(item.textColor),
action: item.action,
}));
@ -504,14 +538,15 @@ export async function POST(request: Request) {
.filter(
(item) =>
item.originalText &&
item.editedText &&
item.action !== "delete" &&
item.originalText !== item.editedText,
item.editedText &&
(item.originalText !== item.editedText || Boolean(item.textColor)),
)
.map((item) => ({
id: item.id,
originalText: item.originalText,
editedText: item.editedText,
textColor: item.textColor,
}));
const deletions = normalizedItems

View File

@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, RefreshCw, Sparkles, Trash2, Type, X } from "lucide-react";
import { toast } from "sonner";
import { TextEditColorField } from "@/components/canvas/TextEditColorField";
import {
Dialog,
DialogContent,
@ -12,6 +13,7 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
import {
imageGenerationService,
type CanvasTextEditPayloadItem,
@ -40,6 +42,7 @@ interface AgentImageTextEditItem {
id: string;
originalText: string;
editedText: string;
textColor?: string;
confidence?: number;
}
@ -68,6 +71,7 @@ function buildTextEditRequestItems(
id: item.id,
originalText: item.originalText.trim(),
editedText,
textColor: normalizeOptionalHexColor(item.textColor),
action,
};
})
@ -77,7 +81,8 @@ function buildTextEditRequestItems(
(item.action === "delete" ||
(item.editedText &&
item.editedText.length > 0 &&
item.originalText !== item.editedText))
(item.originalText !== item.editedText ||
Boolean(item.textColor))))
);
const removedItems: CanvasTextEditPayloadItem[] = deletedTextItems
@ -197,6 +202,7 @@ export function AgentImageTextEditDialog({
id: item.id || String(index + 1),
originalText: text,
editedText: text,
textColor: undefined,
confidence: item.confidence,
});
});
@ -245,6 +251,16 @@ export function AgentImageTextEditDialog({
);
}, []);
const handleTextItemColorChange = useCallback((id: string, value?: string) => {
setTextEditItems((prev) =>
prev.map((item) =>
item.id === id
? { ...item, textColor: normalizeOptionalHexColor(value) }
: item
)
);
}, []);
const handleRemoveTextItem = useCallback((id: string) => {
setTextEditItems((prev) => {
const target = prev.find((item) => item.id === id);
@ -384,7 +400,7 @@ export function AgentImageTextEditDialog({
}
if (textEditRequestItems.length === 0) {
toast.info("请先把要替换的文字改成新文案,或点击垃圾桶删除该文字");
toast.info("请先修改文字、选择文字颜色,或点击垃圾桶删除该文字");
return;
}
@ -508,6 +524,10 @@ export function AgentImageTextEditDialog({
onChange={(event) => handleTextItemChange(item.id, event.target.value)}
placeholder="输入替换后的文字"
/>
<TextEditColorField
value={item.textColor}
onChange={(value) => handleTextItemColorChange(item.id, value)}
/>
<div className="space-y-3 rounded-xl bg-slate-50/80 p-3 dark:bg-zinc-900/80">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-xs text-slate-500 dark:text-zinc-400">

View File

@ -0,0 +1,149 @@
"use client";
import { RotateCcw } from "lucide-react";
import { useEffect, useId, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
formatHexColor,
normalizeOptionalHexColor,
TEXT_EDIT_COLOR_SWATCHES,
} from "@/lib/canvas/text-edit-colors";
import { cn } from "@/lib/utils";
interface TextEditColorFieldProps {
value?: string;
onChange: (value?: string) => void;
className?: string;
}
const COLOR_RING_BACKGROUND =
"conic-gradient(from 180deg, #ef4444, #f59e0b, #84cc16, #22c55e, #14b8a6, #3b82f6, #8b5cf6, #ec4899, #ef4444)";
export function TextEditColorField({
value,
onChange,
className,
}: TextEditColorFieldProps) {
const inputId = useId();
const normalizedValue = useMemo(
() => normalizeOptionalHexColor(value),
[value]
);
const [draftHex, setDraftHex] = useState(() => formatHexColor(value));
useEffect(() => {
setDraftHex(formatHexColor(value));
}, [value]);
const handleHexCommit = () => {
const normalized = normalizeOptionalHexColor(draftHex);
if (!draftHex.trim()) {
onChange(undefined);
setDraftHex("");
return;
}
if (normalized) {
onChange(normalized);
setDraftHex(normalized.toUpperCase());
return;
}
setDraftHex(formatHexColor(normalizedValue));
};
return (
<div
className={cn(
"space-y-2 rounded-xl border border-slate-200/80 bg-slate-50/70 p-3 dark:border-zinc-800 dark:bg-zinc-900/80",
className
)}
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<label
htmlFor={inputId}
className="font-medium text-[12px] text-slate-700 dark:text-zinc-200"
>
文字颜色
</label>
<p className="text-[11px] leading-5 text-slate-500 dark:text-zinc-400">
不选则保持原图文字颜色;选了色号后,后端会明确要求只改这段文字的颜色。
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px] text-slate-500 hover:text-slate-900 dark:text-zinc-400 dark:hover:text-zinc-100"
disabled={!normalizedValue}
onClick={() => onChange(undefined)}
>
<RotateCcw className="mr-1 h-3.5 w-3.5" />
保持原色
</Button>
</div>
<div className="flex items-center gap-3">
<label
htmlFor={inputId}
className="relative block h-11 w-11 shrink-0 cursor-pointer rounded-full shadow-sm"
title="点击选择文字颜色"
>
<span
className="absolute inset-0 rounded-full"
style={{ background: COLOR_RING_BACKGROUND }}
/>
<span
className="absolute inset-[4px] rounded-full border border-white/90 shadow-inner"
style={{
backgroundColor: normalizedValue || "#ffffff",
}}
/>
<input
id={inputId}
type="color"
value={normalizedValue || "#22c55e"}
onChange={(event) => onChange(event.target.value)}
className="absolute inset-0 cursor-pointer opacity-0"
/>
</label>
<Input
value={draftHex}
onChange={(event) => setDraftHex(event.target.value)}
onBlur={handleHexCommit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleHexCommit();
}
}}
placeholder="#22C55E"
maxLength={7}
className="h-10"
/>
</div>
<div className="grid grid-cols-7 gap-2">
{TEXT_EDIT_COLOR_SWATCHES.map((color) => {
const isActive = normalizedValue === color;
return (
<button
key={color}
type="button"
aria-label={`选择 ${color.toUpperCase()}`}
className={cn(
"h-7 rounded-full border transition-transform hover:scale-[1.04]",
isActive
? "border-slate-900 ring-2 ring-slate-300 dark:border-white dark:ring-zinc-700"
: "border-slate-200 dark:border-zinc-700"
)}
style={{ backgroundColor: color }}
onClick={() => onChange(color)}
/>
);
})}
</div>
</div>
);
}

View File

@ -46,6 +46,7 @@ import { createPortal } from "react-dom";
import { toast } from "sonner";
import useSWR from "swr";
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
import { TextEditColorField } from "@/components/canvas/TextEditColorField";
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
@ -199,6 +200,7 @@ import {
type AiWashControlLevel,
type AiWashPreset,
} from "@/lib/ai-wash/config";
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
import { cn, fetcher } from "@/lib/utils";
import type {
@ -577,6 +579,7 @@ const normalizeTextEditItems = (
id: item.id || String(index + 1),
originalText,
editedText: (item.editedText || item.originalText || "").trim(),
textColor: normalizeOptionalHexColor(item.textColor),
confidence: item.confidence,
} as ImageTextEditItem;
})
@ -5355,6 +5358,7 @@ export function ImageNode({
id: item.id || String(index + 1),
originalText: text,
editedText: text,
textColor: undefined,
confidence: item.confidence,
} as ImageTextEditItem;
})
@ -5445,6 +5449,16 @@ export function ImageNode({
);
};
const handleTextItemColorChange = (id: string, value?: string) => {
setTextEditItems((prev) =>
prev.map((item) =>
item.id === id
? { ...item, textColor: normalizeOptionalHexColor(value) }
: item
)
);
};
const handleRemoveTextItem = (id: string) => {
setTextEditItems((prev) => {
const target = prev.find((item) => item.id === id);
@ -5465,7 +5479,9 @@ export function ImageNode({
const editedText = item.editedText.trim();
return (
originalText.length > 0 &&
(editedText.length === 0 || editedText !== originalText)
(editedText.length === 0 ||
editedText !== originalText ||
Boolean(normalizeOptionalHexColor(item.textColor)))
);
}) ||
deletedTextItems.some((item) => item.originalText.trim().length > 0),
@ -5492,6 +5508,7 @@ export function ImageNode({
id: item.id,
originalText: item.originalText.trim(),
editedText,
textColor: normalizeOptionalHexColor(item.textColor),
action,
};
})
@ -5501,7 +5518,8 @@ export function ImageNode({
(item.action === "delete" ||
(item.editedText &&
item.editedText.length > 0 &&
item.originalText !== item.editedText))
(item.originalText !== item.editedText ||
Boolean(item.textColor))))
);
const removedItems: CanvasTextEditPayloadItem[] = deletedTextItems
@ -5515,7 +5533,7 @@ export function ImageNode({
const requestItems = [...changedItems, ...removedItems];
if (requestItems.length === 0) {
toast.info("请先把要替换的文字改成新文案,或点击垃圾桶删除该文字");
toast.info("请先修改文字内容、选择文字颜色,或点击垃圾桶删除该文字");
return;
}
@ -10687,6 +10705,12 @@ export function ImageNode({
placeholder="输入替换后的文字"
value={item.editedText}
/>
<TextEditColorField
value={item.textColor}
onChange={(value) =>
handleTextItemColorChange(item.id, value)
}
/>
</div>
))}

View File

@ -0,0 +1,39 @@
export const TEXT_EDIT_COLOR_SWATCHES = [
"#111827",
"#ffffff",
"#ef4444",
"#f97316",
"#f59e0b",
"#84cc16",
"#22c55e",
"#14b8a6",
"#06b6d4",
"#3b82f6",
"#6366f1",
"#8b5cf6",
"#d946ef",
"#ec4899",
] as const;
export function normalizeOptionalHexColor(value: unknown): string | undefined {
const normalized = String(value || "").trim().toLowerCase();
if (!normalized) return undefined;
if (/^[0-9a-f]{6}$/i.test(normalized)) {
return `#${normalized}`;
}
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(normalized)) {
if (normalized.length === 4) {
return `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`;
}
return normalized;
}
return undefined;
}
export function formatHexColor(value: string | undefined | null) {
const normalized = normalizeOptionalHexColor(value);
return normalized ? normalized.toUpperCase() : "";
}

View File

@ -56,6 +56,7 @@ export interface CanvasTextEditPayloadItem {
id: string;
originalText: string;
editedText?: string;
textColor?: string;
action?: 'replace' | 'delete';
}

View File

@ -50,6 +50,7 @@ export interface ImageTextEditItem {
id: string;
originalText: string;
editedText: string;
textColor?: string;
confidence?: number;
}