feat(workbench): dock task and chart workspaces
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { FloatingWindow } from "./floating-window";
|
||||
import { floatingWindowEntries, floatingWindowOrder } from "./floating-window-layer";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -35,3 +37,61 @@ it("retains manual opening, Escape dismissal and focus return", () => {
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole("dialog", { name: "压力分析" })).toBeVisible();
|
||||
});
|
||||
|
||||
function DockFixture({ multiple = false }: { multiple?: boolean }) {
|
||||
const target = useRef<HTMLDivElement>(null);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const activate = useCallback((id: string) => setActiveId(id), []);
|
||||
const deactivate = useCallback((id?: string) => setActiveId((current) => !id || current === id ? null : current), []);
|
||||
return <>
|
||||
<div ref={target} data-testid="workspace-slot" />
|
||||
<button onClick={() => deactivate()}>切换工作区</button>
|
||||
<FloatingWindow title="停靠分析" dock={{ target, activeId, activate, deactivate, view: "map_split", setView: () => undefined }}>
|
||||
{() => <input aria-label="分析备注" />}
|
||||
</FloatingWindow>
|
||||
{multiple ? <FloatingWindow title="停靠对比" dock={{ target, activeId, activate, deactivate, view: "map_split", setView: () => undefined }}>
|
||||
{() => <p>第二个来源</p>}
|
||||
</FloatingWindow> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
it("preserves the live document when a docked window is hidden and located again", () => {
|
||||
render(<DockFixture />);
|
||||
const trigger = screen.getByRole("button", { name: "放大查看 停靠分析" });
|
||||
fireEvent.click(trigger);
|
||||
const dialog = screen.getByRole("dialog", { name: "停靠分析" });
|
||||
expect(screen.getByTestId("workspace-slot")).toContainElement(dialog);
|
||||
expect(dialog).toHaveAttribute("data-window-presentation", "docked");
|
||||
expect(floatingWindowEntries()).toEqual([]);
|
||||
expect(floatingWindowOrder()).toEqual([]);
|
||||
const editor = screen.getByLabelText("分析备注");
|
||||
fireEvent.change(editor, { target: { value: "保留核查说明" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换工作区" }));
|
||||
expect(dialog).not.toBeVisible();
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByLabelText("分析备注")).toBe(editor);
|
||||
expect(editor).toHaveValue("保留核查说明");
|
||||
expect(dialog).toBeVisible();
|
||||
expect(floatingWindowEntries()).toEqual([]);
|
||||
expect(floatingWindowOrder()).toEqual([]);
|
||||
fireEvent.keyDown(dialog, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides inactive docking hosts so another source can occupy the same workspace", () => {
|
||||
render(<DockFixture multiple />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "放大查看 停靠分析" }));
|
||||
const first = screen.getByRole("dialog", { name: "停靠分析" });
|
||||
const editor = screen.getByLabelText("分析备注");
|
||||
fireEvent.change(editor, { target: { value: "保留第一个来源" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "放大查看 停靠对比" }));
|
||||
const second = screen.getByRole("dialog", { name: "停靠对比" });
|
||||
expect(first.parentElement).not.toBeVisible();
|
||||
expect(first.parentElement).toHaveAttribute("hidden");
|
||||
expect(second.parentElement).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("button", { name: "放大查看 停靠分析" }));
|
||||
expect(first.parentElement).toBeVisible();
|
||||
expect(second.parentElement).not.toBeVisible();
|
||||
expect(screen.getByLabelText("分析备注")).toBe(editor);
|
||||
expect(editor).toHaveValue("保留第一个来源");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Maximize2, Minimize2, X } from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
@@ -8,6 +8,14 @@ import { useFloatingWindowDrag } from "./use-floating-window-drag";
|
||||
import { useFloatingWindowResize, type ResizeEdge } from "./use-floating-window-resize";
|
||||
|
||||
type Bounds = { x: number; y: number; width: number; height: number };
|
||||
export type FloatingWindowDock = {
|
||||
target: RefObject<HTMLDivElement | null>;
|
||||
activeId: string | null;
|
||||
view: "map_split" | "focus" | "map_only";
|
||||
activate: (id: string) => void;
|
||||
deactivate: (id?: string) => void;
|
||||
setView: (view: FloatingWindowDock["view"]) => void;
|
||||
};
|
||||
const resizeHandles: Record<ResizeEdge, React.CSSProperties> = {
|
||||
top: { top: 0, left: 18, right: 18, height: 8, cursor: "ns-resize" },
|
||||
right: { right: 0, top: 18, bottom: 18, width: 8, cursor: "ew-resize" },
|
||||
@@ -28,7 +36,7 @@ function fitWindow(bounds: Bounds): Bounds {
|
||||
y: Math.max(top, Math.min(bounds.y, window.innerHeight - height - 8)) };
|
||||
}
|
||||
|
||||
export function FloatingWindow({ title, navigationTitle, triggerLabel, fillContent = false, openRequestKey, triggerContent, initialSize = { width: 800, height: 560 }, children }: {
|
||||
export function FloatingWindow({ title, navigationTitle, triggerLabel, fillContent = false, openRequestKey, triggerContent, initialSize = { width: 800, height: 560 }, dock, children }: {
|
||||
title: string;
|
||||
navigationTitle?: string;
|
||||
triggerLabel?: string;
|
||||
@@ -37,6 +45,8 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
fillContent?: boolean;
|
||||
/** A new display request opens the window without taking keyboard focus. */
|
||||
openRequestKey?: string;
|
||||
/** An optional layout host; individual windows remain floating by default. */
|
||||
dock?: FloatingWindowDock | null;
|
||||
children: (size: { width: number; height: number }) => ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
@@ -55,11 +65,23 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const heading = useRef<HTMLDivElement>(null);
|
||||
const focusOnOpen = useRef(false);
|
||||
const raise = () => raiseFloatingWindow(id);
|
||||
const [dockHost] = useState(() => {
|
||||
const element = document.createElement("div");
|
||||
element.className = "h-full min-h-0 min-w-0";
|
||||
return element;
|
||||
});
|
||||
const docked = Boolean(dock);
|
||||
const focusWindow = () => {
|
||||
dock?.activate(id);
|
||||
if (!docked) raiseFloatingWindow(id);
|
||||
requestAnimationFrame(() => heading.current?.focus({ preventScroll: true }));
|
||||
};
|
||||
const raise = () => { if (!docked) raiseFloatingWindow(id); };
|
||||
const close = () => {
|
||||
setManuallyOpen(false);
|
||||
setRequest({ key: openRequestKey, dismissed: true });
|
||||
removeFloatingWindow(id);
|
||||
dock?.deactivate(id);
|
||||
if (heading.current?.closest('[data-floating-window]')?.contains(document.activeElement))
|
||||
trigger.current?.focus({ preventScroll: true });
|
||||
};
|
||||
@@ -67,14 +89,33 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
const closeRef = useRef(close);
|
||||
closeRef.current = close;
|
||||
useLayoutEffect(() => {
|
||||
if (open) registerFloatingWindow({ id, title: navigationTitle ?? title,
|
||||
focus: () => { raiseFloatingWindow(id); heading.current?.focus({ preventScroll: true }); },
|
||||
if (open && !docked) registerFloatingWindow({ id, title: navigationTitle ?? title,
|
||||
focus: focusWindow,
|
||||
close: () => closeRef.current() });
|
||||
}, [id, open, title, navigationTitle]);
|
||||
});
|
||||
|
||||
const activateDock = dock?.activate;
|
||||
const deactivateDock = dock?.deactivate;
|
||||
const dockTarget = dock?.target;
|
||||
useLayoutEffect(() => {
|
||||
dockHost.hidden = Boolean(dock && dock.activeId !== id);
|
||||
}, [dock, dockHost, id]);
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !dockTarget) return;
|
||||
dockTarget.current?.append(dockHost);
|
||||
activateDock?.(id);
|
||||
return () => { dockHost.remove(); deactivateDock?.(id); };
|
||||
}, [id, open, dockHost, dockTarget, activateDock, deactivateDock]);
|
||||
useLayoutEffect(() => {
|
||||
if (open && focusOnOpen.current && (!dock || dock.activeId === id)) {
|
||||
heading.current?.focus({ preventScroll: true });
|
||||
focusOnOpen.current = false;
|
||||
}
|
||||
}, [open, dock, id]);
|
||||
|
||||
useEffect(() => () => removeFloatingWindow(id), [id]);
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open || docked) return;
|
||||
const others = floatingWindowOrder().filter((windowId) => windowId !== id);
|
||||
const headingHeight = Array.from(floatingWindowHost().querySelectorAll<HTMLElement>(".floating-window-heading"))
|
||||
.reduce((height, element) => Math.max(height, element.offsetHeight), 48);
|
||||
@@ -89,8 +130,6 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
setBounds(fitWindow({ x: (window.innerWidth - initialSizeRef.current.width) / 2 + slot * 24, y,
|
||||
width: initialSizeRef.current.width, height: Math.min(initialSizeRef.current.height, window.innerHeight - y - 8) }));
|
||||
raiseFloatingWindow(id);
|
||||
if (focusOnOpen.current) heading.current?.focus({ preventScroll: true });
|
||||
focusOnOpen.current = false;
|
||||
const resize = () => {
|
||||
setViewport({ width: window.innerWidth, height: window.innerHeight });
|
||||
setBounds((current) => {
|
||||
@@ -104,16 +143,16 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
window.removeEventListener("resize", resize);
|
||||
removeFloatingWindow(id);
|
||||
};
|
||||
}, [id, open]);
|
||||
}, [id, open, docked]);
|
||||
|
||||
const current = maximized
|
||||
? { x: 8, y: floatingWindowTopInset(), width: viewport.width - 16, height: viewport.height - floatingWindowTopInset() - 8 }
|
||||
: bounds;
|
||||
const startDrag = useFloatingWindowDrag({ enabled: open && !maximized, position: current,
|
||||
const startDrag = useFloatingWindowDrag({ enabled: open && !maximized && !docked, position: current,
|
||||
width: current.width, height: current.height,
|
||||
onCommit: (position) => setBounds((previous) => fitWindow({ ...previous, ...position })) });
|
||||
|
||||
const startResize = useFloatingWindowResize({ enabled: open && !maximized, bounds: current, onCommit: (next) => setBounds(fitWindow(next)) });
|
||||
const startResize = useFloatingWindowResize({ enabled: open && !maximized && !docked, bounds: current, onCommit: (next) => setBounds(fitWindow(next)) });
|
||||
|
||||
return <>
|
||||
<Button ref={trigger} type="button" variant="ghost" size={triggerContent ? "sm" : "icon-sm"} className="shrink-0"
|
||||
@@ -124,24 +163,26 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
setMaximized(false);
|
||||
focusOnOpen.current = true;
|
||||
setManuallyOpen(true);
|
||||
} else heading.current?.focus({ preventScroll: true });
|
||||
} else focusWindow();
|
||||
raise();
|
||||
}}><Maximize2 size={15} aria-hidden />{triggerContent}</Button>
|
||||
{open ? createPortal(
|
||||
<div style={{ position: "absolute", left: 0, top: 0, width: current.width, height: current.height,
|
||||
<div style={docked ? { position: "relative", width: "100%", height: "100%" } : { position: "absolute", left: 0, top: 0, width: current.width, height: current.height,
|
||||
transform: `translate(${current.x}px, ${current.y}px)`, zIndex: order.indexOf(id) + 1 }}
|
||||
className="workbench-shadcn-surface pointer-events-auto flex flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg"
|
||||
className={`workbench-shadcn-surface pointer-events-auto flex flex-col overflow-hidden border border-border bg-background text-foreground ${docked ? "pb-28 lg:pb-14" : "rounded-xl shadow-lg"}`}
|
||||
role="dialog" aria-modal="false" aria-labelledby={titleId} data-floating-window="" data-floating-window-id={id}
|
||||
data-window-presentation={docked ? "docked" : "floating"}
|
||||
hidden={Boolean(dock && dock.activeId !== id)}
|
||||
onPointerDownCapture={raise} onFocusCapture={raise}
|
||||
onKeyDown={(event: React.KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); }
|
||||
}}>
|
||||
<div ref={heading} tabIndex={0}
|
||||
onPointerDown={startDrag}
|
||||
className="floating-window-heading flex min-h-12 shrink-0 cursor-move touch-none select-none items-center justify-between gap-3 border-b border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-ring"
|
||||
aria-label="窗口标题栏,可用方向键移动,Shift 加方向键调整大小"
|
||||
className={`floating-window-heading flex min-h-12 shrink-0 ${docked ? "" : "cursor-move touch-none"} select-none items-center justify-between gap-3 border-b border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-ring`}
|
||||
aria-label={docked ? `${title}标题栏` : "窗口标题栏,可用方向键移动,Shift 加方向键调整大小"}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget || maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
if (docked || event.target !== event.currentTarget || maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const dx = event.key === "ArrowLeft" ? -16 : event.key === "ArrowRight" ? 16 : 0;
|
||||
const dy = event.key === "ArrowUp" ? -16 : event.key === "ArrowDown" ? 16 : 0;
|
||||
@@ -151,10 +192,10 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
}}>
|
||||
<h2 id={titleId} className="min-w-0 break-words text-sm font-semibold leading-5">{title}</h2>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label={maximized ? "还原窗口" : "最大化窗口"}
|
||||
{!docked ? <Button type="button" variant="ghost" size="icon-sm" aria-label={maximized ? "还原窗口" : "最大化窗口"}
|
||||
title={maximized ? "还原窗口" : "最大化窗口"} onClick={() => setMaximized(!maximized)}>
|
||||
{maximized ? <Minimize2 size={15} aria-hidden /> : <Maximize2 size={15} aria-hidden />}
|
||||
</Button>
|
||||
</Button> : null}
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="关闭窗口" title="关闭窗口" onClick={close}>
|
||||
<X size={16} aria-hidden />
|
||||
</Button>
|
||||
@@ -165,13 +206,13 @@ export function FloatingWindow({ title, navigationTitle, triggerLabel, fillConte
|
||||
{children({ width: current.width - 26, height: current.height - 76 })}
|
||||
</div>
|
||||
</div>
|
||||
{!maximized ? (Object.entries(resizeHandles) as Array<[ResizeEdge, React.CSSProperties]>).map(([edge, style]) => (
|
||||
{!maximized && !docked ? (Object.entries(resizeHandles) as Array<[ResizeEdge, React.CSSProperties]>).map(([edge, style]) => (
|
||||
<div key={edge} aria-hidden data-window-resize-handle={edge}
|
||||
className={`absolute z-10 touch-none ${edge === "bottomRight" ? "floating-window-resize" : ""}`}
|
||||
style={style} onPointerDown={(event) => startResize(event, edge)}>
|
||||
{edge === "bottomRight" ? <span className="pointer-events-none absolute bottom-1 right-1 size-2 border-b-2 border-r-2 border-muted-foreground/60" /> : null}
|
||||
</div>
|
||||
)) : null}
|
||||
</div>, floatingWindowHost()) : null}
|
||||
</div>, docked ? dockHost : floatingWindowHost()) : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user