feat: add workspace task view
This commit is contained in:
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Columns2,
|
|
||||||
Grid2X2,
|
|
||||||
Layers3,
|
|
||||||
LayoutDashboard,
|
|
||||||
LayoutTemplate,
|
|
||||||
Map as MapIcon,
|
Map as MapIcon,
|
||||||
PanelsTopLeft,
|
PanelsTopLeft,
|
||||||
Sparkles
|
Sparkles
|
||||||
@@ -13,22 +8,13 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode
|
type ReactNode
|
||||||
} from "react";
|
} from "react";
|
||||||
import { showMapNotice } from "@/features/map/core";
|
import { showMapNotice } from "@/features/map/core";
|
||||||
import { cn } from "@/shared/ui/cn";
|
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 { AnalysisDocumentView } from "./analysis-document-view";
|
||||||
import { createAnalysisDocumentFixture } from "./analysis-document-fixtures";
|
import { createAnalysisDocumentFixture } from "./analysis-document-fixtures";
|
||||||
import {
|
import {
|
||||||
@@ -42,7 +28,6 @@ import {
|
|||||||
openAnalysisDocument,
|
openAnalysisDocument,
|
||||||
resizeWorkspace,
|
resizeWorkspace,
|
||||||
restoreWorkspaceWindow,
|
restoreWorkspaceWindow,
|
||||||
setWorkspacePrimaryWindow,
|
|
||||||
swapWorkspaceWindows,
|
swapWorkspaceWindows,
|
||||||
updateWorkspaceWindowRect,
|
updateWorkspaceWindowRect,
|
||||||
type WorkspaceBounds,
|
type WorkspaceBounds,
|
||||||
@@ -50,6 +35,13 @@ import {
|
|||||||
type WorkspaceRect,
|
type WorkspaceRect,
|
||||||
type WorkspaceState
|
type WorkspaceState
|
||||||
} from "./workspace-model";
|
} 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";
|
import { WorkspaceWindow } from "./workspace-window";
|
||||||
|
|
||||||
type WorkbenchMainFrameProps = {
|
type WorkbenchMainFrameProps = {
|
||||||
@@ -75,13 +67,24 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
|
|||||||
}, ref) {
|
}, ref) {
|
||||||
const workspaceRef = useRef<HTMLDivElement | null>(null);
|
const workspaceRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dragStateRef = useRef<WorkspaceDragState | null>(null);
|
const dragStateRef = useRef<WorkspaceDragState | null>(null);
|
||||||
|
const taskViewButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS);
|
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS);
|
||||||
const [workspace, setWorkspace] = useState(() => createInitialWorkspaceState(INITIAL_BOUNDS));
|
const [workspace, setWorkspace] = useState(() => createInitialWorkspaceState(INITIAL_BOUNDS));
|
||||||
const [desktop, setDesktop] = useState(false);
|
const [desktop, setDesktop] = useState(false);
|
||||||
const [dragState, setDragState] = useState<WorkspaceDragState | null>(null);
|
const [dragState, setDragState] = useState<WorkspaceDragState | null>(null);
|
||||||
|
const [taskViewOpen, setTaskViewOpen] = useState(false);
|
||||||
|
const [taskViewFocusWindowId, setTaskViewFocusWindowId] = useState("workspace-map");
|
||||||
const [workspaceAnnouncement, setWorkspaceAnnouncement] = useState("");
|
const [workspaceAnnouncement, setWorkspaceAnnouncement] = useState("");
|
||||||
const fixtureIndexRef = useRef(0);
|
const fixtureIndexRef = useRef(0);
|
||||||
const fixtureInstanceRef = 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(() => {
|
useEffect(() => {
|
||||||
const element = workspaceRef.current;
|
const element = workspaceRef.current;
|
||||||
@@ -100,6 +103,19 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
|
|||||||
return () => observer.disconnect();
|
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(() => {
|
useEffect(() => {
|
||||||
const mediaQuery = window.matchMedia("(min-width: 1024px)");
|
const mediaQuery = window.matchMedia("(min-width: 1024px)");
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
@@ -188,6 +204,66 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
|
|||||||
setWorkspaceAnnouncement("已取消窗口移动");
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="workbench-main-frame pointer-events-none absolute inset-0 lg:top-14"
|
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}
|
active={workspace.activeWindowId === workspace.map.id}
|
||||||
kind="map"
|
kind="map"
|
||||||
swapTarget={dragState?.targetId === workspace.map.id}
|
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))}
|
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, current.map.id))}
|
||||||
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, current.map.id, rect, bounds))}
|
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, current.map.id, rect, bounds))}
|
||||||
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, current.map.id, bounds))}
|
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, current.map.id, bounds))}
|
||||||
@@ -222,7 +308,7 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
|
|||||||
{mapOverlay}
|
{mapOverlay}
|
||||||
</WorkspaceWindow>
|
</WorkspaceWindow>
|
||||||
|
|
||||||
{workspace.analyses.map((window) => (
|
{workspace.analyses.map((window, index) => (
|
||||||
<WorkspaceWindow
|
<WorkspaceWindow
|
||||||
key={window.id}
|
key={window.id}
|
||||||
id={window.id}
|
id={window.id}
|
||||||
@@ -233,6 +319,16 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
|
|||||||
active={workspace.activeWindowId === window.id}
|
active={workspace.activeWindowId === window.id}
|
||||||
kind="analysis"
|
kind="analysis"
|
||||||
swapTarget={dragState?.targetId === window.id}
|
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))}
|
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, window.id))}
|
||||||
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, window.id, rect, bounds))}
|
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, window.id, rect, bounds))}
|
||||||
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id, bounds))}
|
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id, bounds))}
|
||||||
@@ -247,6 +343,20 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
|
|||||||
<AnalysisDocumentView document={window.document} />
|
<AnalysisDocumentView document={window.document} />
|
||||||
</WorkspaceWindow>
|
</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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -254,12 +364,32 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
|
|||||||
role="toolbar"
|
role="toolbar"
|
||||||
aria-label="工作区窗口任务栏"
|
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
|
<TaskbarButton
|
||||||
active={workspace.activeWindowId === workspace.map.id && workspace.map.mode !== "minimized"}
|
active={workspace.activeWindowId === workspace.map.id && workspace.map.mode !== "minimized"}
|
||||||
minimized={workspace.map.mode === "minimized"}
|
minimized={workspace.map.mode === "minimized"}
|
||||||
icon={<MapIcon size={15} aria-hidden="true" />}
|
icon={<MapIcon size={15} aria-hidden="true" />}
|
||||||
label="供水管网地图"
|
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) => (
|
{workspace.analyses.map((window) => (
|
||||||
<TaskbarButton
|
<TaskbarButton
|
||||||
@@ -268,21 +398,12 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
|
|||||||
minimized={window.mode === "minimized"}
|
minimized={window.mode === "minimized"}
|
||||||
icon={<Sparkles size={14} aria-hidden="true" />}
|
icon={<Sparkles size={14} aria-hidden="true" />}
|
||||||
label={window.title}
|
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>
|
<span className="ml-auto shrink-0 text-xs text-slate-500 tabular-nums">{workspace.analyses.length} / {MAX_ANALYSIS_WINDOWS} 个临时分析窗</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="sr-only" aria-live="polite" aria-atomic="true">{workspaceAnnouncement}</span>
|
<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) {
|
function getWorkspaceWindowTitle(state: WorkspaceState, windowId: string) {
|
||||||
if (windowId === state.map.id) return state.map.title;
|
if (windowId === state.map.id) return state.map.title;
|
||||||
return state.analyses.find((window) => window.id === windowId)?.title ?? "工作区窗口";
|
return state.analyses.find((window) => window.id === windowId)?.title ?? "工作区窗口";
|
||||||
|
|||||||
@@ -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<WorkspaceLayoutMode, string> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<HTMLButtonElement>, index: number) => void;
|
||||||
|
onLayoutChange: (mode: WorkspaceLayoutMode) => void;
|
||||||
|
}) {
|
||||||
|
function handleDialogKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const controls = [...event.currentTarget.querySelectorAll<HTMLButtonElement>("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 (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 z-[2147482000] cursor-default bg-[#e7edf3]/96"
|
||||||
|
onPointerDown={onClose}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="任务视图"
|
||||||
|
className="pointer-events-none absolute inset-0 z-[2147482200]"
|
||||||
|
onKeyDown={handleDialogKeyDown}
|
||||||
|
>
|
||||||
|
<div className="absolute left-7 top-4 flex items-center gap-2 text-slate-700">
|
||||||
|
<PanelsTopLeft size={17} aria-hidden="true" />
|
||||||
|
<span className="text-sm font-semibold">任务视图</span>
|
||||||
|
<span className="text-xs text-slate-500 tabular-nums">{windows.length} 个窗口</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{windows.map((window, index) => {
|
||||||
|
const rect = previewRects[index];
|
||||||
|
if (!rect) return null;
|
||||||
|
const focused = focusedWindowId === window.id;
|
||||||
|
const minimized = window.mode === "minimized";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={window.id}
|
||||||
|
type="button"
|
||||||
|
data-task-view-window-id={window.id}
|
||||||
|
aria-label={`打开${window.title}并铺满主视图${minimized ? ",当前已最小化" : ""}`}
|
||||||
|
tabIndex={0}
|
||||||
|
className={cn(
|
||||||
|
"group pointer-events-auto absolute rounded-md transition-[background-color,box-shadow,scale] duration-150 active:scale-[0.99] focus-visible:outline-hidden motion-reduce:transition-none",
|
||||||
|
focused
|
||||||
|
? "bg-blue-500/8 shadow-[0_0_0_2px_rgba(37,99,235,0.92)]"
|
||||||
|
: "[@media(hover:hover)]:hover:bg-white/45 [@media(hover:hover)]:hover:shadow-[0_0_0_2px_rgba(148,163,184,0.8)]"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: rect.x,
|
||||||
|
top: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height + 28
|
||||||
|
}}
|
||||||
|
onClick={() => onSelectWindow(window.id)}
|
||||||
|
onFocus={() => onFocusWindow(window.id)}
|
||||||
|
onPointerEnter={() => onFocusWindow(window.id)}
|
||||||
|
onKeyDown={(event) => onWindowKeyDown(event, index)}
|
||||||
|
>
|
||||||
|
<span className="absolute bottom-0 left-1/2 flex max-w-[calc(100%-0.75rem)] -translate-x-1/2 items-center gap-1.5 rounded-md bg-slate-950/88 px-2.5 py-1 text-xs font-semibold text-white shadow-[0_2px_8px_rgba(15,23,42,0.2)]">
|
||||||
|
{window.kind === "map" ? <MapIcon size={13} aria-hidden="true" /> : <Sparkles size={13} aria-hidden="true" />}
|
||||||
|
<span className="truncate">{window.title}</span>
|
||||||
|
{minimized ? <span className="shrink-0 text-[10px] text-slate-300">已最小化</span> : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="pointer-events-auto absolute bottom-4 left-1/2 flex -translate-x-1/2 items-end gap-1.5 rounded-xl bg-white px-3 py-2.5 shadow-[0_12px_32px_rgba(15,23,42,0.18)]">
|
||||||
|
<span className="mr-1 self-center text-xs font-semibold text-slate-500">贴靠布局</span>
|
||||||
|
{SNAP_LAYOUT_OPTIONS.map((option) => {
|
||||||
|
const disabled = windows.length < option.minimumWindows;
|
||||||
|
const active = layoutMode === option.mode;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.mode}
|
||||||
|
type="button"
|
||||||
|
aria-label={`${WORKSPACE_LAYOUT_LABELS[option.mode]}:${option.description}`}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={option.description}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"group/layout flex h-16 w-[76px] flex-col items-center justify-center gap-1 rounded-lg text-[11px] font-semibold transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-40",
|
||||||
|
active ? "bg-blue-50 text-blue-700" : "text-slate-600 hover:bg-slate-100"
|
||||||
|
)}
|
||||||
|
onClick={() => onLayoutChange(option.mode)}
|
||||||
|
>
|
||||||
|
<SnapLayoutDiagram mode={option.mode} active={active} />
|
||||||
|
{WORKSPACE_LAYOUT_LABELS[option.mode]}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SnapLayoutDiagram({ mode, active }: { mode: WorkspaceLayoutMode; active: boolean }) {
|
||||||
|
const cellClass = active ? "bg-blue-600" : "bg-slate-400 group-hover/layout:bg-slate-500";
|
||||||
|
|
||||||
|
if (mode === "primary") {
|
||||||
|
return (
|
||||||
|
<span className="grid h-7 w-11 grid-cols-[2fr_1fr] gap-0.5" aria-hidden="true">
|
||||||
|
<span className={cn("rounded-[2px]", cellClass)} />
|
||||||
|
<span className="grid grid-rows-2 gap-0.5">
|
||||||
|
<span className={cn("rounded-[2px]", cellClass)} />
|
||||||
|
<span className={cn("rounded-[2px]", cellClass)} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "split") {
|
||||||
|
return (
|
||||||
|
<span className="grid h-7 w-11 grid-cols-2 gap-0.5" aria-hidden="true">
|
||||||
|
<span className={cn("rounded-[2px]", cellClass)} />
|
||||||
|
<span className={cn("rounded-[2px]", cellClass)} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "grid") {
|
||||||
|
return (
|
||||||
|
<span className="grid h-7 w-11 grid-cols-2 grid-rows-2 gap-0.5" aria-hidden="true">
|
||||||
|
{Array.from({ length: 4 }, (_, index) => <span key={index} className={cn("rounded-[2px]", cellClass)} />)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "cascade") {
|
||||||
|
return (
|
||||||
|
<span className="relative h-7 w-11" aria-hidden="true">
|
||||||
|
<span className={cn("absolute left-0 top-0 h-5 w-8 rounded-[2px] opacity-50", cellClass)} />
|
||||||
|
<span className={cn("absolute bottom-0 right-0 h-5 w-8 rounded-[2px]", cellClass)} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="relative h-7 w-11" aria-hidden="true">
|
||||||
|
<span className={cn("absolute left-0 top-1 h-4 w-6 rounded-[2px] opacity-60", cellClass)} />
|
||||||
|
<span className={cn("absolute bottom-0 right-0 h-4 w-7 rounded-[2px]", cellClass)} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
type ReactNode
|
type ReactNode
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -24,6 +25,12 @@ import type { WorkspaceRect, WorkspaceWindowMode } from "./workspace-model";
|
|||||||
|
|
||||||
type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
|
type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
|
||||||
|
|
||||||
|
export type WorkspaceTaskViewPreview = {
|
||||||
|
sourceRect: WorkspaceRect;
|
||||||
|
targetRect: WorkspaceRect;
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
|
||||||
type WorkspaceWindowProps = {
|
type WorkspaceWindowProps = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -33,6 +40,7 @@ type WorkspaceWindowProps = {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
kind: "map" | "analysis";
|
kind: "map" | "analysis";
|
||||||
swapTarget?: boolean;
|
swapTarget?: boolean;
|
||||||
|
taskViewPreview?: WorkspaceTaskViewPreview;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
onRectChange: (rect: WorkspaceRect) => void;
|
onRectChange: (rect: WorkspaceRect) => void;
|
||||||
@@ -55,6 +63,7 @@ export function WorkspaceWindow({
|
|||||||
active,
|
active,
|
||||||
kind,
|
kind,
|
||||||
swapTarget = false,
|
swapTarget = false,
|
||||||
|
taskViewPreview,
|
||||||
children,
|
children,
|
||||||
onFocus,
|
onFocus,
|
||||||
onRectChange,
|
onRectChange,
|
||||||
@@ -75,13 +84,19 @@ export function WorkspaceWindow({
|
|||||||
|
|
||||||
const floating = mode === "floating";
|
const floating = mode === "floating";
|
||||||
const displayedRect = dragRect ?? rect;
|
const displayedRect = dragRect ?? rect;
|
||||||
const displayedZIndex = swapTarget ? 2_147_483_000 : zIndex;
|
const displayedZIndex = taskViewPreview
|
||||||
const style = dragRect
|
? 2_147_482_100 + taskViewPreview.index
|
||||||
|
: swapTarget
|
||||||
|
? 2_147_483_000
|
||||||
|
: zIndex;
|
||||||
|
const style: CSSProperties = taskViewPreview
|
||||||
|
? getTaskViewStyle(taskViewPreview, displayedZIndex)
|
||||||
|
: dragRect
|
||||||
? { left: displayedRect.x, top: displayedRect.y, width: displayedRect.width, height: displayedRect.height, zIndex: displayedZIndex }
|
? { left: displayedRect.x, top: displayedRect.y, width: displayedRect.width, height: displayedRect.height, zIndex: displayedZIndex }
|
||||||
: mode === "docked"
|
: mode === "docked"
|
||||||
? { inset: 0, zIndex: displayedZIndex }
|
? { inset: 0, zIndex: displayedZIndex }
|
||||||
: mode === "maximized"
|
: mode === "maximized"
|
||||||
? { inset: 8, zIndex: displayedZIndex }
|
? { inset: 0, zIndex: displayedZIndex }
|
||||||
: { left: displayedRect.x, top: displayedRect.y, width: displayedRect.width, height: displayedRect.height, zIndex: displayedZIndex };
|
: { left: displayedRect.x, top: displayedRect.y, width: displayedRect.width, height: displayedRect.height, zIndex: displayedZIndex };
|
||||||
|
|
||||||
useEffect(() => () => cleanupPointerActionRef.current?.(), []);
|
useEffect(() => () => cleanupPointerActionRef.current?.(), []);
|
||||||
@@ -201,17 +216,22 @@ export function WorkspaceWindow({
|
|||||||
id={id}
|
id={id}
|
||||||
data-workspace-window-id={id}
|
data-workspace-window-id={id}
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
|
aria-hidden={taskViewPreview ? true : undefined}
|
||||||
|
inert={taskViewPreview ? true : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/workspace-window pointer-events-auto absolute min-h-0 flex-col overflow-hidden rounded-none bg-white text-slate-900 transition-[box-shadow,opacity] duration-150 ease-out motion-reduce:transition-none",
|
"group/workspace-window absolute min-h-0 flex-col overflow-hidden rounded-none bg-white text-slate-900 transition-[transform,box-shadow,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none",
|
||||||
mode === "minimized" ? "hidden" : kind === "analysis" ? "hidden lg:flex" : "flex",
|
taskViewPreview ? "pointer-events-none flex" : "pointer-events-auto",
|
||||||
(mode !== "docked" || dragRect) && "lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]",
|
taskViewPreview
|
||||||
|
? kind === "analysis" ? "hidden lg:flex" : "flex"
|
||||||
|
: mode === "minimized" ? "hidden" : kind === "analysis" ? "hidden lg:flex" : "flex",
|
||||||
|
(taskViewPreview || mode !== "docked" || dragRect) && "lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]",
|
||||||
swapTarget && "lg:ring-2 lg:ring-blue-600 lg:ring-inset [@media(forced-colors:active)]:outline [@media(forced-colors:active)]:outline-2"
|
swapTarget && "lg:ring-2 lg:ring-blue-600 lg:ring-inset [@media(forced-colors:active)]:outline [@media(forced-colors:active)]:outline-2"
|
||||||
)}
|
)}
|
||||||
style={style}
|
style={style}
|
||||||
onPointerDownCapture={handleWindowPointerDownCapture}
|
onPointerDownCapture={handleWindowPointerDownCapture}
|
||||||
>
|
>
|
||||||
<div className="relative min-h-0 flex-1 overflow-hidden">{children}</div>
|
<div className="relative min-h-0 flex-1 overflow-hidden">{children}</div>
|
||||||
{active ? (
|
{active && !taskViewPreview ? (
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -263,7 +283,7 @@ export function WorkspaceWindow({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : null}
|
) : null}
|
||||||
{swapTarget ? (
|
{swapTarget && !taskViewPreview ? (
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute inset-2 z-30 grid place-items-center rounded-md bg-blue-50/90 opacity-100 ring-2 ring-inset ring-blue-600 transition-opacity duration-150 motion-reduce:transition-none"
|
className="pointer-events-none absolute inset-2 z-30 grid place-items-center rounded-md bg-blue-50/90 opacity-100 ring-2 ring-inset ring-blue-600 transition-opacity duration-150 motion-reduce:transition-none"
|
||||||
@@ -273,7 +293,7 @@ export function WorkspaceWindow({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{floating ? RESIZE_DIRECTIONS.map((direction) => (
|
{floating && !taskViewPreview ? RESIZE_DIRECTIONS.map((direction) => (
|
||||||
<span
|
<span
|
||||||
key={direction}
|
key={direction}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -287,6 +307,31 @@ export function WorkspaceWindow({
|
|||||||
|
|
||||||
const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
|
const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
|
||||||
|
|
||||||
|
function getTaskViewStyle(
|
||||||
|
preview: WorkspaceTaskViewPreview,
|
||||||
|
zIndex: number
|
||||||
|
): CSSProperties {
|
||||||
|
const { sourceRect, targetRect } = preview;
|
||||||
|
const scale = Math.min(
|
||||||
|
targetRect.width / sourceRect.width,
|
||||||
|
targetRect.height / sourceRect.height
|
||||||
|
);
|
||||||
|
const renderedWidth = sourceRect.width * scale;
|
||||||
|
const renderedHeight = sourceRect.height * scale;
|
||||||
|
const targetLeft = targetRect.x + (targetRect.width - renderedWidth) / 2;
|
||||||
|
const targetTop = targetRect.y + (targetRect.height - renderedHeight) / 2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: sourceRect.x,
|
||||||
|
top: sourceRect.y,
|
||||||
|
width: sourceRect.width,
|
||||||
|
height: sourceRect.height,
|
||||||
|
transform: `translate(${targetLeft - sourceRect.x}px, ${targetTop - sourceRect.y}px) scale(${scale})`,
|
||||||
|
transformOrigin: "top left",
|
||||||
|
zIndex
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resizeRect(rect: WorkspaceRect, direction: "move" | ResizeDirection, deltaX: number, deltaY: number): WorkspaceRect {
|
function resizeRect(rect: WorkspaceRect, direction: "move" | ResizeDirection, deltaX: number, deltaY: number): WorkspaceRect {
|
||||||
if (direction === "move") return { ...rect, x: rect.x + deltaX, y: rect.y + deltaY };
|
if (direction === "move") return { ...rect, x: rect.x + deltaX, y: rect.y + deltaY };
|
||||||
const west = direction.includes("w");
|
const west = direction.includes("w");
|
||||||
|
|||||||
@@ -87,6 +87,37 @@ test("the test control cycles through three trusted document profiles", async ({
|
|||||||
await expect(page.getByText("3 / 6 个临时分析窗")).toBeVisible();
|
await expect(page.getByText("3 / 6 个临时分析窗")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("task view shows every window and fills the workspace with the selected one", async ({ page }) => {
|
||||||
|
await prepareMainFrame(page);
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
const testButton = page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first();
|
||||||
|
|
||||||
|
await testButton.click();
|
||||||
|
await testButton.click();
|
||||||
|
await openWindowOperationMenu(page, "北辰分区压力异常诊断");
|
||||||
|
await page.getByRole("menuitem", { name: "最小化" }).click();
|
||||||
|
await page.getByRole("button", { name: "任务视图" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("dialog", { name: "任务视图" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /打开供水管网地图并铺满主视图/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /打开北辰分区压力异常诊断并铺满主视图,当前已最小化/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /打开供水服务分区平衡分析并铺满主视图/ })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /打开供水服务分区平衡分析并铺满主视图/ }).click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "任务视图" })).toHaveCount(0);
|
||||||
|
const workspaceBox = await page.locator("#main-workspace").boundingBox();
|
||||||
|
const selectedBox = await page.locator("section[aria-label='供水服务分区平衡分析']").boundingBox();
|
||||||
|
expect(workspaceBox).not.toBeNull();
|
||||||
|
expect(selectedBox).not.toBeNull();
|
||||||
|
expect(Math.abs(selectedBox!.width - workspaceBox!.width)).toBeLessThanOrEqual(2);
|
||||||
|
expect(Math.abs(selectedBox!.height - workspaceBox!.height)).toBeLessThanOrEqual(2);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "任务视图" }).click();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByRole("dialog", { name: "任务视图" })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "任务视图" })).toBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
test("temporary documents survive a mobile breakpoint round trip", async ({ page }) => {
|
test("temporary documents survive a mobile breakpoint round trip", async ({ page }) => {
|
||||||
await prepareMainFrame(page);
|
await prepareMainFrame(page);
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
@@ -113,8 +144,7 @@ test("layout presets arrange transient windows and keep the map visible", async
|
|||||||
await testButton.click();
|
await testButton.click();
|
||||||
await testButton.click();
|
await testButton.click();
|
||||||
await testButton.click();
|
await testButton.click();
|
||||||
await page.getByRole("button", { name: "设置窗口布局,当前自由排列" }).click();
|
await applyTaskViewLayout(page, "主次");
|
||||||
await page.getByRole("menuitemradio", { name: /主次/ }).click();
|
|
||||||
|
|
||||||
const workspace = page.locator("#main-workspace");
|
const workspace = page.locator("#main-workspace");
|
||||||
await expect(workspace).toHaveAttribute("data-layout-mode", "primary");
|
await expect(workspace).toHaveAttribute("data-layout-mode", "primary");
|
||||||
@@ -140,8 +170,7 @@ test("dragging a small window onto the primary window swaps their complete slots
|
|||||||
await prepareMainFrame(page);
|
await prepareMainFrame(page);
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
await page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first().click();
|
await page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first().click();
|
||||||
await page.getByRole("button", { name: "设置窗口布局,当前自由排列" }).click();
|
await applyTaskViewLayout(page, "主次");
|
||||||
await page.getByRole("menuitemradio", { name: /主次/ }).click();
|
|
||||||
|
|
||||||
const mapWindow = page.locator("#workspace-map");
|
const mapWindow = page.locator("#workspace-map");
|
||||||
const analysisWindow = page.locator("section[aria-label='北辰分区压力异常诊断']");
|
const analysisWindow = page.locator("section[aria-label='北辰分区压力异常诊断']");
|
||||||
@@ -177,8 +206,7 @@ test("dropping on blank workspace exits auto layout and Escape cancels a drag",
|
|||||||
await prepareMainFrame(page);
|
await prepareMainFrame(page);
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
await page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first().click();
|
await page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first().click();
|
||||||
await page.getByRole("button", { name: "设置窗口布局,当前自由排列" }).click();
|
await applyTaskViewLayout(page, "双栏");
|
||||||
await page.getByRole("menuitemradio", { name: /双栏/ }).click();
|
|
||||||
|
|
||||||
const workspace = page.locator("#main-workspace");
|
const workspace = page.locator("#main-workspace");
|
||||||
const analysisWindow = page.locator("section[aria-label='北辰分区压力异常诊断']");
|
const analysisWindow = page.locator("section[aria-label='北辰分区压力异常诊断']");
|
||||||
@@ -214,6 +242,11 @@ async function openWindowOperationMenu(page: Page, title: string) {
|
|||||||
await page.getByRole("button", { name: `打开${title}窗口操作` }).click();
|
await page.getByRole("button", { name: `打开${title}窗口操作` }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applyTaskViewLayout(page: Page, layoutName: string) {
|
||||||
|
await page.getByRole("button", { name: "任务视图" }).click();
|
||||||
|
await page.getByRole("button", { name: new RegExp(`^${layoutName}:`) }).click();
|
||||||
|
}
|
||||||
|
|
||||||
async function prepareMainFrame(page: Page) {
|
async function prepareMainFrame(page: Page) {
|
||||||
await mockAgentApi(page);
|
await mockAgentApi(page);
|
||||||
await page.route("**/runtime-config.js", async (route) => {
|
await page.route("**/runtime-config.js", async (route) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user