diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40fca25..a7921a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## Unreleased
++ [优化] 画布默认改为拖动疏朗虚线框选,虚线显示不受画布缩放影响,左下工具可切换选择与移动模式,并支持按住 Control 或空格临时平移画布。
+ [优化] 画布多图生成结果合并为单个图片节点,生成期间可展开查看每张图片的实时状态,优先展示最先成功的结果,并以紧凑堆叠展示已有图片。
+ [优化] 从左侧元素列表定位画布节点时,自动缩放上限调整为 100%。
+ [修复] 画布多图切换主图时保持节点当前视觉尺度和中心位置,避免节点突然放大造成视口缩放错觉。
diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx
index ce953ae..396b786 100644
--- a/docs/content/docs/progress/pending-test.mdx
+++ b/docs/content/docs/progress/pending-test.mdx
@@ -7,6 +7,7 @@ description: Implemented changes that still need manual verification
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.
- 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.
diff --git a/docs/content/docs/progress/pending-test.zh-CN.mdx b/docs/content/docs/progress/pending-test.zh-CN.mdx
index 75d9a37..27b1237 100644
--- a/docs/content/docs/progress/pending-test.zh-CN.mdx
+++ b/docs/content/docs/progress/pending-test.zh-CN.mdx
@@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试
+- 画布选择与平移:左下工具栏应能切换「选择」与「移动」模式并用图标显示当前模式;选择模式拖动空白区域应显示疏朗清晰的虚线框,虚线长度、间距和粗细在任意缩放比例下保持一致;移动模式应能从空白区域或节点上拖动画布。按住 Control 或空格应临时平移且不改变工具栏模式,中键平移以及 Shift/Cmd 追加选择节点应继续可用。
- 文档站国际化:访问文档站首页及 `/docs` 应默认显示英文,切换简体中文后 URL 应带 `/zh-CN` 且首页、顶部导航、侧边栏、正文、搜索框和页面操作文案均显示中文;刷新、站内跳转和再次切回 English 后语言应保持正确,搜索结果与 Markdown/LLM 文档内容不得混入另一种语言。
- 国际化基础框架:从页面右上角切换简体中文或 English 后,首页、顶部及移动端导航、版本弹窗、配置主面板、渠道编辑、模型选择、页面标题、页面描述和 Ant Design 内置文案应立即切换,刷新页面后保持所选语言;偏好设置中不应再显示语言选项,WebDAV 同步错误、提示词来源及其他尚未接入翻译的业务模块暂时仍可能显示中文。
- 全局弹层主题:全站 Dropdown、Menu、Select、Cascader、TreeSelect 等弹层在浅色主题下应使用白色背景、轻灰悬停和选中态,在深色主题下应使用深色背景与低对比度浅色交互态,不应再出现默认黑灰色块、文字对比不足或单个页面配色不一致。
diff --git a/web/src/components/canvas/canvas-toolbar.tsx b/web/src/components/canvas/canvas-toolbar.tsx
index e31d3a7..a0ac4c7 100644
--- a/web/src/components/canvas/canvas-toolbar.tsx
+++ b/web/src/components/canvas/canvas-toolbar.tsx
@@ -1,7 +1,7 @@
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
import { useEffect, useRef, useState } from "react";
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 { getNodePluginId, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
export function CanvasToolbar({
selectedCount,
+ canvasTool,
canUndo,
canRedo,
backgroundMode,
@@ -27,11 +28,12 @@ export function CanvasToolbar({
onUpload,
onDelete,
onClear,
- onDeselect,
+ onCanvasToolChange,
onBackgroundModeChange,
onShowImageInfoChange,
}: {
selectedCount: number;
+ canvasTool: "select" | "pan";
canUndo: boolean;
canRedo: boolean;
backgroundMode: CanvasBackgroundMode;
@@ -48,7 +50,7 @@ export function CanvasToolbar({
onUpload: () => void;
onDelete: () => void;
onClear: () => void;
- onDeselect: () => void;
+ onCanvasToolChange: (tool: "select" | "pan") => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
onShowImageInfoChange: (show: boolean) => void;
}) {
@@ -89,8 +91,8 @@ export function CanvasToolbar({
{tip ?
: null}
-
-
+ onCanvasToolChange(canvasTool === "select" ? "pan" : "select")}>
+ {canvasTool === "select" ? : }
@@ -350,7 +352,8 @@ function DockTip({ label, x, theme }: { label: string; x: number; theme: CanvasT
}
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-redo") return t("canvas.redo");
if (id === "tool-text") return t("canvas.toolbar.text");
diff --git a/web/src/components/canvas/canvas-top-bar.tsx b/web/src/components/canvas/canvas-top-bar.tsx
index 3792321..86358c6 100644
--- a/web/src/components/canvas/canvas-top-bar.tsx
+++ b/web/src/components/canvas/canvas-top-bar.tsx
@@ -154,11 +154,11 @@ export function CanvasTopBar({
setShortcutsOpen(false)} footer={null} centered>
-
+
-
-
+
+
diff --git a/web/src/components/canvas/canvas-zoom-controls.tsx b/web/src/components/canvas/canvas-zoom-controls.tsx
index aa07313..854e0bc 100644
--- a/web/src/components/canvas/canvas-zoom-controls.tsx
+++ b/web/src/components/canvas/canvas-zoom-controls.tsx
@@ -61,10 +61,10 @@ export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpe
setShortcutsOpen(false)} footer={null} centered>
-
+
-
-
+
+
diff --git a/web/src/components/canvas/infinite-canvas.tsx b/web/src/components/canvas/infinite-canvas.tsx
index 1704d72..a45d79c 100644
--- a/web/src/components/canvas/infinite-canvas.tsx
+++ b/web/src/components/canvas/infinite-canvas.tsx
@@ -7,6 +7,7 @@ import type { ViewportTransform } from "@/types/canvas";
type InfiniteCanvasProps = {
containerRef: React.RefObject;
viewport: ViewportTransform;
+ tool: "select" | "pan";
backgroundMode?: CanvasBackgroundMode;
onViewportChange: (viewport: ViewportTransform) => void;
onCanvasMouseDown?: (event: React.PointerEvent) => void;
@@ -17,7 +18,7 @@ type InfiniteCanvasProps = {
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 panState = useRef({
isPanning: false,
@@ -26,11 +27,14 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
initialX: 0,
initialY: 0,
hasMoved: false,
+ startedOnBackground: false,
});
const scaleRef = useRef(viewport.k);
const frameRef = useRef(null);
const nextViewportRef = useRef(null);
const [isSpacePressed, setIsSpacePressed] = useState(false);
+ const [isControlPressed, setIsControlPressed] = useState(false);
+ const [isPanning, setIsPanning] = useState(false);
useEffect(() => {
scaleRef.current = viewport.k;
@@ -45,20 +49,30 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
- if (event.code !== "Space") return;
- if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
- setIsSpacePressed(true);
+ if (event.key === "Control") setIsControlPressed(true);
+ if (event.code === "Space" && !(event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement)) setIsSpacePressed(true);
};
const handleKeyUp = (event: KeyboardEvent) => {
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("keyup", handleKeyUp);
+ window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown);
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-connection-create-menu]")) return;
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) {
- event.preventDefault();
- event.currentTarget.setPointerCapture(event.pointerId);
- onCanvasMouseDown?.(event);
- return;
- }
-
- if (event.button === 1 || (event.button === 0 && !isSpacePressed && isBackgroundClick)) {
+ if (shouldPan) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
panState.current = {
@@ -107,13 +115,17 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
initialX: viewport.x,
initialY: viewport.y,
hasMoved: false,
+ startedOnBackground: isBackgroundClick,
};
+ setIsPanning(true);
document.body.style.cursor = "grabbing";
return;
}
- if (event.button === 0 && isSpacePressed && isBackgroundClick) {
+ if (event.button === 0 && isBackgroundClick) {
event.preventDefault();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ onCanvasMouseDown?.(event);
}
};
@@ -148,18 +160,22 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
const handlePointerUp = () => {
if (!panState.current.isPanning) return;
- if (!panState.current.hasMoved) {
+ if (!panState.current.hasMoved && panState.current.startedOnBackground) {
onCanvasDeselect?.();
}
panState.current.isPanning = false;
+ setIsPanning(false);
document.body.style.cursor = "";
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
+ window.addEventListener("pointercancel", handlePointerUp);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
+ window.removeEventListener("pointercancel", handlePointerUp);
+ document.body.style.cursor = "";
};
}, [onCanvasDeselect, onViewportChange]);
@@ -177,11 +193,13 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
return () => container.removeEventListener("wheel", preventWheelScroll);
}, [containerRef]);
+ const cursor = isPanning ? "grabbing" : tool === "pan" || isControlPressed || isSpacePressed ? "grab" : undefined;
+
return (
([]);
const [activeChatId, setActiveChatId] = useState
(null);
const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 });
+ const [canvasTool, setCanvasTool] = useState<"select" | "pan">("select");
const [size, setSize] = useState({ width: 1200, height: 720 });
const [selectedNodeIds, setSelectedNodeIds] = useState>(new Set());
const [selectedConnectionId, setSelectedConnectionId] = useState(null);
@@ -977,13 +978,6 @@ function InfiniteCanvasPage() {
if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate();
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 nextSelectionBox = {
startWorldX: world.x,
@@ -2775,6 +2769,7 @@ function InfiniteCanvasPage() {
{
setViewport(next);
@@ -2863,17 +2858,17 @@ function InfiniteCanvasPage() {
))}
{selectionBox ? (
-
+ >
+
+
) : null}
{pendingConnectionCreate ? createConnectedNode(type, pendingConnectionCreate)} onClose={cancelPendingConnectionCreate} /> : null}
{nodeCreatePosition ? (
@@ -2918,6 +2913,7 @@ function InfiniteCanvasPage() {
handleUploadRequest()}
onDelete={() => deleteNodes(new Set(selectedNodeIds))}
onClear={() => setClearConfirmOpen(true)}
- onDeselect={deselectCanvas}
+ onCanvasToolChange={setCanvasTool}
onBackgroundModeChange={setBackgroundMode}
onShowImageInfoChange={setShowImageInfo}
/>