fix: improve canvas image toolbar and tags

Add watermark removal, improve dark mode toolbar styling, and sync tag deletion with attachments.
This commit is contained in:
Your Name 2026-02-03 15:51:30 +08:00
parent 4b79e33dac
commit bb1667881d
3 changed files with 107 additions and 21 deletions

View File

@ -268,6 +268,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
tagSpan.setAttribute('data-attachment-url', url);
tagSpan.setAttribute('data-picked-object', 'true');
tagSpan.setAttribute('data-object-name', objectName);
tagSpan.setAttribute('data-object-position', objectPosition);
// 样式与普通图片标签一致
tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none';
// 使用图片缩略图,不使用 emoji 或图标
@ -1001,11 +1002,54 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
e.target.value = '';
};
// 移除附件(通过 URL 删除,更可靠)
const removeAttachmentByUrl = (url: string) => {
setAttachments(prev => prev.filter(a => a.url !== url));
console.log('[CanvasChat] 已移除附件:', url.substring(0, 50));
// 移除附件(通过标签元素删除,支持同图多物品)
const removeAttachmentByTagElement = (tagEl: HTMLElement) => {
const url = tagEl.getAttribute('data-attachment-url') || '';
const isPicked = tagEl.getAttribute('data-picked-object') === 'true';
const objectName = tagEl.getAttribute('data-object-name') || '';
const objectPosition = tagEl.getAttribute('data-object-position') || '';
setAttachments(prev => prev.filter(a => {
if (!url) return true;
if (isPicked) {
return !(
a.url === url &&
a.pickedObject?.objectName === objectName &&
a.pickedObject?.objectPosition === objectPosition
);
}
return a.url !== url;
}));
if (url) {
console.log('[CanvasChat] 已移除附件:', url.substring(0, 50));
}
};
// 从编辑器 DOM 同步附件列表,避免删除标签后仍发送
const syncAttachmentsWithEditor = useCallback((editableDiv?: HTMLDivElement | null) => {
const container = editableDiv || (textareaRef.current as HTMLDivElement | null);
if (!container) return;
const tagElements = Array.from(container.querySelectorAll('[data-attachment-url]')) as HTMLElement[];
const tagKeys = new Set<string>();
tagElements.forEach((el) => {
const url = el.getAttribute('data-attachment-url') || '';
if (!url) return;
const isPicked = el.getAttribute('data-picked-object') === 'true';
if (isPicked) {
const objectName = el.getAttribute('data-object-name') || '';
const objectPosition = el.getAttribute('data-object-position') || '';
tagKeys.add(`picked|${url}|${objectName}|${objectPosition}`);
} else {
tagKeys.add(`image|${url}`);
}
});
setAttachments(prev => prev.filter((att) => {
if (att.pickedObject) {
const key = `picked|${att.url}|${att.pickedObject.objectName}|${att.pickedObject.objectPosition}`;
return tagKeys.has(key);
}
return tagKeys.has(`image|${att.url}`);
}));
}, []);
// 移除附件(通过索引删除,保留兼容性)
const removeAttachment = (index: number) => {
@ -1318,6 +1362,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
textContent += node.textContent;
}
setInput(textContent.trim());
syncAttachmentsWithEditor(target);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@ -1336,8 +1381,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
e.preventDefault();
e.stopPropagation(); // 阻止事件冒泡,防止触发全局删除
// 使用 URL 来删除附件,更可靠
const url = prevSibling.getAttribute('data-attachment-url') || '';
removeAttachmentByUrl(url);
removeAttachmentByTagElement(prevSibling);
prevSibling.remove();
}
}

View File

@ -2,7 +2,7 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { ImagePlus, Loader2, AlertCircle, MoreVertical, Scissors, Sparkles, Expand, Maximize2, Move3D, Layers, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X } from "lucide-react";
import { ImagePlus, Loader2, AlertCircle, MoreVertical, Scissors, Eraser, Sparkles, Expand, Maximize2, Move3D, Layers, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -320,6 +320,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const getActionName = (action: string): string => {
const names: Record<string, string> = {
'rmbg': '抠图',
'watermark': '去水印',
'enhance': '变清晰',
'extend': '图片扩展',
'resize': '尺寸调整',
@ -381,6 +382,9 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
case 'rmbg':
result = await imageGenerationService.removeBackground(data.src);
break;
case 'watermark':
result = await imageGenerationService.removeWatermark(data.src);
break;
case 'enhance':
result = await imageGenerationService.enhanceImage(data.src);
break;
@ -742,6 +746,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Scissors className="h-4 w-4 mr-2" />
抠图
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('watermark')}>
<Eraser className="h-4 w-4 mr-2" />
去水印
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('enhance')}>
<Sparkles className="h-4 w-4 mr-2" />
变清晰
@ -856,7 +864,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
{isSelected && data.src && !isProcessing && typeof document !== 'undefined' && createPortal(
<TooltipProvider delayDuration={200}>
<div
className="fixed flex items-center gap-0.5 bg-white border border-gray-200 rounded-full shadow-xl px-2 py-1 z-[9999]"
className="fixed flex items-center gap-0.5 rounded-full shadow-xl px-2 py-1 z-[9999] bg-white/95 border border-gray-200 text-gray-800 dark:bg-gray-900/95 dark:border-gray-700 dark:text-gray-100"
style={{
top: getToolbarPosition().top,
left: getToolbarPosition().left,
@ -871,7 +879,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-blue-50 text-blue-600 hover:text-blue-700"
className="h-8 w-8 rounded-full hover:bg-blue-50 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 dark:hover:bg-blue-900/30"
onClick={() => {
addPendingChatAttachment({
url: data.src!,
@ -893,7 +901,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-orange-50 text-orange-600 hover:text-orange-700"
className="h-8 w-8 rounded-full hover:bg-orange-50 text-orange-600 hover:text-orange-700 dark:text-orange-400 dark:hover:text-orange-300 dark:hover:bg-orange-900/30"
onClick={() => {
setPickerMode(true, node.id);
toast.info('点击图片中的物品进行拾取');
@ -906,7 +914,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
</Tooltip>
{/* 分隔线 */}
<div className="h-5 w-px bg-gray-200 mx-0.5" />
<div className="h-5 w-px bg-gray-200 dark:bg-gray-700 mx-0.5" />
{/* 放大/高清 */}
<Tooltip>
@ -914,10 +922,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-gray-100"
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10"
onClick={() => handleAction('enhance')}
>
<span className="text-[10px] font-medium text-gray-500 border border-gray-300 rounded px-1">HD</span>
<span className="text-[10px] font-medium text-gray-500 border border-gray-300 rounded px-1 dark:text-gray-200 dark:border-gray-600">HD</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">高清放大</TooltipContent>
@ -929,7 +937,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-gray-100"
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10"
onClick={() => handleAction('rmbg')}
>
<Scissors className="h-4 w-4" />
@ -937,6 +945,21 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">移除背景</TooltipContent>
</Tooltip>
{/* 去水印 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10"
onClick={() => handleAction('watermark')}
>
<Eraser className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">去水印</TooltipContent>
</Tooltip>
{/* 场景融合 */}
<Tooltip>
@ -944,7 +967,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-gray-100"
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10"
onClick={() => handleAction('scene-fusion')}
>
<Layers className="h-4 w-4" />
@ -959,7 +982,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-gray-100"
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10"
onClick={() => handleAction('angle')}
>
<Move3D className="h-4 w-4" />
@ -974,7 +997,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-gray-100"
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10"
onClick={() => handleAction('extend')}
>
<Expand className="h-4 w-4" />
@ -989,7 +1012,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-gray-100"
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10"
onClick={() => handleAction('resize')}
>
<Maximize2 className="h-4 w-4" />
@ -1004,7 +1027,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8 rounded-full hover:bg-gray-100", data.hasAnnotations && "text-primary")}
className={cn("h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10", data.hasAnnotations && "text-primary")}
onClick={() => handleAction('annotate')}
>
<Pencil className="h-4 w-4" />
@ -1019,7 +1042,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-gray-100"
className="h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-white/10"
onClick={handleDownload}
>
<Download className="h-4 w-4" />
@ -1034,7 +1057,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full hover:bg-red-50 text-red-500 hover:text-red-600"
className="h-8 w-8 rounded-full hover:bg-red-50 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-900/30"
onClick={() => deleteNode(node.id)}
>
<Trash2 className="h-4 w-4" />

View File

@ -179,6 +179,25 @@ export const imageGenerationService = {
}
},
// 去水印 - 使用图片URL
async removeWatermark(imageUrl: string): Promise<ApiResponse<{ url: string }>> {
try {
const response = await fetch('/api/watermark-remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_url: imageUrl }),
});
const data = await response.json();
if (data.success && data.original_url) {
return { success: true, data: { url: data.original_url } };
}
return { success: false, error: data.error || '去水印失败' };
} catch (error) {
console.error('[Canvas] 去水印失败:', error);
return { success: false, error: '去水印失败' };
}
},
// 图片增强 - 使用图片URL
async enhanceImage(imageUrl: string): Promise<ApiResponse<{ url: string }>> {
try {