mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
feat(toolbar): enhance image node hover toolbar with new tools and settings modal
This commit is contained in:
@@ -5,7 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
|
||||
# 待测试
|
||||
|
||||
- 图片节点悬浮工具栏新增“放大”和“超分”入口;“放大”可在弹窗中选择 1K/2K/4K 目标像素和高清插值、双线性、最近邻算法,按原图比例生成新图片节点,已达到的目标像素会禁用并提示无需放大,最高不超过 4K;“超分”当前只打开暂未实现弹窗。
|
||||
- 图片节点悬浮工具栏新增“复制提示词”、“放大”和“超分”入口,并在末尾增加 `...` 更多按钮;点击“复制提示词”会复制生成该图片的提示词,图片没有提示词时会提示暂无可复制内容;点击 `...` 后打开 Ant Design 风格的“自定义工具栏”弹窗,可在图片节点占位上预览悬浮工具栏,信息、删除、存素材、下载、编辑和图片工具都在同一个快捷工具列表中勾选配置,并可切换是否显示按钮文字,保存后写入本地配置;`...` 配置入口固定显示,预览工具栏下方提供常驻横向滚动控制条。“放大”可在弹窗中选择 1K/2K/4K 目标像素和高清插值、双线性、最近邻算法,按原图比例生成新图片节点,已达到的目标像素会禁用并提示无需放大,最高不超过 4K;“超分”当前只打开暂未实现弹窗。
|
||||
- Canvas 资源节点会按当前生成上下文显示 `图片1`、`视频1`、`音频1`、`文本1` 角标;文本节点内容框、节点底部 prompt 面板和生成配置预览文本编辑框输入 `@` 时应弹出已连接资源选择器,点击缩略图或文字可插入纯文本编号,并以蓝色 token 视觉高亮。
|
||||
- 文本节点连接到生成配置节点时,`@` 候选和实际生成输入应读取该生成配置节点的上游参考资源;生成配置输入统计区域应可拖动整个配置节点,预览按钮和设置控件仍保持可点击。
|
||||
- 配置弹窗改为“配置与用户偏好”并放大为可滚动弹窗;本地直连支持配置生图、视频、文本、音频四类可选模型列表和默认模型,新建画布生图和配置节点会读取“画布默认生图张数”,需要验证远程渠道和本地直连模型列表都能正确显示。
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const scrollbarTrackRef = useRef<HTMLInputElement>(null);
|
||||
const [previewScroll, setPreviewScroll] = useState<PreviewScroll>({ 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: <Ellipsis className="size-4" />, 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 (
|
||||
<Modal
|
||||
title="自定义工具栏"
|
||||
open={open}
|
||||
centered
|
||||
width={760}
|
||||
onCancel={onCancel}
|
||||
destroyOnHidden
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>显示按钮文字</span>
|
||||
<Switch checked={showLabels} onChange={onShowLabelsChange} />
|
||||
</div>
|
||||
<Space>
|
||||
<Button onClick={onCancel}>取消</Button>
|
||||
<Button type="primary" onClick={onSave}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph type="secondary" className="!mb-4">
|
||||
选择你想在图片节点编辑栏中使用的快捷工具。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space size={6}>
|
||||
<Settings2 className="size-4" />
|
||||
节点预览
|
||||
</Space>
|
||||
}
|
||||
className="mb-4"
|
||||
>
|
||||
<div className="relative flex min-h-[300px] w-full justify-center pt-20 pb-9">
|
||||
<div
|
||||
ref={previewToolbarRef}
|
||||
className="hide-scrollbar absolute left-2 right-2 top-3 z-10 flex h-12 items-center overflow-x-auto rounded-[18px] border px-1 text-[13px]"
|
||||
style={{ background: token.colorBgElevated, borderColor: token.colorBorderSecondary, boxShadow: token.boxShadowSecondary, color: token.colorText }}
|
||||
onScroll={syncPreviewScroll}
|
||||
>
|
||||
{previewTools.map((tool) => (
|
||||
<PreviewToolbarItem key={tool.id} tool={tool} showLabels={showLabels} />
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-48 w-full max-w-[360px] flex-col items-center justify-center rounded-xl border"
|
||||
style={{ background: token.colorFillAlter, borderColor: token.colorBorderSecondary, color: token.colorTextSecondary }}
|
||||
>
|
||||
<ImageIcon className="mb-2 size-8" />
|
||||
<Typography.Text type="secondary">图片节点</Typography.Text>
|
||||
</div>
|
||||
<input
|
||||
ref={scrollbarTrackRef}
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(previewScroll.max, 1)}
|
||||
value={Math.min(previewScroll.left, Math.max(previewScroll.max, 1))}
|
||||
disabled={previewScroll.max <= 0}
|
||||
className="absolute bottom-4 left-10 right-10 h-2.5 cursor-pointer appearance-none bg-transparent disabled:cursor-default [&::-moz-range-thumb]:h-2.5 [&::-moz-range-thumb]:w-[var(--preview-scrollbar-thumb-width)] [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-[#8d9498] [&::-moz-range-track]:h-2.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-[#bdc4c8] [&::-webkit-slider-runnable-track]:h-2.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-[#bdc4c8] [&::-webkit-slider-thumb]:h-2.5 [&::-webkit-slider-thumb]:w-[var(--preview-scrollbar-thumb-width)] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[#8d9498]"
|
||||
style={{ "--preview-scrollbar-thumb-width": `${scrollbarThumbWidth}px` } as CSSProperties}
|
||||
onInput={(event) => setPreviewScrollLeft(Number(event.currentTarget.value))}
|
||||
onChange={(event) => setPreviewScrollLeft(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Form layout="vertical" className="!mb-0">
|
||||
<Form.Item
|
||||
className="!mb-4"
|
||||
label={
|
||||
<Space size={8}>
|
||||
<span>快捷工具</span>
|
||||
<Tag className="m-0">
|
||||
{selectedTools.length}/{tools.length}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Checkbox.Group value={selectedIds} className="grid w-full gap-3 md:grid-cols-3" onChange={(values) => updateSelectedTools(values as ImageQuickToolId[])}>
|
||||
{tools.map((tool) => (
|
||||
<Checkbox key={tool.id} value={tool.id} className="m-0">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{tool.icon}
|
||||
{tool.label}
|
||||
</span>
|
||||
</Checkbox>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewToolbarItem({ tool, showLabels }: { tool: PreviewTool; showLabels: boolean }) {
|
||||
return (
|
||||
<Tooltip title={tool.title}>
|
||||
<span className="flex h-12 shrink-0 items-center px-1.5" style={{ color: tool.danger ? "#ef4444" : undefined }}>
|
||||
<span className={`flex h-9 items-center rounded-lg px-2 ${showLabels ? "gap-2" : "justify-center"}`}>
|
||||
{tool.icon}
|
||||
{showLabels ? <span className="whitespace-nowrap">{tool.label}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -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: () => <Copy className="size-4" />,
|
||||
run: (node, handlers) => handlers.onCopyPrompt(node),
|
||||
},
|
||||
{
|
||||
id: "replace",
|
||||
defaultVisible: true,
|
||||
panelLabel: "替换图片",
|
||||
label: "替换图片",
|
||||
title: "替换图片",
|
||||
icon: () => <Upload className="size-4" />,
|
||||
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 ? <LockOpen className="size-4" /> : <Lock className="size-4" />),
|
||||
active: (node) => Boolean(node.metadata?.freeResize),
|
||||
run: (node, handlers) => handlers.onToggleFreeResize(node),
|
||||
},
|
||||
{
|
||||
id: "crop",
|
||||
defaultVisible: true,
|
||||
panelLabel: "裁剪",
|
||||
label: "裁剪",
|
||||
title: "裁剪并生成新节点",
|
||||
icon: () => <Scissors className="size-4" />,
|
||||
run: (node, handlers) => handlers.onCrop(node),
|
||||
},
|
||||
{
|
||||
id: "upscale",
|
||||
defaultVisible: true,
|
||||
panelLabel: "放大",
|
||||
label: "放大",
|
||||
title: "放大图片分辨率",
|
||||
icon: () => <ZoomIn className="size-4" />,
|
||||
run: (node, handlers) => handlers.onUpscale(node),
|
||||
},
|
||||
{
|
||||
id: "superResolve",
|
||||
defaultVisible: true,
|
||||
panelLabel: "超分",
|
||||
label: "超分",
|
||||
title: "AI 超分",
|
||||
icon: () => <Sparkles className="size-4" />,
|
||||
run: (node, handlers) => handlers.onSuperResolve(node),
|
||||
},
|
||||
{
|
||||
id: "angle",
|
||||
defaultVisible: true,
|
||||
panelLabel: "多角度",
|
||||
label: "多角度",
|
||||
title: "生成角度",
|
||||
icon: () => <Camera className="size-4" />,
|
||||
run: (node, handlers) => handlers.onAngle(node),
|
||||
},
|
||||
{
|
||||
id: "view",
|
||||
defaultVisible: true,
|
||||
panelLabel: "查看大图",
|
||||
label: "查看大图",
|
||||
title: "查看图片详情",
|
||||
icon: () => <Maximize2 className="size-4" />,
|
||||
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<ImageQuickToolsConfig>;
|
||||
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;
|
||||
}
|
||||
@@ -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}
|
||||
onMouseLeave={() => {
|
||||
if (!imageToolSettingsOpen) 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}
|
||||
{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