源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
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
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
This commit is contained in:
+242
@@ -0,0 +1,242 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DESKTOP_WINDOW_TOP_MARGIN } from "../../workspaceDesktop/constants";
|
||||
import { ComposerFloatingMenuSurface } from "./ComposerFloatingMenuSurface";
|
||||
|
||||
function mockRect(rect: {
|
||||
bottom: number;
|
||||
height: number;
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
width: number;
|
||||
}): DOMRect {
|
||||
return { ...rect, x: rect.left, y: rect.top, toJSON: () => ({}) } as DOMRect;
|
||||
}
|
||||
|
||||
describe("ComposerFloatingMenuSurface", () => {
|
||||
it("keeps viewport coordinates when a workbench boundary requests a body portal", () => {
|
||||
const boundary = document.createElement("div");
|
||||
boundary.setAttribute("data-slot", "viewport-menu-boundary");
|
||||
boundary.setAttribute("data-viewport-menu-portal-target", "body");
|
||||
boundary.setAttribute("data-workbench-window-id", "node-1");
|
||||
boundary.style.zIndex = "4";
|
||||
|
||||
const anchorRef = createRef<HTMLDivElement>();
|
||||
const anchor = document.createElement("div");
|
||||
anchor.getBoundingClientRect = vi.fn(() =>
|
||||
mockRect({
|
||||
bottom: 560,
|
||||
height: 30,
|
||||
left: 312,
|
||||
right: 588,
|
||||
top: 530,
|
||||
width: 276
|
||||
})
|
||||
);
|
||||
boundary.appendChild(anchor);
|
||||
document.body.appendChild(boundary);
|
||||
anchorRef.current = anchor;
|
||||
|
||||
render(
|
||||
<ComposerFloatingMenuSurface
|
||||
anchorRef={anchorRef}
|
||||
maxHeight={320}
|
||||
open
|
||||
placement="fixed-height"
|
||||
testId="composer-floating-menu-surface-workbench"
|
||||
>
|
||||
<div>menu content</div>
|
||||
</ComposerFloatingMenuSurface>
|
||||
);
|
||||
|
||||
const surface = screen.getByTestId(
|
||||
"composer-floating-menu-surface-workbench"
|
||||
);
|
||||
expect(surface.parentElement).toBe(document.body);
|
||||
expect(surface.style.left).toBe("312px");
|
||||
|
||||
boundary.remove();
|
||||
});
|
||||
|
||||
it("keeps a fixed-height menu clear of the workspace window's traffic-light header even when the anchor sits near the top of the viewport", () => {
|
||||
const anchorRef = createRef<HTMLDivElement>();
|
||||
const anchor = document.createElement("div");
|
||||
anchor.getBoundingClientRect = vi.fn(() =>
|
||||
mockRect({
|
||||
bottom: 90,
|
||||
height: 30,
|
||||
left: 12,
|
||||
right: 200,
|
||||
top: 60,
|
||||
width: 188
|
||||
})
|
||||
);
|
||||
document.body.appendChild(anchor);
|
||||
anchorRef.current = anchor;
|
||||
|
||||
render(
|
||||
<ComposerFloatingMenuSurface
|
||||
anchorRef={anchorRef}
|
||||
maxHeight={320}
|
||||
open
|
||||
placement="fixed-height"
|
||||
testId="composer-floating-menu-surface"
|
||||
>
|
||||
<div>menu content</div>
|
||||
</ComposerFloatingMenuSurface>
|
||||
);
|
||||
|
||||
const surface = screen.getByTestId("composer-floating-menu-surface");
|
||||
const top = Number.parseFloat(surface.style.top);
|
||||
expect(top).toBeGreaterThanOrEqual(DESKTOP_WINDOW_TOP_MARGIN);
|
||||
|
||||
anchor.remove();
|
||||
});
|
||||
|
||||
it("clamps the menu against a compact virtual node window's own header, not just the real viewport's 52px margin", () => {
|
||||
// Simulate a short WorkspaceNodeWindow (well under COMPOSER_MENU_MIN_HEIGHT_PX
|
||||
// of 280px) parked in the middle of the workspace canvas, far from the real
|
||||
// screen's top edge. Its own decorative header sits at, say, 400-436px in
|
||||
// viewport coordinates -- nowhere near the real-window 52px safe area, so the
|
||||
// 52px floor alone can't protect it.
|
||||
const nodeRoot = document.createElement("div");
|
||||
nodeRoot.setAttribute("data-workspace-node-window-root", "true");
|
||||
nodeRoot.getBoundingClientRect = vi.fn(() =>
|
||||
mockRect({
|
||||
bottom: 570,
|
||||
height: 170,
|
||||
left: 300,
|
||||
right: 600,
|
||||
top: 400,
|
||||
width: 300
|
||||
})
|
||||
);
|
||||
|
||||
const header = document.createElement("header");
|
||||
header.setAttribute("data-workspace-node-window-header", "true");
|
||||
header.getBoundingClientRect = vi.fn(() =>
|
||||
mockRect({
|
||||
bottom: 436,
|
||||
height: 36,
|
||||
left: 300,
|
||||
right: 600,
|
||||
top: 400,
|
||||
width: 300
|
||||
})
|
||||
);
|
||||
nodeRoot.appendChild(header);
|
||||
|
||||
const anchorRef = createRef<HTMLDivElement>();
|
||||
const anchor = document.createElement("div");
|
||||
// Composer sits directly below the header, near the bottom of the short node.
|
||||
anchor.getBoundingClientRect = vi.fn(() =>
|
||||
mockRect({
|
||||
bottom: 560,
|
||||
height: 30,
|
||||
left: 312,
|
||||
right: 588,
|
||||
top: 530,
|
||||
width: 276
|
||||
})
|
||||
);
|
||||
nodeRoot.appendChild(anchor);
|
||||
document.body.appendChild(nodeRoot);
|
||||
anchorRef.current = anchor;
|
||||
|
||||
render(
|
||||
<ComposerFloatingMenuSurface
|
||||
anchorRef={anchorRef}
|
||||
maxHeight={320}
|
||||
open
|
||||
placement="fixed-height"
|
||||
testId="composer-floating-menu-surface-node"
|
||||
>
|
||||
<div>menu content</div>
|
||||
</ComposerFloatingMenuSurface>
|
||||
);
|
||||
|
||||
const surface = screen.getByTestId("composer-floating-menu-surface-node");
|
||||
// The palette must render inside the node (portal target), not document.body.
|
||||
expect(surface.parentElement).toBe(nodeRoot);
|
||||
|
||||
const top = Number.parseFloat(surface.style.top);
|
||||
const minHeight = Number.parseFloat(surface.style.minHeight);
|
||||
|
||||
// The node's own header bottom edge (436) is a far more restrictive floor
|
||||
// here than the real-viewport 52px margin -- the fix must honor it.
|
||||
expect(top).toBeGreaterThanOrEqual(436);
|
||||
// The forced minimum height must never be so large that top + minHeight
|
||||
// pushes back above the node's own header, even though the palette's
|
||||
// "natural" minimum (280px) alone would do exactly that from this anchor.
|
||||
expect(top + minHeight).toBeLessThanOrEqual(560 - 8 + 0.5);
|
||||
expect(minHeight).toBeLessThanOrEqual(280);
|
||||
});
|
||||
|
||||
it("still applies the real-viewport 52px floor when the enclosing node's own header is above it (real window case is unaffected)", () => {
|
||||
const nodeRoot = document.createElement("div");
|
||||
nodeRoot.setAttribute("data-workspace-node-window-root", "true");
|
||||
nodeRoot.getBoundingClientRect = vi.fn(() =>
|
||||
mockRect({
|
||||
bottom: 400,
|
||||
height: 400,
|
||||
left: 0,
|
||||
right: 400,
|
||||
top: 0,
|
||||
width: 400
|
||||
})
|
||||
);
|
||||
|
||||
const header = document.createElement("header");
|
||||
header.setAttribute("data-workspace-node-window-header", "true");
|
||||
// Header bottom edge sits above the real-window 52px safe area (e.g. a
|
||||
// maximized node docked flush with the canvas top).
|
||||
header.getBoundingClientRect = vi.fn(() =>
|
||||
mockRect({
|
||||
bottom: 36,
|
||||
height: 36,
|
||||
left: 0,
|
||||
right: 400,
|
||||
top: 0,
|
||||
width: 400
|
||||
})
|
||||
);
|
||||
nodeRoot.appendChild(header);
|
||||
|
||||
const anchorRef = createRef<HTMLDivElement>();
|
||||
const anchor = document.createElement("div");
|
||||
anchor.getBoundingClientRect = vi.fn(() =>
|
||||
mockRect({
|
||||
bottom: 90,
|
||||
height: 30,
|
||||
left: 12,
|
||||
right: 200,
|
||||
top: 60,
|
||||
width: 188
|
||||
})
|
||||
);
|
||||
nodeRoot.appendChild(anchor);
|
||||
document.body.appendChild(nodeRoot);
|
||||
anchorRef.current = anchor;
|
||||
|
||||
render(
|
||||
<ComposerFloatingMenuSurface
|
||||
anchorRef={anchorRef}
|
||||
maxHeight={320}
|
||||
open
|
||||
placement="fixed-height"
|
||||
testId="composer-floating-menu-surface-real-floor"
|
||||
>
|
||||
<div>menu content</div>
|
||||
</ComposerFloatingMenuSurface>
|
||||
);
|
||||
|
||||
const surface = screen.getByTestId(
|
||||
"composer-floating-menu-surface-real-floor"
|
||||
);
|
||||
const top = Number.parseFloat(surface.style.top);
|
||||
expect(top).toBeGreaterThanOrEqual(DESKTOP_WINDOW_TOP_MARGIN);
|
||||
});
|
||||
});
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type RefObject
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "../../../app/renderer/lib/utils";
|
||||
import { DESKTOP_WINDOW_TOP_MARGIN } from "../../workspaceDesktop/constants";
|
||||
import { resolveComposerPortalTarget } from "../composer/composerPortalTarget";
|
||||
|
||||
const COMPOSER_MENU_GAP_PX = 8;
|
||||
const COMPOSER_MENU_VIEWPORT_PADDING_PX = 8;
|
||||
// Keep the menu clear of the workspace window's own header, where the macOS
|
||||
// traffic lights live (see `workspaceWindowHeaderHeightPx` in
|
||||
// apps/desktop/src/main/windows/workspaceWindow.ts and the matching
|
||||
// `DESKTOP_WINDOW_TOP_MARGIN` reserve used to place node windows below that
|
||||
// header). Anchors near the top of the canvas otherwise collapse this menu's
|
||||
// top offset down to the bare viewport padding, landing it on top of the
|
||||
// traffic lights and drag region.
|
||||
const COMPOSER_MENU_TOP_SAFE_AREA_PX = DESKTOP_WINDOW_TOP_MARGIN;
|
||||
const COMPOSER_MENU_MIN_HEIGHT_PX = 280;
|
||||
|
||||
export interface ComposerAnchoredMenuFrame {
|
||||
bottom: number;
|
||||
height: number;
|
||||
left: number;
|
||||
minHeight: number;
|
||||
portalTarget: Element;
|
||||
top: number;
|
||||
width: number;
|
||||
zIndex: number | string;
|
||||
}
|
||||
|
||||
export interface UseComposerAnchoredMenuFrameOptions {
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
maxHeight: number;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export type ComposerFloatingMenuPlacement = "fixed-height" | "dynamic-above";
|
||||
|
||||
export interface ComposerFloatingMenuSurfaceProps {
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
dismissBoundaryRef?: RefObject<HTMLElement | null>;
|
||||
maxHeight: number;
|
||||
onDismiss?: () => void;
|
||||
open: boolean;
|
||||
placement: ComposerFloatingMenuPlacement;
|
||||
surfaceRef?: RefObject<HTMLDivElement | null>;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
const COMPOSER_MENU_WINDOW_FRAME_SELECTOR =
|
||||
"[data-workbench-window-id], [data-workspace-node-window-root='true']";
|
||||
|
||||
function resolveComposerMenuWindowFrame(anchor: HTMLElement): Element | null {
|
||||
return anchor.closest(COMPOSER_MENU_WINDOW_FRAME_SELECTOR);
|
||||
}
|
||||
|
||||
function resolveComposerMenuZIndex(anchor: HTMLElement): number | string {
|
||||
let current: HTMLElement | null = anchor;
|
||||
while (current) {
|
||||
if (current.matches(COMPOSER_MENU_WINDOW_FRAME_SELECTOR)) {
|
||||
const windowZIndex = Number.parseInt(
|
||||
window.getComputedStyle(current).zIndex,
|
||||
10
|
||||
);
|
||||
if (Number.isFinite(windowZIndex)) {
|
||||
return windowZIndex + 1;
|
||||
}
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return "var(--z-popover)";
|
||||
}
|
||||
|
||||
// Mirrors the real workspace window's `COMPOSER_MENU_TOP_SAFE_AREA_PX` floor,
|
||||
// but scoped to a virtual node window on the workspace canvas
|
||||
// (`WorkspaceNodeWindow.tsx`). Each node draws its own decorative header with
|
||||
// fake traffic-light buttons (`data-workspace-node-window-header="true"`);
|
||||
// clamping only against the real viewport's 52px chrome ignores that a
|
||||
// compact node's header can sit well below (or, rarer, above) that mark.
|
||||
// Falls back to the enclosing frame's own top edge when no explicit header
|
||||
// is found, so the menu never renders above the window/node it lives in.
|
||||
function resolveComposerMenuTopSafeArea(anchor: HTMLElement): number {
|
||||
const windowFrame = resolveComposerMenuWindowFrame(anchor);
|
||||
if (!windowFrame) {
|
||||
return COMPOSER_MENU_TOP_SAFE_AREA_PX;
|
||||
}
|
||||
|
||||
const header = windowFrame.querySelector(
|
||||
'[data-workspace-node-window-header="true"]'
|
||||
);
|
||||
const localFloor = (header ?? windowFrame).getBoundingClientRect()[
|
||||
header ? "bottom" : "top"
|
||||
];
|
||||
|
||||
return Math.max(COMPOSER_MENU_TOP_SAFE_AREA_PX, localFloor);
|
||||
}
|
||||
|
||||
function computeComposerAnchoredMenuFrame(
|
||||
anchor: HTMLElement,
|
||||
maxHeight: number
|
||||
): ComposerAnchoredMenuFrame {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const width = Math.max(
|
||||
0,
|
||||
Math.min(rect.width, viewportWidth - COMPOSER_MENU_VIEWPORT_PADDING_PX * 2)
|
||||
);
|
||||
const left = Math.max(
|
||||
COMPOSER_MENU_VIEWPORT_PADDING_PX,
|
||||
Math.min(
|
||||
rect.left,
|
||||
viewportWidth - COMPOSER_MENU_VIEWPORT_PADDING_PX - width
|
||||
)
|
||||
);
|
||||
const topSafeArea = resolveComposerMenuTopSafeArea(anchor);
|
||||
const availableAbove = rect.top - COMPOSER_MENU_GAP_PX - topSafeArea;
|
||||
// Never demand more minimum height than the enclosing window/node actually
|
||||
// has room for above its own safe area — otherwise a compact node (as
|
||||
// short as `DESKTOP_WINDOW_MIN_HEIGHT`) is forced to render the menu past
|
||||
// its own header, regardless of the safe-area clamp below.
|
||||
const minHeight = Math.max(
|
||||
0,
|
||||
Math.min(COMPOSER_MENU_MIN_HEIGHT_PX, availableAbove)
|
||||
);
|
||||
const height =
|
||||
availableAbove >= maxHeight
|
||||
? maxHeight
|
||||
: Math.max(minHeight, Math.min(maxHeight, availableAbove));
|
||||
|
||||
return {
|
||||
bottom: Math.max(
|
||||
COMPOSER_MENU_VIEWPORT_PADDING_PX,
|
||||
viewportHeight - rect.top + COMPOSER_MENU_GAP_PX
|
||||
),
|
||||
height,
|
||||
left,
|
||||
minHeight,
|
||||
portalTarget: resolveComposerPortalTarget(anchor),
|
||||
top: Math.max(
|
||||
topSafeArea,
|
||||
Math.min(
|
||||
rect.top - COMPOSER_MENU_GAP_PX - height,
|
||||
viewportHeight - COMPOSER_MENU_VIEWPORT_PADDING_PX - height
|
||||
)
|
||||
),
|
||||
width,
|
||||
zIndex: resolveComposerMenuZIndex(anchor)
|
||||
};
|
||||
}
|
||||
|
||||
function sameComposerAnchoredMenuFrame(
|
||||
left: ComposerAnchoredMenuFrame | null,
|
||||
right: ComposerAnchoredMenuFrame
|
||||
): boolean {
|
||||
return (
|
||||
left !== null &&
|
||||
left.portalTarget === right.portalTarget &&
|
||||
left.zIndex === right.zIndex &&
|
||||
Math.abs(left.left - right.left) < 0.5 &&
|
||||
Math.abs(left.top - right.top) < 0.5 &&
|
||||
Math.abs(left.bottom - right.bottom) < 0.5 &&
|
||||
Math.abs(left.width - right.width) < 0.5 &&
|
||||
Math.abs(left.height - right.height) < 0.5 &&
|
||||
Math.abs(left.minHeight - right.minHeight) < 0.5
|
||||
);
|
||||
}
|
||||
|
||||
export function useComposerAnchoredMenuFrame({
|
||||
anchorRef,
|
||||
maxHeight,
|
||||
open
|
||||
}: UseComposerAnchoredMenuFrameOptions): ComposerAnchoredMenuFrame | null {
|
||||
const [frame, setFrame] = useState<ComposerAnchoredMenuFrame | null>(null);
|
||||
|
||||
const syncFrame = useCallback((): void => {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor || typeof window === "undefined") {
|
||||
setFrame(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFrame = computeComposerAnchoredMenuFrame(anchor, maxHeight);
|
||||
setFrame((previous) =>
|
||||
sameComposerAnchoredMenuFrame(previous, nextFrame) ? previous : nextFrame
|
||||
);
|
||||
}, [anchorRef, maxHeight]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
setFrame(null);
|
||||
return;
|
||||
}
|
||||
|
||||
syncFrame();
|
||||
const anchor = anchorRef.current;
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(syncFrame);
|
||||
if (anchor) {
|
||||
resizeObserver?.observe(anchor);
|
||||
}
|
||||
window.addEventListener("resize", syncFrame);
|
||||
window.addEventListener("scroll", syncFrame, {
|
||||
capture: true,
|
||||
passive: true
|
||||
});
|
||||
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener("resize", syncFrame);
|
||||
window.removeEventListener("scroll", syncFrame, true);
|
||||
};
|
||||
}, [anchorRef, open, syncFrame]);
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: RefObject<T | null> | undefined, value: T | null) {
|
||||
if (ref) {
|
||||
ref.current = value;
|
||||
}
|
||||
}
|
||||
|
||||
export const ComposerFloatingMenuSurface = forwardRef<
|
||||
HTMLDivElement,
|
||||
ComposerFloatingMenuSurfaceProps
|
||||
>(function ComposerFloatingMenuSurface(
|
||||
{
|
||||
anchorRef,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
dismissBoundaryRef,
|
||||
maxHeight,
|
||||
onDismiss,
|
||||
open,
|
||||
placement,
|
||||
surfaceRef,
|
||||
testId
|
||||
},
|
||||
forwardedRef
|
||||
): React.JSX.Element | null {
|
||||
const localSurfaceRef = useRef<HTMLDivElement | null>(null);
|
||||
const frame = useComposerAnchoredMenuFrame({
|
||||
anchorRef,
|
||||
maxHeight,
|
||||
open
|
||||
});
|
||||
|
||||
const setSurfaceRef = useCallback(
|
||||
(node: HTMLDivElement | null): void => {
|
||||
localSurfaceRef.current = node;
|
||||
assignRef(surfaceRef, node);
|
||||
if (typeof forwardedRef === "function") {
|
||||
forwardedRef(node);
|
||||
} else if (forwardedRef) {
|
||||
forwardedRef.current = node;
|
||||
}
|
||||
},
|
||||
[forwardedRef, surfaceRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !onDismiss) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isInsideMenu = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Node)) {
|
||||
return false;
|
||||
}
|
||||
const boundaryRef = dismissBoundaryRef ?? anchorRef;
|
||||
return Boolean(
|
||||
boundaryRef.current?.contains(target) ||
|
||||
localSurfaceRef.current?.contains(target)
|
||||
);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onDismiss();
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent): void => {
|
||||
if (isInsideMenu(event.target)) {
|
||||
return;
|
||||
}
|
||||
onDismiss();
|
||||
};
|
||||
|
||||
const handleFocusIn = (event: FocusEvent): void => {
|
||||
if (isInsideMenu(event.target)) {
|
||||
return;
|
||||
}
|
||||
onDismiss();
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
document.addEventListener("focusin", handleFocusIn, true);
|
||||
window.addEventListener("blur", onDismiss);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
document.removeEventListener("focusin", handleFocusIn, true);
|
||||
window.removeEventListener("blur", onDismiss);
|
||||
};
|
||||
}, [anchorRef, dismissBoundaryRef, onDismiss, open]);
|
||||
|
||||
const style = useMemo<CSSProperties>(() => {
|
||||
const baseStyle: CSSProperties = {
|
||||
position: "fixed",
|
||||
left: `${frame?.left ?? 0}px`,
|
||||
width: `${frame?.width ?? 0}px`,
|
||||
maxWidth: `${frame?.width ?? 0}px`,
|
||||
overflow: "hidden",
|
||||
zIndex: frame?.zIndex ?? "var(--z-popover)"
|
||||
};
|
||||
if (placement === "fixed-height") {
|
||||
return {
|
||||
...baseStyle,
|
||||
top: `${frame?.top ?? 0}px`,
|
||||
minHeight: `${frame?.minHeight ?? COMPOSER_MENU_MIN_HEIGHT_PX}px`,
|
||||
height: `${frame?.height ?? maxHeight}px`,
|
||||
maxHeight: `${maxHeight}px`
|
||||
};
|
||||
}
|
||||
return {
|
||||
...baseStyle,
|
||||
bottom: `${frame?.bottom ?? 0}px`,
|
||||
maxHeight: `${frame?.height ?? maxHeight}px`
|
||||
};
|
||||
}, [frame, maxHeight, placement]);
|
||||
|
||||
if (!open || typeof document === "undefined" || !document.body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const portalTarget =
|
||||
frame?.portalTarget ??
|
||||
(anchorRef.current
|
||||
? resolveComposerPortalTarget(anchorRef.current)
|
||||
: document.body);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
data-testid={testId}
|
||||
ref={setSurfaceRef}
|
||||
className={cn(
|
||||
"nodrag isolate rounded-[12px] border border-hairline bg-background-fronted p-[4px] text-foreground shadow-[var(--tsh-shell-shadow)] [-webkit-app-region:no-drag]",
|
||||
"overflow-hidden",
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{contentClassName ? (
|
||||
<div className={contentClassName}>{children}</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>,
|
||||
portalTarget
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user