mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-07 01:54:24 +08:00
feat(canvas): enhance canvas toolbar for mode switching and improve selection box behavior
This commit is contained in:
@@ -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 等弹层在浅色主题下应使用白色背景、轻灰悬停和选中态,在深色主题下应使用深色背景与低对比度浅色交互态,不应再出现默认黑灰色块、文字对比不足或单个页面配色不一致。
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user