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";