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:
parent
4b79e33dac
commit
bb1667881d
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user