"use client"; import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts"; import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef, useState } from "react"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { Button } from "~/components/ui/button"; import { toastManager } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { cn } from "~/lib/utils"; import { useThreadPreviewState } from "~/previewStateStore"; import { type PreviewMiniPlayerSize, selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "~/previewMiniPlayerStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { previewBridge } from "./previewBridge"; import { clampPreviewMiniPlayerPosition, PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, type PreviewMiniPlayerFrame, resizePreviewMiniPlayer, resolvePreviewMiniPlayerFrame, resolvePreviewMiniPlayerSourceSize, } from "./previewMiniPlayerLayout"; interface PointerGesture { readonly pointerId: number; readonly pointerX: number; readonly pointerY: number; readonly frame: PreviewMiniPlayerFrame; readonly direction: BrowserViewportResizeDirection | null; } interface Props { readonly threadRef: ScopedThreadRef; readonly tabId: string; readonly bottomInset: number; } const PREVIEW_MINI_PLAYER_CORNER_RADIUS = 12; // Invisible grab zones straddling each edge; the cursor is the only affordance. const RESIZE_HANDLES: ReadonlyArray<{ readonly direction: BrowserViewportResizeDirection; readonly className: string; }> = [ { direction: "north", className: "inset-x-0 -top-1 h-2 cursor-ns-resize" }, { direction: "south", className: "inset-x-0 -bottom-1 h-2 cursor-ns-resize" }, { direction: "west", className: "inset-y-0 -left-1 w-2 cursor-ew-resize" }, { direction: "east", className: "inset-y-0 -right-1 w-2 cursor-ew-resize" }, { direction: "northwest", className: "-left-2 -top-2 size-4 cursor-nwse-resize" }, { direction: "northeast", className: "-right-2 -top-2 size-4 cursor-nesw-resize" }, { direction: "southwest", className: "-bottom-2 -left-2 size-4 cursor-nesw-resize" }, { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" }, ]; /** * Floats the thread's browser surface over chat. Native clipping and the DOM * frame use the same radius so their separately composited edges stay aligned. */ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props) { const containerRef = useRef(null); const gestureRef = useRef(null); const [container, setContainer] = useState(null); const miniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), ); const previewState = useThreadPreviewState(threadRef); const snapshot = previewState.sessions[tabId] ?? null; const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId); const desktopOverlay = previewState.desktopByTabId[tabId] ?? null; const fittedSourceContent = useBrowserSurfaceStore( (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null, ); const source = resolvePreviewMiniPlayerSourceSize( snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT, fittedSourceContent, desktopOverlay?.zoomFactor ?? 1, ); const frame = container && miniPlayer?.tabId === tabId ? resolvePreviewMiniPlayerFrame({ width: miniPlayer.width, position: miniPlayer.position, source, container, bottomInset, }) : null; const close = () => { usePreviewMiniPlayerStore.getState().close(threadRef); }; const openInPanel = () => { usePreviewMiniPlayerStore.getState().close(threadRef); useRightPanelStore.getState().openBrowser(threadRef, tabId); }; const toggleNativePictureInPicture = () => { if (!previewBridge) return; const operation = desktopOverlay?.pictureInPicture ? previewBridge.pictureInPicture.close : previewBridge.pictureInPicture.open; void operation(runtimeTabId).catch((error) => { toastManager.add({ type: "error", title: "Unable to update popped-out preview", description: error instanceof Error ? error.message : "An error occurred.", }); }); }; useLayoutEffect(() => { const element = containerRef.current; if (!element) return; const measure = () => { setContainer((current) => current?.width === element.clientWidth && current.height === element.clientHeight ? current : { width: element.clientWidth, height: element.clientHeight }, ); }; measure(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(measure); observer.observe(element); return () => observer.disconnect(); }, []); const beginGesture = ( event: ReactPointerEvent, direction: BrowserViewportResizeDirection | null, ) => { if (event.button !== 0 || !frame) return; gestureRef.current = { pointerId: event.pointerId, pointerX: event.clientX, pointerY: event.clientY, frame, direction, }; event.currentTarget.setPointerCapture(event.pointerId); event.preventDefault(); event.stopPropagation(); }; const handlePointerMove = (event: ReactPointerEvent) => { const gesture = gestureRef.current; if (!gesture || gesture.pointerId !== event.pointerId || !container) return; const delta = { x: event.clientX - gesture.pointerX, y: event.clientY - gesture.pointerY }; const store = usePreviewMiniPlayerStore.getState(); if (gesture.direction === null) { store.move( threadRef, tabId, clampPreviewMiniPlayerPosition( { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y }, container, gesture.frame, bottomInset, ), ); return; } const next = resizePreviewMiniPlayer({ start: gesture.frame, direction: gesture.direction, delta, source, container, bottomInset, }); store.resize(threadRef, tabId, next.width); store.move(threadRef, tabId, { x: next.x, y: next.y }); }; const endGesture = (event: ReactPointerEvent) => { if (gestureRef.current?.pointerId !== event.pointerId) return; gestureRef.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } }; if (!snapshot || miniPlayer?.tabId !== tabId) return null; return (
{frame ? (
{!desktopOverlay?.hasWebContents ? (
Reconnecting preview…
) : null} {RESIZE_HANDLES.map(({ direction, className }) => (
beginGesture(event, direction)} onPointerMove={handlePointerMove} onPointerUp={endGesture} onPointerCancel={endGesture} /> ))}
) : null}
); }