diff --git a/src/features/workbench/workspace/workbench-main-frame.tsx b/src/features/workbench/workspace/workbench-main-frame.tsx index 187676f..2cec66b 100644 --- a/src/features/workbench/workspace/workbench-main-frame.tsx +++ b/src/features/workbench/workspace/workbench-main-frame.tsx @@ -1,9 +1,4 @@ import { - Columns2, - Grid2X2, - Layers3, - LayoutDashboard, - LayoutTemplate, Map as MapIcon, PanelsTopLeft, Sparkles @@ -13,22 +8,13 @@ import { useCallback, useEffect, useImperativeHandle, + useMemo, useRef, useState, type ReactNode } 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 { @@ -42,7 +28,6 @@ import { openAnalysisDocument, resizeWorkspace, restoreWorkspaceWindow, - setWorkspacePrimaryWindow, swapWorkspaceWindows, updateWorkspaceWindowRect, type WorkspaceBounds, @@ -50,6 +35,13 @@ import { type WorkspaceRect, type WorkspaceState } from "./workspace-model"; +import { + getTaskViewColumnCount, + getTaskViewPreviewRects, + getTaskViewSourceRect, + WORKSPACE_LAYOUT_LABELS +} from "./workspace-task-view-layout"; +import { WorkspaceTaskView } from "./workspace-task-view"; import { WorkspaceWindow } from "./workspace-window"; type WorkbenchMainFrameProps = { @@ -75,13 +67,24 @@ export const WorkbenchMainFrame = forwardRef(null); const dragStateRef = useRef(null); + const taskViewButtonRef = 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 [taskViewOpen, setTaskViewOpen] = useState(false); + const [taskViewFocusWindowId, setTaskViewFocusWindowId] = useState("workspace-map"); const [workspaceAnnouncement, setWorkspaceAnnouncement] = useState(""); const fixtureIndexRef = useRef(0); const fixtureInstanceRef = useRef(0); + const taskViewWindows = useMemo( + () => [workspace.map, ...workspace.analyses], + [workspace.analyses, workspace.map] + ); + const taskViewPreviewRects = useMemo( + () => getTaskViewPreviewRects(taskViewWindows.length, bounds), + [bounds, taskViewWindows.length] + ); useEffect(() => { const element = workspaceRef.current; @@ -100,6 +103,19 @@ export const WorkbenchMainFrame = forwardRef observer.disconnect(); }, []); + useEffect(() => { + if (!taskViewOpen) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + setTaskViewOpen(false); + setWorkspaceAnnouncement("已关闭任务视图"); + window.requestAnimationFrame(() => taskViewButtonRef.current?.focus()); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [taskViewOpen]); + useEffect(() => { const mediaQuery = window.matchMedia("(min-width: 1024px)"); const handleChange = () => { @@ -188,6 +204,66 @@ export const WorkbenchMainFrame = forwardRef { + workspaceRef.current + ?.querySelector(`[data-task-view-window-id="${windowId}"]`) + ?.focus(); + }); + } + + function openTaskView() { + const windowId = workspace.activeWindowId; + setTaskViewFocusWindowId(windowId); + setTaskViewOpen(true); + setWorkspaceAnnouncement(`任务视图已打开,共 ${taskViewWindows.length} 个窗口`); + window.requestAnimationFrame(() => focusTaskViewWindow(windowId)); + } + + function closeTaskView() { + setTaskViewOpen(false); + setWorkspaceAnnouncement("已关闭任务视图"); + window.requestAnimationFrame(() => taskViewButtonRef.current?.focus()); + } + + function activateTaskViewWindow(windowId: string) { + const title = getWorkspaceWindowTitle(workspace, windowId); + setWorkspace((current) => maximizeWorkspaceWindow(current, windowId)); + setTaskViewOpen(false); + setWorkspaceAnnouncement(`${title}已铺满主视图`); + window.requestAnimationFrame(() => taskViewButtonRef.current?.focus()); + } + + function applyTaskViewLayout(mode: WorkspaceLayoutMode) { + setWorkspace((current) => applyWorkspaceLayout( + focusWorkspaceWindow(current, taskViewFocusWindowId), + mode, + bounds + )); + setTaskViewOpen(false); + setWorkspaceAnnouncement(`已应用${WORKSPACE_LAYOUT_LABELS[mode]}布局`); + window.requestAnimationFrame(() => taskViewButtonRef.current?.focus()); + } + + function handleTaskViewWindowKeyDown( + event: React.KeyboardEvent, + index: number + ) { + const columns = getTaskViewColumnCount(taskViewWindows.length); + let nextIndex = index; + if (event.key === "ArrowLeft") nextIndex = Math.max(0, index - 1); + else if (event.key === "ArrowRight") nextIndex = Math.min(taskViewWindows.length - 1, index + 1); + else if (event.key === "ArrowUp") nextIndex = Math.max(0, index - columns); + else if (event.key === "ArrowDown") nextIndex = Math.min(taskViewWindows.length - 1, index + columns); + else if (event.key === "Home") nextIndex = 0; + else if (event.key === "End") nextIndex = taskViewWindows.length - 1; + else return; + event.preventDefault(); + const nextWindow = taskViewWindows[nextIndex]; + if (nextWindow) focusTaskViewWindow(nextWindow.id); + } + 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, bounds))} @@ -222,7 +308,7 @@ export const WorkbenchMainFrame = forwardRef - {workspace.analyses.map((window) => ( + {workspace.analyses.map((window, index) => ( setWorkspace((current) => focusWorkspaceWindow(current, window.id))} onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, window.id, rect, bounds))} onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id, bounds))} @@ -247,6 +343,20 @@ export const WorkbenchMainFrame = forwardRef ))} + + {taskViewOpen ? ( + + ) : null}
+ +
{workspaceAnnouncement} @@ -309,92 +430,6 @@ 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 ?? "工作区窗口"; diff --git a/src/features/workbench/workspace/workspace-task-view-layout.ts b/src/features/workbench/workspace/workspace-task-view-layout.ts new file mode 100644 index 0000000..8b74070 --- /dev/null +++ b/src/features/workbench/workspace/workspace-task-view-layout.ts @@ -0,0 +1,81 @@ +import type { + WorkspaceBounds, + WorkspaceLayoutMode, + WorkspaceRect +} from "./workspace-model"; + +const HORIZONTAL_INSET = 28; +const TOP_INSET = 48; +const LAYOUT_RAIL_SPACE = 112; +const PREVIEW_GAP = 20; +const LABEL_SPACE = 28; + +export const WORKSPACE_LAYOUT_LABELS: Record = { + free: "自由排列", + primary: "主次", + split: "双栏", + grid: "网格", + cascade: "层叠" +}; + +export function getTaskViewPreviewRects( + count: number, + bounds: WorkspaceBounds +): WorkspaceRect[] { + if (count <= 0) return []; + + const columns = getTaskViewColumnCount(count); + const rows = Math.ceil(count / columns); + const availableWidth = Math.max(1, bounds.width - HORIZONTAL_INSET * 2); + const availableHeight = Math.max( + 1, + bounds.height - TOP_INSET - LAYOUT_RAIL_SPACE + ); + const cellWidth = Math.max( + 1, + (availableWidth - PREVIEW_GAP * (columns - 1)) / columns + ); + const cellHeight = Math.max( + 1, + (availableHeight - PREVIEW_GAP * (rows - 1)) / rows + ); + const previewHeight = Math.max(1, cellHeight - LABEL_SPACE); + const occupiedColumns = Math.min(count, columns); + const firstRowOffset = (availableWidth + - occupiedColumns * cellWidth + - Math.max(0, occupiedColumns - 1) * PREVIEW_GAP) / 2; + + return Array.from({ length: count }, (_, index) => { + const row = Math.floor(index / columns); + const column = index % columns; + const itemsInRow = Math.min(columns, count - row * columns); + const rowOffset = row === 0 + ? firstRowOffset + : (availableWidth + - itemsInRow * cellWidth + - Math.max(0, itemsInRow - 1) * PREVIEW_GAP) / 2; + + return { + x: HORIZONTAL_INSET + rowOffset + column * (cellWidth + PREVIEW_GAP), + y: TOP_INSET + row * (cellHeight + PREVIEW_GAP), + width: cellWidth, + height: previewHeight + }; + }); +} + +export function getTaskViewColumnCount(count: number) { + return count <= 1 ? 1 : count <= 4 ? 2 : 3; +} + +export function getTaskViewSourceRect( + mode: "docked" | "floating" | "maximized" | "minimized", + rect: WorkspaceRect, + restoreRect: WorkspaceRect, + bounds: WorkspaceBounds +): WorkspaceRect { + if (mode === "docked" || mode === "maximized") { + return { x: 0, y: 0, width: bounds.width, height: bounds.height }; + } + return mode === "minimized" ? restoreRect : rect; +} diff --git a/src/features/workbench/workspace/workspace-task-view.test.ts b/src/features/workbench/workspace/workspace-task-view.test.ts new file mode 100644 index 0000000..490efed --- /dev/null +++ b/src/features/workbench/workspace/workspace-task-view.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + getTaskViewPreviewRects, + getTaskViewSourceRect +} from "./workspace-task-view-layout"; + +const bounds = { width: 1440, height: 804 }; + +describe("workspace task view geometry", () => { + it.each([1, 2, 4, 7])("keeps %i previews inside the task view area", (count) => { + const rects = getTaskViewPreviewRects(count, bounds); + + expect(rects).toHaveLength(count); + for (const rect of rects) { + expect(rect.x).toBeGreaterThanOrEqual(28); + expect(rect.y).toBeGreaterThanOrEqual(48); + expect(rect.x + rect.width).toBeLessThanOrEqual(bounds.width - 28); + expect(rect.y + rect.height).toBeLessThanOrEqual(bounds.height - 112); + expect(rect.width).toBeGreaterThan(0); + expect(rect.height).toBeGreaterThan(0); + } + }); + + it("centers an incomplete last row", () => { + const rects = getTaskViewPreviewRects(7, bounds); + const last = rects.at(-1); + + expect(last).toBeDefined(); + expect(Math.abs((last!.x + last!.width / 2) - bounds.width / 2)).toBeLessThan(1); + }); + + it("uses the visible workspace for filled windows and restore geometry for minimized windows", () => { + const rect = { x: 80, y: 60, width: 720, height: 520 }; + const restoreRect = { x: 120, y: 90, width: 660, height: 480 }; + + expect(getTaskViewSourceRect("docked", rect, restoreRect, bounds)).toEqual({ + x: 0, + y: 0, + width: bounds.width, + height: bounds.height + }); + expect(getTaskViewSourceRect("minimized", rect, restoreRect, bounds)).toBe(restoreRect); + expect(getTaskViewSourceRect("floating", rect, restoreRect, bounds)).toBe(rect); + }); +}); diff --git a/src/features/workbench/workspace/workspace-task-view.tsx b/src/features/workbench/workspace/workspace-task-view.tsx new file mode 100644 index 0000000..c3b8eeb --- /dev/null +++ b/src/features/workbench/workspace/workspace-task-view.tsx @@ -0,0 +1,197 @@ +import { Map as MapIcon, PanelsTopLeft, Sparkles } from "lucide-react"; +import type { KeyboardEvent } from "react"; +import { cn } from "@/shared/ui/cn"; +import type { + WorkspaceLayoutMode, + WorkspaceRect, + WorkspaceState +} from "./workspace-model"; +import { WORKSPACE_LAYOUT_LABELS } from "./workspace-task-view-layout"; + +const SNAP_LAYOUT_OPTIONS: Array<{ + mode: WorkspaceLayoutMode; + description: string; + minimumWindows: number; +}> = [ + { mode: "primary", description: "一个主窗口与辅助窗口", minimumWindows: 2 }, + { mode: "split", description: "两个窗口等宽并排", minimumWindows: 2 }, + { mode: "grid", description: "全部窗口自适应平铺", minimumWindows: 2 }, + { mode: "cascade", description: "全部窗口依次层叠", minimumWindows: 2 }, + { mode: "free", description: "保留窗口的自由位置", minimumWindows: 1 } +]; + +type TaskViewWindow = WorkspaceState["map"] | WorkspaceState["analyses"][number]; + +export function WorkspaceTaskView({ + windows, + previewRects, + focusedWindowId, + layoutMode, + onClose, + onFocusWindow, + onSelectWindow, + onWindowKeyDown, + onLayoutChange +}: { + windows: TaskViewWindow[]; + previewRects: WorkspaceRect[]; + focusedWindowId: string; + layoutMode: WorkspaceLayoutMode; + onClose: () => void; + onFocusWindow: (windowId: string) => void; + onSelectWindow: (windowId: string) => void; + onWindowKeyDown: (event: KeyboardEvent, index: number) => void; + onLayoutChange: (mode: WorkspaceLayoutMode) => void; +}) { + function handleDialogKeyDown(event: KeyboardEvent) { + if (event.key !== "Tab") return; + const controls = [...event.currentTarget.querySelectorAll("button:not(:disabled)")] + .filter((control) => control.tabIndex >= 0); + const first = controls[0]; + const last = controls.at(-1); + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + + return ( + <> +