diff --git a/src/features/workbench/workspace/workbench-main-frame.tsx b/src/features/workbench/workspace/workbench-main-frame.tsx index 754243b..ac84a49 100644 --- a/src/features/workbench/workspace/workbench-main-frame.tsx +++ b/src/features/workbench/workspace/workbench-main-frame.tsx @@ -3,10 +3,15 @@ import { CalendarClock, ChevronsLeft, ChevronsRight, + Columns2, FlaskConical, + Grid2X2, Layers3, + LayoutDashboard, + LayoutTemplate, Map as MapIcon, PanelLeft, + PanelsTopLeft, SlidersHorizontal, Sparkles } from "lucide-react"; @@ -20,10 +25,21 @@ import { } from "react"; import { showMapNotice } from "@/features/map/core"; import { cn } from "@/shared/ui/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@/shared/ui/dropdown-menu"; import { AnalysisDocumentView } from "./analysis-document-view"; import { createAnalysisDocumentFixture } from "./analysis-document-fixtures"; import { MAX_ANALYSIS_WINDOWS, + applyWorkspaceLayout, closeAnalysisWindow, createInitialWorkspaceState, focusWorkspaceWindow, @@ -32,8 +48,13 @@ import { openAnalysisDocument, resizeWorkspace, restoreWorkspaceWindow, + setWorkspacePrimaryWindow, + swapWorkspaceWindows, updateWorkspaceWindowRect, - type WorkspaceBounds + type WorkspaceBounds, + type WorkspaceLayoutMode, + type WorkspaceRect, + type WorkspaceState } from "./workspace-model"; import { WorkspaceWindow } from "./workspace-window"; @@ -62,6 +83,12 @@ const NAVIGATION_ITEMS: Array<{ id: WorkbenchNavigationSection; label: string; i const INITIAL_BOUNDS: WorkspaceBounds = { width: 960, height: 680 }; +type WorkspaceDragState = { + sourceId: string; + sourceRect: WorkspaceRect; + targetId: string | null; +}; + export function WorkbenchMainFrame({ renderAgentPanel, conditionsPanel, @@ -76,9 +103,12 @@ export function WorkbenchMainFrame({ onNavigationCollapsedChange }: WorkbenchMainFrameProps) { const workspaceRef = useRef(null); + const dragStateRef = useRef(null); const [bounds, setBounds] = useState(INITIAL_BOUNDS); const [workspace, setWorkspace] = useState(() => createInitialWorkspaceState(INITIAL_BOUNDS)); const [desktop, setDesktop] = useState(false); + const [dragState, setDragState] = useState(null); + const [workspaceAnnouncement, setWorkspaceAnnouncement] = useState(""); const fixtureIndexRef = useRef(0); const fixtureInstanceRef = useRef(0); @@ -124,6 +154,67 @@ export function WorkbenchMainFrame({ setWorkspace((current) => openAnalysisDocument(current, document, bounds)); }, [bounds, workspace.analyses.length]); + const findSwapTarget = useCallback((sourceId: string, pointer: { x: number; y: number }) => { + const root = workspaceRef.current; + if (!root) return null; + return [...root.querySelectorAll("[data-workspace-window-id]")] + .filter((element) => element.dataset.workspaceWindowId !== sourceId) + .filter((element) => { + const rect = element.getBoundingClientRect(); + const inset = Math.min(12, rect.width / 4, rect.height / 4); + return rect.width > 0 && rect.height > 0 + && pointer.x >= rect.left + inset + && pointer.x <= rect.right - inset + && pointer.y >= rect.top + inset + && pointer.y <= rect.bottom - inset; + }) + .sort((a, b) => Number(b.style.zIndex || 0) - Number(a.style.zIndex || 0))[0] + ?.dataset.workspaceWindowId ?? null; + }, []); + + const startWindowMove = useCallback((sourceId: string, sourceRect: WorkspaceRect) => { + const next = { sourceId, sourceRect, targetId: null }; + dragStateRef.current = next; + setDragState(next); + setWorkspaceAnnouncement(""); + }, []); + + const previewWindowMove = useCallback((sourceId: string, pointer: { x: number; y: number }) => { + const current = dragStateRef.current; + if (!current || current.sourceId !== sourceId) return; + const targetId = findSwapTarget(sourceId, pointer); + if (current.targetId === targetId) return; + const next = { ...current, targetId }; + dragStateRef.current = next; + setDragState(next); + }, [findSwapTarget]); + + const finishWindowMove = useCallback(( + sourceId: string, + rect: WorkspaceRect, + pointer: { x: number; y: number } + ) => { + const current = dragStateRef.current; + const targetId = current?.sourceId === sourceId + ? current.targetId ?? findSwapTarget(sourceId, pointer) + : findSwapTarget(sourceId, pointer); + if (targetId) { + setWorkspace((state) => swapWorkspaceWindows(state, sourceId, targetId, bounds)); + setWorkspaceAnnouncement(`${getWorkspaceWindowTitle(workspace, sourceId)}与${getWorkspaceWindowTitle(workspace, targetId)}已交换位置`); + } else { + setWorkspace((state) => updateWorkspaceWindowRect(state, sourceId, rect, bounds)); + setWorkspaceAnnouncement(`${getWorkspaceWindowTitle(workspace, sourceId)}已转为自由排列`); + } + dragStateRef.current = null; + setDragState(null); + }, [bounds, findSwapTarget, workspace]); + + const cancelWindowMove = useCallback(() => { + dragStateRef.current = null; + setDragState(null); + setWorkspaceAnnouncement("已取消窗口移动"); + }, []); + const navigationWidth = navigationCollapsed ? 48 : 368; return (
setWorkspace((current) => focusWorkspaceWindow(current, current.map.id))} onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, current.map.id, rect, bounds))} - onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, current.map.id))} + onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, current.map.id, bounds))} onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, current.map.id))} onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, current.map.id, bounds))} + onMoveStart={(rect) => startWindowMove(workspace.map.id, rect)} + onMovePreview={(_, pointer) => previewWindowMove(workspace.map.id, pointer)} + onMoveEnd={(rect, pointer) => finishWindowMove(workspace.map.id, rect, pointer)} + onMoveCancel={cancelWindowMove} > {mapContent} {mapOverlay} @@ -256,12 +353,17 @@ export function WorkbenchMainFrame({ zIndex={window.zIndex} active={workspace.activeWindowId === window.id} kind="analysis" + swapTarget={dragState?.targetId === window.id} onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, window.id))} onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, window.id, rect, bounds))} - onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id))} + onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id, bounds))} onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, window.id))} onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, window.id, bounds))} - onClose={() => setWorkspace((current) => closeAnalysisWindow(current, window.id))} + onClose={() => setWorkspace((current) => closeAnalysisWindow(current, window.id, bounds))} + onMoveStart={(rect) => startWindowMove(window.id, rect)} + onMovePreview={(_, pointer) => previewWindowMove(window.id, pointer)} + onMoveEnd={(rect, pointer) => finishWindowMove(window.id, rect, pointer)} + onMoveCancel={cancelWindowMove} > @@ -290,8 +392,21 @@ export function WorkbenchMainFrame({ onClick={() => setWorkspace((current) => window.mode === "minimized" ? restoreWorkspaceWindow(current, window.id, bounds) : focusWorkspaceWindow(current, window.id))} /> ))} + { + setWorkspace((current) => applyWorkspaceLayout(current, mode, bounds)); + setWorkspaceAnnouncement(`已应用${LAYOUT_LABELS[mode]}布局`); + }} + onSetPrimary={() => { + setWorkspace((current) => setWorkspacePrimaryWindow(current, current.activeWindowId, bounds)); + setWorkspaceAnnouncement(`${getWorkspaceWindowTitle(workspace, workspace.activeWindowId)}已设为主窗口`); + }} + /> {workspace.analyses.length} / {MAX_ANALYSIS_WINDOWS} 个临时分析窗
+ {workspaceAnnouncement} {!desktop ? 移动端保持地图与抽屉工作台 : null} ); @@ -315,6 +430,97 @@ function TaskbarButton({ active, minimized, icon, label, onClick }: { active: bo ); } +const LAYOUT_LABELS: Record = { + free: "自由排列", + primary: "主次", + split: "双栏", + grid: "网格", + cascade: "层叠" +}; + +const LAYOUT_OPTIONS: Array<{ + mode: WorkspaceLayoutMode; + description: string; + icon: typeof LayoutTemplate; +}> = [ + { mode: "primary", description: "主窗口与两个辅助窗口", icon: LayoutDashboard }, + { mode: "split", description: "两个窗口等宽并排", icon: Columns2 }, + { mode: "grid", description: "全部窗口自适应平铺", icon: Grid2X2 }, + { mode: "cascade", description: "全部窗口依次层叠", icon: Layers3 }, + { mode: "free", description: "保留当前位置手动排列", icon: PanelsTopLeft } +]; + +function WorkspaceLayoutMenu({ + mode, + activeWindowTitle, + onLayoutChange, + onSetPrimary +}: { + mode: WorkspaceLayoutMode; + activeWindowTitle: string; + onLayoutChange: (mode: WorkspaceLayoutMode) => void; + onSetPrimary: () => void; +}) { + return ( + + + + + + + 窗口布局 + + onLayoutChange(value as WorkspaceLayoutMode)}> + {LAYOUT_OPTIONS.map((option) => { + const Icon = option.icon; + return ( + + + + + {LAYOUT_LABELS[option.mode]} + {option.description} + + + ); + })} + + + + + + + ); +} + +function getWorkspaceWindowTitle(state: WorkspaceState, windowId: string) { + if (windowId === state.map.id) return state.map.title; + return state.analyses.find((window) => window.id === windowId)?.title ?? "工作区窗口"; +} + function NavigationPanel({ active, children }: { active: boolean; children: ReactNode }) { return (