feat(canvas): enhance canvas toolbar for mode switching and improve selection box behavior

This commit is contained in:
HouYunFei
2026-08-06 15:00:01 +08:00
parent 4272b8e1b7
commit 3a9ca04ad0
10 changed files with 63 additions and 43 deletions
+1
View File
@@ -2,6 +2,7 @@
## Unreleased ## Unreleased
+ [优化] 画布默认改为拖动疏朗虚线框选,虚线显示不受画布缩放影响,左下工具可切换选择与移动模式,并支持按住 Control 或空格临时平移画布。
+ [优化] 画布多图生成结果合并为单个图片节点,生成期间可展开查看每张图片的实时状态,优先展示最先成功的结果,并以紧凑堆叠展示已有图片。 + [优化] 画布多图生成结果合并为单个图片节点,生成期间可展开查看每张图片的实时状态,优先展示最先成功的结果,并以紧凑堆叠展示已有图片。
+ [优化] 从左侧元素列表定位画布节点时,自动缩放上限调整为 100%。 + [优化] 从左侧元素列表定位画布节点时,自动缩放上限调整为 100%。
+ [修复] 画布多图切换主图时保持节点当前视觉尺度和中心位置,避免节点突然放大造成视口缩放错觉。 + [修复] 画布多图切换主图时保持节点当前视觉尺度和中心位置,避免节点突然放大造成视口缩放错觉。
@@ -7,6 +7,7 @@ description: Implemented changes that still need manual verification
The current release needs manual verification in these areas: The current release needs manual verification in these areas:
- Canvas navigation: the bottom toolbar should switch between Select and Move modes and reflect the active mode with its icon; Select should draw a clearly spaced dashed range whose dash length, gap, and stroke width remain visually constant at every zoom level, while Move should pan from either the background or a node. Holding Control or Space should temporarily pan without changing the selected toolbar mode; middle-button panning and additive node selection with Shift/Cmd should continue to work.
- English and Simplified Chinese switching across navigation, settings, titles, descriptions, Ant Design components, and persisted preferences. - English and Simplified Chinese switching across navigation, settings, titles, descriptions, Ant Design components, and persisted preferences.
- Global Dropdown, Menu, Select, Cascader, and TreeSelect popup backgrounds, hover states, and selected states in both light and dark themes. - Global Dropdown, Menu, Select, Cascader, and TreeSelect popup backgrounds, hover states, and selected states in both light and dark themes.
- Canvas node resize stability, prompt scrolling, generated-prompt restoration, image editing, drag-and-drop references, and generation configuration. - Canvas node resize stability, prompt scrolling, generated-prompt restoration, image editing, drag-and-drop references, and generation configuration.
@@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试 # 待测试
- 画布选择与平移:左下工具栏应能切换「选择」与「移动」模式并用图标显示当前模式;选择模式拖动空白区域应显示疏朗清晰的虚线框,虚线长度、间距和粗细在任意缩放比例下保持一致;移动模式应能从空白区域或节点上拖动画布。按住 Control 或空格应临时平移且不改变工具栏模式,中键平移以及 Shift/Cmd 追加选择节点应继续可用。
- 文档站国际化:访问文档站首页及 `/docs` 应默认显示英文,切换简体中文后 URL 应带 `/zh-CN` 且首页、顶部导航、侧边栏、正文、搜索框和页面操作文案均显示中文;刷新、站内跳转和再次切回 English 后语言应保持正确,搜索结果与 Markdown/LLM 文档内容不得混入另一种语言。 - 文档站国际化:访问文档站首页及 `/docs` 应默认显示英文,切换简体中文后 URL 应带 `/zh-CN` 且首页、顶部导航、侧边栏、正文、搜索框和页面操作文案均显示中文;刷新、站内跳转和再次切回 English 后语言应保持正确,搜索结果与 Markdown/LLM 文档内容不得混入另一种语言。
- 国际化基础框架:从页面右上角切换简体中文或 English 后,首页、顶部及移动端导航、版本弹窗、配置主面板、渠道编辑、模型选择、页面标题、页面描述和 Ant Design 内置文案应立即切换,刷新页面后保持所选语言;偏好设置中不应再显示语言选项,WebDAV 同步错误、提示词来源及其他尚未接入翻译的业务模块暂时仍可能显示中文。 - 国际化基础框架:从页面右上角切换简体中文或 English 后,首页、顶部及移动端导航、版本弹窗、配置主面板、渠道编辑、模型选择、页面标题、页面描述和 Ant Design 内置文案应立即切换,刷新页面后保持所选语言;偏好设置中不应再显示语言选项,WebDAV 同步错误、提示词来源及其他尚未接入翻译的业务模块暂时仍可能显示中文。
- 全局弹层主题:全站 Dropdown、Menu、Select、Cascader、TreeSelect 等弹层在浅色主题下应使用白色背景、轻灰悬停和选中态,在深色主题下应使用深色背景与低对比度浅色交互态,不应再出现默认黑灰色块、文字对比不足或单个页面配色不一致。 - 全局弹层主题:全站 Dropdown、Menu、Select、Cascader、TreeSelect 等弹层在浅色主题下应使用白色背景、轻灰悬停和选中态,在深色主题下应使用深色背景与低对比度浅色交互态,不应再出现默认黑灰色块、文字对比不足或单个页面配色不一致。
+9 -6
View File
@@ -1,7 +1,7 @@
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react"; import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Button, Segmented, Switch } from "antd"; import { Button, Segmented, Switch } from "antd";
import { CircleDot, Eraser, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Puzzle, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react"; import { CircleDot, Eraser, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, MousePointer2, Music2, Palette, Puzzle, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme"; import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
import { getNodePluginId, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry"; import { getNodePluginId, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
export function CanvasToolbar({ export function CanvasToolbar({
selectedCount, selectedCount,
canvasTool,
canUndo, canUndo,
canRedo, canRedo,
backgroundMode, backgroundMode,
@@ -27,11 +28,12 @@ export function CanvasToolbar({
onUpload, onUpload,
onDelete, onDelete,
onClear, onClear,
onDeselect, onCanvasToolChange,
onBackgroundModeChange, onBackgroundModeChange,
onShowImageInfoChange, onShowImageInfoChange,
}: { }: {
selectedCount: number; selectedCount: number;
canvasTool: "select" | "pan";
canUndo: boolean; canUndo: boolean;
canRedo: boolean; canRedo: boolean;
backgroundMode: CanvasBackgroundMode; backgroundMode: CanvasBackgroundMode;
@@ -48,7 +50,7 @@ export function CanvasToolbar({
onUpload: () => void; onUpload: () => void;
onDelete: () => void; onDelete: () => void;
onClear: () => void; onClear: () => void;
onDeselect: () => void; onCanvasToolChange: (tool: "select" | "pan") => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void; onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
onShowImageInfoChange: (show: boolean) => void; onShowImageInfoChange: (show: boolean) => void;
}) { }) {
@@ -89,8 +91,8 @@ export function CanvasToolbar({
<div ref={rootRef} className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}> <div ref={rootRef} className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
{tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null} {tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
<div ref={wrapRef} className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0" style={dockStyle}> <div ref={wrapRef} className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0" style={dockStyle}>
<ToolbarButton id="tool-hand" label={t("canvas.toolbar.move")} active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}> <ToolbarButton id={`tool-${canvasTool}`} label={t(`canvas.toolbar.${canvasTool}`)} active hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={() => onCanvasToolChange(canvasTool === "select" ? "pan" : "select")}>
<Hand className="size-4.5" /> {canvasTool === "select" ? <MousePointer2 className="size-4.5" /> : <Hand className="size-4.5" />}
</ToolbarButton> </ToolbarButton>
<ToolbarButton id="tool-undo" label={t("canvas.undo")} disabled={!canUndo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUndo}> <ToolbarButton id="tool-undo" label={t("canvas.undo")} disabled={!canUndo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUndo}>
<Undo2 className="size-4.5" /> <Undo2 className="size-4.5" />
@@ -350,7 +352,8 @@ function DockTip({ label, x, theme }: { label: string; x: number; theme: CanvasT
} }
function toolLabel(id: string, t: (key: string) => string) { function toolLabel(id: string, t: (key: string) => string) {
if (id === "tool-hand") return t("canvas.toolbar.move"); if (id === "tool-select") return t("canvas.toolbar.select");
if (id === "tool-pan") return t("canvas.toolbar.pan");
if (id === "tool-undo") return t("canvas.undo"); if (id === "tool-undo") return t("canvas.undo");
if (id === "tool-redo") return t("canvas.redo"); if (id === "tool-redo") return t("canvas.redo");
if (id === "tool-text") return t("canvas.toolbar.text"); if (id === "tool-text") return t("canvas.toolbar.text");
+3 -3
View File
@@ -154,11 +154,11 @@ export function CanvasTopBar({
</div> </div>
<Modal title={t("canvas.shortcuts")} open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered> <Modal title={t("canvas.shortcuts")} open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<div className="space-y-2 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}> <div className="space-y-2 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
<Shortcut keys={[t("canvas.shortcut.dragCanvas")]} value={t("canvas.shortcut.pan")} /> <Shortcut keys={["Ctrl / Space", t("canvas.shortcut.drag")]} value={t("canvas.shortcut.pan")} />
<Shortcut keys={[t("canvas.shortcut.wheel")]} value={t("canvas.shortcut.zoom")} /> <Shortcut keys={[t("canvas.shortcut.wheel")]} value={t("canvas.shortcut.zoom")} />
<Shortcut keys={[t("canvas.shortcut.zoomSlider")]} value={t("canvas.shortcut.preciseZoom")} /> <Shortcut keys={[t("canvas.shortcut.zoomSlider")]} value={t("canvas.shortcut.preciseZoom")} />
<Shortcut keys={["Ctrl / Cmd", t("canvas.shortcut.drag")]} value={t("canvas.shortcut.boxSelect")} /> <Shortcut keys={[t("canvas.shortcut.drag")]} value={t("canvas.shortcut.boxSelect")} />
<Shortcut keys={["Shift / Ctrl / Cmd", t("canvas.shortcut.click")]} value={t("canvas.shortcut.addSelection")} /> <Shortcut keys={["Shift / Cmd", t("canvas.shortcut.click")]} value={t("canvas.shortcut.addSelection")} />
<Shortcut keys={["Ctrl / Cmd", "A"]} value={t("canvas.shortcut.selectAll")} /> <Shortcut keys={["Ctrl / Cmd", "A"]} value={t("canvas.shortcut.selectAll")} />
<Shortcut keys={["Ctrl / Cmd", "C / V"]} value={t("canvas.shortcut.copyPaste")} /> <Shortcut keys={["Ctrl / Cmd", "C / V"]} value={t("canvas.shortcut.copyPaste")} />
<Shortcut keys={["Ctrl / Cmd", "Z"]} value={t("canvas.undo")} /> <Shortcut keys={["Ctrl / Cmd", "Z"]} value={t("canvas.undo")} />
@@ -61,10 +61,10 @@ export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpe
</div> </div>
<Modal title={t("canvas.shortcuts")} open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered> <Modal title={t("canvas.shortcuts")} open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<div className="space-y-3 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}> <div className="space-y-3 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
<Shortcut label={t("canvas.shortcut.dragCanvas")} value={t("canvas.shortcut.pan")} /> <Shortcut label={`Ctrl / Space + ${t("canvas.shortcut.drag")}`} value={t("canvas.shortcut.pan")} />
<Shortcut label={t("canvas.shortcut.wheel")} value={t("canvas.shortcut.zoom")} /> <Shortcut label={t("canvas.shortcut.wheel")} value={t("canvas.shortcut.zoom")} />
<Shortcut label={`Ctrl / Cmd + ${t("canvas.shortcut.drag")}`} value={t("canvas.shortcut.boxSelect")} /> <Shortcut label={t("canvas.shortcut.drag")} value={t("canvas.shortcut.boxSelect")} />
<Shortcut label={`Shift / Ctrl / Cmd + ${t("canvas.shortcut.click")}`} value={t("canvas.shortcut.addSelection")} /> <Shortcut label={`Shift / Cmd + ${t("canvas.shortcut.click")}`} value={t("canvas.shortcut.addSelection")} />
<Shortcut label="Ctrl / Cmd + C / V" value={t("canvas.shortcut.copyPasteNodes")} /> <Shortcut label="Ctrl / Cmd + C / V" value={t("canvas.shortcut.copyPasteNodes")} />
<Shortcut label="Delete / Backspace" value={t("canvas.shortcut.delete")} /> <Shortcut label="Delete / Backspace" value={t("canvas.shortcut.delete")} />
</div> </div>
+34 -16
View File
@@ -7,6 +7,7 @@ import type { ViewportTransform } from "@/types/canvas";
type InfiniteCanvasProps = { type InfiniteCanvasProps = {
containerRef: React.RefObject<HTMLDivElement | null>; containerRef: React.RefObject<HTMLDivElement | null>;
viewport: ViewportTransform; viewport: ViewportTransform;
tool: "select" | "pan";
backgroundMode?: CanvasBackgroundMode; backgroundMode?: CanvasBackgroundMode;
onViewportChange: (viewport: ViewportTransform) => void; onViewportChange: (viewport: ViewportTransform) => void;
onCanvasMouseDown?: (event: React.PointerEvent<HTMLDivElement>) => void; onCanvasMouseDown?: (event: React.PointerEvent<HTMLDivElement>) => void;
@@ -17,7 +18,7 @@ type InfiniteCanvasProps = {
children: React.ReactNode; children: React.ReactNode;
}; };
export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onCanvasMouseDown, onCanvasDeselect, onCanvasDoubleClick, onContextMenu, onDrop, children }: InfiniteCanvasProps) { export function InfiniteCanvas({ containerRef, viewport, tool, backgroundMode = "lines", onViewportChange, onCanvasMouseDown, onCanvasDeselect, onCanvasDoubleClick, onContextMenu, onDrop, children }: InfiniteCanvasProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)]; const theme = canvasThemes[useThemeStore((state) => state.theme)];
const panState = useRef({ const panState = useRef({
isPanning: false, isPanning: false,
@@ -26,11 +27,14 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
initialX: 0, initialX: 0,
initialY: 0, initialY: 0,
hasMoved: false, hasMoved: false,
startedOnBackground: false,
}); });
const scaleRef = useRef(viewport.k); const scaleRef = useRef(viewport.k);
const frameRef = useRef<number | null>(null); const frameRef = useRef<number | null>(null);
const nextViewportRef = useRef<ViewportTransform | null>(null); const nextViewportRef = useRef<ViewportTransform | null>(null);
const [isSpacePressed, setIsSpacePressed] = useState(false); const [isSpacePressed, setIsSpacePressed] = useState(false);
const [isControlPressed, setIsControlPressed] = useState(false);
const [isPanning, setIsPanning] = useState(false);
useEffect(() => { useEffect(() => {
scaleRef.current = viewport.k; scaleRef.current = viewport.k;
@@ -45,20 +49,30 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.code !== "Space") return; if (event.key === "Control") setIsControlPressed(true);
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return; if (event.code === "Space" && !(event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement)) setIsSpacePressed(true);
setIsSpacePressed(true);
}; };
const handleKeyUp = (event: KeyboardEvent) => { const handleKeyUp = (event: KeyboardEvent) => {
if (event.code === "Space") setIsSpacePressed(false); if (event.code === "Space") setIsSpacePressed(false);
if (event.key === "Control") setIsControlPressed(false);
};
const handleBlur = () => {
setIsSpacePressed(false);
setIsControlPressed(false);
panState.current.isPanning = false;
setIsPanning(false);
document.body.style.cursor = "";
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp); window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => { return () => {
window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
}; };
}, []); }, []);
@@ -89,15 +103,9 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
if (target?.closest("[data-canvas-no-zoom]")) return; if (target?.closest("[data-canvas-no-zoom]")) return;
if (target?.closest("[data-connection-create-menu]")) return; if (target?.closest("[data-connection-create-menu]")) return;
const isBackgroundClick = !target?.closest("[data-node-id],[data-connection-id]"); const isBackgroundClick = !target?.closest("[data-node-id],[data-connection-id]");
const shouldPan = event.button === 1 || (event.button === 0 && (tool === "pan" || event.ctrlKey || isSpacePressed));
if (event.button === 0 && (event.ctrlKey || event.metaKey) && isBackgroundClick) { if (shouldPan) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
onCanvasMouseDown?.(event);
return;
}
if (event.button === 1 || (event.button === 0 && !isSpacePressed && isBackgroundClick)) {
event.preventDefault(); event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
panState.current = { panState.current = {
@@ -107,13 +115,17 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
initialX: viewport.x, initialX: viewport.x,
initialY: viewport.y, initialY: viewport.y,
hasMoved: false, hasMoved: false,
startedOnBackground: isBackgroundClick,
}; };
setIsPanning(true);
document.body.style.cursor = "grabbing"; document.body.style.cursor = "grabbing";
return; return;
} }
if (event.button === 0 && isSpacePressed && isBackgroundClick) { if (event.button === 0 && isBackgroundClick) {
event.preventDefault(); event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
onCanvasMouseDown?.(event);
} }
}; };
@@ -148,18 +160,22 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
const handlePointerUp = () => { const handlePointerUp = () => {
if (!panState.current.isPanning) return; if (!panState.current.isPanning) return;
if (!panState.current.hasMoved) { if (!panState.current.hasMoved && panState.current.startedOnBackground) {
onCanvasDeselect?.(); onCanvasDeselect?.();
} }
panState.current.isPanning = false; panState.current.isPanning = false;
setIsPanning(false);
document.body.style.cursor = ""; document.body.style.cursor = "";
}; };
window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
return () => { return () => {
window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
document.body.style.cursor = "";
}; };
}, [onCanvasDeselect, onViewportChange]); }, [onCanvasDeselect, onViewportChange]);
@@ -177,11 +193,13 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
return () => container.removeEventListener("wheel", preventWheelScroll); return () => container.removeEventListener("wheel", preventWheelScroll);
}, [containerRef]); }, [containerRef]);
const cursor = isPanning ? "grabbing" : tool === "pan" || isControlPressed || isSpacePressed ? "grab" : undefined;
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="relative h-full w-full cursor-grab select-none overflow-hidden" className="relative h-full w-full select-none overflow-hidden"
style={{ background: theme.canvas.background }} style={{ background: theme.canvas.background, cursor }}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
onWheel={handleWheel} onWheel={handleWheel}
+1 -1
View File
@@ -242,7 +242,7 @@ export default {
openAgent: "Open local Codex panel", openAgent: "Open local Codex panel",
nodeTypes: { image: "Image", text: "Text", config: "Generation config", video: "Video", audio: "Audio", group: "Group" }, nodeTypes: { image: "Image", text: "Text", config: "Generation config", video: "Video", audio: "Audio", group: "Group" },
toolbar: { toolbar: {
move: "Move/select", text: "Text", image: "Image", video: "Video", audio: "Audio", config: "Generation config", group: "Group", extensions: "Extension nodes", upload: "Upload assets", appearance: "Canvas appearance", clear: "Clear canvas", select: "Select", pan: "Move", text: "Text", image: "Image", video: "Video", audio: "Audio", config: "Generation config", group: "Group", extensions: "Extension nodes", upload: "Upload assets", appearance: "Canvas appearance", clear: "Clear canvas",
themeMode: "Theme", light: "Light", dark: "Dark", gridStyle: "Grid style", dots: "Dots", lines: "Lines", blank: "Blank", imageInfo: "Image info", themeMode: "Theme", light: "Light", dark: "Dark", gridStyle: "Grid style", dots: "Dots", lines: "Lines", blank: "Blank", imageInfo: "Image info",
}, },
project: { project: {
+1 -1
View File
@@ -242,7 +242,7 @@ export default {
openAgent: "打开本地 Codex 面板", openAgent: "打开本地 Codex 面板",
nodeTypes: { image: "图片", text: "文本", config: "生成配置", video: "视频", audio: "音频", group: "组" }, nodeTypes: { image: "图片", text: "文本", config: "生成配置", video: "视频", audio: "音频", group: "组" },
toolbar: { toolbar: {
move: "移动/选择", text: "文本", image: "图片", video: "视频", audio: "音频", config: "生成配置", group: "组", extensions: "扩展节点", upload: "上传资产", appearance: "画布外观", clear: "清空画布", select: "选择", pan: "移动", text: "文本", image: "图片", video: "视频", audio: "音频", config: "生成配置", group: "组", extensions: "扩展节点", upload: "上传资产", appearance: "画布外观", clear: "清空画布",
themeMode: "主题模式", light: "浅色", dark: "深色", gridStyle: "网格样式", dots: "点", lines: "线", blank: "空白", imageInfo: "图片信息", themeMode: "主题模式", light: "浅色", dark: "深色", gridStyle: "网格样式", dots: "点", lines: "线", blank: "空白", imageInfo: "图片信息",
}, },
project: { project: {
+9 -13
View File
@@ -199,6 +199,7 @@ function InfiniteCanvasPage() {
const [chatSessions, setChatSessions] = useState<CanvasAssistantSession[]>([]); const [chatSessions, setChatSessions] = useState<CanvasAssistantSession[]>([]);
const [activeChatId, setActiveChatId] = useState<string | null>(null); const [activeChatId, setActiveChatId] = useState<string | null>(null);
const [viewport, setViewport] = useState<ViewportTransform>({ x: 0, y: 0, k: 1 }); const [viewport, setViewport] = useState<ViewportTransform>({ x: 0, y: 0, k: 1 });
const [canvasTool, setCanvasTool] = useState<"select" | "pan">("select");
const [size, setSize] = useState({ width: 1200, height: 720 }); const [size, setSize] = useState({ width: 1200, height: 720 });
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set()); const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null); const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
@@ -977,13 +978,6 @@ function InfiniteCanvasPage() {
if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate(); if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate();
if (event.button !== 0) return; if (event.button !== 0) return;
if (!event.ctrlKey && !event.metaKey) {
setSelectionBox(null);
setSelectedNodeIds(new Set());
setSelectedConnectionId(null);
return;
}
const world = screenToCanvas(event.clientX, event.clientY); const world = screenToCanvas(event.clientX, event.clientY);
const nextSelectionBox = { const nextSelectionBox = {
startWorldX: world.x, startWorldX: world.x,
@@ -2775,6 +2769,7 @@ function InfiniteCanvasPage() {
<InfiniteCanvas <InfiniteCanvas
containerRef={containerRef} containerRef={containerRef}
viewport={viewport} viewport={viewport}
tool={canvasTool}
backgroundMode={backgroundMode} backgroundMode={backgroundMode}
onViewportChange={(next) => { onViewportChange={(next) => {
setViewport(next); setViewport(next);
@@ -2863,17 +2858,17 @@ function InfiniteCanvasPage() {
))} ))}
{selectionBox ? ( {selectionBox ? (
<div <svg
className="pointer-events-none absolute z-[100] border" className="pointer-events-none absolute z-[100] overflow-visible"
style={{ style={{
left: Math.min(selectionBox.startWorldX, selectionBox.currentWorldX), left: Math.min(selectionBox.startWorldX, selectionBox.currentWorldX),
top: Math.min(selectionBox.startWorldY, selectionBox.currentWorldY), top: Math.min(selectionBox.startWorldY, selectionBox.currentWorldY),
width: Math.abs(selectionBox.currentWorldX - selectionBox.startWorldX), width: Math.abs(selectionBox.currentWorldX - selectionBox.startWorldX),
height: Math.abs(selectionBox.currentWorldY - selectionBox.startWorldY), height: Math.abs(selectionBox.currentWorldY - selectionBox.startWorldY),
borderColor: theme.canvas.selectionStroke,
background: theme.canvas.selectionFill,
}} }}
/> >
<rect width="100%" height="100%" fill={theme.canvas.selectionFill} stroke={theme.canvas.selectionStroke} strokeWidth={1.5 / viewport.k} strokeDasharray={`${10 / viewport.k} ${6 / viewport.k}`} />
</svg>
) : null} ) : null}
{pendingConnectionCreate ? <ConnectionCreateMenu pending={pendingConnectionCreate} onCreate={(type) => createConnectedNode(type, pendingConnectionCreate)} onClose={cancelPendingConnectionCreate} /> : null} {pendingConnectionCreate ? <ConnectionCreateMenu pending={pendingConnectionCreate} onCreate={(type) => createConnectedNode(type, pendingConnectionCreate)} onClose={cancelPendingConnectionCreate} /> : null}
{nodeCreatePosition ? ( {nodeCreatePosition ? (
@@ -2918,6 +2913,7 @@ function InfiniteCanvasPage() {
<CanvasToolbar <CanvasToolbar
selectedCount={selectedNodeIds.size} selectedCount={selectedNodeIds.size}
canvasTool={canvasTool}
canUndo={historyState.canUndo} canUndo={historyState.canUndo}
canRedo={historyState.canRedo} canRedo={historyState.canRedo}
backgroundMode={backgroundMode} backgroundMode={backgroundMode}
@@ -2934,7 +2930,7 @@ function InfiniteCanvasPage() {
onUpload={() => handleUploadRequest()} onUpload={() => handleUploadRequest()}
onDelete={() => deleteNodes(new Set(selectedNodeIds))} onDelete={() => deleteNodes(new Set(selectedNodeIds))}
onClear={() => setClearConfirmOpen(true)} onClear={() => setClearConfirmOpen(true)}
onDeselect={deselectCanvas} onCanvasToolChange={setCanvasTool}
onBackgroundModeChange={setBackgroundMode} onBackgroundModeChange={setBackgroundMode}
onShowImageInfoChange={setShowImageInfo} onShowImageInfoChange={setShowImageInfo}
/> />