feat: add workspace task view
This commit is contained in:
@@ -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<WorkbenchMainFrameHandle, Workbench
|
||||
}, ref) {
|
||||
const workspaceRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragStateRef = useRef<WorkspaceDragState | null>(null);
|
||||
const taskViewButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS);
|
||||
const [workspace, setWorkspace] = useState(() => createInitialWorkspaceState(INITIAL_BOUNDS));
|
||||
const [desktop, setDesktop] = useState(false);
|
||||
const [dragState, setDragState] = useState<WorkspaceDragState | null>(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<WorkbenchMainFrameHandle, Workbench
|
||||
return () => 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<WorkbenchMainFrameHandle, Workbench
|
||||
setWorkspaceAnnouncement("已取消窗口移动");
|
||||
}, []);
|
||||
|
||||
function focusTaskViewWindow(windowId: string) {
|
||||
setTaskViewFocusWindowId(windowId);
|
||||
window.requestAnimationFrame(() => {
|
||||
workspaceRef.current
|
||||
?.querySelector<HTMLButtonElement>(`[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<HTMLButtonElement>,
|
||||
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 (
|
||||
<div
|
||||
className="workbench-main-frame pointer-events-none absolute inset-0 lg:top-14"
|
||||
@@ -208,6 +284,16 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
|
||||
active={workspace.activeWindowId === workspace.map.id}
|
||||
kind="map"
|
||||
swapTarget={dragState?.targetId === workspace.map.id}
|
||||
taskViewPreview={taskViewOpen && taskViewPreviewRects[0] ? {
|
||||
sourceRect: getTaskViewSourceRect(
|
||||
workspace.map.mode,
|
||||
workspace.map.rect,
|
||||
workspace.map.restoreRect,
|
||||
bounds
|
||||
),
|
||||
targetRect: taskViewPreviewRects[0],
|
||||
index: 0
|
||||
} : undefined}
|
||||
onFocus={() => 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<WorkbenchMainFrameHandle, Workbench
|
||||
{mapOverlay}
|
||||
</WorkspaceWindow>
|
||||
|
||||
{workspace.analyses.map((window) => (
|
||||
{workspace.analyses.map((window, index) => (
|
||||
<WorkspaceWindow
|
||||
key={window.id}
|
||||
id={window.id}
|
||||
@@ -233,6 +319,16 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
|
||||
active={workspace.activeWindowId === window.id}
|
||||
kind="analysis"
|
||||
swapTarget={dragState?.targetId === window.id}
|
||||
taskViewPreview={taskViewOpen && taskViewPreviewRects[index + 1] ? {
|
||||
sourceRect: getTaskViewSourceRect(
|
||||
window.mode,
|
||||
window.rect,
|
||||
window.restoreRect,
|
||||
bounds
|
||||
),
|
||||
targetRect: taskViewPreviewRects[index + 1],
|
||||
index: index + 1
|
||||
} : undefined}
|
||||
onFocus={() => 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<WorkbenchMainFrameHandle, Workbench
|
||||
<AnalysisDocumentView document={window.document} />
|
||||
</WorkspaceWindow>
|
||||
))}
|
||||
|
||||
{taskViewOpen ? (
|
||||
<WorkspaceTaskView
|
||||
windows={taskViewWindows}
|
||||
previewRects={taskViewPreviewRects}
|
||||
focusedWindowId={taskViewFocusWindowId}
|
||||
layoutMode={workspace.layout.mode}
|
||||
onClose={closeTaskView}
|
||||
onFocusWindow={setTaskViewFocusWindowId}
|
||||
onSelectWindow={activateTaskViewWindow}
|
||||
onWindowKeyDown={handleTaskViewWindowKeyDown}
|
||||
onLayoutChange={applyTaskViewLayout}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -254,12 +364,32 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
|
||||
role="toolbar"
|
||||
aria-label="工作区窗口任务栏"
|
||||
>
|
||||
<button
|
||||
ref={taskViewButtonRef}
|
||||
type="button"
|
||||
aria-label="任务视图"
|
||||
aria-pressed={taskViewOpen}
|
||||
title="任务视图"
|
||||
className={cn(
|
||||
"grid h-8 w-10 shrink-0 place-items-center rounded-md transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||
taskViewOpen
|
||||
? "bg-blue-600 text-white shadow-[0_1px_3px_rgba(37,99,235,0.28)]"
|
||||
: "text-slate-700 hover:bg-white"
|
||||
)}
|
||||
onClick={taskViewOpen ? closeTaskView : openTaskView}
|
||||
>
|
||||
<PanelsTopLeft size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<span className="mx-1 h-5 w-px shrink-0 bg-slate-300" aria-hidden="true" />
|
||||
<TaskbarButton
|
||||
active={workspace.activeWindowId === workspace.map.id && workspace.map.mode !== "minimized"}
|
||||
minimized={workspace.map.mode === "minimized"}
|
||||
icon={<MapIcon size={15} aria-hidden="true" />}
|
||||
label="供水管网地图"
|
||||
onClick={() => setWorkspace((current) => current.map.mode === "minimized" ? restoreWorkspaceWindow(current, current.map.id, bounds) : focusWorkspaceWindow(current, current.map.id))}
|
||||
onClick={() => {
|
||||
setTaskViewOpen(false);
|
||||
setWorkspace((current) => current.map.mode === "minimized" ? restoreWorkspaceWindow(current, current.map.id, bounds) : focusWorkspaceWindow(current, current.map.id));
|
||||
}}
|
||||
/>
|
||||
{workspace.analyses.map((window) => (
|
||||
<TaskbarButton
|
||||
@@ -268,21 +398,12 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
|
||||
minimized={window.mode === "minimized"}
|
||||
icon={<Sparkles size={14} aria-hidden="true" />}
|
||||
label={window.title}
|
||||
onClick={() => setWorkspace((current) => window.mode === "minimized" ? restoreWorkspaceWindow(current, window.id, bounds) : focusWorkspaceWindow(current, window.id))}
|
||||
onClick={() => {
|
||||
setTaskViewOpen(false);
|
||||
setWorkspace((current) => window.mode === "minimized" ? restoreWorkspaceWindow(current, window.id, bounds) : focusWorkspaceWindow(current, window.id));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<WorkspaceLayoutMenu
|
||||
mode={workspace.layout.mode}
|
||||
activeWindowTitle={getWorkspaceWindowTitle(workspace, workspace.activeWindowId)}
|
||||
onLayoutChange={(mode) => {
|
||||
setWorkspace((current) => applyWorkspaceLayout(current, mode, bounds));
|
||||
setWorkspaceAnnouncement(`已应用${LAYOUT_LABELS[mode]}布局`);
|
||||
}}
|
||||
onSetPrimary={() => {
|
||||
setWorkspace((current) => setWorkspacePrimaryWindow(current, current.activeWindowId, bounds));
|
||||
setWorkspaceAnnouncement(`${getWorkspaceWindowTitle(workspace, workspace.activeWindowId)}已设为主窗口`);
|
||||
}}
|
||||
/>
|
||||
<span className="ml-auto shrink-0 text-xs text-slate-500 tabular-nums">{workspace.analyses.length} / {MAX_ANALYSIS_WINDOWS} 个临时分析窗</span>
|
||||
</div>
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">{workspaceAnnouncement}</span>
|
||||
@@ -309,92 +430,6 @@ function TaskbarButton({ active, minimized, icon, label, onClick }: { active: bo
|
||||
);
|
||||
}
|
||||
|
||||
const LAYOUT_LABELS: Record<WorkspaceLayoutMode, string> = {
|
||||
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 (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`设置窗口布局,当前${LAYOUT_LABELS[mode]}`}
|
||||
className="relative ml-1 flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold text-slate-700 before:absolute before:-inset-y-1 before:content-[''] transition-[background-color,color,scale] hover:bg-white active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 data-[state=open]:bg-white data-[state=open]:text-blue-700"
|
||||
>
|
||||
<LayoutTemplate size={15} aria-hidden="true" />
|
||||
布局 · {LAYOUT_LABELS[mode]}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-72 rounded-lg border-slate-200 bg-white p-1.5 shadow-[0_12px_32px_rgba(15,23,42,0.18)] motion-reduce:animate-none"
|
||||
>
|
||||
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs font-semibold text-slate-500">
|
||||
窗口布局
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup value={mode} onValueChange={(value) => onLayoutChange(value as WorkspaceLayoutMode)}>
|
||||
{LAYOUT_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.mode}
|
||||
value={option.mode}
|
||||
className="my-0.5 min-h-11 gap-2 rounded-md py-2 pl-8 pr-2 focus:bg-blue-50 focus:text-blue-800 data-[state=checked]:bg-blue-50/80"
|
||||
>
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-slate-100 text-slate-700">
|
||||
<Icon size={16} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs font-semibold text-slate-900">{LAYOUT_LABELS[option.mode]}</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-4 text-slate-500">{option.description}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator className="my-1.5 bg-slate-200" />
|
||||
<DropdownMenuItem
|
||||
disabled={mode === "free"}
|
||||
className="min-h-10 rounded-md px-2 text-xs font-semibold text-slate-700 focus:bg-blue-50 focus:text-blue-800"
|
||||
onSelect={onSetPrimary}
|
||||
>
|
||||
<PanelsTopLeft size={16} aria-hidden="true" />
|
||||
<span className="min-w-0 truncate">将“{activeWindowTitle}”设为主窗口</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function getWorkspaceWindowTitle(state: WorkspaceState, windowId: string) {
|
||||
if (windowId === state.map.id) return state.map.title;
|
||||
return state.analyses.find((window) => window.id === windowId)?.title ?? "工作区窗口";
|
||||
|
||||
Reference in New Issue
Block a user