From 0317ac5bc723e7b6ef5b5e88f0b70720cb3377fc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=88=B1=E7=AC=91=E7=9A=84=E6=AF=9B=E6=AF=9B=E8=99=AB?=
<315515767@qq.com>
Date: Sun, 2 Aug 2026 14:40:20 +0800
Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E6=A1=86=E9=80=89=20-=20?=
=?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=B7=A5=E5=85=B7=E6=A0=8F=E3=80=8C=E6=A1=86?=
=?UTF-8?q?=E9=80=89=E3=80=8D=E5=B7=A5=E5=85=B7=EF=BC=8C=E7=A9=BA=E7=99=BD?=
=?UTF-8?q?=E5=A4=84=E6=8B=96=E5=8A=A8=E6=A1=86=E9=80=89=E5=90=8E=E8=87=AA?=
=?UTF-8?q?=E5=8A=A8=E5=9B=9E=E5=88=B0=E7=A7=BB=E5=8A=A8=E4=B8=8E=E9=80=89?=
=?UTF-8?q?=E6=8B=A9=20(#78)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: sikongyue <3155157671@qq.com>
---
web/src/components/canvas/canvas-toolbar.tsx | 11 ++++++++---
web/src/components/canvas/infinite-canvas.tsx | 7 ++++---
web/src/pages/canvas/canvas-project-top-bar.tsx | 1 +
web/src/pages/canvas/project.tsx | 6 ++++++
.../pages/canvas/use-canvas-selection-controller.ts | 5 ++++-
web/src/types/canvas.ts | 1 +
6 files changed, 24 insertions(+), 7 deletions(-)
diff --git a/web/src/components/canvas/canvas-toolbar.tsx b/web/src/components/canvas/canvas-toolbar.tsx
index 1e5186337..10ecc97b3 100644
--- a/web/src/components/canvas/canvas-toolbar.tsx
+++ b/web/src/components/canvas/canvas-toolbar.tsx
@@ -1,7 +1,7 @@
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import { Segmented, Switch } from "antd";
-import { CircleDot, Clapperboard, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Info, Layers3, Moon, Music2, Palette, PanelTop, Pencil, Plus, Redo2, Square, Sun, Trash2, Type, Undo2, UploadCloud, UserRound, Video, X } from "lucide-react";
+import { CircleDot, Clapperboard, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Info, Layers3, Moon, Music2, Palette, PanelTop, Pencil, Plus, Redo2, Square, SquareDashedMousePointer, Sun, Trash2, Type, Undo2, UploadCloud, UserRound, Video, X } from "lucide-react";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
@@ -11,11 +11,13 @@ import { aceternityMotion } from "@/lib/aceternity-motion";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
-import type { CanvasWorkspaceMode } from "@/types/canvas";
+import type { CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
export function CanvasToolbar({
selectedCount,
workspaceMode,
+ canvasTool,
+ onToolChange,
isProjectLinked,
canUndo,
canRedo,
@@ -43,6 +45,8 @@ export function CanvasToolbar({
}: {
selectedCount: number;
workspaceMode: CanvasWorkspaceMode;
+ canvasTool: CanvasToolMode;
+ onToolChange: (tool: CanvasToolMode) => void;
isProjectLinked: boolean;
canUndo: boolean;
canRedo: boolean;
@@ -96,7 +100,8 @@ export function CanvasToolbar({
}, [addOpen, appearanceOpen]);
const items: FloatingDockEntry[] = [
- { id: selectedCount ? "tool-close-selection" : "tool-hand", label: selectedCount ? `取消选择${selectedCount > 1 ? ` ${selectedCount} 个节点` : ""}` : "移动与选择", icon: selectedCount ? : , active: !selectedCount, onClick: () => onDeselect() },
+ { id: canvasTool === "box-select" ? "tool-move" : selectedCount ? "tool-close-selection" : "tool-move", label: canvasTool === "box-select" ? "移动与选择" : selectedCount ? `取消选择${selectedCount > 1 ? ` ${selectedCount} 个节点` : ""}` : "移动与选择", icon: canvasTool === "box-select" ? : selectedCount ? : , active: canvasTool === "move", onClick: () => { if (canvasTool !== "move") onToolChange("move"); else onDeselect(); } },
+ { id: "tool-box-select", label: "框选", icon: , active: canvasTool === "box-select", onClick: () => onToolChange(canvasTool === "box-select" ? "move" : "box-select") },
{ kind: "separator", id: "history-separator" },
{ id: "tool-undo", label: "撤销", icon: , disabled: !canUndo, onClick: () => onUndo() },
{ id: "tool-redo", label: "重做", icon: , disabled: !canRedo, onClick: () => onRedo() },
diff --git a/web/src/components/canvas/infinite-canvas.tsx b/web/src/components/canvas/infinite-canvas.tsx
index ffbe2d75a..a701b7833 100644
--- a/web/src/components/canvas/infinite-canvas.tsx
+++ b/web/src/components/canvas/infinite-canvas.tsx
@@ -12,6 +12,7 @@ type InfiniteCanvasProps = {
onViewportChange: (viewport: ViewportTransform) => void;
onViewportPreviewChange?: (viewport: ViewportTransform) => void;
onCanvasMouseDown?: (event: React.PointerEvent) => void;
+ boxSelectEnabled?: boolean;
onCanvasDoubleClick?: (event: React.MouseEvent) => void;
onCanvasDeselect?: () => void;
onContextMenu?: (event: React.MouseEvent) => void;
@@ -38,7 +39,7 @@ type PinchState = {
initialScale: number;
};
-export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "dots", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) {
+export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "dots", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, boxSelectEnabled = false, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const panState = useRef({
isPanning: false,
@@ -196,7 +197,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "dots"
const isTouch = event.pointerType === "touch";
const hasSelectionModifier = event.shiftKey || event.ctrlKey || event.metaKey || event.altKey;
- if (event.button === 0 && !isSpacePressed && !isTouch && isBackgroundClick && hasSelectionModifier) {
+ if (event.button === 0 && !isSpacePressed && !isTouch && isBackgroundClick && (hasSelectionModifier || boxSelectEnabled)) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
onCanvasMouseDown?.(event);
@@ -368,7 +369,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "dots"
return (
+
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index d65ef3a61..0a6480666 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -106,6 +106,7 @@ import {
type StoryboardShotDuration,
type CanvasWorkflowKind,
type CanvasWorkspaceMode,
+ type CanvasToolMode,
type ContextMenuState,
type Position,
type ViewportTransform,
@@ -204,6 +205,7 @@ function InfiniteCanvasPage() {
const [isMiniMapOpen, setIsMiniMapOpen] = useState(false);
const [backgroundMode, setBackgroundMode] = useState("dots");
const [showImageInfo, setShowImageInfo] = useState(false);
+ const [canvasTool, setCanvasTool] = useState("move");
const [mediaPerformanceMode, setMediaPerformanceMode] = useState(readCanvasMediaPerformanceMode);
const [projectLoaded, setProjectLoaded] = useState(false);
const [workspaceMode, setWorkspaceMode] = useState(readCanvasWorkspaceMode);
@@ -700,6 +702,7 @@ function InfiniteCanvasPage() {
onNodeInteractionStart: handleNodeInteractionStart,
onNodeClick: handleSelectedNodeClick,
onDeselect: handleCanvasDeselect,
+ onSelectionBoxEnd: () => setCanvasTool((tool) => (tool === "box-select" ? "move" : tool)),
});
const keepNodeToolbar = useCallback(
@@ -1430,6 +1433,7 @@ function InfiniteCanvasPage() {
onViewportChange={handleViewportChange}
onViewportPreviewChange={handleViewportPreviewChange}
onCanvasMouseDown={handleCanvasMouseDown}
+ boxSelectEnabled={canvasTool === "box-select"}
onCanvasDoubleClick={handleCanvasDoubleClick}
onCanvasDeselect={deselectCanvas}
onContextMenu={handleCanvasContextMenu}
@@ -1568,6 +1572,8 @@ function InfiniteCanvasPage() {
void;
onNodeClick: (node: CanvasNodeData) => void;
onDeselect: () => void;
+ onSelectionBoxEnd?: () => void;
};
type DragState = {
@@ -57,6 +58,7 @@ export function useCanvasSelectionController({
onNodeInteractionStart,
onNodeClick,
onDeselect,
+ onSelectionBoxEnd,
}: UseCanvasSelectionControllerOptions) {
const dragFrameRef = useRef(null);
const pendingNodeDragRef = useRef({ x: 0, y: 0 });
@@ -296,7 +298,8 @@ export function useCanvasSelectionController({
const wasSelection = selectionActivatedRef.current;
cancelSelectionBox();
if (hadPendingSelection && !wasSelection) deselectCanvas();
- }, [cancelSelectionBox, deselectCanvas]);
+ if (hadPendingSelection) onSelectionBoxEnd?.();
+ }, [cancelSelectionBox, deselectCanvas, onSelectionBoxEnd]);
useEffect(() => {
const handleMouseUp = (event: MouseEvent) => {
diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts
index 0ff5d8d78..495b8c413 100644
--- a/web/src/types/canvas.ts
+++ b/web/src/types/canvas.ts
@@ -26,6 +26,7 @@ export enum CanvasNodeType {
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
export type CanvasMediaPerformanceMode = "auto" | "quality" | "performance";
export type CanvasWorkspaceMode = "simple" | "professional";
+export type CanvasToolMode = "move" | "box-select";
export type StoryboardShotDuration = "auto" | "5" | "10" | "15" | "30";
export type StoryboardShotCount = "auto" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10";
export type CanvasGenerationMode = "text" | "image" | "video" | "audio";