diff --git a/app/api/canvas/text-edit/route.ts b/app/api/canvas/text-edit/route.ts index f297c1311..7e12e5164 100644 --- a/app/api/canvas/text-edit/route.ts +++ b/app/api/canvas/text-edit/route.ts @@ -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 diff --git a/components/agents/AgentImageTextEditDialog.tsx b/components/agents/AgentImageTextEditDialog.tsx index f99e28ca4..d7c784e3e 100644 --- a/components/agents/AgentImageTextEditDialog.tsx +++ b/components/agents/AgentImageTextEditDialog.tsx @@ -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="输入替换后的文字" /> + handleTextItemColorChange(item.id, value)} + />
diff --git a/components/canvas/TextEditColorField.tsx b/components/canvas/TextEditColorField.tsx new file mode 100644 index 000000000..5898d122e --- /dev/null +++ b/components/canvas/TextEditColorField.tsx @@ -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 ( +
+
+
+ +

+ 不选则保持原图文字颜色;选了色号后,后端会明确要求只改这段文字的颜色。 +

+
+ +
+ +
+ + + setDraftHex(event.target.value)} + onBlur={handleHexCommit} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleHexCommit(); + } + }} + placeholder="#22C55E" + maxLength={7} + className="h-10" + /> +
+ +
+ {TEXT_EDIT_COLOR_SWATCHES.map((color) => { + const isActive = normalizedValue === color; + return ( +
+
+ ); +} diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index f2b2bb924..75c07321e 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -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} /> + + handleTextItemColorChange(item.id, value) + } + />
))} diff --git a/lib/canvas/text-edit-colors.ts b/lib/canvas/text-edit-colors.ts new file mode 100644 index 000000000..d34890fa4 --- /dev/null +++ b/lib/canvas/text-edit-colors.ts @@ -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() : ""; +} diff --git a/services/canvas/index.ts b/services/canvas/index.ts index 3a8bd52b3..5dd580af0 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -56,6 +56,7 @@ export interface CanvasTextEditPayloadItem { id: string; originalText: string; editedText?: string; + textColor?: string; action?: 'replace' | 'delete'; } diff --git a/types/canvas/index.ts b/types/canvas/index.ts index 7a4af629b..5c1f172ca 100644 --- a/types/canvas/index.ts +++ b/types/canvas/index.ts @@ -50,6 +50,7 @@ export interface ImageTextEditItem { id: string; originalText: string; editedText: string; + textColor?: string; confidence?: number; }