feat: add workspace task view

This commit is contained in:
2026-08-19 14:34:03 +08:00
parent be7b4149f2
commit bfadc95f93
6 changed files with 568 additions and 132 deletions
@@ -1,9 +1,4 @@
import {
Columns2,
Grid2X2,
Layers3,
LayoutDashboard,
LayoutTemplate,
Map as MapIcon,
PanelsTopLeft,
Sparkles
@@ -13,22 +8,13 @@ import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ReactNode
} 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 {
@@ -42,7 +28,6 @@ import {
openAnalysisDocument,
resizeWorkspace,
restoreWorkspaceWindow,
setWorkspacePrimaryWindow,
swapWorkspaceWindows,
updateWorkspaceWindowRect,
type WorkspaceBounds,
@@ -50,6 +35,13 @@ import {
type WorkspaceRect,
type WorkspaceState
} from "./workspace-model";
import {
getTaskViewColumnCount,
getTaskViewPreviewRects,
getTaskViewSourceRect,
WORKSPACE_LAYOUT_LABELS
} from "./workspace-task-view-layout";
import { WorkspaceTaskView } from "./workspace-task-view";
import { WorkspaceWindow } from "./workspace-window";
type WorkbenchMainFrameProps = {
@@ -75,13 +67,24 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
}, ref) {
const workspaceRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<WorkspaceDragState | null>(null);
const taskViewButtonRef = useRef<HTMLButtonElement | 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 [taskViewOpen, setTaskViewOpen] = useState(false);
const [taskViewFocusWindowId, setTaskViewFocusWindowId] = useState("workspace-map");
const [workspaceAnnouncement, setWorkspaceAnnouncement] = useState("");
const fixtureIndexRef = useRef(0);
const fixtureInstanceRef = useRef(0);
const taskViewWindows = useMemo(
() => [workspace.map, ...workspace.analyses],
[workspace.analyses, workspace.map]
);
const taskViewPreviewRects = useMemo(
() => getTaskViewPreviewRects(taskViewWindows.length, bounds),
[bounds, taskViewWindows.length]
);
useEffect(() => {
const element = workspaceRef.current;
@@ -100,6 +103,19 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!taskViewOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
setTaskViewOpen(false);
setWorkspaceAnnouncement("已关闭任务视图");
window.requestAnimationFrame(() => taskViewButtonRef.current?.focus());
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [taskViewOpen]);
useEffect(() => {
const mediaQuery = window.matchMedia("(min-width: 1024px)");
const handleChange = () => {
@@ -188,6 +204,66 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
setWorkspaceAnnouncement("已取消窗口移动");
}, []);
function focusTaskViewWindow(windowId: string) {
setTaskViewFocusWindowId(windowId);
window.requestAnimationFrame(() => {
workspaceRef.current
?.querySelector<HTMLButtonElement>(`[data-task-view-window-id="${windowId}"]`)
?.focus();
});
}
function openTaskView() {
const windowId = workspace.activeWindowId;
setTaskViewFocusWindowId(windowId);
setTaskViewOpen(true);
setWorkspaceAnnouncement(`任务视图已打开,共 ${taskViewWindows.length} 个窗口`);
window.requestAnimationFrame(() => focusTaskViewWindow(windowId));
}
function closeTaskView() {
setTaskViewOpen(false);
setWorkspaceAnnouncement("已关闭任务视图");
window.requestAnimationFrame(() => taskViewButtonRef.current?.focus());
}
function activateTaskViewWindow(windowId: string) {
const title = getWorkspaceWindowTitle(workspace, windowId);
setWorkspace((current) => maximizeWorkspaceWindow(current, windowId));
setTaskViewOpen(false);
setWorkspaceAnnouncement(`${title}已铺满主视图`);
window.requestAnimationFrame(() => taskViewButtonRef.current?.focus());
}
function applyTaskViewLayout(mode: WorkspaceLayoutMode) {
setWorkspace((current) => applyWorkspaceLayout(
focusWorkspaceWindow(current, taskViewFocusWindowId),
mode,
bounds
));
setTaskViewOpen(false);
setWorkspaceAnnouncement(`已应用${WORKSPACE_LAYOUT_LABELS[mode]}布局`);
window.requestAnimationFrame(() => taskViewButtonRef.current?.focus());
}
function handleTaskViewWindowKeyDown(
event: React.KeyboardEvent<HTMLButtonElement>,
index: number
) {
const columns = getTaskViewColumnCount(taskViewWindows.length);
let nextIndex = index;
if (event.key === "ArrowLeft") nextIndex = Math.max(0, index - 1);
else if (event.key === "ArrowRight") nextIndex = Math.min(taskViewWindows.length - 1, index + 1);
else if (event.key === "ArrowUp") nextIndex = Math.max(0, index - columns);
else if (event.key === "ArrowDown") nextIndex = Math.min(taskViewWindows.length - 1, index + columns);
else if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = taskViewWindows.length - 1;
else return;
event.preventDefault();
const nextWindow = taskViewWindows[nextIndex];
if (nextWindow) focusTaskViewWindow(nextWindow.id);
}
return (
<div
className="workbench-main-frame pointer-events-none absolute inset-0 lg:top-14"
@@ -208,6 +284,16 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
active={workspace.activeWindowId === workspace.map.id}
kind="map"
swapTarget={dragState?.targetId === workspace.map.id}
taskViewPreview={taskViewOpen && taskViewPreviewRects[0] ? {
sourceRect: getTaskViewSourceRect(
workspace.map.mode,
workspace.map.rect,
workspace.map.restoreRect,
bounds
),
targetRect: taskViewPreviewRects[0],
index: 0
} : undefined}
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, bounds))}
@@ -222,7 +308,7 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
{mapOverlay}
</WorkspaceWindow>
{workspace.analyses.map((window) => (
{workspace.analyses.map((window, index) => (
<WorkspaceWindow
key={window.id}
id={window.id}
@@ -233,6 +319,16 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
active={workspace.activeWindowId === window.id}
kind="analysis"
swapTarget={dragState?.targetId === window.id}
taskViewPreview={taskViewOpen && taskViewPreviewRects[index + 1] ? {
sourceRect: getTaskViewSourceRect(
window.mode,
window.rect,
window.restoreRect,
bounds
),
targetRect: taskViewPreviewRects[index + 1],
index: index + 1
} : undefined}
onFocus={() => setWorkspace((current) => focusWorkspaceWindow(current, window.id))}
onRectChange={(rect) => setWorkspace((current) => updateWorkspaceWindowRect(current, window.id, rect, bounds))}
onMinimize={() => setWorkspace((current) => minimizeWorkspaceWindow(current, window.id, bounds))}
@@ -247,6 +343,20 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
<AnalysisDocumentView document={window.document} />
</WorkspaceWindow>
))}
{taskViewOpen ? (
<WorkspaceTaskView
windows={taskViewWindows}
previewRects={taskViewPreviewRects}
focusedWindowId={taskViewFocusWindowId}
layoutMode={workspace.layout.mode}
onClose={closeTaskView}
onFocusWindow={setTaskViewFocusWindowId}
onSelectWindow={activateTaskViewWindow}
onWindowKeyDown={handleTaskViewWindowKeyDown}
onLayoutChange={applyTaskViewLayout}
/>
) : null}
</div>
<div
@@ -254,12 +364,32 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
role="toolbar"
aria-label="工作区窗口任务栏"
>
<button
ref={taskViewButtonRef}
type="button"
aria-label="任务视图"
aria-pressed={taskViewOpen}
title="任务视图"
className={cn(
"grid h-8 w-10 shrink-0 place-items-center rounded-md transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500",
taskViewOpen
? "bg-blue-600 text-white shadow-[0_1px_3px_rgba(37,99,235,0.28)]"
: "text-slate-700 hover:bg-white"
)}
onClick={taskViewOpen ? closeTaskView : openTaskView}
>
<PanelsTopLeft size={17} aria-hidden="true" />
</button>
<span className="mx-1 h-5 w-px shrink-0 bg-slate-300" aria-hidden="true" />
<TaskbarButton
active={workspace.activeWindowId === workspace.map.id && workspace.map.mode !== "minimized"}
minimized={workspace.map.mode === "minimized"}
icon={<MapIcon size={15} aria-hidden="true" />}
label="供水管网地图"
onClick={() => setWorkspace((current) => current.map.mode === "minimized" ? restoreWorkspaceWindow(current, current.map.id, bounds) : focusWorkspaceWindow(current, current.map.id))}
onClick={() => {
setTaskViewOpen(false);
setWorkspace((current) => current.map.mode === "minimized" ? restoreWorkspaceWindow(current, current.map.id, bounds) : focusWorkspaceWindow(current, current.map.id));
}}
/>
{workspace.analyses.map((window) => (
<TaskbarButton
@@ -268,21 +398,12 @@ export const WorkbenchMainFrame = forwardRef<WorkbenchMainFrameHandle, Workbench
minimized={window.mode === "minimized"}
icon={<Sparkles size={14} aria-hidden="true" />}
label={window.title}
onClick={() => setWorkspace((current) => window.mode === "minimized" ? restoreWorkspaceWindow(current, window.id, bounds) : focusWorkspaceWindow(current, window.id))}
onClick={() => {
setTaskViewOpen(false);
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>
@@ -309,92 +430,6 @@ 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 ?? "工作区窗口";
@@ -0,0 +1,81 @@
import type {
WorkspaceBounds,
WorkspaceLayoutMode,
WorkspaceRect
} from "./workspace-model";
const HORIZONTAL_INSET = 28;
const TOP_INSET = 48;
const LAYOUT_RAIL_SPACE = 112;
const PREVIEW_GAP = 20;
const LABEL_SPACE = 28;
export const WORKSPACE_LAYOUT_LABELS: Record<WorkspaceLayoutMode, string> = {
free: "自由排列",
primary: "主次",
split: "双栏",
grid: "网格",
cascade: "层叠"
};
export function getTaskViewPreviewRects(
count: number,
bounds: WorkspaceBounds
): WorkspaceRect[] {
if (count <= 0) return [];
const columns = getTaskViewColumnCount(count);
const rows = Math.ceil(count / columns);
const availableWidth = Math.max(1, bounds.width - HORIZONTAL_INSET * 2);
const availableHeight = Math.max(
1,
bounds.height - TOP_INSET - LAYOUT_RAIL_SPACE
);
const cellWidth = Math.max(
1,
(availableWidth - PREVIEW_GAP * (columns - 1)) / columns
);
const cellHeight = Math.max(
1,
(availableHeight - PREVIEW_GAP * (rows - 1)) / rows
);
const previewHeight = Math.max(1, cellHeight - LABEL_SPACE);
const occupiedColumns = Math.min(count, columns);
const firstRowOffset = (availableWidth
- occupiedColumns * cellWidth
- Math.max(0, occupiedColumns - 1) * PREVIEW_GAP) / 2;
return Array.from({ length: count }, (_, index) => {
const row = Math.floor(index / columns);
const column = index % columns;
const itemsInRow = Math.min(columns, count - row * columns);
const rowOffset = row === 0
? firstRowOffset
: (availableWidth
- itemsInRow * cellWidth
- Math.max(0, itemsInRow - 1) * PREVIEW_GAP) / 2;
return {
x: HORIZONTAL_INSET + rowOffset + column * (cellWidth + PREVIEW_GAP),
y: TOP_INSET + row * (cellHeight + PREVIEW_GAP),
width: cellWidth,
height: previewHeight
};
});
}
export function getTaskViewColumnCount(count: number) {
return count <= 1 ? 1 : count <= 4 ? 2 : 3;
}
export function getTaskViewSourceRect(
mode: "docked" | "floating" | "maximized" | "minimized",
rect: WorkspaceRect,
restoreRect: WorkspaceRect,
bounds: WorkspaceBounds
): WorkspaceRect {
if (mode === "docked" || mode === "maximized") {
return { x: 0, y: 0, width: bounds.width, height: bounds.height };
}
return mode === "minimized" ? restoreRect : rect;
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
getTaskViewPreviewRects,
getTaskViewSourceRect
} from "./workspace-task-view-layout";
const bounds = { width: 1440, height: 804 };
describe("workspace task view geometry", () => {
it.each([1, 2, 4, 7])("keeps %i previews inside the task view area", (count) => {
const rects = getTaskViewPreviewRects(count, bounds);
expect(rects).toHaveLength(count);
for (const rect of rects) {
expect(rect.x).toBeGreaterThanOrEqual(28);
expect(rect.y).toBeGreaterThanOrEqual(48);
expect(rect.x + rect.width).toBeLessThanOrEqual(bounds.width - 28);
expect(rect.y + rect.height).toBeLessThanOrEqual(bounds.height - 112);
expect(rect.width).toBeGreaterThan(0);
expect(rect.height).toBeGreaterThan(0);
}
});
it("centers an incomplete last row", () => {
const rects = getTaskViewPreviewRects(7, bounds);
const last = rects.at(-1);
expect(last).toBeDefined();
expect(Math.abs((last!.x + last!.width / 2) - bounds.width / 2)).toBeLessThan(1);
});
it("uses the visible workspace for filled windows and restore geometry for minimized windows", () => {
const rect = { x: 80, y: 60, width: 720, height: 520 };
const restoreRect = { x: 120, y: 90, width: 660, height: 480 };
expect(getTaskViewSourceRect("docked", rect, restoreRect, bounds)).toEqual({
x: 0,
y: 0,
width: bounds.width,
height: bounds.height
});
expect(getTaskViewSourceRect("minimized", rect, restoreRect, bounds)).toBe(restoreRect);
expect(getTaskViewSourceRect("floating", rect, restoreRect, bounds)).toBe(rect);
});
});
@@ -0,0 +1,197 @@
import { Map as MapIcon, PanelsTopLeft, Sparkles } from "lucide-react";
import type { KeyboardEvent } from "react";
import { cn } from "@/shared/ui/cn";
import type {
WorkspaceLayoutMode,
WorkspaceRect,
WorkspaceState
} from "./workspace-model";
import { WORKSPACE_LAYOUT_LABELS } from "./workspace-task-view-layout";
const SNAP_LAYOUT_OPTIONS: Array<{
mode: WorkspaceLayoutMode;
description: string;
minimumWindows: number;
}> = [
{ mode: "primary", description: "一个主窗口与辅助窗口", minimumWindows: 2 },
{ mode: "split", description: "两个窗口等宽并排", minimumWindows: 2 },
{ mode: "grid", description: "全部窗口自适应平铺", minimumWindows: 2 },
{ mode: "cascade", description: "全部窗口依次层叠", minimumWindows: 2 },
{ mode: "free", description: "保留窗口的自由位置", minimumWindows: 1 }
];
type TaskViewWindow = WorkspaceState["map"] | WorkspaceState["analyses"][number];
export function WorkspaceTaskView({
windows,
previewRects,
focusedWindowId,
layoutMode,
onClose,
onFocusWindow,
onSelectWindow,
onWindowKeyDown,
onLayoutChange
}: {
windows: TaskViewWindow[];
previewRects: WorkspaceRect[];
focusedWindowId: string;
layoutMode: WorkspaceLayoutMode;
onClose: () => void;
onFocusWindow: (windowId: string) => void;
onSelectWindow: (windowId: string) => void;
onWindowKeyDown: (event: KeyboardEvent<HTMLButtonElement>, index: number) => void;
onLayoutChange: (mode: WorkspaceLayoutMode) => void;
}) {
function handleDialogKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key !== "Tab") return;
const controls = [...event.currentTarget.querySelectorAll<HTMLButtonElement>("button:not(:disabled)")]
.filter((control) => control.tabIndex >= 0);
const first = controls[0];
const last = controls.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
return (
<>
<div
aria-hidden="true"
className="absolute inset-0 z-[2147482000] cursor-default bg-[#e7edf3]/96"
onPointerDown={onClose}
/>
<div
role="dialog"
aria-modal="true"
aria-label="任务视图"
className="pointer-events-none absolute inset-0 z-[2147482200]"
onKeyDown={handleDialogKeyDown}
>
<div className="absolute left-7 top-4 flex items-center gap-2 text-slate-700">
<PanelsTopLeft size={17} aria-hidden="true" />
<span className="text-sm font-semibold"></span>
<span className="text-xs text-slate-500 tabular-nums">{windows.length} </span>
</div>
{windows.map((window, index) => {
const rect = previewRects[index];
if (!rect) return null;
const focused = focusedWindowId === window.id;
const minimized = window.mode === "minimized";
return (
<button
key={window.id}
type="button"
data-task-view-window-id={window.id}
aria-label={`打开${window.title}并铺满主视图${minimized ? ",当前已最小化" : ""}`}
tabIndex={0}
className={cn(
"group pointer-events-auto absolute rounded-md transition-[background-color,box-shadow,scale] duration-150 active:scale-[0.99] focus-visible:outline-hidden motion-reduce:transition-none",
focused
? "bg-blue-500/8 shadow-[0_0_0_2px_rgba(37,99,235,0.92)]"
: "[@media(hover:hover)]:hover:bg-white/45 [@media(hover:hover)]:hover:shadow-[0_0_0_2px_rgba(148,163,184,0.8)]"
)}
style={{
left: rect.x,
top: rect.y,
width: rect.width,
height: rect.height + 28
}}
onClick={() => onSelectWindow(window.id)}
onFocus={() => onFocusWindow(window.id)}
onPointerEnter={() => onFocusWindow(window.id)}
onKeyDown={(event) => onWindowKeyDown(event, index)}
>
<span className="absolute bottom-0 left-1/2 flex max-w-[calc(100%-0.75rem)] -translate-x-1/2 items-center gap-1.5 rounded-md bg-slate-950/88 px-2.5 py-1 text-xs font-semibold text-white shadow-[0_2px_8px_rgba(15,23,42,0.2)]">
{window.kind === "map" ? <MapIcon size={13} aria-hidden="true" /> : <Sparkles size={13} aria-hidden="true" />}
<span className="truncate">{window.title}</span>
{minimized ? <span className="shrink-0 text-[10px] text-slate-300"></span> : null}
</span>
</button>
);
})}
<div className="pointer-events-auto absolute bottom-4 left-1/2 flex -translate-x-1/2 items-end gap-1.5 rounded-xl bg-white px-3 py-2.5 shadow-[0_12px_32px_rgba(15,23,42,0.18)]">
<span className="mr-1 self-center text-xs font-semibold text-slate-500"></span>
{SNAP_LAYOUT_OPTIONS.map((option) => {
const disabled = windows.length < option.minimumWindows;
const active = layoutMode === option.mode;
return (
<button
key={option.mode}
type="button"
aria-label={`${WORKSPACE_LAYOUT_LABELS[option.mode]}${option.description}`}
aria-pressed={active}
title={option.description}
disabled={disabled}
className={cn(
"group/layout flex h-16 w-[76px] flex-col items-center justify-center gap-1 rounded-lg text-[11px] font-semibold transition-[background-color,color,scale] active:scale-[0.96] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-40",
active ? "bg-blue-50 text-blue-700" : "text-slate-600 hover:bg-slate-100"
)}
onClick={() => onLayoutChange(option.mode)}
>
<SnapLayoutDiagram mode={option.mode} active={active} />
{WORKSPACE_LAYOUT_LABELS[option.mode]}
</button>
);
})}
</div>
</div>
</>
);
}
function SnapLayoutDiagram({ mode, active }: { mode: WorkspaceLayoutMode; active: boolean }) {
const cellClass = active ? "bg-blue-600" : "bg-slate-400 group-hover/layout:bg-slate-500";
if (mode === "primary") {
return (
<span className="grid h-7 w-11 grid-cols-[2fr_1fr] gap-0.5" aria-hidden="true">
<span className={cn("rounded-[2px]", cellClass)} />
<span className="grid grid-rows-2 gap-0.5">
<span className={cn("rounded-[2px]", cellClass)} />
<span className={cn("rounded-[2px]", cellClass)} />
</span>
</span>
);
}
if (mode === "split") {
return (
<span className="grid h-7 w-11 grid-cols-2 gap-0.5" aria-hidden="true">
<span className={cn("rounded-[2px]", cellClass)} />
<span className={cn("rounded-[2px]", cellClass)} />
</span>
);
}
if (mode === "grid") {
return (
<span className="grid h-7 w-11 grid-cols-2 grid-rows-2 gap-0.5" aria-hidden="true">
{Array.from({ length: 4 }, (_, index) => <span key={index} className={cn("rounded-[2px]", cellClass)} />)}
</span>
);
}
if (mode === "cascade") {
return (
<span className="relative h-7 w-11" aria-hidden="true">
<span className={cn("absolute left-0 top-0 h-5 w-8 rounded-[2px] opacity-50", cellClass)} />
<span className={cn("absolute bottom-0 right-0 h-5 w-8 rounded-[2px]", cellClass)} />
</span>
);
}
return (
<span className="relative h-7 w-11" aria-hidden="true">
<span className={cn("absolute left-0 top-1 h-4 w-6 rounded-[2px] opacity-60", cellClass)} />
<span className={cn("absolute bottom-0 right-0 h-4 w-7 rounded-[2px]", cellClass)} />
</span>
);
}
@@ -9,6 +9,7 @@ import {
useEffect,
useRef,
useState,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
type ReactNode
} from "react";
@@ -24,6 +25,12 @@ import type { WorkspaceRect, WorkspaceWindowMode } from "./workspace-model";
type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
export type WorkspaceTaskViewPreview = {
sourceRect: WorkspaceRect;
targetRect: WorkspaceRect;
index: number;
};
type WorkspaceWindowProps = {
id: string;
title: string;
@@ -33,6 +40,7 @@ type WorkspaceWindowProps = {
active: boolean;
kind: "map" | "analysis";
swapTarget?: boolean;
taskViewPreview?: WorkspaceTaskViewPreview;
children: ReactNode;
onFocus: () => void;
onRectChange: (rect: WorkspaceRect) => void;
@@ -55,6 +63,7 @@ export function WorkspaceWindow({
active,
kind,
swapTarget = false,
taskViewPreview,
children,
onFocus,
onRectChange,
@@ -75,13 +84,19 @@ export function WorkspaceWindow({
const floating = mode === "floating";
const displayedRect = dragRect ?? rect;
const displayedZIndex = swapTarget ? 2_147_483_000 : zIndex;
const style = dragRect
const displayedZIndex = taskViewPreview
? 2_147_482_100 + taskViewPreview.index
: swapTarget
? 2_147_483_000
: zIndex;
const style: CSSProperties = taskViewPreview
? getTaskViewStyle(taskViewPreview, displayedZIndex)
: dragRect
? { left: displayedRect.x, top: displayedRect.y, width: displayedRect.width, height: displayedRect.height, zIndex: displayedZIndex }
: mode === "docked"
? { inset: 0, zIndex: displayedZIndex }
: mode === "maximized"
? { inset: 8, zIndex: displayedZIndex }
? { inset: 0, zIndex: displayedZIndex }
: { left: displayedRect.x, top: displayedRect.y, width: displayedRect.width, height: displayedRect.height, zIndex: displayedZIndex };
useEffect(() => () => cleanupPointerActionRef.current?.(), []);
@@ -201,17 +216,22 @@ export function WorkspaceWindow({
id={id}
data-workspace-window-id={id}
aria-label={title}
aria-hidden={taskViewPreview ? true : undefined}
inert={taskViewPreview ? true : undefined}
className={cn(
"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) && "lg:shadow-[0_16px_48px_rgba(15,23,42,0.22)]",
"group/workspace-window absolute min-h-0 flex-col overflow-hidden rounded-none bg-white text-slate-900 transition-[transform,box-shadow,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none",
taskViewPreview ? "pointer-events-none flex" : "pointer-events-auto",
taskViewPreview
? kind === "analysis" ? "hidden lg:flex" : "flex"
: mode === "minimized" ? "hidden" : kind === "analysis" ? "hidden lg:flex" : "flex",
(taskViewPreview || 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={handleWindowPointerDownCapture}
>
<div className="relative min-h-0 flex-1 overflow-hidden">{children}</div>
{active ? (
{active && !taskViewPreview ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
@@ -263,7 +283,7 @@ export function WorkspaceWindow({
</DropdownMenuContent>
</DropdownMenu>
) : null}
{swapTarget ? (
{swapTarget && !taskViewPreview ? (
<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"
@@ -273,7 +293,7 @@ export function WorkspaceWindow({
</span>
</div>
) : null}
{floating ? RESIZE_DIRECTIONS.map((direction) => (
{floating && !taskViewPreview ? RESIZE_DIRECTIONS.map((direction) => (
<span
key={direction}
aria-hidden="true"
@@ -287,6 +307,31 @@ export function WorkspaceWindow({
const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
function getTaskViewStyle(
preview: WorkspaceTaskViewPreview,
zIndex: number
): CSSProperties {
const { sourceRect, targetRect } = preview;
const scale = Math.min(
targetRect.width / sourceRect.width,
targetRect.height / sourceRect.height
);
const renderedWidth = sourceRect.width * scale;
const renderedHeight = sourceRect.height * scale;
const targetLeft = targetRect.x + (targetRect.width - renderedWidth) / 2;
const targetTop = targetRect.y + (targetRect.height - renderedHeight) / 2;
return {
left: sourceRect.x,
top: sourceRect.y,
width: sourceRect.width,
height: sourceRect.height,
transform: `translate(${targetLeft - sourceRect.x}px, ${targetTop - sourceRect.y}px) scale(${scale})`,
transformOrigin: "top left",
zIndex
};
}
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");