Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

207 lines
7.0 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import type { JSX, SVGProps } from "react";
import { GridBottomLinedIcon } from "../../../app/renderer/components/icons/GridBottomLinedIcon";
import { GridLeftLinedIcon } from "../../../app/renderer/components/icons/GridLeftLinedIcon";
import { GridRightLinedIcon } from "../../../app/renderer/components/icons/GridRightLinedIcon";
import { GridTopLinedIcon } from "../../../app/renderer/components/icons/GridTopLinedIcon";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from "@tutti-os/ui-system";
import { Button } from "../../../app/renderer/components/ui/button";
import { useTranslation } from "../../../i18n/index";
import { CanvasNodeGhostIconButton } from "../../shared/CanvasNodeGhostIconButton";
import {
CanvasNodeLayoutLinedIcon,
CanvasNodeMaximizeLinedIcon,
CanvasNodeMinimizeLinedIcon
} from "../../shared/canvasNodeChromeIcons";
import { useDesktopStore } from "../store";
import type { DesktopSize, WindowQuickLayoutTarget } from "../types";
const QUICK_LAYOUT_OPTIONS: Array<{
target: WindowQuickLayoutTarget;
icon: (props: SVGProps<SVGSVGElement>) => JSX.Element;
labelKey: string;
}> = [
{
target: "left",
icon: GridLeftLinedIcon,
labelKey: "workspaceWindowLayout.left"
},
{
target: "right",
icon: GridRightLinedIcon,
labelKey: "workspaceWindowLayout.right"
},
{
target: "top",
icon: GridTopLinedIcon,
labelKey: "workspaceWindowLayout.top"
},
{
target: "bottom",
icon: GridBottomLinedIcon,
labelKey: "workspaceWindowLayout.bottom"
}
];
const WINDOW_LAYOUT_MENU_HOVER_OPEN_DELAY_MS = 300;
interface WindowLayoutMenuButtonProps {
windowId: string;
desktopSize: DesktopSize;
className?: string;
"data-window-header"?: string;
}
export function WindowLayoutMenuButton({
windowId,
desktopSize,
className,
"data-window-header": windowHeader
}: WindowLayoutMenuButtonProps): JSX.Element {
"use memo";
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const hoverOpenTimeoutRef = useRef<number | null>(null);
const applyQuickLayout = useDesktopStore((state) => state.applyQuickLayout);
const enterFullscreenWindow = useDesktopStore(
(state) => state.enterFullscreenWindow
);
const exitFullscreenWindow = useDesktopStore(
(state) => state.exitFullscreenWindow
);
const isFullscreen = useDesktopStore(
(state) =>
state.windows.find((window) => window.id === windowId)?.displayMode ===
"fullscreen"
);
const fullscreenLabelKey = isFullscreen
? "workspaceWindowLayout.restore"
: "workspaceWindowLayout.fullscreen";
const fullscreenIconState = isFullscreen ? "restore" : "fullscreen";
const FullscreenIcon = isFullscreen
? CanvasNodeMinimizeLinedIcon
: CanvasNodeMaximizeLinedIcon;
const applyLayout = (target: WindowQuickLayoutTarget) => {
applyQuickLayout(windowId, target, desktopSize);
setOpen(false);
};
const clearHoverOpenTimeout = () => {
if (hoverOpenTimeoutRef.current !== null) {
window.clearTimeout(hoverOpenTimeoutRef.current);
hoverOpenTimeoutRef.current = null;
}
};
const scheduleHoverOpen = () => {
if (open || hoverOpenTimeoutRef.current !== null) {
return;
}
hoverOpenTimeoutRef.current = window.setTimeout(() => {
hoverOpenTimeoutRef.current = null;
setOpen(true);
}, WINDOW_LAYOUT_MENU_HOVER_OPEN_DELAY_MS);
};
const toggleFullscreen = () => {
if (isFullscreen) {
exitFullscreenWindow(windowId, desktopSize);
} else {
enterFullscreenWindow(windowId, desktopSize);
}
setOpen(false);
};
useEffect(() => clearHoverOpenTimeout, []);
return (
<DropdownMenu
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
clearHoverOpenTimeout();
}
setOpen(nextOpen);
}}
>
<DropdownMenuTrigger asChild>
<CanvasNodeGhostIconButton
className={className}
data-window-header={windowHeader}
data-window-layout-menu-trigger="true"
data-menu-open={open ? "true" : undefined}
data-testid="window-layout-menu-trigger"
aria-label={t("workspaceWindowLayout.trigger")}
title={t("workspaceWindowLayout.trigger")}
onPointerEnter={scheduleHoverOpen}
onPointerLeave={clearHoverOpenTimeout}
>
<CanvasNodeLayoutLinedIcon
width={18}
height={18}
aria-hidden="true"
data-testid="window-layout-menu-trigger-icon"
/>
</CanvasNodeGhostIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={10}
className="t-dropdown is-open relative min-w-[160px] rounded-[var(--tutti-radius-xl)] border border-hairline bg-background-fronted p-0 text-foreground shadow-[var(--tsh-shell-shadow)]"
data-origin="top-right"
data-testid="window-layout-menu"
>
<div className="p-2">
<div className="mx-1 mt-0.5 mb-1.5 px-1 text-[13px] leading-[20px] font-semibold text-[var(--text-primary)]">
{t("workspaceWindowLayout.moveAndResize")}
</div>
<div className="grid grid-cols-4 items-center gap-1">
{QUICK_LAYOUT_OPTIONS.map((option) => {
const Icon = option.icon;
return (
<Button
key={option.target}
type="button"
variant="ghost"
size="iconClose"
aria-label={t(option.labelKey)}
title={t(option.labelKey)}
data-testid={`window-layout-menu-quick-layout-${option.target}`}
onClick={() => applyLayout(option.target)}
>
<Icon width={16} height={16} aria-hidden="true" />
</Button>
);
})}
</div>
</div>
<div className="mx-1.5 my-1 border-t border-[var(--line-2)]" />
<div className="px-1">
<button
className="inline-flex min-h-8 w-full items-center gap-2 whitespace-nowrap rounded-[6px] border border-transparent bg-transparent px-0 text-left text-[13px] leading-[20px] font-semibold text-[var(--text-primary)] transition-[background-color,color,border-color] duration-150 ease-in-out hover:bg-block focus-visible:bg-block focus-visible:outline-none"
type="button"
aria-label={t(fullscreenLabelKey)}
title={t(fullscreenLabelKey)}
onClick={toggleFullscreen}
>
<FullscreenIcon
width={16}
height={16}
aria-hidden="true"
data-testid="window-layout-menu-fullscreen-icon"
data-window-layout-fullscreen-icon={fullscreenIconState}
className="shrink-0"
/>
<span className="whitespace-nowrap">{t(fullscreenLabelKey)}</span>
</button>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}