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 { createKrea } from "@/lib/ai/providers/krea";
|
||||||
import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision";
|
import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision";
|
||||||
import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
|
import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
|
||||||
|
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
@ -30,6 +31,7 @@ const textItemSchema = z.object({
|
|||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
originalText: z.string().min(1),
|
originalText: z.string().min(1),
|
||||||
editedText: z.string().optional(),
|
editedText: z.string().optional(),
|
||||||
|
textColor: z.string().optional(),
|
||||||
action: z.enum(["replace", "delete"]).optional(),
|
action: z.enum(["replace", "delete"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -371,22 +373,53 @@ function parseImageFromGenerationText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildTextEditPrompt(
|
function buildTextEditPrompt(
|
||||||
replacements: Array<{ originalText: string; editedText: string }>,
|
replacements: Array<{
|
||||||
|
originalText: string;
|
||||||
|
editedText: string;
|
||||||
|
textColor?: string;
|
||||||
|
}>,
|
||||||
deletions: Array<{ originalText: string }>,
|
deletions: Array<{ originalText: string }>,
|
||||||
) {
|
) {
|
||||||
const sections: string[] = [
|
const sections: string[] = [
|
||||||
"请基于输入图片执行精确文字替换。",
|
"请基于输入图片执行精确文字替换。",
|
||||||
"硬性要求:",
|
"硬性要求:",
|
||||||
"1. 仅替换下方列出的文字内容,不要修改其他主体、配色、材质、光影与构图。",
|
"1. 仅编辑下方列出的目标文字区域,不要修改其他主体、产品、背景、材质、配色、光影、图标、装饰元素与构图。",
|
||||||
"2. 必须保持原图排版、字重、笔画风格与字体形状尽可能一致,仅替换指定文字内容,其他视觉元素不变。",
|
"2. 必须保持原图排版、字重、笔画风格、字体形状、字号关系与文字所在位置尽可能一致,仅替换指定文字内容或指定文字颜色。",
|
||||||
"3. 输出图严格保持原图宽高比例,不裁切、不拉伸、不加边框。",
|
"3. 输出图严格保持原图宽高比例,不裁切、不拉伸、不加边框。",
|
||||||
"4. 若目标文案更长,优先微调字距或字号,确保自然贴合原位置。",
|
"4. 若目标文案更长,优先微调字距或字号,确保自然贴合原位置。",
|
||||||
"5. 必须输出一张新生成的图片,不要直接返回输入原图或源图链接;如果无法完成替换,请明确失败,不要用原图冒充结果。",
|
"5. 如果指定了文字颜色,只允许修改该段文字本身的颜色,绝对禁止把产品、包装、背景、阴影、高光、边框或其他非文字区域一起染色。",
|
||||||
|
"6. 如未指定文字颜色,则尽量保持原图该文字本来的颜色与质感。",
|
||||||
|
"7. 删除文字时请仅移除对应文字并自然修复背景,不要改动周围产品外观。",
|
||||||
|
"8. 必须输出一张新生成的图片,不要直接返回输入原图或源图链接;如果无法完成替换,请明确失败,不要用原图冒充结果。",
|
||||||
];
|
];
|
||||||
|
|
||||||
if (replacements.length > 0) {
|
if (replacements.length > 0) {
|
||||||
const replacementLines = replacements
|
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");
|
.join("\n");
|
||||||
sections.push("替换清单:", replacementLines);
|
sections.push("替换清单:", replacementLines);
|
||||||
}
|
}
|
||||||
@ -398,7 +431,7 @@ function buildTextEditPrompt(
|
|||||||
sections.push(
|
sections.push(
|
||||||
"删除清单:",
|
"删除清单:",
|
||||||
deletionLines,
|
deletionLines,
|
||||||
"删除时请清除对应文字并自然修复背景,不保留残影或笔画痕迹。",
|
"删除时请清除对应文字并自然修复背景,不保留残影或笔画痕迹,也不要影响产品主体颜色。",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -497,6 +530,7 @@ export async function POST(request: Request) {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
originalText: item.originalText.trim(),
|
originalText: item.originalText.trim(),
|
||||||
editedText: (item.editedText || "").trim(),
|
editedText: (item.editedText || "").trim(),
|
||||||
|
textColor: normalizeOptionalHexColor(item.textColor),
|
||||||
action: item.action,
|
action: item.action,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -504,14 +538,15 @@ export async function POST(request: Request) {
|
|||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.originalText &&
|
item.originalText &&
|
||||||
item.editedText &&
|
|
||||||
item.action !== "delete" &&
|
item.action !== "delete" &&
|
||||||
item.originalText !== item.editedText,
|
item.editedText &&
|
||||||
|
(item.originalText !== item.editedText || Boolean(item.textColor)),
|
||||||
)
|
)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
originalText: item.originalText,
|
originalText: item.originalText,
|
||||||
editedText: item.editedText,
|
editedText: item.editedText,
|
||||||
|
textColor: item.textColor,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const deletions = normalizedItems
|
const deletions = normalizedItems
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Loader2, RefreshCw, Sparkles, Trash2, Type, X } from "lucide-react";
|
import { Loader2, RefreshCw, Sparkles, Trash2, Type, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { TextEditColorField } from "@/components/canvas/TextEditColorField";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -12,6 +13,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||||||
import {
|
import {
|
||||||
imageGenerationService,
|
imageGenerationService,
|
||||||
type CanvasTextEditPayloadItem,
|
type CanvasTextEditPayloadItem,
|
||||||
@ -40,6 +42,7 @@ interface AgentImageTextEditItem {
|
|||||||
id: string;
|
id: string;
|
||||||
originalText: string;
|
originalText: string;
|
||||||
editedText: string;
|
editedText: string;
|
||||||
|
textColor?: string;
|
||||||
confidence?: number;
|
confidence?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +71,7 @@ function buildTextEditRequestItems(
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
originalText: item.originalText.trim(),
|
originalText: item.originalText.trim(),
|
||||||
editedText,
|
editedText,
|
||||||
|
textColor: normalizeOptionalHexColor(item.textColor),
|
||||||
action,
|
action,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -77,7 +81,8 @@ function buildTextEditRequestItems(
|
|||||||
(item.action === "delete" ||
|
(item.action === "delete" ||
|
||||||
(item.editedText &&
|
(item.editedText &&
|
||||||
item.editedText.length > 0 &&
|
item.editedText.length > 0 &&
|
||||||
item.originalText !== item.editedText))
|
(item.originalText !== item.editedText ||
|
||||||
|
Boolean(item.textColor))))
|
||||||
);
|
);
|
||||||
|
|
||||||
const removedItems: CanvasTextEditPayloadItem[] = deletedTextItems
|
const removedItems: CanvasTextEditPayloadItem[] = deletedTextItems
|
||||||
@ -197,6 +202,7 @@ export function AgentImageTextEditDialog({
|
|||||||
id: item.id || String(index + 1),
|
id: item.id || String(index + 1),
|
||||||
originalText: text,
|
originalText: text,
|
||||||
editedText: text,
|
editedText: text,
|
||||||
|
textColor: undefined,
|
||||||
confidence: item.confidence,
|
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) => {
|
const handleRemoveTextItem = useCallback((id: string) => {
|
||||||
setTextEditItems((prev) => {
|
setTextEditItems((prev) => {
|
||||||
const target = prev.find((item) => item.id === id);
|
const target = prev.find((item) => item.id === id);
|
||||||
@ -384,7 +400,7 @@ export function AgentImageTextEditDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (textEditRequestItems.length === 0) {
|
if (textEditRequestItems.length === 0) {
|
||||||
toast.info("请先把要替换的文字改成新文案,或点击垃圾桶删除该文字");
|
toast.info("请先修改文字、选择文字颜色,或点击垃圾桶删除该文字");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -508,6 +524,10 @@ export function AgentImageTextEditDialog({
|
|||||||
onChange={(event) => handleTextItemChange(item.id, event.target.value)}
|
onChange={(event) => handleTextItemChange(item.id, event.target.value)}
|
||||||
placeholder="输入替换后的文字"
|
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="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="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="text-xs text-slate-500 dark:text-zinc-400">
|
<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 { toast } from "sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
|
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
|
||||||
|
import { TextEditColorField } from "@/components/canvas/TextEditColorField";
|
||||||
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
|
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
|
||||||
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
||||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||||
@ -199,6 +200,7 @@ import {
|
|||||||
type AiWashControlLevel,
|
type AiWashControlLevel,
|
||||||
type AiWashPreset,
|
type AiWashPreset,
|
||||||
} from "@/lib/ai-wash/config";
|
} from "@/lib/ai-wash/config";
|
||||||
|
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||||||
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
||||||
import { cn, fetcher } from "@/lib/utils";
|
import { cn, fetcher } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
@ -577,6 +579,7 @@ const normalizeTextEditItems = (
|
|||||||
id: item.id || String(index + 1),
|
id: item.id || String(index + 1),
|
||||||
originalText,
|
originalText,
|
||||||
editedText: (item.editedText || item.originalText || "").trim(),
|
editedText: (item.editedText || item.originalText || "").trim(),
|
||||||
|
textColor: normalizeOptionalHexColor(item.textColor),
|
||||||
confidence: item.confidence,
|
confidence: item.confidence,
|
||||||
} as ImageTextEditItem;
|
} as ImageTextEditItem;
|
||||||
})
|
})
|
||||||
@ -5355,6 +5358,7 @@ export function ImageNode({
|
|||||||
id: item.id || String(index + 1),
|
id: item.id || String(index + 1),
|
||||||
originalText: text,
|
originalText: text,
|
||||||
editedText: text,
|
editedText: text,
|
||||||
|
textColor: undefined,
|
||||||
confidence: item.confidence,
|
confidence: item.confidence,
|
||||||
} as ImageTextEditItem;
|
} 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) => {
|
const handleRemoveTextItem = (id: string) => {
|
||||||
setTextEditItems((prev) => {
|
setTextEditItems((prev) => {
|
||||||
const target = prev.find((item) => item.id === id);
|
const target = prev.find((item) => item.id === id);
|
||||||
@ -5465,7 +5479,9 @@ export function ImageNode({
|
|||||||
const editedText = item.editedText.trim();
|
const editedText = item.editedText.trim();
|
||||||
return (
|
return (
|
||||||
originalText.length > 0 &&
|
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),
|
deletedTextItems.some((item) => item.originalText.trim().length > 0),
|
||||||
@ -5492,6 +5508,7 @@ export function ImageNode({
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
originalText: item.originalText.trim(),
|
originalText: item.originalText.trim(),
|
||||||
editedText,
|
editedText,
|
||||||
|
textColor: normalizeOptionalHexColor(item.textColor),
|
||||||
action,
|
action,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -5501,7 +5518,8 @@ export function ImageNode({
|
|||||||
(item.action === "delete" ||
|
(item.action === "delete" ||
|
||||||
(item.editedText &&
|
(item.editedText &&
|
||||||
item.editedText.length > 0 &&
|
item.editedText.length > 0 &&
|
||||||
item.originalText !== item.editedText))
|
(item.originalText !== item.editedText ||
|
||||||
|
Boolean(item.textColor))))
|
||||||
);
|
);
|
||||||
|
|
||||||
const removedItems: CanvasTextEditPayloadItem[] = deletedTextItems
|
const removedItems: CanvasTextEditPayloadItem[] = deletedTextItems
|
||||||
@ -5515,7 +5533,7 @@ export function ImageNode({
|
|||||||
const requestItems = [...changedItems, ...removedItems];
|
const requestItems = [...changedItems, ...removedItems];
|
||||||
|
|
||||||
if (requestItems.length === 0) {
|
if (requestItems.length === 0) {
|
||||||
toast.info("请先把要替换的文字改成新文案,或点击垃圾桶删除该文字");
|
toast.info("请先修改文字内容、选择文字颜色,或点击垃圾桶删除该文字");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -10687,6 +10705,12 @@ export function ImageNode({
|
|||||||
placeholder="输入替换后的文字"
|
placeholder="输入替换后的文字"
|
||||||
value={item.editedText}
|
value={item.editedText}
|
||||||
/>
|
/>
|
||||||
|
<TextEditColorField
|
||||||
|
value={item.textColor}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleTextItemColorChange(item.id, value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</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;
|
id: string;
|
||||||
originalText: string;
|
originalText: string;
|
||||||
editedText?: string;
|
editedText?: string;
|
||||||
|
textColor?: string;
|
||||||
action?: 'replace' | 'delete';
|
action?: 'replace' | 'delete';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -50,6 +50,7 @@ export interface ImageTextEditItem {
|
|||||||
id: string;
|
id: string;
|
||||||
originalText: string;
|
originalText: string;
|
||||||
editedText: string;
|
editedText: string;
|
||||||
|
textColor?: string;
|
||||||
confidence?: number;
|
confidence?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user