From 68aca6fa165f1937fa88053a39076e077f4f47af Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Wed, 3 Jun 2026 17:52:01 +0800 Subject: [PATCH] feat(toolbar): enhance image node hover toolbar with new tools and settings modal --- docs/content/docs/progress/pending-test.mdx | 2 +- .../canvas-image-toolbar-settings-modal.tsx | 231 ++++++++++++++++++ .../components/canvas-image-toolbar-tools.tsx | 149 +++++++++++ .../components/canvas-node-hover-toolbar.tsx | 187 +++++++++----- 4 files changed, 512 insertions(+), 57 deletions(-) create mode 100644 web/src/app/(user)/canvas/components/canvas-image-toolbar-settings-modal.tsx create mode 100644 web/src/app/(user)/canvas/components/canvas-image-toolbar-tools.tsx diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index a641a72..176b67f 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,7 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 -- 图片节点悬浮工具栏新增“放大”和“超分”入口;“放大”可在弹窗中选择 1K/2K/4K 目标像素和高清插值、双线性、最近邻算法,按原图比例生成新图片节点,已达到的目标像素会禁用并提示无需放大,最高不超过 4K;“超分”当前只打开暂未实现弹窗。 +- 图片节点悬浮工具栏新增“复制提示词”、“放大”和“超分”入口,并在末尾增加 `...` 更多按钮;点击“复制提示词”会复制生成该图片的提示词,图片没有提示词时会提示暂无可复制内容;点击 `...` 后打开 Ant Design 风格的“自定义工具栏”弹窗,可在图片节点占位上预览悬浮工具栏,信息、删除、存素材、下载、编辑和图片工具都在同一个快捷工具列表中勾选配置,并可切换是否显示按钮文字,保存后写入本地配置;`...` 配置入口固定显示,预览工具栏下方提供常驻横向滚动控制条。“放大”可在弹窗中选择 1K/2K/4K 目标像素和高清插值、双线性、最近邻算法,按原图比例生成新图片节点,已达到的目标像素会禁用并提示无需放大,最高不超过 4K;“超分”当前只打开暂未实现弹窗。 - Canvas 资源节点会按当前生成上下文显示 `图片1`、`视频1`、`音频1`、`文本1` 角标;文本节点内容框、节点底部 prompt 面板和生成配置预览文本编辑框输入 `@` 时应弹出已连接资源选择器,点击缩略图或文字可插入纯文本编号,并以蓝色 token 视觉高亮。 - 文本节点连接到生成配置节点时,`@` 候选和实际生成输入应读取该生成配置节点的上游参考资源;生成配置输入统计区域应可拖动整个配置节点,预览按钮和设置控件仍保持可点击。 - 配置弹窗改为“配置与用户偏好”并放大为可滚动弹窗;本地直连支持配置生图、视频、文本、音频四类可选模型列表和默认模型,新建画布生图和配置节点会读取“画布默认生图张数”,需要验证远程渠道和本地直连模型列表都能正确显示。 diff --git a/web/src/app/(user)/canvas/components/canvas-image-toolbar-settings-modal.tsx b/web/src/app/(user)/canvas/components/canvas-image-toolbar-settings-modal.tsx new file mode 100644 index 0000000..8fb1026 --- /dev/null +++ b/web/src/app/(user)/canvas/components/canvas-image-toolbar-settings-modal.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { Button, Card, Checkbox, Form, Modal, Space, Switch, Tag, Tooltip, Typography, theme as antdTheme } from "antd"; +import { Ellipsis, Image as ImageIcon, Settings2 } from "lucide-react"; + +import type { ImageQuickToolId } from "./canvas-image-toolbar-tools"; + +export type ImageToolbarSettingsTool = { + id: ImageQuickToolId; + title: string; + label: string; + icon: ReactNode; + active?: boolean; + danger?: boolean; +}; + +type PreviewTool = ImageToolbarSettingsTool | { + id: "more"; + title: string; + label: string; + icon: ReactNode; + active?: boolean; + danger?: boolean; +}; + +type PreviewScroll = { + left: number; + max: number; + viewport: number; + content: number; +}; + +export function ImageToolSettingsModal({ + open, + tools, + selectedIds, + showLabels, + onToggle, + onShowLabelsChange, + onCancel, + onSave, +}: { + open: boolean; + tools: ImageToolbarSettingsTool[]; + selectedIds: ImageQuickToolId[]; + showLabels: boolean; + onToggle: (id: ImageQuickToolId, visible: boolean) => void; + onShowLabelsChange: (value: boolean) => void; + onCancel: () => void; + onSave: () => void; +}) { + const { token } = antdTheme.useToken(); + const previewToolbarRef = useRef(null); + const scrollbarTrackRef = useRef(null); + const [previewScroll, setPreviewScroll] = useState({ left: 0, max: 0, viewport: 1, content: 1 }); + const selected = useMemo(() => new Set(selectedIds), [selectedIds]); + const selectedTools = tools.filter((tool) => selected.has(tool.id)); + const previewTools: PreviewTool[] = [ + ...selectedTools, + { id: "more", title: "配置快捷工具", label: "更多", icon: , active: true }, + ]; + + const syncPreviewScroll = useCallback(() => { + const toolbar = previewToolbarRef.current; + if (!toolbar) return; + setPreviewScroll({ + left: toolbar.scrollLeft, + max: Math.max(0, toolbar.scrollWidth - toolbar.clientWidth), + viewport: Math.max(1, toolbar.clientWidth), + content: Math.max(1, toolbar.scrollWidth), + }); + }, []); + + const setPreviewScrollLeft = useCallback( + (left: number) => { + const toolbar = previewToolbarRef.current; + if (!toolbar) return; + toolbar.scrollLeft = left; + syncPreviewScroll(); + }, + [syncPreviewScroll], + ); + + const updateSelectedTools = (values: ImageQuickToolId[]) => { + const next = new Set(values); + tools.forEach((tool) => { + const visible = next.has(tool.id); + if (selected.has(tool.id) !== visible) onToggle(tool.id, visible); + }); + }; + + useEffect(() => { + if (!open) return; + const toolbar = previewToolbarRef.current; + const sync = () => syncPreviewScroll(); + const frames: number[] = []; + const firstFrame = window.requestAnimationFrame(() => { + sync(); + frames.push(window.requestAnimationFrame(sync)); + }); + frames.push(firstFrame); + const timer = window.setTimeout(sync, 120); + const resizeObserver = typeof ResizeObserver !== "undefined" && toolbar ? new ResizeObserver(sync) : null; + resizeObserver?.observe(toolbar); + toolbar?.childNodes.forEach((child) => { + if (child instanceof Element) resizeObserver?.observe(child); + }); + sync(); + window.addEventListener("resize", syncPreviewScroll); + return () => { + frames.forEach((frame) => window.cancelAnimationFrame(frame)); + window.clearTimeout(timer); + resizeObserver?.disconnect(); + window.removeEventListener("resize", syncPreviewScroll); + }; + }, [open, selectedIds, showLabels, previewTools.length, syncPreviewScroll]); + + const scrollbarWidth = scrollbarTrackRef.current?.clientWidth || previewScroll.viewport; + const scrollbarThumbWidth = previewScroll.max > 0 ? Math.min(scrollbarWidth, Math.max(64, (previewScroll.viewport / previewScroll.content) * scrollbarWidth)) : scrollbarWidth; + + return ( + +
+ 显示按钮文字 + +
+ + + + + + } + > + + 选择你想在图片节点编辑栏中使用的快捷工具。 + + + + + 节点预览 + + } + className="mb-4" + > +
+
+ {previewTools.map((tool) => ( + + ))} +
+
+ + 图片节点 +
+ setPreviewScrollLeft(Number(event.currentTarget.value))} + onChange={(event) => setPreviewScrollLeft(Number(event.target.value))} + /> +
+
+ +
+ + 快捷工具 + + {selectedTools.length}/{tools.length} + + + } + > + updateSelectedTools(values as ImageQuickToolId[])}> + {tools.map((tool) => ( + + + {tool.icon} + {tool.label} + + + ))} + + +
+
+ ); +} + +function PreviewToolbarItem({ tool, showLabels }: { tool: PreviewTool; showLabels: boolean }) { + return ( + + + + {tool.icon} + {showLabels ? {tool.label} : null} + + + + ); +} diff --git a/web/src/app/(user)/canvas/components/canvas-image-toolbar-tools.tsx b/web/src/app/(user)/canvas/components/canvas-image-toolbar-tools.tsx new file mode 100644 index 0000000..e900377 --- /dev/null +++ b/web/src/app/(user)/canvas/components/canvas-image-toolbar-tools.tsx @@ -0,0 +1,149 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Camera, Copy, Lock, LockOpen, Maximize2, Scissors, Sparkles, Upload, ZoomIn } from "lucide-react"; + +import type { CanvasNodeData } from "../types"; + +export type ImageNodeActionToolId = "copyPrompt" | "replace" | "resize" | "crop" | "upscale" | "superResolve" | "angle" | "view"; +export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId; + +export type ImageToolHandlers = { + onUpload: (node: CanvasNodeData) => void; + onToggleFreeResize: (node: CanvasNodeData) => void; + onCrop: (node: CanvasNodeData) => void; + onUpscale: (node: CanvasNodeData) => void; + onSuperResolve: (node: CanvasNodeData) => void; + onAngle: (node: CanvasNodeData) => void; + onViewImage: (node: CanvasNodeData) => void; + onCopyPrompt: (node: CanvasNodeData) => void; +}; + +export type ImageToolDefinition = { + id: ImageNodeActionToolId; + defaultVisible: boolean; + panelLabel: string; + label: string | ((node: CanvasNodeData) => string); + title: string | ((node: CanvasNodeData) => string); + icon: (node: CanvasNodeData) => ReactNode; + active?: (node: CanvasNodeData) => boolean; + run: (node: CanvasNodeData, handlers: ImageToolHandlers) => void; +}; + +export type ImageQuickToolsConfig = { + ids: ImageQuickToolId[]; + showLabels: boolean; +}; + +export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v3"; + +const defaultBaseToolIds: ImageQuickToolId[] = ["info", "delete", "saveAsset", "download", "edit"]; + +export const imageToolDefinitions: ImageToolDefinition[] = [ + { + id: "copyPrompt", + defaultVisible: true, + panelLabel: "复制提示词", + label: "复制提示词", + title: "复制生成该图片的提示词", + icon: () => , + run: (node, handlers) => handlers.onCopyPrompt(node), + }, + { + id: "replace", + defaultVisible: true, + panelLabel: "替换图片", + label: "替换图片", + title: "替换图片", + icon: () => , + run: (node, handlers) => handlers.onUpload(node), + }, + { + id: "resize", + defaultVisible: true, + panelLabel: "锁比例", + label: (node) => (node.metadata?.freeResize ? "自由比例" : "锁比例"), + title: (node) => (node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"), + icon: (node) => (node.metadata?.freeResize ? : ), + active: (node) => Boolean(node.metadata?.freeResize), + run: (node, handlers) => handlers.onToggleFreeResize(node), + }, + { + id: "crop", + defaultVisible: true, + panelLabel: "裁剪", + label: "裁剪", + title: "裁剪并生成新节点", + icon: () => , + run: (node, handlers) => handlers.onCrop(node), + }, + { + id: "upscale", + defaultVisible: true, + panelLabel: "放大", + label: "放大", + title: "放大图片分辨率", + icon: () => , + run: (node, handlers) => handlers.onUpscale(node), + }, + { + id: "superResolve", + defaultVisible: true, + panelLabel: "超分", + label: "超分", + title: "AI 超分", + icon: () => , + run: (node, handlers) => handlers.onSuperResolve(node), + }, + { + id: "angle", + defaultVisible: true, + panelLabel: "多角度", + label: "多角度", + title: "生成角度", + icon: () => , + run: (node, handlers) => handlers.onAngle(node), + }, + { + id: "view", + defaultVisible: true, + panelLabel: "查看大图", + label: "查看大图", + title: "查看图片详情", + icon: () => , + run: (node, handlers) => handlers.onViewImage(node), + }, +]; + +export const defaultImageQuickToolIds: ImageQuickToolId[] = [...defaultBaseToolIds, ...imageToolDefinitions.filter((tool) => tool.defaultVisible).map((tool) => tool.id)]; + +export function buildImageToolbarTools(node: CanvasNodeData, handlers: ImageToolHandlers) { + return imageToolDefinitions.map((tool) => ({ + id: tool.id, + label: resolveToolText(tool.label, node), + title: resolveToolText(tool.title, node), + icon: tool.icon(node), + active: tool.active?.(node), + onClick: () => tool.run(node, handlers), + })); +} + +export function normalizeImageQuickToolIds(value: unknown[]) { + const allIds: ImageQuickToolId[] = [...defaultBaseToolIds, ...imageToolDefinitions.map((tool) => tool.id)]; + const ids = new Set(allIds); + return allIds.filter((id) => value.includes(id) && ids.has(id)); +} + +export function readImageQuickToolsConfig(value: unknown): ImageQuickToolsConfig { + if (Array.isArray(value)) return { ids: normalizeImageQuickToolIds(value), showLabels: true }; + if (!value || typeof value !== "object") return { ids: defaultImageQuickToolIds, showLabels: true }; + const data = value as Partial; + return { + ids: Array.isArray(data.ids) ? normalizeImageQuickToolIds(data.ids) : defaultImageQuickToolIds, + showLabels: data.showLabels !== false, + }; +} + +function resolveToolText(value: string | ((node: CanvasNodeData) => string), node: CanvasNodeData) { + return typeof value === "function" ? value(node) : value; +} diff --git a/web/src/app/(user)/canvas/components/canvas-node-hover-toolbar.tsx b/web/src/app/(user)/canvas/components/canvas-node-hover-toolbar.tsx index 9e56706..ddab7b2 100644 --- a/web/src/app/(user)/canvas/components/canvas-node-hover-toolbar.tsx +++ b/web/src/app/(user)/canvas/components/canvas-node-hover-toolbar.tsx @@ -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(defaultImageQuickToolIds); + const [showImageToolLabels, setShowImageToolLabels] = useState(true); + const [draftImageToolIds, setDraftImageToolIds] = useState(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: , onClick: () => onInfo(node) }, + { id: "delete", title: "移除节点", label: "删除", icon: , onClick: () => onDelete(node), danger: true }, + ]; + const nodeToolbarTools: ToolbarTool[] = [ + ...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", icon: , onClick: () => onRetry(node) }] : []), + ...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的素材", label: "存素材", icon: , onClick: () => onSaveAsset(node) }] : []), + ...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: , onClick: () => onDownload(node) }] : []), + ...(canOpenDialog ? [{ id: "edit", title: "编辑", label: "编辑", icon: , onClick: () => onToggleDialog(node) }] : []), + ...(isText ? [{ id: "editText", title: "编辑文本", label: "编辑文字", icon: , onClick: () => onEditText(node) }] : []), + ...(isText ? [{ id: "generateImage", title: "用文本生图", label: "生图", icon: , onClick: () => onGenerateImage(node) }] : []), + ...(isConfig ? [{ id: "config", title: "生成配置", label: "生成配置", icon: , onClick: () => onInfo(node) }] : []), + ...(isText ? [{ id: "decreaseFont", title: "减小字号", label: "缩小", icon: , onClick: () => onDecreaseFont(node) }] : []), + ...(isText ? [{ id: "increaseFont", title: "增大字号", label: "放大", icon: , onClick: () => onIncreaseFont(node) }] : []), + ...(isImage && !hasImage ? [{ id: "uploadImage", title: "上传图片", label: "上传图片", icon: , onClick: () => onUpload(node) }] : []), + ...(isVideo ? [{ id: "uploadVideo", title: hasVideo ? "替换视频" : "上传视频", label: hasVideo ? "替换视频" : "上传视频", icon: