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} />