feat: add workspace layout and window swapping

This commit is contained in:
2026-08-19 13:52:44 +08:00
parent f58e0a70f4
commit 1068304da5
5 changed files with 936 additions and 132 deletions
@@ -3,10 +3,15 @@ import {
CalendarClock, CalendarClock,
ChevronsLeft, ChevronsLeft,
ChevronsRight, ChevronsRight,
Columns2,
FlaskConical, FlaskConical,
Grid2X2,
Layers3, Layers3,
LayoutDashboard,
LayoutTemplate,
Map as MapIcon, Map as MapIcon,
PanelLeft, PanelLeft,
PanelsTopLeft,
SlidersHorizontal, SlidersHorizontal,
Sparkles Sparkles
} from "lucide-react"; } from "lucide-react";
@@ -20,10 +25,21 @@ import {
} 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 {
MAX_ANALYSIS_WINDOWS, MAX_ANALYSIS_WINDOWS,
applyWorkspaceLayout,
closeAnalysisWindow, closeAnalysisWindow,
createInitialWorkspaceState, createInitialWorkspaceState,
focusWorkspaceWindow, focusWorkspaceWindow,
@@ -32,8 +48,13 @@ import {
openAnalysisDocument, openAnalysisDocument,
resizeWorkspace, resizeWorkspace,
restoreWorkspaceWindow, restoreWorkspaceWindow,
setWorkspacePrimaryWindow,
swapWorkspaceWindows,
updateWorkspaceWindowRect, updateWorkspaceWindowRect,
type WorkspaceBounds type WorkspaceBounds,
type WorkspaceLayoutMode,
type WorkspaceRect,
type WorkspaceState
} from "./workspace-model"; } from "./workspace-model";
import { WorkspaceWindow } from "./workspace-window"; 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 }; const INITIAL_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
type WorkspaceDragState = {
sourceId: string;
sourceRect: WorkspaceRect;
targetId: string | null;
};
export function WorkbenchMainFrame({ export function WorkbenchMainFrame({
renderAgentPanel, renderAgentPanel,
conditionsPanel, conditionsPanel,
@@ -76,9 +103,12 @@ export function WorkbenchMainFrame({
onNavigationCollapsedChange onNavigationCollapsedChange
}: WorkbenchMainFrameProps) { }: WorkbenchMainFrameProps) {
const workspaceRef = useRef<HTMLDivElement | null>(null); const workspaceRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<WorkspaceDragState | 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 [workspaceAnnouncement, setWorkspaceAnnouncement] = useState("");
const fixtureIndexRef = useRef(0); const fixtureIndexRef = useRef(0);
const fixtureInstanceRef = useRef(0); const fixtureInstanceRef = useRef(0);
@@ -124,6 +154,67 @@ export function WorkbenchMainFrame({
setWorkspace((current) => openAnalysisDocument(current, document, bounds)); setWorkspace((current) => openAnalysisDocument(current, document, bounds));
}, [bounds, workspace.analyses.length]); }, [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<HTMLElement>("[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; const navigationWidth = navigationCollapsed ? 48 : 368;
return ( return (
<div <div
@@ -226,6 +317,7 @@ export function WorkbenchMainFrame({
<div <div
ref={workspaceRef} ref={workspaceRef}
id="main-workspace" id="main-workspace"
data-layout-mode={workspace.layout.mode}
className="pointer-events-auto absolute inset-0 left-0 overflow-hidden bg-[#dce3ea] lg:bottom-10 lg:left-[var(--workbench-frame-navigation-width)]" className="pointer-events-auto absolute inset-0 left-0 overflow-hidden bg-[#dce3ea] lg:bottom-10 lg:left-[var(--workbench-frame-navigation-width)]"
> >
<WorkspaceWindow <WorkspaceWindow
@@ -236,11 +328,16 @@ export function WorkbenchMainFrame({
zIndex={workspace.map.zIndex} zIndex={workspace.map.zIndex}
active={workspace.activeWindowId === workspace.map.id} active={workspace.activeWindowId === workspace.map.id}
kind="map" kind="map"
swapTarget={dragState?.targetId === workspace.map.id}
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))} onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, current.map.id, bounds))}
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, current.map.id))} onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, current.map.id))}
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, current.map.id, bounds))} 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} {mapContent}
{mapOverlay} {mapOverlay}
@@ -256,12 +353,17 @@ export function WorkbenchMainFrame({
zIndex={window.zIndex} zIndex={window.zIndex}
active={workspace.activeWindowId === window.id} active={workspace.activeWindowId === window.id}
kind="analysis" kind="analysis"
swapTarget={dragState?.targetId === window.id}
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))} onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id, bounds))}
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, window.id))} onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, window.id))}
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, window.id, bounds))} 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}
> >
<AnalysisDocumentView document={window.document} /> <AnalysisDocumentView document={window.document} />
</WorkspaceWindow> </WorkspaceWindow>
@@ -290,8 +392,21 @@ export function WorkbenchMainFrame({
onClick={() => setWorkspace((current) => window.mode === "minimized" ? restoreWorkspaceWindow(current, window.id, bounds) : focusWorkspaceWindow(current, window.id))} onClick={() => 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>
{!desktop ? <span className="sr-only"></span> : null} {!desktop ? <span className="sr-only"></span> : null}
</div> </div>
); );
@@ -315,6 +430,97 @@ 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 ?? "工作区窗口";
}
function NavigationPanel({ active, children }: { active: boolean; children: ReactNode }) { function NavigationPanel({ active, children }: { active: boolean; children: ReactNode }) {
return ( return (
<div hidden={!active} className="absolute inset-0 min-h-0 overflow-hidden"> <div hidden={!active} className="absolute inset-0 min-h-0 overflow-hidden">
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
MAX_ANALYSIS_WINDOWS, MAX_ANALYSIS_WINDOWS,
applyWorkspaceLayout,
closeAnalysisWindow, closeAnalysisWindow,
createInitialWorkspaceState, createInitialWorkspaceState,
maximizeWorkspaceWindow, maximizeWorkspaceWindow,
@@ -8,8 +9,11 @@ import {
openAnalysisDocument, openAnalysisDocument,
resizeWorkspace, resizeWorkspace,
restoreWorkspaceWindow, restoreWorkspaceWindow,
setWorkspacePrimaryWindow,
swapWorkspaceWindows,
updateWorkspaceWindowRect, updateWorkspaceWindowRect,
type AnalysisDocument type AnalysisDocument,
type WorkspaceState
} from "./workspace-model"; } from "./workspace-model";
const bounds = { width: 1180, height: 760 }; const bounds = { width: 1180, height: 760 };
@@ -24,6 +28,19 @@ function documentAt(index: number): AnalysisDocument {
}; };
} }
function openDocuments(count: number) {
return Array.from({ length: count }, (_, index) => index + 1).reduce(
(current, index) => openAnalysisDocument(current, documentAt(index), bounds),
createInitialWorkspaceState(bounds)
);
}
function visibleIds(state: WorkspaceState) {
return [state.map, ...state.analyses]
.filter((window) => window.mode !== "minimized")
.map((window) => window.id);
}
describe("workspace window lifecycle", () => { describe("workspace window lifecycle", () => {
it("minimizes the map for the first analysis and restores it after the last window closes", () => { it("minimizes the map for the first analysis and restores it after the last window closes", () => {
const initial = createInitialWorkspaceState(bounds); const initial = createInitialWorkspaceState(bounds);
@@ -59,10 +76,7 @@ describe("workspace window lifecycle", () => {
}); });
it("caps transient analysis windows without deleting an existing result", () => { it("caps transient analysis windows without deleting an existing result", () => {
const state = Array.from({ length: MAX_ANALYSIS_WINDOWS }, (_, index) => index + 1).reduce( const state = openDocuments(MAX_ANALYSIS_WINDOWS);
(current, index) => openAnalysisDocument(current, documentAt(index), bounds),
createInitialWorkspaceState(bounds)
);
const overflow = openAnalysisDocument(state, documentAt(99), bounds); const overflow = openAnalysisDocument(state, documentAt(99), bounds);
expect(overflow).toBe(state); expect(overflow).toBe(state);
@@ -86,3 +100,131 @@ describe("workspace window lifecycle", () => {
expect(resized.map.restoreRect.y + resized.map.restoreRect.height).toBeLessThanOrEqual(588); expect(resized.map.restoreRect.y + resized.map.restoreRect.height).toBeLessThanOrEqual(588);
}); });
}); });
describe("workspace layout presets", () => {
it("places the active window first, keeps the map visible and minimizes primary-layout overflow", () => {
const arranged = applyWorkspaceLayout(openDocuments(5), "primary", bounds);
expect(arranged.layout.mode).toBe("primary");
expect(arranged.layout.orderedWindowIds[0]).toBe("analysis-5");
expect(visibleIds(arranged)).toEqual(["workspace-map", "analysis-4", "analysis-5"]);
expect(arranged.analyses.filter((window) => window.minimizedByLayout)).toHaveLength(3);
const primary = arranged.analyses.find((window) => window.id === "analysis-5");
expect(primary?.rect.width).toBeGreaterThan(arranged.map.rect.width);
expect(primary?.rect.height).toBe(bounds.height - 16);
});
it("restores every window into a bounded grid", () => {
const arranged = applyWorkspaceLayout(openDocuments(6), "grid", bounds);
const windows = [arranged.map, ...arranged.analyses];
expect(windows).toHaveLength(7);
expect(windows.every((window) => window.mode === "floating")).toBe(true);
for (const window of windows) {
expect(window.rect.x).toBeGreaterThanOrEqual(8);
expect(window.rect.y).toBeGreaterThanOrEqual(8);
expect(window.rect.x + window.rect.width).toBeLessThanOrEqual(bounds.width - 8);
expect(window.rect.y + window.rect.height).toBeLessThanOrEqual(bounds.height - 8);
}
});
it("uses two visible slots for split and restores all windows when changing presets", () => {
const primary = applyWorkspaceLayout(openDocuments(4), "primary", bounds);
const split = applyWorkspaceLayout(primary, "split", bounds);
expect(visibleIds(split)).toHaveLength(2);
expect(visibleIds(split)).toContain("workspace-map");
expect(split.analyses.filter((window) => window.minimizedByLayout)).toHaveLength(3);
const grid = applyWorkspaceLayout(split, "grid", bounds);
expect(visibleIds(grid)).toHaveLength(5);
});
it("auto-inserts a new Agent result into the current layout", () => {
const primary = applyWorkspaceLayout(openDocuments(2), "primary", bounds);
const opened = openAnalysisDocument(primary, documentAt(3), bounds);
expect(opened.layout.mode).toBe("primary");
expect(opened.layout.orderedWindowIds[0]).toBe("analysis-3");
expect(opened.activeWindowId).toBe("analysis-3");
expect(opened.map.mode).not.toBe("minimized");
});
it("reflows after manual minimize, taskbar restore and workspace resize", () => {
const arranged = applyWorkspaceLayout(openDocuments(4), "primary", bounds);
const minimized = minimizeWorkspaceWindow(arranged, "analysis-4", bounds);
const replacement = minimized.analyses.find((window) => window.id === "analysis-3");
expect(minimized.analyses.find((window) => window.id === "analysis-4")?.minimizedByLayout).toBe(false);
expect(replacement?.mode).toBe("floating");
const restored = restoreWorkspaceWindow(minimized, "analysis-4", bounds);
expect(restored.layout.orderedWindowIds[0]).toBe("analysis-4");
expect(restored.analyses.find((window) => window.id === "analysis-4")?.mode).toBe("floating");
const resized = resizeWorkspace(restored, { width: 900, height: 620 });
expect(resized.analyses.find((window) => window.id === "analysis-4")?.rect.height).toBe(604);
});
it("supports cascade layout and an explicit primary-window command", () => {
const cascade = applyWorkspaceLayout(openDocuments(3), "cascade", bounds);
expect(visibleIds(cascade)).toHaveLength(4);
expect(cascade.analyses[0]?.rect.x).toBeGreaterThan(cascade.analyses[1]?.rect.x ?? 0);
const primary = applyWorkspaceLayout(cascade, "primary", bounds);
const promoted = setWorkspacePrimaryWindow(primary, "analysis-1", bounds);
expect(promoted.layout.orderedWindowIds[0]).toBe("analysis-1");
expect(promoted.analyses.find((window) => window.id === "analysis-1")?.rect.width).toBeGreaterThan(promoted.map.rect.width);
});
});
describe("workspace window exchange", () => {
it("swaps layout slots and preserves the order after resize", () => {
const arranged = applyWorkspaceLayout(openDocuments(2), "primary", bounds);
const mapRect = arranged.map.rect;
const primaryRect = arranged.analyses.find((window) => window.id === "analysis-2")?.rect;
const swapped = swapWorkspaceWindows(arranged, "workspace-map", "analysis-2", bounds);
expect(swapped.map.rect).toEqual(primaryRect);
expect(swapped.analyses.find((window) => window.id === "analysis-2")?.rect).toEqual(mapRect);
const resized = resizeWorkspace(swapped, { width: 1000, height: 700 });
expect(resized.layout.orderedWindowIds.indexOf("workspace-map")).toBeLessThan(
resized.layout.orderedWindowIds.indexOf("analysis-2")
);
expect(resized.map.rect.width).toBeGreaterThan(resized.analyses.find((window) => window.id === "analysis-2")!.rect.width);
});
it("exchanges full window roles and rectangles in free layout", () => {
const initial = createInitialWorkspaceState(bounds);
const mapRect = initial.map.rect;
const opened = openAnalysisDocument(initial, documentAt(1), bounds);
const analysisRect = opened.analyses[0]!.rect;
const restoredMap = { ...opened, map: { ...opened.map, mode: "docked" as const } };
const swapped = swapWorkspaceWindows(restoredMap, "analysis-1", "workspace-map", bounds);
expect(swapped.analyses[0]?.mode).toBe("docked");
expect(swapped.analyses[0]?.rect).toEqual(mapRect);
expect(swapped.map.mode).toBe("floating");
expect(swapped.map.rect).toEqual(analysisRect);
});
it("exits automatic layout when a window is placed in blank workspace", () => {
const arranged = applyWorkspaceLayout(openDocuments(2), "grid", bounds);
const moved = updateWorkspaceWindowRect(
arranged,
"analysis-2",
{ x: 90, y: 70, width: 420, height: 360 },
bounds
);
expect(moved.layout.mode).toBe("free");
expect(moved.analyses.find((window) => window.id === "analysis-2")?.rect).toEqual({
x: 90,
y: 70,
width: 420,
height: 360
});
});
});
@@ -13,6 +13,7 @@ export type WorkspaceBounds = {
}; };
export type WorkspaceWindowMode = "docked" | "floating" | "maximized" | "minimized"; export type WorkspaceWindowMode = "docked" | "floating" | "maximized" | "minimized";
export type WorkspaceLayoutMode = "free" | "primary" | "split" | "grid" | "cascade";
export type AnalysisMetric = { export type AnalysisMetric = {
label: string; label: string;
@@ -85,25 +86,25 @@ export type AnalysisDocument = {
blocks: AnalysisBlock[]; blocks: AnalysisBlock[];
}; };
export type AnalysisWorkspaceWindow = { type WorkspaceWindowState = {
id: string;
kind: "agent-analysis";
title: string;
mode: Exclude<WorkspaceWindowMode, "docked">;
rect: WorkspaceRect;
restoreRect: WorkspaceRect;
zIndex: number;
document: AnalysisDocument;
};
export type MapWorkspaceWindow = {
id: "workspace-map";
kind: "map";
title: string;
mode: WorkspaceWindowMode; mode: WorkspaceWindowMode;
rect: WorkspaceRect; rect: WorkspaceRect;
restoreRect: WorkspaceRect; restoreRect: WorkspaceRect;
zIndex: number; zIndex: number;
minimizedByLayout: boolean;
};
export type AnalysisWorkspaceWindow = WorkspaceWindowState & {
id: string;
kind: "agent-analysis";
title: string;
document: AnalysisDocument;
};
export type MapWorkspaceWindow = WorkspaceWindowState & {
id: "workspace-map";
kind: "map";
title: string;
}; };
export type WorkspaceState = { export type WorkspaceState = {
@@ -111,25 +112,34 @@ export type WorkspaceState = {
analyses: AnalysisWorkspaceWindow[]; analyses: AnalysisWorkspaceWindow[];
activeWindowId: string; activeWindowId: string;
nextZIndex: number; nextZIndex: number;
layout: {
mode: WorkspaceLayoutMode;
orderedWindowIds: string[];
};
}; };
const FALLBACK_BOUNDS: WorkspaceBounds = { width: 960, height: 680 }; const FALLBACK_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
const MAP_WINDOW_ID = "workspace-map";
const LAYOUT_INSET = 8;
const LAYOUT_GAP = 8;
export function createInitialWorkspaceState(bounds: WorkspaceBounds = FALLBACK_BOUNDS): WorkspaceState { export function createInitialWorkspaceState(bounds: WorkspaceBounds = FALLBACK_BOUNDS): WorkspaceState {
const mapRect = getDefaultMapRect(bounds); const mapRect = getDefaultMapRect(bounds);
return { return {
map: { map: {
id: "workspace-map", id: MAP_WINDOW_ID,
kind: "map", kind: "map",
title: "供水管网地图", title: "供水管网地图",
mode: "docked", mode: "docked",
rect: mapRect, rect: mapRect,
restoreRect: mapRect, restoreRect: mapRect,
zIndex: 1 zIndex: 1,
minimizedByLayout: false
}, },
analyses: [], analyses: [],
activeWindowId: "workspace-map", activeWindowId: MAP_WINDOW_ID,
nextZIndex: 2 nextZIndex: 2,
layout: { mode: "free", orderedWindowIds: [MAP_WINDOW_ID] }
}; };
} }
@@ -138,9 +148,7 @@ export function openAnalysisDocument(
document: AnalysisDocument, document: AnalysisDocument,
bounds: WorkspaceBounds bounds: WorkspaceBounds
): WorkspaceState { ): WorkspaceState {
if (state.analyses.length >= MAX_ANALYSIS_WINDOWS) { if (state.analyses.length >= MAX_ANALYSIS_WINDOWS) return state;
return state;
}
const rect = getDefaultAnalysisRect(bounds, state.analyses.length); const rect = getDefaultAnalysisRect(bounds, state.analyses.length);
const window: AnalysisWorkspaceWindow = { const window: AnalysisWorkspaceWindow = {
@@ -151,93 +159,92 @@ export function openAnalysisDocument(
rect, rect,
restoreRect: rect, restoreRect: rect,
zIndex: state.nextZIndex, zIndex: state.nextZIndex,
minimizedByLayout: false,
document document
}; };
const opened: WorkspaceState = {
return { ...state,
map: { ...state.map, mode: "minimized" },
analyses: [...state.analyses, window], analyses: [...state.analyses, window],
activeWindowId: window.id, activeWindowId: window.id,
nextZIndex: state.nextZIndex + 1 nextZIndex: state.nextZIndex + 1,
layout: {
...state.layout,
orderedWindowIds: uniqueIds([window.id, MAP_WINDOW_ID, ...state.layout.orderedWindowIds])
}
}; };
if (state.layout.mode !== "free") return arrangeWorkspaceLayout(opened, bounds);
return { ...opened, map: { ...opened.map, mode: "minimized", minimizedByLayout: false } };
} }
export function closeAnalysisWindow(state: WorkspaceState, windowId: string): WorkspaceState { export function closeAnalysisWindow(
state: WorkspaceState,
windowId: string,
bounds: WorkspaceBounds = FALLBACK_BOUNDS
): WorkspaceState {
const analyses = state.analyses.filter((window) => window.id !== windowId); const analyses = state.analyses.filter((window) => window.id !== windowId);
if (analyses.length === state.analyses.length) { if (analyses.length === state.analyses.length) return state;
return state;
}
if (analyses.length === 0) { if (analyses.length === 0) {
return { return {
...state, ...state,
map: { ...state.map, mode: "docked", zIndex: state.nextZIndex }, map: {
...state.map,
mode: "docked",
minimizedByLayout: false,
zIndex: state.nextZIndex
},
analyses, analyses,
activeWindowId: state.map.id, activeWindowId: MAP_WINDOW_ID,
nextZIndex: state.nextZIndex + 1 nextZIndex: state.nextZIndex + 1,
layout: { mode: "free", orderedWindowIds: [MAP_WINDOW_ID] }
}; };
} }
const nextActive = [...analyses].sort((a, b) => b.zIndex - a.zIndex)[0]; const orderedWindowIds = state.layout.orderedWindowIds.filter((id) => id !== windowId);
return { const nextActive = state.activeWindowId === windowId
? orderedWindowIds.find((id) => getWindowById({ ...state, analyses }, id)?.mode !== "minimized")
: state.activeWindowId;
const closed: WorkspaceState = {
...state, ...state,
analyses, analyses,
activeWindowId: nextActive?.id ?? state.map.id activeWindowId: nextActive ?? MAP_WINDOW_ID,
layout: { ...state.layout, orderedWindowIds }
}; };
return state.layout.mode === "free" ? closed : arrangeWorkspaceLayout(closed, bounds);
} }
export function focusWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState { export function focusWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
if (windowId === state.map.id) { if (!getWindowById(state, windowId)) return state;
return { return updateWindowById(
...state, { ...state, activeWindowId: windowId, nextZIndex: state.nextZIndex + 1 },
map: { ...state.map, zIndex: state.nextZIndex }, windowId,
activeWindowId: windowId, (window) => ({ ...window, zIndex: state.nextZIndex })
nextZIndex: state.nextZIndex + 1 );
};
}
if (!state.analyses.some((window) => window.id === windowId)) {
return state;
}
return {
...state,
analyses: state.analyses.map((window) =>
window.id === windowId ? { ...window, zIndex: state.nextZIndex } : window
),
activeWindowId: windowId,
nextZIndex: state.nextZIndex + 1
};
} }
export function minimizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState { export function minimizeWorkspaceWindow(
if (windowId === state.map.id) { state: WorkspaceState,
return { ...state, map: { ...state.map, mode: "minimized" } }; windowId: string,
} bounds: WorkspaceBounds = FALLBACK_BOUNDS
): WorkspaceState {
return { if (!getWindowById(state, windowId)) return state;
...state, const minimized = updateWindowById(state, windowId, (window) => ({
analyses: state.analyses.map((window) => ...window,
window.id === windowId ? { ...window, mode: "minimized" } : window mode: "minimized",
) minimizedByLayout: false
}; }));
return state.layout.mode === "free" ? minimized : arrangeWorkspaceLayout(minimized, bounds);
} }
export function maximizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState { export function maximizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
if (windowId === state.map.id) { if (!getWindowById(state, windowId)) return state;
return focusWorkspaceWindow(
{ ...state, map: { ...state.map, mode: "maximized" } },
windowId
);
}
return focusWorkspaceWindow( return focusWorkspaceWindow(
{ updateWindowById(state, windowId, (window) => ({
...state, ...window,
analyses: state.analyses.map((window) => mode: "maximized",
window.id === windowId ? { ...window, mode: "maximized" } : window minimizedByLayout: false
) })),
},
windowId windowId
); );
} }
@@ -247,27 +254,33 @@ export function restoreWorkspaceWindow(
windowId: string, windowId: string,
bounds: WorkspaceBounds bounds: WorkspaceBounds
): WorkspaceState { ): WorkspaceState {
if (windowId === state.map.id) { const window = getWindowById(state, windowId);
const rect = clampWorkspaceRect(state.map.restoreRect, bounds, "map"); if (!window) return state;
return focusWorkspaceWindow(
{ ...state, map: { ...state.map, mode: "floating", rect } }, if (state.layout.mode !== "free") {
windowId const restored = updateWindowById(
{
...state,
layout: {
...state.layout,
orderedWindowIds: uniqueIds([windowId, ...state.layout.orderedWindowIds])
}
},
windowId,
(current) => ({ ...current, mode: "floating", minimizedByLayout: false })
); );
return focusWorkspaceWindow(arrangeWorkspaceLayout(restored, bounds), windowId);
} }
const kind = windowId === MAP_WINDOW_ID ? "map" : "analysis";
const rect = clampWorkspaceRect(window.restoreRect, bounds, kind);
return focusWorkspaceWindow( return focusWorkspaceWindow(
{ updateWindowById(state, windowId, (current) => ({
...state, ...current,
analyses: state.analyses.map((window) => mode: "floating",
window.id === windowId rect,
? { minimizedByLayout: false
...window, })),
mode: "floating",
rect: clampWorkspaceRect(window.restoreRect, bounds, "analysis")
}
: window
)
},
windowId windowId
); );
} }
@@ -278,25 +291,110 @@ export function updateWorkspaceWindowRect(
rect: WorkspaceRect, rect: WorkspaceRect,
bounds: WorkspaceBounds bounds: WorkspaceBounds
): WorkspaceState { ): WorkspaceState {
if (windowId === state.map.id) { if (!getWindowById(state, windowId)) return state;
const nextRect = clampWorkspaceRect(rect, bounds, "map"); const kind = windowId === MAP_WINDOW_ID ? "map" : "analysis";
return { const nextRect = clampWorkspaceRect(rect, bounds, kind);
return updateWindowById(
{
...state, ...state,
map: { ...state.map, mode: "floating", rect: nextRect, restoreRect: nextRect } layout: { ...state.layout, mode: "free" }
}; },
windowId,
(window) => ({
...window,
mode: "floating",
rect: nextRect,
restoreRect: nextRect,
minimizedByLayout: false
})
);
}
export function applyWorkspaceLayout(
state: WorkspaceState,
mode: WorkspaceLayoutMode,
bounds: WorkspaceBounds
): WorkspaceState {
if (mode === "free") {
return { ...state, layout: { ...state.layout, mode } };
} }
return { const orderedWindowIds = uniqueIds([
state.activeWindowId,
MAP_WINDOW_ID,
...allWorkspaceWindows(state).sort((a, b) => b.zIndex - a.zIndex).map((window) => window.id),
...state.layout.orderedWindowIds
]);
const restored = mapAllWorkspaceWindows(
{
...state,
layout: { mode, orderedWindowIds }
},
(window) => ({ ...window, mode: "floating", minimizedByLayout: false })
);
return arrangeWorkspaceLayout(restored, bounds, true);
}
export function setWorkspacePrimaryWindow(
state: WorkspaceState,
windowId: string,
bounds: WorkspaceBounds
): WorkspaceState {
if (!getWindowById(state, windowId) || state.layout.mode === "free") return state;
const next = {
...state, ...state,
analyses: state.analyses.map((window) => { layout: {
if (window.id !== windowId) return window; ...state.layout,
const nextRect = clampWorkspaceRect(rect, bounds, "analysis"); orderedWindowIds: uniqueIds([windowId, ...state.layout.orderedWindowIds])
return { ...window, mode: "floating", rect: nextRect, restoreRect: nextRect }; }
})
}; };
return focusWorkspaceWindow(arrangeWorkspaceLayout(next, bounds), windowId);
}
export function swapWorkspaceWindows(
state: WorkspaceState,
sourceId: string,
targetId: string,
bounds: WorkspaceBounds
): WorkspaceState {
const source = getWindowById(state, sourceId);
const target = getWindowById(state, targetId);
if (!source || !target || sourceId === targetId || source.mode === "minimized" || target.mode === "minimized") {
return state;
}
if (state.layout.mode !== "free") {
const order = normalizeWindowOrder(state);
const sourceIndex = order.indexOf(sourceId);
const targetIndex = order.indexOf(targetId);
if (sourceIndex < 0 || targetIndex < 0) return state;
[order[sourceIndex], order[targetIndex]] = [order[targetIndex], order[sourceIndex]];
const swapped = { ...state, layout: { ...state.layout, orderedWindowIds: order } };
return focusWorkspaceWindow(arrangeWorkspaceLayout(swapped, bounds), sourceId);
}
const swapped = updateWindowById(
updateWindowById(state, sourceId, (window) => ({
...window,
mode: target.mode,
rect: target.rect,
restoreRect: target.restoreRect,
minimizedByLayout: false
})),
targetId,
(window) => ({
...window,
mode: source.mode,
rect: source.rect,
restoreRect: source.restoreRect,
minimizedByLayout: false
})
);
return focusWorkspaceWindow(swapped, sourceId);
} }
export function resizeWorkspace(state: WorkspaceState, bounds: WorkspaceBounds): WorkspaceState { export function resizeWorkspace(state: WorkspaceState, bounds: WorkspaceBounds): WorkspaceState {
if (state.layout.mode !== "free") return arrangeWorkspaceLayout(state, bounds);
return { return {
...state, ...state,
map: { map: {
@@ -317,8 +415,8 @@ export function clampWorkspaceRect(
bounds: WorkspaceBounds, bounds: WorkspaceBounds,
kind: "map" | "analysis" kind: "map" | "analysis"
): WorkspaceRect { ): WorkspaceRect {
const minimumWidth = kind === "map" ? 360 : 640; const minimumWidth = kind === "map" ? 320 : 360;
const minimumHeight = kind === "map" ? 280 : 420; const minimumHeight = kind === "map" ? 240 : 280;
const availableWidth = Math.max(280, bounds.width - 24); const availableWidth = Math.max(280, bounds.width - 24);
const availableHeight = Math.max(240, bounds.height - 24); const availableHeight = Math.max(240, bounds.height - 24);
const width = clamp(rect.width, Math.min(minimumWidth, availableWidth), availableWidth); const width = clamp(rect.width, Math.min(minimumWidth, availableWidth), availableWidth);
@@ -331,6 +429,173 @@ export function clampWorkspaceRect(
}; };
} }
function arrangeWorkspaceLayout(
state: WorkspaceState,
bounds: WorkspaceBounds,
restoreAll = false
): WorkspaceState {
if (state.layout.mode === "free") return state;
const orderedWindowIds = normalizeWindowOrder(state);
const eligibleIds = orderedWindowIds.filter((id) => {
const window = getWindowById(state, id);
return window && (restoreAll || window.mode !== "minimized" || window.minimizedByLayout);
});
const visibleIds = selectVisibleWindowIds(eligibleIds, state.layout.mode);
const visibleSet = new Set(visibleIds);
const eligibleSet = new Set(eligibleIds);
const rects = getLayoutRects(state.layout.mode, visibleIds.length, bounds);
let next = mapAllWorkspaceWindows(state, (window) => {
const visibleIndex = visibleIds.indexOf(window.id);
if (visibleIndex >= 0) {
const rect = rects[visibleIndex] ?? window.rect;
return {
...window,
mode: !restoreAll && window.mode === "maximized" ? "maximized" : "floating",
rect,
restoreRect: rect,
minimizedByLayout: false
};
}
if (eligibleSet.has(window.id) && !visibleSet.has(window.id)) {
return { ...window, mode: "minimized", minimizedByLayout: true };
}
return window;
});
const activeWindowId = visibleSet.has(next.activeWindowId)
? next.activeWindowId
: visibleIds[0] ?? next.activeWindowId;
next = {
...next,
activeWindowId,
layout: { ...next.layout, orderedWindowIds }
};
return next;
}
function selectVisibleWindowIds(ids: string[], mode: WorkspaceLayoutMode) {
const capacity = mode === "split" ? 2 : mode === "primary" ? 3 : ids.length;
const selected = ids.slice(0, capacity);
if (
(mode === "split" || mode === "primary") &&
ids.includes(MAP_WINDOW_ID) &&
!selected.includes(MAP_WINDOW_ID)
) {
selected[selected.length - 1] = MAP_WINDOW_ID;
}
return selected;
}
function getLayoutRects(mode: WorkspaceLayoutMode, count: number, bounds: WorkspaceBounds): WorkspaceRect[] {
if (count === 0) return [];
const width = Math.max(1, bounds.width - LAYOUT_INSET * 2);
const height = Math.max(1, bounds.height - LAYOUT_INSET * 2);
if (mode === "primary") {
if (count === 1) return [{ x: LAYOUT_INSET, y: LAYOUT_INSET, width, height }];
const primaryWidth = Math.floor((width - LAYOUT_GAP) * 0.68);
const secondaryWidth = width - LAYOUT_GAP - primaryWidth;
const secondaryHeight = count === 2 ? height : Math.floor((height - LAYOUT_GAP) / 2);
return [
{ x: LAYOUT_INSET, y: LAYOUT_INSET, width: primaryWidth, height },
{ x: LAYOUT_INSET + primaryWidth + LAYOUT_GAP, y: LAYOUT_INSET, width: secondaryWidth, height: secondaryHeight },
...(count === 3
? [{
x: LAYOUT_INSET + primaryWidth + LAYOUT_GAP,
y: LAYOUT_INSET + secondaryHeight + LAYOUT_GAP,
width: secondaryWidth,
height: height - secondaryHeight - LAYOUT_GAP
}]
: [])
];
}
if (mode === "split") {
if (count === 1) return [{ x: LAYOUT_INSET, y: LAYOUT_INSET, width, height }];
const firstWidth = Math.floor((width - LAYOUT_GAP) / 2);
return [
{ x: LAYOUT_INSET, y: LAYOUT_INSET, width: firstWidth, height },
{
x: LAYOUT_INSET + firstWidth + LAYOUT_GAP,
y: LAYOUT_INSET,
width: width - firstWidth - LAYOUT_GAP,
height
}
];
}
if (mode === "cascade") {
const windowWidth = Math.max(280, Math.floor(width * 0.78));
const windowHeight = Math.max(240, Math.floor(height * 0.78));
return Array.from({ length: count }, (_, index) => ({
x: Math.min(LAYOUT_INSET + index * 28, Math.max(LAYOUT_INSET, bounds.width - windowWidth - LAYOUT_INSET)),
y: Math.min(LAYOUT_INSET + index * 28, Math.max(LAYOUT_INSET, bounds.height - windowHeight - LAYOUT_INSET)),
width: Math.min(windowWidth, width),
height: Math.min(windowHeight, height)
}));
}
const columns = count === 1 ? 1 : count <= 4 ? 2 : 3;
const rows = Math.ceil(count / columns);
const cellWidth = Math.floor((width - LAYOUT_GAP * (columns - 1)) / columns);
const cellHeight = Math.floor((height - LAYOUT_GAP * (rows - 1)) / rows);
return Array.from({ length: count }, (_, index) => {
const column = index % columns;
const row = Math.floor(index / columns);
return {
x: LAYOUT_INSET + column * (cellWidth + LAYOUT_GAP),
y: LAYOUT_INSET + row * (cellHeight + LAYOUT_GAP),
width: column === columns - 1 ? width - column * (cellWidth + LAYOUT_GAP) : cellWidth,
height: row === rows - 1 ? height - row * (cellHeight + LAYOUT_GAP) : cellHeight
};
});
}
function normalizeWindowOrder(state: WorkspaceState) {
const ids = new Set(allWorkspaceWindows(state).map((window) => window.id));
return uniqueIds([
...state.layout.orderedWindowIds,
...allWorkspaceWindows(state).sort((a, b) => b.zIndex - a.zIndex).map((window) => window.id)
]).filter((id) => ids.has(id));
}
function getWindowById(state: WorkspaceState, windowId: string): MapWorkspaceWindow | AnalysisWorkspaceWindow | undefined {
return windowId === MAP_WINDOW_ID ? state.map : state.analyses.find((window) => window.id === windowId);
}
function updateWindowById(
state: WorkspaceState,
windowId: string,
update: <T extends MapWorkspaceWindow | AnalysisWorkspaceWindow>(window: T) => T
): WorkspaceState {
if (windowId === MAP_WINDOW_ID) {
return { ...state, map: update(state.map) };
}
return {
...state,
analyses: state.analyses.map((window) => window.id === windowId ? update(window) : window)
};
}
function mapAllWorkspaceWindows(
state: WorkspaceState,
update: <T extends MapWorkspaceWindow | AnalysisWorkspaceWindow>(window: T) => T
): WorkspaceState {
return {
...state,
map: update(state.map),
analyses: state.analyses.map((window) => update(window))
};
}
function allWorkspaceWindows(state: WorkspaceState) {
return [state.map, ...state.analyses] as Array<MapWorkspaceWindow | AnalysisWorkspaceWindow>;
}
function uniqueIds(ids: string[]) {
return [...new Set(ids)];
}
function getDefaultAnalysisRect(bounds: WorkspaceBounds, index: number): WorkspaceRect { function getDefaultAnalysisRect(bounds: WorkspaceBounds, index: number): WorkspaceRect {
const width = Math.min(Math.max(bounds.width * 0.8, 640), Math.max(640, bounds.width - 48)); const width = Math.min(Math.max(bounds.width * 0.8, 640), Math.max(640, bounds.width - 48));
const height = Math.min(Math.max(bounds.height * 0.84, 420), Math.max(420, bounds.height - 48)); const height = Math.min(Math.max(bounds.height * 0.84, 420), Math.max(420, bounds.height - 48));
@@ -6,7 +6,13 @@ import {
PanelTopClose, PanelTopClose,
X X
} from "lucide-react"; } from "lucide-react";
import { useRef, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import {
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type ReactNode
} from "react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import type { WorkspaceRect, WorkspaceWindowMode } from "./workspace-model"; import type { WorkspaceRect, WorkspaceWindowMode } from "./workspace-model";
@@ -20,6 +26,7 @@ type WorkspaceWindowProps = {
zIndex: number; zIndex: number;
active: boolean; active: boolean;
kind: "map" | "analysis"; kind: "map" | "analysis";
swapTarget?: boolean;
children: ReactNode; children: ReactNode;
onFocus: () => void; onFocus: () => void;
onRectChange: (rect: WorkspaceRect) => void; onRectChange: (rect: WorkspaceRect) => void;
@@ -27,6 +34,10 @@ type WorkspaceWindowProps = {
onMaximize: () => void; onMaximize: () => void;
onRestore: () => void; onRestore: () => void;
onClose?: () => void; onClose?: () => void;
onMoveStart?: (rect: WorkspaceRect) => void;
onMovePreview?: (rect: WorkspaceRect, pointer: { x: number; y: number }) => void;
onMoveEnd?: (rect: WorkspaceRect, pointer: { x: number; y: number }) => void;
onMoveCancel?: () => void;
}; };
export function WorkspaceWindow({ export function WorkspaceWindow({
@@ -37,47 +48,116 @@ export function WorkspaceWindow({
zIndex, zIndex,
active, active,
kind, kind,
swapTarget = false,
children, children,
onFocus, onFocus,
onRectChange, onRectChange,
onMinimize, onMinimize,
onMaximize, onMaximize,
onRestore, onRestore,
onClose onClose,
onMoveStart,
onMovePreview,
onMoveEnd,
onMoveCancel
}: WorkspaceWindowProps) { }: WorkspaceWindowProps) {
const sectionRef = useRef<HTMLElement | null>(null);
const cleanupPointerActionRef = useRef<(() => void) | null>(null);
const [dragRect, setDragRect] = useState<WorkspaceRect | null>(null);
const rectRef = useRef(rect); const rectRef = useRef(rect);
rectRef.current = rect; rectRef.current = rect;
const floating = mode === "floating"; const floating = mode === "floating";
const style = mode === "docked" const displayedRect = dragRect ?? rect;
? { inset: 0, zIndex } const displayedZIndex = swapTarget ? 2_147_483_000 : zIndex;
const style = dragRect
? { left: displayedRect.x, top: displayedRect.y, width: displayedRect.width, height: displayedRect.height, zIndex: displayedZIndex }
: mode === "docked"
? { inset: 0, zIndex: displayedZIndex }
: mode === "maximized" : mode === "maximized"
? { inset: 8, zIndex } ? { inset: 8, zIndex: displayedZIndex }
: { left: rect.x, top: rect.y, width: rect.width, height: rect.height, zIndex }; : { left: displayedRect.x, top: displayedRect.y, width: displayedRect.width, height: displayedRect.height, zIndex: displayedZIndex };
useEffect(() => () => cleanupPointerActionRef.current?.(), []);
const beginPointerAction = ( const beginPointerAction = (
event: ReactPointerEvent, event: ReactPointerEvent,
action: "move" | ResizeDirection action: "move" | ResizeDirection
) => { ) => {
if (!floating || event.button !== 0) return; const moving = action === "move";
if ((!moving && !floating) || (moving && mode !== "floating" && mode !== "docked") || event.button !== 0) return;
if (event.target instanceof Element && event.target.closest("button")) return; if (event.target instanceof Element && event.target.closest("button")) return;
event.preventDefault(); event.preventDefault();
onFocus(); onFocus();
const startX = event.clientX; const startX = event.clientX;
const startY = event.clientY; const startY = event.clientY;
const startRect = rectRef.current; const startRect = rectRef.current;
const sectionBounds = sectionRef.current?.getBoundingClientRect();
const workspaceBounds = sectionRef.current?.parentElement?.getBoundingClientRect();
const pointerOffsetX = sectionBounds ? event.clientX - sectionBounds.left : 24;
const pointerOffsetY = sectionBounds ? event.clientY - sectionBounds.top : 22;
let moved = false;
if (moving) onMoveStart?.(startRect);
const handleMove = (pointerEvent: PointerEvent) => { const handleMove = (pointerEvent: PointerEvent) => {
const deltaX = pointerEvent.clientX - startX; const deltaX = pointerEvent.clientX - startX;
const deltaY = pointerEvent.clientY - startY; const deltaY = pointerEvent.clientY - startY;
if (!moved && Math.hypot(deltaX, deltaY) < 3) return;
moved = true;
if (moving) {
const nextRect = mode === "docked" && workspaceBounds
? {
...startRect,
x: pointerEvent.clientX - workspaceBounds.left - Math.min(pointerOffsetX, startRect.width - 40),
y: pointerEvent.clientY - workspaceBounds.top - Math.min(pointerOffsetY, 40)
}
: resizeRect(startRect, "move", deltaX, deltaY);
setDragRect(nextRect);
onMovePreview?.(nextRect, { x: pointerEvent.clientX, y: pointerEvent.clientY });
return;
}
onRectChange(resizeRect(startRect, action, deltaX, deltaY)); onRectChange(resizeRect(startRect, action, deltaX, deltaY));
}; };
const handleUp = () => { const cleanup = () => {
window.removeEventListener("pointermove", handleMove); window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp); window.removeEventListener("pointerup", handleUp);
window.removeEventListener("pointercancel", handleCancel);
window.removeEventListener("keydown", handleKeyDown);
cleanupPointerActionRef.current = null;
}; };
const handleUp = (pointerEvent: PointerEvent) => {
cleanup();
if (!moving || !moved) {
if (moving) onMoveCancel?.();
setDragRect(null);
return;
}
const finalRect = mode === "docked" && workspaceBounds
? {
...startRect,
x: pointerEvent.clientX - workspaceBounds.left - Math.min(pointerOffsetX, startRect.width - 40),
y: pointerEvent.clientY - workspaceBounds.top - Math.min(pointerOffsetY, 40)
}
: resizeRect(startRect, "move", pointerEvent.clientX - startX, pointerEvent.clientY - startY);
setDragRect(null);
onMoveEnd?.(finalRect, { x: pointerEvent.clientX, y: pointerEvent.clientY });
};
const handleCancel = () => {
cleanup();
setDragRect(null);
if (moving) onMoveCancel?.();
};
const handleKeyDown = (keyboardEvent: KeyboardEvent) => {
if (keyboardEvent.key !== "Escape") return;
keyboardEvent.preventDefault();
handleCancel();
};
cleanupPointerActionRef.current?.();
cleanupPointerActionRef.current = cleanup;
window.addEventListener("pointermove", handleMove); window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp, { once: true }); window.addEventListener("pointerup", handleUp, { once: true });
window.addEventListener("pointercancel", handleCancel, { once: true });
if (moving) window.addEventListener("keydown", handleKeyDown);
}; };
const handleTitleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => { const handleTitleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
@@ -95,13 +175,19 @@ export function WorkspaceWindow({
return ( return (
<section <section
ref={sectionRef}
id={id} id={id}
data-workspace-window-id={id}
aria-label={title} aria-label={title}
className={cn( className={cn(
"pointer-events-auto absolute min-h-0 flex-col overflow-hidden bg-white text-slate-900", "pointer-events-auto absolute min-h-0 flex-col overflow-hidden bg-white text-slate-900 transition-[box-shadow,opacity] duration-150 ease-out motion-reduce:transition-none",
mode === "minimized" ? "hidden" : kind === "analysis" ? "hidden lg:flex" : "flex", mode === "minimized" ? "hidden" : kind === "analysis" ? "hidden lg:flex" : "flex",
mode === "docked" ? "rounded-none" : "lg:rounded-lg lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]", mode === "docked" && !dragRect ? "rounded-none" : "lg:rounded-lg lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]",
active ? "lg:ring-1 lg:ring-blue-500/40" : "lg:ring-1 lg:ring-slate-300/90" swapTarget
? "lg:ring-2 lg:ring-blue-600 lg:ring-inset [@media(forced-colors:active)]:outline [@media(forced-colors:active)]:outline-2"
: active
? "lg:ring-1 lg:ring-blue-500/40"
: "lg:ring-1 lg:ring-slate-300/90"
)} )}
style={style} style={style}
onPointerDownCapture={onFocus} onPointerDownCapture={onFocus}
@@ -111,7 +197,7 @@ export function WorkspaceWindow({
className={cn( className={cn(
"workspace-window-titlebar hidden h-11 shrink-0 select-none items-center gap-2 border-b px-2 outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 lg:flex", "workspace-window-titlebar hidden h-11 shrink-0 select-none items-center gap-2 border-b px-2 outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 lg:flex",
active ? "border-blue-200 bg-[#eaf2fb]" : "border-slate-200 bg-[#f4f6f8]", active ? "border-blue-200 bg-[#eaf2fb]" : "border-slate-200 bg-[#f4f6f8]",
floating && "cursor-move" (floating || mode === "docked") && "cursor-move"
)} )}
onDoubleClick={mode === "maximized" || mode === "docked" ? onRestore : onMaximize} onDoubleClick={mode === "maximized" || mode === "docked" ? onRestore : onMaximize}
onKeyDown={handleTitleKeyDown} onKeyDown={handleTitleKeyDown}
@@ -134,6 +220,16 @@ export function WorkspaceWindow({
</div> </div>
</header> </header>
<div className="relative min-h-0 flex-1 overflow-hidden">{children}</div> <div className="relative min-h-0 flex-1 overflow-hidden">{children}</div>
{swapTarget ? (
<div
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"
>
<span className="rounded-md bg-blue-700 px-3 py-2 text-xs font-semibold text-white shadow-[0_2px_8px_rgba(29,78,216,0.28)]">
</span>
</div>
) : null}
{floating ? RESIZE_DIRECTIONS.map((direction) => ( {floating ? RESIZE_DIRECTIONS.map((direction) => (
<span <span
key={direction} key={direction}
+95
View File
@@ -92,6 +92,101 @@ test("temporary documents survive a mobile breakpoint round trip", async ({ page
await expect(page.locator("#workspace-map")).toBeHidden(); await expect(page.locator("#workspace-map")).toBeHidden();
}); });
test("layout presets arrange transient windows and keep the map visible", 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 testButton.click();
await page.getByRole("button", { name: "设置窗口布局,当前自由排列" }).click();
await page.getByRole("menuitemradio", { name: /主次/ }).click();
const workspace = page.locator("#main-workspace");
await expect(workspace).toHaveAttribute("data-layout-mode", "primary");
await expect(page.locator("#workspace-map")).toBeVisible();
await expect(page.locator("section[aria-label='低压事件调度处置流程']")).toBeVisible();
await expect(page.locator("section[aria-label='北辰分区压力异常诊断']")).toBeHidden();
const workspaceBox = await workspace.boundingBox();
const primaryBox = await page.locator("section[aria-label='低压事件调度处置流程']").boundingBox();
expect(workspaceBox).not.toBeNull();
expect(primaryBox).not.toBeNull();
expect(primaryBox!.width).toBeGreaterThan(workspaceBox!.width * 0.6);
await testButton.click();
await expect(page.locator("section[aria-label='北辰分区压力异常诊断']").last()).toBeVisible();
const newPrimaryBox = await page.locator("section[aria-label='北辰分区压力异常诊断']").last().boundingBox();
expect(newPrimaryBox).not.toBeNull();
expect(newPrimaryBox!.width).toBeGreaterThan(workspaceBox!.width * 0.6);
});
test("dragging a small window onto the primary window swaps their complete slots", async ({ page }) => {
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first().click();
await page.getByRole("button", { name: "设置窗口布局,当前自由排列" }).click();
await page.getByRole("menuitemradio", { name: /主次/ }).click();
const mapWindow = page.locator("#workspace-map");
const analysisWindow = page.locator("section[aria-label='北辰分区压力异常诊断']");
const mapBefore = await mapWindow.boundingBox();
const analysisBefore = await analysisWindow.boundingBox();
expect(mapBefore).not.toBeNull();
expect(analysisBefore).not.toBeNull();
expect(mapBefore!.width).toBeLessThan(analysisBefore!.width);
await page.mouse.move(mapBefore!.x + 72, mapBefore!.y + 22);
await page.mouse.down();
await page.mouse.move(
analysisBefore!.x + analysisBefore!.width / 2,
analysisBefore!.y + analysisBefore!.height / 2,
{ steps: 10 }
);
await expect(page.getByText("释放以交换")).toBeVisible();
await page.mouse.up();
const mapAfter = await mapWindow.boundingBox();
const analysisAfter = await analysisWindow.boundingBox();
expect(mapAfter).not.toBeNull();
expect(analysisAfter).not.toBeNull();
expect(mapAfter!.width).toBeGreaterThan(analysisAfter!.width);
expect(Math.abs(mapAfter!.x - analysisBefore!.x)).toBeLessThanOrEqual(2);
expect(Math.abs(analysisAfter!.x - mapBefore!.x)).toBeLessThanOrEqual(2);
await expect(page.getByText("供水管网地图与北辰分区压力异常诊断已交换位置")).toBeAttached();
});
test("dropping on blank workspace exits auto layout and Escape cancels a drag", async ({ page }) => {
await prepareMainFrame(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "测试打开 Agent 多图表窗口" }).first().click();
await page.getByRole("button", { name: "设置窗口布局,当前自由排列" }).click();
await page.getByRole("menuitemradio", { name: /双栏/ }).click();
const workspace = page.locator("#main-workspace");
const analysisWindow = page.locator("section[aria-label='北辰分区压力异常诊断']");
const before = await analysisWindow.boundingBox();
const workspaceBox = await workspace.boundingBox();
expect(before).not.toBeNull();
expect(workspaceBox).not.toBeNull();
await page.mouse.move(before!.x + 80, before!.y + 22);
await page.mouse.down();
await page.mouse.move(workspaceBox!.x + 4, workspaceBox!.y + 4, { steps: 8 });
await page.keyboard.press("Escape");
await expect(workspace).toHaveAttribute("data-layout-mode", "split");
const afterCancel = await analysisWindow.boundingBox();
expect(Math.abs(afterCancel!.x - before!.x)).toBeLessThanOrEqual(2);
await page.mouse.move(before!.x + 80, before!.y + 22);
await page.mouse.down();
await page.mouse.move(workspaceBox!.x + 4, workspaceBox!.y + 4, { steps: 8 });
await page.mouse.up();
await expect(workspace).toHaveAttribute("data-layout-mode", "free");
await expect(page.getByText("北辰分区压力异常诊断已转为自由排列")).toBeAttached();
});
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) => {