feat: support canvas text color editing
This commit is contained in:
parent
c88cf6146b
commit
8ec27aa225
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
149
components/canvas/TextEditColorField.tsx
Normal file
149
components/canvas/TextEditColorField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
|
||||
39
lib/canvas/text-edit-colors.ts
Normal file
39
lib/canvas/text-edit-colors.ts
Normal 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() : "";
|
||||
}
|
||||
@ -56,6 +56,7 @@ export interface CanvasTextEditPayloadItem {
|
||||
id: string;
|
||||
originalText: string;
|
||||
editedText?: string;
|
||||
textColor?: string;
|
||||
action?: 'replace' | 'delete';
|
||||
}
|
||||
|
||||
|
||||
@ -50,6 +50,7 @@ export interface ImageTextEditItem {
|
||||
id: string;
|
||||
originalText: string;
|
||||
editedText: string;
|
||||
textColor?: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user