mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 17:04:27 +08:00
feat(toolbar): enhance image node hover toolbar with new tools and settings modal
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Modal, Segmented, Tooltip } from "antd";
|
||||
import { Camera, Download, FolderPlus, Image as ImageIcon, Info, Lock, LockOpen, Maximize2, MessageSquare, Minus, Music2, Pencil, Plus, RefreshCw, Scissors, Settings2, Sparkles, Trash2, Upload, Video, ZoomIn } from "lucide-react";
|
||||
import { App, Modal, Segmented, Tooltip } from "antd";
|
||||
import { Download, Ellipsis, FolderPlus, Image as ImageIcon, Info, MessageSquare, Minus, Music2, Pencil, Plus, RefreshCw, Settings2, Trash2, Upload, Video } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
|
||||
import { ImageToolSettingsModal, type ImageToolbarSettingsTool } from "./canvas-image-toolbar-settings-modal";
|
||||
import { IMAGE_QUICK_TOOLS_STORAGE_KEY, buildImageToolbarTools, defaultImageQuickToolIds, readImageQuickToolsConfig, type ImageQuickToolId } from "./canvas-image-toolbar-tools";
|
||||
|
||||
type CanvasNodeHoverToolbarProps = {
|
||||
node: CanvasNodeData | null;
|
||||
@@ -33,6 +36,16 @@ type CanvasNodeHoverToolbarProps = {
|
||||
onDelete: (node: CanvasNodeData) => void;
|
||||
};
|
||||
|
||||
type ToolbarTool = {
|
||||
id: string;
|
||||
title: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
export function CanvasNodeHoverToolbar({
|
||||
node,
|
||||
viewport,
|
||||
@@ -56,6 +69,31 @@ export function CanvasNodeHoverToolbar({
|
||||
onToggleFreeResize,
|
||||
onDelete,
|
||||
}: CanvasNodeHoverToolbarProps) {
|
||||
const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
|
||||
const [showImageToolLabels, setShowImageToolLabels] = useState(true);
|
||||
const [draftImageToolIds, setDraftImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
|
||||
const [draftShowImageToolLabels, setDraftShowImageToolLabels] = useState(true);
|
||||
const [imageToolSettingsOpen, setImageToolSettingsOpen] = useState(false);
|
||||
const { message } = App.useApp();
|
||||
const copyText = useCopyText();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(IMAGE_QUICK_TOOLS_STORAGE_KEY);
|
||||
if (!stored) return;
|
||||
const parsed = JSON.parse(stored) as unknown;
|
||||
const config = readImageQuickToolsConfig(parsed);
|
||||
setQuickImageToolIds(config.ids);
|
||||
setShowImageToolLabels(config.showLabels);
|
||||
} catch {
|
||||
window.localStorage.removeItem(IMAGE_QUICK_TOOLS_STORAGE_KEY);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setImageToolSettingsOpen(false);
|
||||
}, [node?.id]);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const left = viewport.x + (node.position.x + node.width / 2) * viewport.k;
|
||||
@@ -70,47 +108,98 @@ export function CanvasNodeHoverToolbar({
|
||||
const isConfig = node.type === CanvasNodeType.Config;
|
||||
const canOpenDialog = isText || hasImage || isVideo;
|
||||
const canRetry = node.metadata?.status === "error";
|
||||
const hasSpecificTools = canRetry || isText || isImage || isVideo || isAudio || isConfig;
|
||||
const quickImageToolIdSet = new Set(quickImageToolIds);
|
||||
const copyImagePrompt = (target: CanvasNodeData) => {
|
||||
const prompt = target.metadata?.prompt?.trim();
|
||||
if (!prompt) {
|
||||
message.warning("暂无可复制的提示词");
|
||||
return;
|
||||
}
|
||||
copyText(prompt, "提示词已复制");
|
||||
};
|
||||
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onCrop, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt });
|
||||
|
||||
function openImageToolSettings() {
|
||||
onKeep(node.id);
|
||||
setDraftImageToolIds(quickImageToolIds);
|
||||
setDraftShowImageToolLabels(showImageToolLabels);
|
||||
setImageToolSettingsOpen(true);
|
||||
}
|
||||
|
||||
const baseToolbarTools: ToolbarTool[] = [
|
||||
{ id: "info", title: "查看节点信息", label: "信息", icon: <Info className="size-4" />, onClick: () => onInfo(node) },
|
||||
{ id: "delete", title: "移除节点", label: "删除", icon: <Trash2 className="size-4" />, onClick: () => onDelete(node), danger: true },
|
||||
];
|
||||
const nodeToolbarTools: ToolbarTool[] = [
|
||||
...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
|
||||
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的素材", label: "存素材", icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
|
||||
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
|
||||
...(canOpenDialog ? [{ id: "edit", title: "编辑", label: "编辑", icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
|
||||
...(isText ? [{ id: "editText", title: "编辑文本", label: "编辑文字", icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
|
||||
...(isText ? [{ id: "generateImage", title: "用文本生图", label: "生图", icon: <ImageIcon className="size-4" />, onClick: () => onGenerateImage(node) }] : []),
|
||||
...(isConfig ? [{ id: "config", title: "生成配置", label: "生成配置", icon: <Settings2 className="size-4" />, onClick: () => onInfo(node) }] : []),
|
||||
...(isText ? [{ id: "decreaseFont", title: "减小字号", label: "缩小", icon: <Minus className="size-4" />, onClick: () => onDecreaseFont(node) }] : []),
|
||||
...(isText ? [{ id: "increaseFont", title: "增大字号", label: "放大", icon: <Plus className="size-4" />, onClick: () => onIncreaseFont(node) }] : []),
|
||||
...(isImage && !hasImage ? [{ id: "uploadImage", title: "上传图片", label: "上传图片", icon: <Upload className="size-4" />, onClick: () => onUpload(node) }] : []),
|
||||
...(isVideo ? [{ id: "uploadVideo", title: hasVideo ? "替换视频" : "上传视频", label: hasVideo ? "替换视频" : "上传视频", icon: <Video className="size-4" />, onClick: () => onUpload(node) }] : []),
|
||||
...(isAudio ? [{ id: "uploadAudio", title: hasAudio ? "替换音频" : "上传音频", label: hasAudio ? "替换音频" : "上传音频", icon: <Music2 className="size-4" />, onClick: () => onUpload(node) }] : []),
|
||||
...(hasImage ? imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, onClick: tool.onClick })) : []),
|
||||
];
|
||||
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools];
|
||||
const selectableImageToolbarTools = [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => tool.id !== "retry") as ImageToolbarSettingsTool[];
|
||||
|
||||
const closeImageToolSettings = () => {
|
||||
setImageToolSettingsOpen(false);
|
||||
onLeave();
|
||||
};
|
||||
|
||||
const setDraftImageToolVisible = (id: ImageQuickToolId, visible: boolean) => {
|
||||
setDraftImageToolIds((current) => {
|
||||
const selected = new Set(current);
|
||||
if (visible) selected.add(id);
|
||||
else selected.delete(id);
|
||||
return selectableImageToolbarTools.filter((tool) => selected.has(tool.id)).map((tool) => tool.id);
|
||||
});
|
||||
};
|
||||
|
||||
const saveImageToolSettings = () => {
|
||||
const config = { ids: draftImageToolIds, showLabels: draftShowImageToolLabels };
|
||||
setQuickImageToolIds(config.ids);
|
||||
setShowImageToolLabels(config.showLabels);
|
||||
window.localStorage.setItem(IMAGE_QUICK_TOOLS_STORAGE_KEY, JSON.stringify(config));
|
||||
closeImageToolSettings();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[70] flex h-12 -translate-x-1/2 -translate-y-full items-center overflow-visible rounded-[18px] border border-black/10 bg-white text-[15px] text-[#242529] shadow-[0_8px_28px_rgba(15,23,42,.12)]"
|
||||
style={{ left, top }}
|
||||
onMouseEnter={() => onKeep(node.id)}
|
||||
onMouseLeave={onLeave}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ToolbarAction title="查看节点信息" label="信息" icon={<Info className="size-4" />} onClick={() => onInfo(node)} />
|
||||
<ToolbarAction title="移除节点" label="删除" icon={<Trash2 className="size-4" />} onClick={() => onDelete(node)} danger />
|
||||
{hasSpecificTools ? <ToolbarDivider /> : null}
|
||||
{canRetry ? <ToolbarAction title="重新生成" label="重试" icon={<RefreshCw className="size-4" />} onClick={() => onRetry(node)} /> : null}
|
||||
{hasImage || hasVideo || isText ? <ToolbarAction title="加入我的素材" label="存素材" icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(node)} /> : null}
|
||||
{hasImage || hasVideo || hasAudio ? <IconAction title={hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片"} icon={<Download className="size-5" />} onClick={() => onDownload(node)} /> : null}
|
||||
{canOpenDialog ? <ToolbarAction title="编辑" label="编辑" icon={<MessageSquare className="size-4" />} onClick={() => onToggleDialog(node)} /> : null}
|
||||
{isText ? <ToolbarAction title="编辑文本" label="编辑文字" icon={<Pencil className="size-4" />} onClick={() => onEditText(node)} /> : null}
|
||||
{isText ? <ToolbarAction title="用文本生图" label="生图" icon={<ImageIcon className="size-4" />} onClick={() => onGenerateImage(node)} /> : null}
|
||||
{isConfig ? <ToolbarAction title="生成配置" label="生成配置" icon={<Settings2 className="size-4" />} onClick={() => onInfo(node)} /> : null}
|
||||
{isText ? <ToolbarAction title="减小字号" label="缩小" icon={<Minus className="size-4" />} onClick={() => onDecreaseFont(node)} /> : null}
|
||||
{isText ? <ToolbarAction title="增大字号" label="放大" icon={<Plus className="size-4" />} onClick={() => onIncreaseFont(node)} /> : null}
|
||||
{isImage ? <ToolbarAction title={hasImage ? "替换图片" : "上传图片"} label={hasImage ? "替换图片" : "上传图片"} icon={<Upload className="size-4" />} onClick={() => onUpload(node)} /> : null}
|
||||
{isVideo ? <ToolbarAction title={hasVideo ? "替换视频" : "上传视频"} label={hasVideo ? "替换视频" : "上传视频"} icon={<Video className="size-4" />} onClick={() => onUpload(node)} /> : null}
|
||||
{isAudio ? <ToolbarAction title={hasAudio ? "替换音频" : "上传音频"} label={hasAudio ? "替换音频" : "上传音频"} icon={<Music2 className="size-4" />} onClick={() => onUpload(node)} /> : null}
|
||||
<>
|
||||
<div
|
||||
className="absolute z-[70] flex h-12 -translate-x-1/2 -translate-y-full items-center overflow-visible rounded-[18px] border border-black/10 bg-white text-[15px] text-[#242529] shadow-[0_8px_28px_rgba(15,23,42,.12)]"
|
||||
style={{ left, top }}
|
||||
onMouseEnter={() => onKeep(node.id)}
|
||||
onMouseLeave={() => {
|
||||
if (!imageToolSettingsOpen) onLeave();
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{toolbarTools.map((tool) => (
|
||||
<ToolbarAction key={tool.id} {...tool} showLabel={showImageToolLabels} />
|
||||
))}
|
||||
{hasImage ? <ToolbarAction id="more" title="配置快捷工具" label="更多" icon={<Ellipsis className="size-4" />} active={imageToolSettingsOpen} onClick={openImageToolSettings} showLabel={showImageToolLabels} /> : null}
|
||||
</div>
|
||||
{hasImage ? (
|
||||
<ToolbarAction
|
||||
title={node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"}
|
||||
label={node.metadata?.freeResize ? "自由比例" : "锁比例"}
|
||||
icon={node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />}
|
||||
onClick={() => onToggleFreeResize(node)}
|
||||
active={node.metadata?.freeResize}
|
||||
<ImageToolSettingsModal
|
||||
open={imageToolSettingsOpen}
|
||||
tools={selectableImageToolbarTools}
|
||||
selectedIds={draftImageToolIds}
|
||||
showLabels={draftShowImageToolLabels}
|
||||
onToggle={setDraftImageToolVisible}
|
||||
onShowLabelsChange={setDraftShowImageToolLabels}
|
||||
onCancel={closeImageToolSettings}
|
||||
onSave={saveImageToolSettings}
|
||||
/>
|
||||
) : null}
|
||||
{hasImage ? <ToolbarAction title="裁剪并生成新节点" label="裁剪" icon={<Scissors className="size-4" />} onClick={() => onCrop(node)} /> : null}
|
||||
{hasImage ? <ToolbarAction title="放大图片分辨率" label="放大" icon={<ZoomIn className="size-4" />} onClick={() => onUpscale(node)} /> : null}
|
||||
{hasImage ? <ToolbarAction title="AI 超分" label="超分" icon={<Sparkles className="size-4" />} onClick={() => onSuperResolve(node)} /> : null}
|
||||
{hasImage ? <ToolbarAction title="生成角度" label="多角度" icon={<Camera className="size-4" />} onClick={() => onAngle(node)} /> : null}
|
||||
{hasImage ? <ToolbarAction title="查看图片详情" label="查看大图" icon={<Maximize2 className="size-4" />} onClick={() => onViewImage(node)} /> : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -184,34 +273,20 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarAction({ title, label, icon, onClick, hint, active = false, danger = false }: { title: string; label: string; icon: ReactNode; onClick?: () => void; hint?: string; active?: boolean; danger?: boolean }) {
|
||||
function ToolbarAction({ title, label, icon, onClick, showLabel, active = false, danger = false }: ToolbarTool & { showLabel: boolean }) {
|
||||
const hasText = showLabel && Boolean(label);
|
||||
return (
|
||||
<Tooltip title={title} placement="top" mouseEnterDelay={0.2} color="#ffffff" styles={{ body: { color: "#242529", boxShadow: "0 8px 24px rgba(15,23,42,.16)", fontSize: 13, fontWeight: 500 } }}>
|
||||
<button type="button" className={`group relative flex h-12 items-center whitespace-nowrap px-1.5 ${danger ? "text-[#ef4444]" : ""}`} onClick={onClick} aria-label={title}>
|
||||
<span className={`flex h-9 items-center gap-2 rounded-lg px-2.5 transition group-hover:bg-[#f0f0f1] ${active ? "bg-[#eeeeef]" : ""}`}>
|
||||
<span className={`flex h-9 items-center ${hasText ? "gap-2 px-2.5" : "justify-center px-2"} rounded-lg transition group-hover:bg-[#f0f0f1] ${active ? "bg-[#eeeeef]" : ""}`}>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
{hint ? <span className="text-[#a3a3a3]">{hint}</span> : null}
|
||||
{hasText ? <span>{label}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function IconAction({ title, icon, onClick }: { title: string; icon: ReactNode; onClick: () => void }) {
|
||||
return (
|
||||
<Tooltip title={title} placement="top" mouseEnterDelay={0.2} color="#ffffff" styles={{ body: { color: "#242529", boxShadow: "0 8px 24px rgba(15,23,42,.16)", fontSize: 13, fontWeight: 500 } }}>
|
||||
<button type="button" className="group relative grid h-12 w-12 place-items-center px-1.5" onClick={onClick} aria-label={title}>
|
||||
<span className="grid size-9 place-items-center rounded-lg transition group-hover:bg-[#f0f0f1]">{icon}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarDivider() {
|
||||
return <span className="mx-1 h-7 w-px scale-x-50 bg-[#dedee2]" />;
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-3">
|
||||
|
||||
Reference in New Issue
Block a user