feat: redesign floating workspace interactions

This commit is contained in:
2026-08-19 14:12:08 +08:00
parent 1068304da5
commit be7b4149f2
9 changed files with 210 additions and 281 deletions
@@ -10,7 +10,7 @@ import type {
export function AnalysisDocumentView({ document }: { document: AnalysisDocument }) {
return (
<article className="h-full overflow-y-auto bg-[#f4f7fa] text-slate-900" lang="zh-CN">
<header className="sticky top-0 z-10 flex items-start justify-between gap-5 border-b border-slate-200 bg-white/95 px-5 py-4 shadow-[0_1px_3px_rgba(15,23,42,0.08)] backdrop-blur-sm">
<header className="sticky top-0 z-10 flex items-start justify-between gap-5 border-b border-slate-200 bg-white/95 px-5 py-4 shadow-[0_1px_3px_rgba(15,23,42,0.08)] backdrop-blur-sm lg:pl-16">
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs font-semibold text-blue-700">
<Sparkles size={14} aria-hidden="true" />
@@ -1,26 +1,20 @@
import {
Bot,
CalendarClock,
ChevronsLeft,
ChevronsRight,
Columns2,
FlaskConical,
Grid2X2,
Layers3,
LayoutDashboard,
LayoutTemplate,
Map as MapIcon,
PanelLeft,
PanelsTopLeft,
SlidersHorizontal,
Sparkles
} from "lucide-react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
type CSSProperties,
type ReactNode
} from "react";
import { showMapNotice } from "@/features/map/core";
@@ -58,28 +52,14 @@ import {
} from "./workspace-model";
import { WorkspaceWindow } from "./workspace-window";
export type WorkbenchNavigationSection = "agent" | "conditions" | "layers" | "tools";
type WorkbenchMainFrameProps = {
renderAgentPanel: (onCollapse: () => void) => ReactNode;
conditionsPanel: ReactNode;
layersPanel: ReactNode;
toolsPanel: ReactNode;
mapContent: ReactNode;
mapOverlay?: ReactNode;
testEnabled: boolean;
activeSection: WorkbenchNavigationSection;
navigationCollapsed: boolean;
onActiveSectionChange: (section: WorkbenchNavigationSection) => void;
onNavigationCollapsedChange: (collapsed: boolean) => void;
};
const NAVIGATION_ITEMS: Array<{ id: WorkbenchNavigationSection; label: string; icon: typeof Bot }> = [
{ id: "agent", label: "Agent", icon: Bot },
{ id: "conditions", label: "工况任务", icon: CalendarClock },
{ id: "layers", label: "图层", icon: Layers3 },
{ id: "tools", label: "地图工具", icon: SlidersHorizontal }
];
export type WorkbenchMainFrameHandle = {
openTestAnalysis: () => void;
};
const INITIAL_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
@@ -89,19 +69,10 @@ type WorkspaceDragState = {
targetId: string | null;
};
export function WorkbenchMainFrame({
renderAgentPanel,
conditionsPanel,
layersPanel,
toolsPanel,
export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, WorkbenchMainFrameProps>(function WorkbenchMainFrame({
mapContent,
mapOverlay,
testEnabled,
activeSection,
navigationCollapsed,
onActiveSectionChange,
onNavigationCollapsedChange
}: WorkbenchMainFrameProps) {
mapOverlay
}, ref) {
const workspaceRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<WorkspaceDragState | null>(null);
const [bounds, setBounds] = useState<WorkspaceBounds>(INITIAL_BOUNDS);
@@ -154,6 +125,8 @@ export function WorkbenchMainFrame({
setWorkspace((current) => openAnalysisDocument(current, document, bounds));
}, [bounds, workspace.analyses.length]);
useImperativeHandle(ref, () => ({ openTestAnalysis: openTestDocument }), [openTestDocument]);
const findSwapTarget = useCallback((sourceId: string, pointer: { x: number; y: number }) => {
const root = workspaceRef.current;
if (!root) return null;
@@ -215,110 +188,16 @@ export function WorkbenchMainFrame({
setWorkspaceAnnouncement("已取消窗口移动");
}, []);
const navigationWidth = navigationCollapsed ? 48 : 368;
return (
<div
className="workbench-main-frame pointer-events-none absolute inset-0 lg:top-14"
data-testid="workbench-main-frame"
style={{ "--workbench-frame-navigation-width": `${navigationWidth}px` } as CSSProperties}
>
<aside
aria-label="工作台导航"
className="pointer-events-auto absolute bottom-0 left-0 top-0 z-30 hidden overflow-hidden border-r border-slate-300 bg-[#edf1f5] shadow-[1px_0_3px_rgba(15,23,42,0.12)] lg:flex"
style={{ width: navigationWidth }}
>
<nav className="flex w-12 shrink-0 flex-col items-center border-r border-slate-300 bg-[#e2e8ef] py-2" aria-label="主工作区">
<span className="mb-2 grid h-9 w-9 place-items-center rounded-md bg-slate-900 text-white"><PanelLeft size={17} aria-hidden="true" /></span>
{NAVIGATION_ITEMS.map((item) => {
const Icon = item.icon;
const active = item.id === activeSection;
return (
<button
key={item.id}
type="button"
aria-label={item.label}
aria-pressed={active}
title={item.label}
className={cn(
"relative grid h-11 w-11 place-items-center rounded-md text-slate-600 transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
active ? "bg-white text-blue-700 shadow-[0_1px_3px_rgba(15,23,42,0.14)]" : "hover:bg-white/70 hover:text-slate-900"
)}
onClick={() => { onActiveSectionChange(item.id); onNavigationCollapsedChange(false); }}
>
<Icon size={19} aria-hidden="true" />
{active ? <span className="absolute bottom-1.5 h-1 w-1 rounded-full bg-blue-600" aria-hidden="true" /> : null}
</button>
);
})}
<div className="mt-auto flex flex-col items-center gap-1">
{testEnabled ? (
<button
type="button"
aria-label="测试打开 Agent 多图表窗口"
title="测试打开 Agent 多图表窗口"
disabled={workspace.analyses.length >= MAX_ANALYSIS_WINDOWS}
className="grid h-11 w-11 place-items-center rounded-md text-blue-700 transition-[background-color,color,scale] hover:bg-blue-50 active:scale-[0.96] disabled:text-slate-400 disabled:opacity-50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500"
onClick={openTestDocument}
>
<FlaskConical size={19} aria-hidden="true" />
</button>
) : null}
<button
type="button"
aria-label={navigationCollapsed ? "展开工作台导航" : "折叠工作台导航"}
title={navigationCollapsed ? "展开工作台导航" : "折叠工作台导航"}
className="grid h-11 w-11 place-items-center rounded-md text-slate-600 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"
onClick={() => onNavigationCollapsedChange(!navigationCollapsed)}
>
{navigationCollapsed ? <ChevronsRight size={18} aria-hidden="true" /> : <ChevronsLeft size={18} aria-hidden="true" />}
</button>
</div>
</nav>
<div
aria-hidden={navigationCollapsed}
className={cn(
"flex min-w-0 flex-1 flex-col bg-[#f7f9fb]",
navigationCollapsed && "invisible"
)}
>
<header className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 px-3">
<div className="flex min-w-0 items-center gap-2">
<span className="h-2 w-2 rounded-full bg-blue-600" aria-hidden="true" />
<h2 className="truncate text-sm font-semibold text-slate-900">{NAVIGATION_ITEMS.find((item) => item.id === activeSection)?.label}</h2>
</div>
{testEnabled ? (
<button
type="button"
onClick={openTestDocument}
disabled={workspace.analyses.length >= MAX_ANALYSIS_WINDOWS}
className="flex h-9 items-center gap-1.5 rounded-md bg-blue-600 px-3 text-xs font-semibold text-white shadow-[0_1px_3px_rgba(37,99,235,0.3)] transition-[background-color,scale] hover:bg-blue-700 active:scale-[0.96] disabled:bg-slate-300 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
<Sparkles size={14} aria-hidden="true" />
</button>
) : null}
</header>
<div className="relative min-h-0 flex-1 overflow-hidden [--workbench-condition-width:320px]">
{desktop ? (
<>
<NavigationPanel active={activeSection === "agent"}>
{renderAgentPanel(() => onNavigationCollapsedChange(true))}
</NavigationPanel>
<NavigationPanel active={activeSection === "conditions"}>{conditionsPanel}</NavigationPanel>
<NavigationPanel active={activeSection === "layers"}>{layersPanel}</NavigationPanel>
<NavigationPanel active={activeSection === "tools"}>{toolsPanel}</NavigationPanel>
</>
) : null}
</div>
</div>
</aside>
<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)]"
className="pointer-events-auto absolute inset-0 overflow-hidden bg-[#dce3ea] lg:bottom-10"
>
<WorkspaceWindow
id={workspace.map.id}
@@ -371,7 +250,7 @@ export function WorkbenchMainFrame({
</div>
<div
className="pointer-events-auto absolute bottom-0 right-0 z-40 hidden h-10 items-center gap-1 border-t border-slate-300 bg-[#e7ecf1] px-2 lg:left-[var(--workbench-frame-navigation-width)] lg:flex"
className="pointer-events-auto absolute bottom-0 left-0 right-0 z-40 hidden h-10 items-center gap-1 border-t border-slate-300 bg-[#e7ecf1] px-2 lg:flex"
role="toolbar"
aria-label="工作区窗口任务栏"
>
@@ -410,7 +289,7 @@ export function WorkbenchMainFrame({
{!desktop ? <span className="sr-only"></span> : null}
</div>
);
}
});
function TaskbarButton({ active, minimized, icon, label, onClick }: { active: boolean; minimized: boolean; icon: ReactNode; label: string; onClick: () => void }) {
return (
@@ -520,11 +399,3 @@ 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">
{children}
</div>
);
}
@@ -42,11 +42,11 @@ function visibleIds(state: WorkspaceState) {
}
describe("workspace window lifecycle", () => {
it("minimizes the map for the first analysis and restores it after the last window closes", () => {
it("keeps the map visible under the first analysis and restores it after the last window closes", () => {
const initial = createInitialWorkspaceState(bounds);
const opened = openAnalysisDocument(initial, documentAt(1), bounds);
expect(opened.map.mode).toBe("minimized");
expect(opened.map.mode).toBe("docked");
expect(opened.analyses).toHaveLength(1);
const closed = closeAnalysisWindow(opened, "analysis-1");
@@ -174,7 +174,7 @@ export function openAnalysisDocument(
};
if (state.layout.mode !== "free") return arrangeWorkspaceLayout(opened, bounds);
return { ...opened, map: { ...opened.map, mode: "minimized", minimizedByLayout: false } };
return opened;
}
export function closeAnalysisWindow(
@@ -1,9 +1,8 @@
import {
Copy,
Grip,
Maximize2,
Minimize2,
Minus,
PanelTopClose,
X
} from "lucide-react";
import {
@@ -14,6 +13,13 @@ import {
type ReactNode
} from "react";
import { cn } from "@/shared/ui/cn";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import type { WorkspaceRect, WorkspaceWindowMode } from "./workspace-model";
type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
@@ -85,9 +91,20 @@ export function WorkspaceWindow({
action: "move" | ResizeDirection
) => {
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 (
(!moving && !floating)
|| (moving && mode !== "floating" && mode !== "docked" && mode !== "maximized")
|| event.button !== 0
) return;
if (
event.target instanceof Element
&& (
(!moving && event.target.closest("button"))
|| (moving && event.target.closest("[data-window-operation]"))
)
) return;
event.preventDefault();
event.stopPropagation();
onFocus();
const startX = event.clientX;
const startY = event.clientY;
@@ -105,7 +122,7 @@ export function WorkspaceWindow({
if (!moved && Math.hypot(deltaX, deltaY) < 3) return;
moved = true;
if (moving) {
const nextRect = mode === "docked" && workspaceBounds
const nextRect = mode !== "floating" && workspaceBounds
? {
...startRect,
x: pointerEvent.clientX - workspaceBounds.left - Math.min(pointerOffsetX, startRect.width - 40),
@@ -132,7 +149,7 @@ export function WorkspaceWindow({
setDragRect(null);
return;
}
const finalRect = mode === "docked" && workspaceBounds
const finalRect = mode !== "floating" && workspaceBounds
? {
...startRect,
x: pointerEvent.clientX - workspaceBounds.left - Math.min(pointerOffsetX, startRect.width - 40),
@@ -160,7 +177,7 @@ export function WorkspaceWindow({
if (moving) window.addEventListener("keydown", handleKeyDown);
};
const handleTitleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
const handleOperationKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (!floating || !event.key.startsWith("Arrow")) return;
event.preventDefault();
const step = event.altKey ? 2 : 12;
@@ -173,6 +190,11 @@ export function WorkspaceWindow({
);
};
const handleWindowPointerDownCapture = (event: ReactPointerEvent<HTMLElement>) => {
onFocus();
if (event.altKey) beginPointerAction(event, "move");
};
return (
<section
ref={sectionRef}
@@ -180,46 +202,67 @@ export function WorkspaceWindow({
data-workspace-window-id={id}
aria-label={title}
className={cn(
"pointer-events-auto absolute min-h-0 flex-col overflow-hidden bg-white text-slate-900 transition-[box-shadow,opacity] duration-150 ease-out motion-reduce:transition-none",
"group/workspace-window pointer-events-auto absolute min-h-0 flex-col overflow-hidden rounded-none bg-white text-slate-900 transition-[box-shadow,opacity] duration-150 ease-out motion-reduce:transition-none",
mode === "minimized" ? "hidden" : kind === "analysis" ? "hidden lg:flex" : "flex",
mode === "docked" && !dragRect ? "rounded-none" : "lg:rounded-lg lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]",
swapTarget
? "lg:ring-2 lg:ring-blue-600 lg:ring-inset [@media(forced-colors:active)]:outline [@media(forced-colors:active)]:outline-2"
: active
? "lg:ring-1 lg:ring-blue-500/40"
: "lg:ring-1 lg:ring-slate-300/90"
(mode !== "docked" || dragRect) && "lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]",
swapTarget && "lg:ring-2 lg:ring-blue-600 lg:ring-inset [@media(forced-colors:active)]:outline [@media(forced-colors:active)]:outline-2"
)}
style={style}
onPointerDownCapture={onFocus}
onPointerDownCapture={handleWindowPointerDownCapture}
>
<header
tabIndex={0}
className={cn(
"workspace-window-titlebar hidden h-11 shrink-0 select-none items-center gap-2 border-b px-2 outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 lg:flex",
active ? "border-blue-200 bg-[#eaf2fb]" : "border-slate-200 bg-[#f4f6f8]",
(floating || mode === "docked") && "cursor-move"
)}
onDoubleClick={mode === "maximized" || mode === "docked" ? onRestore : onMaximize}
onKeyDown={handleTitleKeyDown}
onPointerDown={(event) => beginPointerAction(event, "move")}
>
<span className={cn("grid h-7 w-7 shrink-0 place-items-center rounded-md", kind === "map" ? "bg-blue-600 text-white" : "bg-slate-900 text-white")}>
{kind === "map" ? <PanelTopClose size={15} aria-hidden="true" /> : <Copy size={14} aria-hidden="true" />}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900">{title}</p>
</div>
<div className="flex shrink-0 items-center">
<WindowButton label={`最小化${title}`} onClick={onMinimize}><Minus size={16} aria-hidden="true" /></WindowButton>
{mode === "maximized" || mode === "docked" ? (
<WindowButton label={`浮动${title}`} onClick={onRestore}><Minimize2 size={15} aria-hidden="true" /></WindowButton>
) : (
<WindowButton label={`最大化${title}`} onClick={onMaximize}><Maximize2 size={15} aria-hidden="true" /></WindowButton>
)}
{onClose ? <WindowButton label={`关闭并销毁${title}`} danger onClick={onClose}><X size={16} aria-hidden="true" /></WindowButton> : null}
</div>
</header>
<div className="relative min-h-0 flex-1 overflow-hidden">{children}</div>
{active ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
data-window-operation
aria-label={`打开${title}窗口操作`}
title={`${title}窗口操作`}
className="absolute left-2 top-2 z-40 hidden h-10 w-10 place-items-center rounded-md bg-slate-950/84 text-white opacity-0 shadow-[0_6px_18px_rgba(15,23,42,0.24)] transition-[opacity,background-color,scale] duration-150 [@media(hover:hover)]:group-hover/workspace-window:grid [@media(hover:hover)]:group-hover/workspace-window:opacity-100 hover:bg-slate-950 active:scale-[0.96] focus:grid focus:opacity-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 data-[state=open]:grid data-[state=open]:opacity-100 lg:grid"
onKeyDown={handleOperationKeyDown}
onPointerDown={(event) => event.stopPropagation()}
>
<Grip size={18} aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-window-operation
side="bottom"
align="start"
sideOffset={6}
className="w-52 rounded-lg border-slate-200 bg-white p-1.5 shadow-[0_12px_32px_rgba(15,23,42,0.2)] motion-reduce:animate-none"
>
<DropdownMenuItem className="min-h-10 rounded-md text-xs font-semibold" onSelect={onMinimize}>
<Minus size={16} aria-hidden="true" />
</DropdownMenuItem>
{mode === "maximized" || mode === "docked" ? (
<DropdownMenuItem className="min-h-10 rounded-md text-xs font-semibold" onSelect={onRestore}>
<Minimize2 size={15} aria-hidden="true" />
</DropdownMenuItem>
) : (
<DropdownMenuItem className="min-h-10 rounded-md text-xs font-semibold" onSelect={onMaximize}>
<Maximize2 size={15} aria-hidden="true" />
</DropdownMenuItem>
)}
{onClose ? (
<>
<DropdownMenuSeparator className="my-1 bg-slate-200" />
<DropdownMenuItem
className="min-h-10 rounded-md text-xs font-semibold text-rose-700 focus:bg-rose-50 focus:text-rose-800"
onSelect={onClose}
>
<X size={16} aria-hidden="true" />
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{swapTarget ? (
<div
aria-hidden="true"
@@ -244,23 +287,6 @@ export function WorkspaceWindow({
const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
function WindowButton({ label, danger, onClick, children }: { label: string; danger?: boolean; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
aria-label={label}
title={label}
className={cn(
"grid h-10 w-10 place-items-center rounded-md text-slate-600 transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
danger ? "hover:bg-rose-100 hover:text-rose-700" : "hover:bg-white hover:text-blue-700"
)}
onClick={(event) => { event.stopPropagation(); onClick(); }}
>
{children}
</button>
);
}
function resizeRect(rect: WorkspaceRect, direction: "move" | ResizeDirection, deltaX: number, deltaY: number): WorkspaceRect {
if (direction === "move") return { ...rect, x: rect.x + deltaX, y: rect.y + deltaY };
const west = direction.includes("w");