feat: add workspace layout and window swapping
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user