feat: add workspace layout and window swapping

This commit is contained in:
2026-08-19 13:52:44 +08:00
parent f58e0a70f4
commit 1068304da5
5 changed files with 936 additions and 132 deletions
@@ -3,10 +3,15 @@ import {
CalendarClock,
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">