mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
topology portal terminal preview popover
This commit is contained in:
1 parent
06fb4f6f2c
commit
c265707236
2 files changed
+97
-23
No files matched your search
@@ -1,15 +1,32 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Terminal } from "lucide-react";
|
||||
import { SessionPreviewPane } from "../preview/SessionPreviewPane.js";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
const TERMINAL_PREVIEW_EVENT = "openrig:topology-terminal-preview";
|
||||
const POPOVER_GAP = 8;
|
||||
const POPOVER_MARGIN = 8;
|
||||
const FALLBACK_POPOVER_WIDTH = 320;
|
||||
const FALLBACK_POPOVER_HEIGHT = 240;
|
||||
|
||||
interface TerminalPreviewEventDetail {
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface AnchorRect {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
interface PopoverPosition {
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
interface TerminalPreviewPopoverProps {
|
||||
rigId: string | null | undefined;
|
||||
logicalId: string;
|
||||
@@ -21,6 +38,32 @@ interface TerminalPreviewPopoverProps {
|
||||
testIdPrefix: string;
|
||||
}
|
||||
|
||||
function rectFromElement(el: HTMLElement | null): AnchorRect {
|
||||
const rect = el?.getBoundingClientRect();
|
||||
return {
|
||||
left: rect?.left ?? POPOVER_MARGIN,
|
||||
right: rect?.right ?? POPOVER_MARGIN,
|
||||
top: rect?.top ?? POPOVER_MARGIN,
|
||||
bottom: rect?.bottom ?? POPOVER_MARGIN,
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function computePopoverPosition(anchor: AnchorRect, width: number, height: number): PopoverPosition {
|
||||
const viewportWidth = window.innerWidth || width + POPOVER_MARGIN * 2;
|
||||
const viewportHeight = window.innerHeight || height + POPOVER_MARGIN * 2;
|
||||
const rightSideLeft = anchor.right + POPOVER_GAP;
|
||||
const leftSideLeft = anchor.left - width - POPOVER_GAP;
|
||||
const left = rightSideLeft + width <= viewportWidth - POPOVER_MARGIN ? rightSideLeft : leftSideLeft;
|
||||
return {
|
||||
left: clamp(left, POPOVER_MARGIN, Math.max(POPOVER_MARGIN, viewportWidth - width - POPOVER_MARGIN)),
|
||||
top: clamp(anchor.top, POPOVER_MARGIN, Math.max(POPOVER_MARGIN, viewportHeight - height - POPOVER_MARGIN)),
|
||||
};
|
||||
}
|
||||
|
||||
export function TerminalPreviewPopover({
|
||||
rigId,
|
||||
logicalId,
|
||||
@@ -33,23 +76,39 @@ export function TerminalPreviewPopover({
|
||||
}: TerminalPreviewPopoverProps) {
|
||||
const key = `${rigId ?? "unknown"}:${logicalId}`;
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [anchorRect, setAnchorRect] = useState<AnchorRect | null>(null);
|
||||
const [position, setPosition] = useState<PopoverPosition | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = (event: Event) => {
|
||||
const detail = (event as CustomEvent<TerminalPreviewEventDetail>).detail;
|
||||
setOpen(detail?.key === key);
|
||||
const nextOpen = detail?.key === key;
|
||||
setOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
const nextAnchor = rectFromElement(rootRef.current);
|
||||
setAnchorRect(nextAnchor);
|
||||
setPosition(computePopoverPosition(nextAnchor, FALLBACK_POPOVER_WIDTH, FALLBACK_POPOVER_HEIGHT));
|
||||
}
|
||||
};
|
||||
window.addEventListener(TERMINAL_PREVIEW_EVENT, handleOpen);
|
||||
return () => window.removeEventListener(TERMINAL_PREVIEW_EVENT, handleOpen);
|
||||
}, [key]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !anchorRect || !popoverRef.current) return;
|
||||
setPosition(computePopoverPosition(anchorRect, popoverRef.current.offsetWidth, popoverRef.current.offsetHeight));
|
||||
}, [anchorRect, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (!rootRef.current?.contains(target)) setOpen(false);
|
||||
if (rootRef.current?.contains(target)) return;
|
||||
if (popoverRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
@@ -62,6 +121,30 @@ export function TerminalPreviewPopover({
|
||||
window.dispatchEvent(new CustomEvent<TerminalPreviewEventDetail>(TERMINAL_PREVIEW_EVENT, { detail: { key } }));
|
||||
};
|
||||
|
||||
const popover = open && position ? createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
data-testid={`${testIdPrefix}-terminal-popover`}
|
||||
data-reduced-motion={reducedMotion ? "true" : "false"}
|
||||
className={cn(
|
||||
"nodrag nopan fixed z-[1000] w-80 border border-outline-variant bg-stone-950 p-1.5 hard-shadow",
|
||||
"cursor-default select-text text-stone-100",
|
||||
popoverClassName,
|
||||
)}
|
||||
style={{ left: position.left, top: position.top }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<SessionPreviewPane
|
||||
sessionName={sessionName}
|
||||
lines={80}
|
||||
testIdPrefix={`${testIdPrefix}-terminal-preview`}
|
||||
variant="compact-terminal"
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={cn("relative inline-flex", wrapperClassName)}>
|
||||
<button
|
||||
@@ -74,26 +157,7 @@ export function TerminalPreviewPopover({
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
data-testid={`${testIdPrefix}-terminal-popover`}
|
||||
data-reduced-motion={reducedMotion ? "true" : "false"}
|
||||
className={cn(
|
||||
"nodrag nopan absolute left-full top-0 z-[80] ml-2 w-80 border border-outline-variant bg-stone-950 p-1.5 hard-shadow",
|
||||
"cursor-default select-text text-stone-100",
|
||||
popoverClassName,
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<SessionPreviewPane
|
||||
sessionName={sessionName}
|
||||
lines={80}
|
||||
testIdPrefix={`${testIdPrefix}-terminal-preview`}
|
||||
variant="compact-terminal"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{popover}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -202,6 +202,12 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
|
||||
expect(screen.queryByText(/captured/i)).toBeNull();
|
||||
expect(screen.queryByText(/1 lines/i)).toBeNull();
|
||||
expect(screen.getByTestId("hybrid-driver-terminal-preview-pane").getAttribute("data-variant")).toBe("compact-terminal");
|
||||
const driverPopover = screen.getByTestId("hybrid-driver-terminal-popover");
|
||||
expect(driverPopover.parentElement).toBe(document.body);
|
||||
expect(driverPopover.className).toContain("fixed");
|
||||
expect(driverPopover.className).toContain("z-[1000]");
|
||||
expect(driverPopover.style.left).not.toBe("");
|
||||
expect(driverPopover.style.top).not.toBe("");
|
||||
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(screen.queryByTestId("hybrid-driver-terminal-popover")).toBeNull();
|
||||
@@ -241,8 +247,12 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
|
||||
expect(rigNode).toMatch(/getActivityCardClasses/);
|
||||
expect(rigNode).toMatch(/TerminalPreviewPopover/);
|
||||
expect(terminalPopover).toMatch(/SessionPreviewPane/);
|
||||
expect(terminalPopover).toMatch(/createPortal/);
|
||||
expect(terminalPopover).toMatch(/document\.body/);
|
||||
expect(terminalPopover).toMatch(/variant="compact-terminal"/);
|
||||
expect(terminalPopover).toMatch(/bg-stone-950/);
|
||||
expect(terminalPopover).toMatch(/z-\[1000\]/);
|
||||
expect(terminalPopover).not.toMatch(/absolute left-full/);
|
||||
expect(terminalPopover).not.toMatch(/terminal preview/);
|
||||
expect(terminalPopover).not.toMatch(/terminal-close/);
|
||||
expect(activityCards).toMatch(/activity-card-active/);
|
||||
|
||||
Reference in new issue
Block a user