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">
|
||||
|
||||
Reference in New Issue
Block a user