feat: add workspace layout and window swapping
This commit is contained in:
@@ -3,10 +3,15 @@ import {
|
||||
CalendarClock,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Columns2,
|
||||
FlaskConical,
|
||||
Grid2X2,
|
||||
Layers3,
|
||||
LayoutDashboard,
|
||||
LayoutTemplate,
|
||||
Map as MapIcon,
|
||||
PanelLeft,
|
||||
PanelsTopLeft,
|
||||
SlidersHorizontal,
|
||||
Sparkles
|
||||
} from "lucide-react";
|
||||
@@ -20,10 +25,21 @@ import {
|
||||
} from "react";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { AnalysisDocumentView } from "./analysis-document-view";
|
||||
import { createAnalysisDocumentFixture } from "./analysis-document-fixtures";
|
||||
import {
|
||||
MAX_ANALYSIS_WINDOWS,
|
||||
applyWorkspaceLayout,
|
||||
closeAnalysisWindow,
|
||||
createInitialWorkspaceState,
|
||||
focusWorkspaceWindow,
|
||||
@@ -32,8 +48,13 @@ import {
|
||||
openAnalysisDocument,
|
||||
resizeWorkspace,
|
||||
restoreWorkspaceWindow,
|
||||
setWorkspacePrimaryWindow,
|
||||
swapWorkspaceWindows,
|
||||
updateWorkspaceWindowRect,
|
||||
type WorkspaceBounds
|
||||
type WorkspaceBounds,
|
||||
type WorkspaceLayoutMode,
|
||||
type WorkspaceRect,
|
||||
type WorkspaceState
|
||||
} from "./workspace-model";
|
||||
import { WorkspaceWindow } from "./workspace-window";
|
||||
|
||||
@@ -62,6 +83,12 @@ const NAVIGATION_ITEMS: Array<{ id: WorkbenchNavigationSection; label: string; i
|
||||
|
||||
const INITIAL_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
|
||||
|
||||
type WorkspaceDragState = {
|
||||
sourceId: string;
|
||||
sourceRect: WorkspaceRect;
|
||||
targetId: string | null;
|
||||
};
|
||||
|
||||
export function WorkbenchMainFrame({
|
||||
renderAgentPanel,
|
||||
conditionsPanel,
|
||||
@@ -76,9 +103,12 @@ export function WorkbenchMainFrame({
|
||||
onNavigationCollapsedChange
|
||||
}: WorkbenchMainFrameProps) {
|
||||
const workspaceRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragStateRef = useRef<WorkspaceDragState | null>(null);
|
||||
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS);
|
||||
const [workspace, setWorkspace] = useState(() => createInitialWorkspaceState(INITIAL_BOUNDS));
|
||||
const [desktop, setDesktop] = useState(false);
|
||||
const [dragState, setDragState] = useState<WorkspaceDragState | null>(null);
|
||||
const [workspaceAnnouncement, setWorkspaceAnnouncement] = useState("");
|
||||
const fixtureIndexRef = useRef(0);
|
||||
const fixtureInstanceRef = useRef(0);
|
||||
|
||||
@@ -124,6 +154,67 @@ export function WorkbenchMainFrame({
|
||||
setWorkspace((current) => openAnalysisDocument(current, document, bounds));
|
||||
}, [bounds, workspace.analyses.length]);
|
||||
|
||||
const findSwapTarget = useCallback((sourceId: string, pointer: { x: number; y: number }) => {
|
||||
const root = workspaceRef.current;
|
||||
if (!root) return null;
|
||||
return [...root.querySelectorAll<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;
|
||||
return (
|
||||
<div
|
||||
@@ -226,6 +317,7 @@ export function WorkbenchMainFrame({
|
||||
<div
|
||||
ref={workspaceRef}
|
||||
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)]"
|
||||
>
|
||||
<WorkspaceWindow
|
||||
@@ -236,11 +328,16 @@ export function WorkbenchMainFrame({
|
||||
zIndex={workspace.map.zIndex}
|
||||
active={workspace.activeWindowId === workspace.map.id}
|
||||
kind="map"
|
||||
swapTarget={dragState?.targetId === workspace.map.id}
|
||||
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, current.map.id))}
|
||||
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, current.map.id, rect, bounds))}
|
||||
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, current.map.id))}
|
||||
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, current.map.id, bounds))}
|
||||
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, current.map.id))}
|
||||
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, current.map.id, bounds))}
|
||||
onMoveStart={(rect) => startWindowMove(workspace.map.id, rect)}
|
||||
onMovePreview={(_, pointer) => previewWindowMove(workspace.map.id, pointer)}
|
||||
onMoveEnd={(rect, pointer) => finishWindowMove(workspace.map.id, rect, pointer)}
|
||||
onMoveCancel={cancelWindowMove}
|
||||
>
|
||||
{mapContent}
|
||||
{mapOverlay}
|
||||
@@ -256,12 +353,17 @@ export function WorkbenchMainFrame({
|
||||
zIndex={window.zIndex}
|
||||
active={workspace.activeWindowId === window.id}
|
||||
kind="analysis"
|
||||
swapTarget={dragState?.targetId === window.id}
|
||||
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, window.id))}
|
||||
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, window.id, rect, bounds))}
|
||||
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id))}
|
||||
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id, bounds))}
|
||||
onMaximize={() => setWorkspace((current) => maximizeWorkspaceWindow(current, window.id))}
|
||||
onRestore={() => setWorkspace((current) => restoreWorkspaceWindow(current, window.id, bounds))}
|
||||
onClose={() => setWorkspace((current) => closeAnalysisWindow(current, window.id))}
|
||||
onClose={() => setWorkspace((current) => closeAnalysisWindow(current, window.id, bounds))}
|
||||
onMoveStart={(rect) => startWindowMove(window.id, rect)}
|
||||
onMovePreview={(_, pointer) => previewWindowMove(window.id, pointer)}
|
||||
onMoveEnd={(rect, pointer) => finishWindowMove(window.id, rect, pointer)}
|
||||
onMoveCancel={cancelWindowMove}
|
||||
>
|
||||
<AnalysisDocumentView document={window.document} />
|
||||
</WorkspaceWindow>
|
||||
@@ -290,8 +392,21 @@ export function WorkbenchMainFrame({
|
||||
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>
|
||||
</div>
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">{workspaceAnnouncement}</span>
|
||||
{!desktop ? <span className="sr-only">移动端保持地图与抽屉工作台</span> : null}
|
||||
</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 }) {
|
||||
return (
|
||||
<div hidden={!active} className="absolute inset-0 min-h-0 overflow-hidden">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_ANALYSIS_WINDOWS,
|
||||
applyWorkspaceLayout,
|
||||
closeAnalysisWindow,
|
||||
createInitialWorkspaceState,
|
||||
maximizeWorkspaceWindow,
|
||||
@@ -8,8 +9,11 @@ import {
|
||||
openAnalysisDocument,
|
||||
resizeWorkspace,
|
||||
restoreWorkspaceWindow,
|
||||
setWorkspacePrimaryWindow,
|
||||
swapWorkspaceWindows,
|
||||
updateWorkspaceWindowRect,
|
||||
type AnalysisDocument
|
||||
type AnalysisDocument,
|
||||
type WorkspaceState
|
||||
} from "./workspace-model";
|
||||
|
||||
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", () => {
|
||||
it("minimizes the map for the first analysis and restores it after the last window closes", () => {
|
||||
const initial = createInitialWorkspaceState(bounds);
|
||||
@@ -59,10 +76,7 @@ describe("workspace window lifecycle", () => {
|
||||
});
|
||||
|
||||
it("caps transient analysis windows without deleting an existing result", () => {
|
||||
const state = Array.from({ length: MAX_ANALYSIS_WINDOWS }, (_, index) => index + 1).reduce(
|
||||
(current, index) => openAnalysisDocument(current, documentAt(index), bounds),
|
||||
createInitialWorkspaceState(bounds)
|
||||
);
|
||||
const state = openDocuments(MAX_ANALYSIS_WINDOWS);
|
||||
|
||||
const overflow = openAnalysisDocument(state, documentAt(99), bounds);
|
||||
expect(overflow).toBe(state);
|
||||
@@ -86,3 +100,131 @@ describe("workspace window lifecycle", () => {
|
||||
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 WorkspaceLayoutMode = "free" | "primary" | "split" | "grid" | "cascade";
|
||||
|
||||
export type AnalysisMetric = {
|
||||
label: string;
|
||||
@@ -85,25 +86,25 @@ export type AnalysisDocument = {
|
||||
blocks: AnalysisBlock[];
|
||||
};
|
||||
|
||||
export type AnalysisWorkspaceWindow = {
|
||||
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;
|
||||
type WorkspaceWindowState = {
|
||||
mode: WorkspaceWindowMode;
|
||||
rect: WorkspaceRect;
|
||||
restoreRect: WorkspaceRect;
|
||||
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 = {
|
||||
@@ -111,25 +112,34 @@ export type WorkspaceState = {
|
||||
analyses: AnalysisWorkspaceWindow[];
|
||||
activeWindowId: string;
|
||||
nextZIndex: number;
|
||||
layout: {
|
||||
mode: WorkspaceLayoutMode;
|
||||
orderedWindowIds: string[];
|
||||
};
|
||||
};
|
||||
|
||||
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 {
|
||||
const mapRect = getDefaultMapRect(bounds);
|
||||
return {
|
||||
map: {
|
||||
id: "workspace-map",
|
||||
id: MAP_WINDOW_ID,
|
||||
kind: "map",
|
||||
title: "供水管网地图",
|
||||
mode: "docked",
|
||||
rect: mapRect,
|
||||
restoreRect: mapRect,
|
||||
zIndex: 1
|
||||
zIndex: 1,
|
||||
minimizedByLayout: false
|
||||
},
|
||||
analyses: [],
|
||||
activeWindowId: "workspace-map",
|
||||
nextZIndex: 2
|
||||
activeWindowId: MAP_WINDOW_ID,
|
||||
nextZIndex: 2,
|
||||
layout: { mode: "free", orderedWindowIds: [MAP_WINDOW_ID] }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,9 +148,7 @@ export function openAnalysisDocument(
|
||||
document: AnalysisDocument,
|
||||
bounds: WorkspaceBounds
|
||||
): WorkspaceState {
|
||||
if (state.analyses.length >= MAX_ANALYSIS_WINDOWS) {
|
||||
return state;
|
||||
}
|
||||
if (state.analyses.length >= MAX_ANALYSIS_WINDOWS) return state;
|
||||
|
||||
const rect = getDefaultAnalysisRect(bounds, state.analyses.length);
|
||||
const window: AnalysisWorkspaceWindow = {
|
||||
@@ -151,93 +159,92 @@ export function openAnalysisDocument(
|
||||
rect,
|
||||
restoreRect: rect,
|
||||
zIndex: state.nextZIndex,
|
||||
minimizedByLayout: false,
|
||||
document
|
||||
};
|
||||
|
||||
return {
|
||||
map: { ...state.map, mode: "minimized" },
|
||||
const opened: WorkspaceState = {
|
||||
...state,
|
||||
analyses: [...state.analyses, window],
|
||||
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);
|
||||
if (analyses.length === state.analyses.length) {
|
||||
return state;
|
||||
}
|
||||
if (analyses.length === state.analyses.length) return state;
|
||||
|
||||
if (analyses.length === 0) {
|
||||
return {
|
||||
...state,
|
||||
map: { ...state.map, mode: "docked", zIndex: state.nextZIndex },
|
||||
map: {
|
||||
...state.map,
|
||||
mode: "docked",
|
||||
minimizedByLayout: false,
|
||||
zIndex: state.nextZIndex
|
||||
},
|
||||
analyses,
|
||||
activeWindowId: state.map.id,
|
||||
nextZIndex: state.nextZIndex + 1
|
||||
activeWindowId: MAP_WINDOW_ID,
|
||||
nextZIndex: state.nextZIndex + 1,
|
||||
layout: { mode: "free", orderedWindowIds: [MAP_WINDOW_ID] }
|
||||
};
|
||||
}
|
||||
|
||||
const nextActive = [...analyses].sort((a, b) => b.zIndex - a.zIndex)[0];
|
||||
return {
|
||||
const orderedWindowIds = state.layout.orderedWindowIds.filter((id) => id !== windowId);
|
||||
const nextActive = state.activeWindowId === windowId
|
||||
? orderedWindowIds.find((id) => getWindowById({ ...state, analyses }, id)?.mode !== "minimized")
|
||||
: state.activeWindowId;
|
||||
const closed: WorkspaceState = {
|
||||
...state,
|
||||
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 {
|
||||
if (windowId === state.map.id) {
|
||||
return {
|
||||
...state,
|
||||
map: { ...state.map, zIndex: state.nextZIndex },
|
||||
activeWindowId: windowId,
|
||||
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
|
||||
};
|
||||
if (!getWindowById(state, windowId)) return state;
|
||||
return updateWindowById(
|
||||
{ ...state, activeWindowId: windowId, nextZIndex: state.nextZIndex + 1 },
|
||||
windowId,
|
||||
(window) => ({ ...window, zIndex: state.nextZIndex })
|
||||
);
|
||||
}
|
||||
|
||||
export function minimizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
|
||||
if (windowId === state.map.id) {
|
||||
return { ...state, map: { ...state.map, mode: "minimized" } };
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
analyses: state.analyses.map((window) =>
|
||||
window.id === windowId ? { ...window, mode: "minimized" } : window
|
||||
)
|
||||
};
|
||||
export function minimizeWorkspaceWindow(
|
||||
state: WorkspaceState,
|
||||
windowId: string,
|
||||
bounds: WorkspaceBounds = FALLBACK_BOUNDS
|
||||
): WorkspaceState {
|
||||
if (!getWindowById(state, windowId)) return state;
|
||||
const minimized = updateWindowById(state, windowId, (window) => ({
|
||||
...window,
|
||||
mode: "minimized",
|
||||
minimizedByLayout: false
|
||||
}));
|
||||
return state.layout.mode === "free" ? minimized : arrangeWorkspaceLayout(minimized, bounds);
|
||||
}
|
||||
|
||||
export function maximizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
|
||||
if (windowId === state.map.id) {
|
||||
return focusWorkspaceWindow(
|
||||
{ ...state, map: { ...state.map, mode: "maximized" } },
|
||||
windowId
|
||||
);
|
||||
}
|
||||
|
||||
if (!getWindowById(state, windowId)) return state;
|
||||
return focusWorkspaceWindow(
|
||||
{
|
||||
...state,
|
||||
analyses: state.analyses.map((window) =>
|
||||
window.id === windowId ? { ...window, mode: "maximized" } : window
|
||||
)
|
||||
},
|
||||
updateWindowById(state, windowId, (window) => ({
|
||||
...window,
|
||||
mode: "maximized",
|
||||
minimizedByLayout: false
|
||||
})),
|
||||
windowId
|
||||
);
|
||||
}
|
||||
@@ -247,27 +254,33 @@ export function restoreWorkspaceWindow(
|
||||
windowId: string,
|
||||
bounds: WorkspaceBounds
|
||||
): WorkspaceState {
|
||||
if (windowId === state.map.id) {
|
||||
const rect = clampWorkspaceRect(state.map.restoreRect, bounds, "map");
|
||||
return focusWorkspaceWindow(
|
||||
{ ...state, map: { ...state.map, mode: "floating", rect } },
|
||||
windowId
|
||||
const window = getWindowById(state, windowId);
|
||||
if (!window) return state;
|
||||
|
||||
if (state.layout.mode !== "free") {
|
||||
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(
|
||||
{
|
||||
...state,
|
||||
analyses: state.analyses.map((window) =>
|
||||
window.id === windowId
|
||||
? {
|
||||
...window,
|
||||
mode: "floating",
|
||||
rect: clampWorkspaceRect(window.restoreRect, bounds, "analysis")
|
||||
}
|
||||
: window
|
||||
)
|
||||
},
|
||||
updateWindowById(state, windowId, (current) => ({
|
||||
...current,
|
||||
mode: "floating",
|
||||
rect,
|
||||
minimizedByLayout: false
|
||||
})),
|
||||
windowId
|
||||
);
|
||||
}
|
||||
@@ -278,25 +291,110 @@ export function updateWorkspaceWindowRect(
|
||||
rect: WorkspaceRect,
|
||||
bounds: WorkspaceBounds
|
||||
): WorkspaceState {
|
||||
if (windowId === state.map.id) {
|
||||
const nextRect = clampWorkspaceRect(rect, bounds, "map");
|
||||
return {
|
||||
if (!getWindowById(state, windowId)) return state;
|
||||
const kind = windowId === MAP_WINDOW_ID ? "map" : "analysis";
|
||||
const nextRect = clampWorkspaceRect(rect, bounds, kind);
|
||||
return updateWindowById(
|
||||
{
|
||||
...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,
|
||||
analyses: state.analyses.map((window) => {
|
||||
if (window.id !== windowId) return window;
|
||||
const nextRect = clampWorkspaceRect(rect, bounds, "analysis");
|
||||
return { ...window, mode: "floating", rect: nextRect, restoreRect: nextRect };
|
||||
})
|
||||
layout: {
|
||||
...state.layout,
|
||||
orderedWindowIds: uniqueIds([windowId, ...state.layout.orderedWindowIds])
|
||||
}
|
||||
};
|
||||
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 {
|
||||
if (state.layout.mode !== "free") return arrangeWorkspaceLayout(state, bounds);
|
||||
return {
|
||||
...state,
|
||||
map: {
|
||||
@@ -317,8 +415,8 @@ export function clampWorkspaceRect(
|
||||
bounds: WorkspaceBounds,
|
||||
kind: "map" | "analysis"
|
||||
): WorkspaceRect {
|
||||
const minimumWidth = kind === "map" ? 360 : 640;
|
||||
const minimumHeight = kind === "map" ? 280 : 420;
|
||||
const minimumWidth = kind === "map" ? 320 : 360;
|
||||
const minimumHeight = kind === "map" ? 240 : 280;
|
||||
const availableWidth = Math.max(280, bounds.width - 24);
|
||||
const availableHeight = Math.max(240, bounds.height - 24);
|
||||
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 {
|
||||
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));
|
||||
|
||||
@@ -6,7 +6,13 @@ import {
|
||||
PanelTopClose,
|
||||
X
|
||||
} 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 type { WorkspaceRect, WorkspaceWindowMode } from "./workspace-model";
|
||||
|
||||
@@ -20,6 +26,7 @@ type WorkspaceWindowProps = {
|
||||
zIndex: number;
|
||||
active: boolean;
|
||||
kind: "map" | "analysis";
|
||||
swapTarget?: boolean;
|
||||
children: ReactNode;
|
||||
onFocus: () => void;
|
||||
onRectChange: (rect: WorkspaceRect) => void;
|
||||
@@ -27,6 +34,10 @@ type WorkspaceWindowProps = {
|
||||
onMaximize: () => void;
|
||||
onRestore: () => 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({
|
||||
@@ -37,47 +48,116 @@ export function WorkspaceWindow({
|
||||
zIndex,
|
||||
active,
|
||||
kind,
|
||||
swapTarget = false,
|
||||
children,
|
||||
onFocus,
|
||||
onRectChange,
|
||||
onMinimize,
|
||||
onMaximize,
|
||||
onRestore,
|
||||
onClose
|
||||
onClose,
|
||||
onMoveStart,
|
||||
onMovePreview,
|
||||
onMoveEnd,
|
||||
onMoveCancel
|
||||
}: WorkspaceWindowProps) {
|
||||
const sectionRef = useRef<HTMLElement | null>(null);
|
||||
const cleanupPointerActionRef = useRef<(() => void) | null>(null);
|
||||
const [dragRect, setDragRect] = useState<WorkspaceRect | null>(null);
|
||||
const rectRef = useRef(rect);
|
||||
rectRef.current = rect;
|
||||
|
||||
const floating = mode === "floating";
|
||||
const style = mode === "docked"
|
||||
? { inset: 0, zIndex }
|
||||
const displayedRect = dragRect ?? rect;
|
||||
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"
|
||||
? { inset: 8, zIndex }
|
||||
: { left: rect.x, top: rect.y, width: rect.width, height: rect.height, zIndex };
|
||||
? { inset: 8, zIndex: displayedZIndex }
|
||||
: { left: displayedRect.x, top: displayedRect.y, width: displayedRect.width, height: displayedRect.height, zIndex: displayedZIndex };
|
||||
|
||||
useEffect(() => () => cleanupPointerActionRef.current?.(), []);
|
||||
|
||||
const beginPointerAction = (
|
||||
event: ReactPointerEvent,
|
||||
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;
|
||||
event.preventDefault();
|
||||
onFocus();
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
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 deltaX = pointerEvent.clientX - startX;
|
||||
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));
|
||||
};
|
||||
const handleUp = () => {
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("pointermove", handleMove);
|
||||
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("pointerup", handleUp, { once: true });
|
||||
window.addEventListener("pointercancel", handleCancel, { once: true });
|
||||
if (moving) window.addEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
|
||||
const handleTitleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
@@ -95,13 +175,19 @@ export function WorkspaceWindow({
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
id={id}
|
||||
data-workspace-window-id={id}
|
||||
aria-label={title}
|
||||
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 === "docked" ? "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"
|
||||
mode === "docked" && !dragRect ? "rounded-none" : "lg:rounded-lg 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"
|
||||
: active
|
||||
? "lg:ring-1 lg:ring-blue-500/40"
|
||||
: "lg:ring-1 lg:ring-slate-300/90"
|
||||
)}
|
||||
style={style}
|
||||
onPointerDownCapture={onFocus}
|
||||
@@ -111,7 +197,7 @@ export function WorkspaceWindow({
|
||||
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",
|
||||
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}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
@@ -134,6 +220,16 @@ export function WorkspaceWindow({
|
||||
</div>
|
||||
</header>
|
||||
<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) => (
|
||||
<span
|
||||
key={direction}
|
||||
|
||||
Reference in New Issue
Block a user