| 1 | "use client"; |
| 2 | |
| 3 | import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts"; |
| 4 | import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; |
| 5 | import { |
| 6 | type PointerEvent as ReactPointerEvent, |
| 7 | type ReactNode, |
| 8 | useCallback, |
| 9 | useEffect, |
| 10 | useLayoutEffect, |
| 11 | useRef, |
| 12 | useState, |
| 13 | } from "react"; |
| 14 | |
| 15 | import { useChatCanvas } from "../chat/ChatCanvasContext"; |
| 16 | import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; |
| 17 | import { |
| 18 | findActiveBrowserRecordingRuntimeTabId, |
| 19 | useActiveBrowserRecordingTabIds, |
| 20 | } from "~/browser/browserRecording"; |
| 21 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; |
| 22 | import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; |
| 23 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; |
| 24 | import { useRendersServerTabNatively } from "~/browser/previewRuntime"; |
| 25 | import { type ServerBrowserHandle, ServerBrowserSurface } from "~/browser/ServerBrowserSurface"; |
| 26 | import { |
| 27 | closeServerPictureInPicture, |
| 28 | openServerPictureInPicture, |
| 29 | serverPictureInPictureKey, |
| 30 | supportsServerPictureInPicture, |
| 31 | useServerPictureInPictureKey, |
| 32 | } from "~/browser/serverPictureInPicture"; |
| 33 | import { Button } from "~/components/ui/button"; |
| 34 | import { toastManager } from "~/components/ui/toast"; |
| 35 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 36 | import { cn } from "~/lib/utils"; |
| 37 | import { useThreadPreviewState } from "~/previewStateStore"; |
| 38 | import { |
| 39 | type PreviewMiniPlayerSize, |
| 40 | type PreviewMiniPlayerSource, |
| 41 | type PreviewMiniPlayerState, |
| 42 | previewMiniPlayerSourceKey, |
| 43 | usePreviewMiniPlayerStore, |
| 44 | } from "~/previewMiniPlayerStore"; |
| 45 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 46 | import { useDeviceState } from "~/state/device"; |
| 47 | |
| 48 | import { DeviceStreamView } from "../device/DeviceStreamView"; |
| 49 | import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream"; |
| 50 | import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/server-browser-stream"; |
| 51 | import { previewBridge } from "./previewBridge"; |
| 52 | import { showPreviewPopup } from "./showPreviewPopup"; |
| 53 | import { |
| 54 | clampPreviewMiniPlayerPosition, |
| 55 | NO_PREVIEW_MINI_PLAYER_OBSTACLES, |
| 56 | PREVIEW_MINI_PLAYER_CORNER_RADIUS, |
| 57 | PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, |
| 58 | type PreviewMiniPlayerFrame, |
| 59 | resizePreviewMiniPlayer, |
| 60 | resolveDeviceMiniPlayerCornerRadius, |
| 61 | resolveDeviceMiniPlayerSourceSize, |
| 62 | resolvePreviewMiniPlayerSourceSize, |
| 63 | } from "./previewMiniPlayerLayout"; |
| 64 | |
| 65 | interface PointerGesture { |
| 66 | readonly target: HTMLElement; |
| 67 | readonly pointerId: number; |
| 68 | readonly pointerType: string; |
| 69 | readonly pointerX: number; |
| 70 | readonly pointerY: number; |
| 71 | readonly frame: PreviewMiniPlayerFrame; |
| 72 | readonly direction: BrowserViewportResizeDirection | null; |
| 73 | moved: boolean; |
| 74 | } |
| 75 | |
| 76 | // A touch that travels less than this is a tap on the handle, not a drag. |
| 77 | const HANDLE_TAP_SLOP_PX = 6; |
| 78 | |
| 79 | interface Props { |
| 80 | readonly threadRef: ScopedThreadRef; |
| 81 | readonly miniPlayer: PreviewMiniPlayerState; |
| 82 | } |
| 83 | |
| 84 | const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS; |
| 85 | |
| 86 | const RESIZE_HANDLES: ReadonlyArray<{ |
| 87 | readonly direction: BrowserViewportResizeDirection; |
| 88 | readonly className: string; |
| 89 | }> = [ |
| 90 | { direction: "north", className: "inset-x-0 -top-1.5 h-3 cursor-ns-resize" }, |
| 91 | { direction: "south", className: "inset-x-0 -bottom-1.5 h-3 cursor-ns-resize" }, |
| 92 | { direction: "west", className: "inset-y-0 -left-1.5 w-3 cursor-ew-resize" }, |
| 93 | { direction: "east", className: "inset-y-0 -right-1.5 w-3 cursor-ew-resize" }, |
| 94 | { direction: "northwest", className: "-left-2.5 -top-2.5 size-5 cursor-nwse-resize" }, |
| 95 | { direction: "northeast", className: "-right-2.5 -top-2.5 size-5 cursor-nesw-resize" }, |
| 96 | { direction: "southwest", className: "-bottom-2.5 -left-2.5 size-5 cursor-nesw-resize" }, |
| 97 | { direction: "southeast", className: "-bottom-2.5 -right-2.5 size-5 cursor-nwse-resize" }, |
| 98 | ]; |
| 99 | |
| 100 | /** Floats the thread's browser tab or device stream over chat. */ |
| 101 | export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer }: Props) { |
| 102 | const { source } = miniPlayer; |
| 103 | return source.kind === "browser" ? ( |
| 104 | <BrowserMiniPlayer |
| 105 | key={source.tabId} |
| 106 | threadRef={threadRef} |
| 107 | tabId={source.tabId} |
| 108 | miniPlayer={miniPlayer} |
| 109 | /> |
| 110 | ) : ( |
| 111 | <DeviceMiniPlayer |
| 112 | key={previewMiniPlayerSourceKey(source)} |
| 113 | threadRef={threadRef} |
| 114 | source={source} |
| 115 | miniPlayer={miniPlayer} |
| 116 | /> |
| 117 | ); |
| 118 | } |
| 119 | |
| 120 | function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly tabId: string }) { |
| 121 | const previewState = useThreadPreviewState(threadRef); |
| 122 | const snapshot = previewState.sessions[tabId] ?? null; |
| 123 | const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId); |
| 124 | const recordingTabIds = useActiveBrowserRecordingTabIds(); |
| 125 | const recording = |
| 126 | recordingTabIds.has(runtimeTabId) || |
| 127 | findActiveBrowserRecordingRuntimeTabId(threadRef, tabId) !== null; |
| 128 | const desktopOverlay = previewState.desktopByTabId[tabId] ?? null; |
| 129 | const fittedSourceContent = useBrowserSurfaceStore( |
| 130 | (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null, |
| 131 | ); |
| 132 | const nativeServerTab = useRendersServerTabNatively(threadRef.environmentId, snapshot); |
| 133 | const serverTab = snapshot?.runtime === "server" && !nativeServerTab; |
| 134 | const [streamViewport, setStreamViewport] = useState<PreviewStreamViewport | null>(null); |
| 135 | const serverSurfaceRef = useRef<ServerBrowserHandle | null>(null); |
| 136 | const serverPictureInPicture = |
| 137 | useServerPictureInPictureKey() === serverPictureInPictureKey(threadRef.threadId, tabId); |
| 138 | const sourceSize = |
| 139 | serverTab && streamViewport |
| 140 | ? streamViewport |
| 141 | : resolvePreviewMiniPlayerSourceSize( |
| 142 | snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT, |
| 143 | fittedSourceContent, |
| 144 | desktopOverlay?.zoomFactor ?? 1, |
| 145 | ); |
| 146 | |
| 147 | const openInPanel = () => { |
| 148 | usePreviewMiniPlayerStore.getState().close(threadRef); |
| 149 | useRightPanelStore.getState().openBrowser(threadRef, tabId); |
| 150 | }; |
| 151 | |
| 152 | const toggleNativePictureInPicture = async () => { |
| 153 | try { |
| 154 | if (serverTab) { |
| 155 | if (serverPictureInPicture) closeServerPictureInPicture(); |
| 156 | else |
| 157 | await openServerPictureInPicture({ |
| 158 | ...threadRef, |
| 159 | tabId, |
| 160 | seed: serverSurfaceRef.current?.canvas() ?? null, |
| 161 | }); |
| 162 | } else if (previewBridge) { |
| 163 | const operation = desktopOverlay?.pictureInPicture |
| 164 | ? previewBridge.pictureInPicture.close |
| 165 | : previewBridge.pictureInPicture.open; |
| 166 | await operation(runtimeTabId); |
| 167 | } |
| 168 | } catch (error) { |
| 169 | toastManager.add({ |
| 170 | type: "error", |
| 171 | title: serverTab ? "Unable to pop out preview" : "Unable to update popped-out preview", |
| 172 | description: error instanceof Error ? error.message : "An error occurred.", |
| 173 | }); |
| 174 | } |
| 175 | }; |
| 176 | |
| 177 | if (!snapshot) return null; |
| 178 | const poppedOut = serverTab ? serverPictureInPicture : Boolean(desktopOverlay?.pictureInPicture); |
| 179 | const canPopOut = serverTab ? supportsServerPictureInPicture() : true; |
| 180 | |
| 181 | return ( |
| 182 | <MiniPlayerShell |
| 183 | threadRef={threadRef} |
| 184 | miniPlayer={miniPlayer} |
| 185 | sourceSize={sourceSize} |
| 186 | label="Floating browser preview" |
| 187 | recording={recording} |
| 188 | onOpenInPanel={openInPanel} |
| 189 | pillActions={ |
| 190 | canPopOut ? ( |
| 191 | <Tooltip> |
| 192 | <TooltipTrigger |
| 193 | render={ |
| 194 | <Button |
| 195 | variant={poppedOut ? "secondary" : "ghost"} |
| 196 | size="icon-xs" |
| 197 | aria-label={ |
| 198 | poppedOut ? "Close popped-out preview" : "Pop preview into separate window" |
| 199 | } |
| 200 | disabled={!serverTab && !desktopOverlay?.hasWebContents} |
| 201 | onPointerDown={(event) => event.stopPropagation()} |
| 202 | onClick={toggleNativePictureInPicture} |
| 203 | /> |
| 204 | } |
| 205 | > |
| 206 | <PictureInPicture2 /> |
| 207 | </TooltipTrigger> |
| 208 | <TooltipPopup side="top"> |
| 209 | {poppedOut ? "Close separate window" : "Pop into separate window"} |
| 210 | </TooltipPopup> |
| 211 | </Tooltip> |
| 212 | ) : null |
| 213 | } |
| 214 | > |
| 215 | {(frame) => |
| 216 | serverTab ? ( |
| 217 | <div |
| 218 | className="pointer-events-auto absolute inset-0 overflow-hidden rounded-[inherit]" |
| 219 | style={{ zIndex: PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX }} |
| 220 | > |
| 221 | <ServerBrowserSurface |
| 222 | ref={serverSurfaceRef} |
| 223 | environmentId={threadRef.environmentId} |
| 224 | threadId={threadRef.threadId} |
| 225 | tabId={tabId} |
| 226 | visible |
| 227 | followSize={false} |
| 228 | controlPosition="bottom" |
| 229 | onViewport={setStreamViewport} |
| 230 | onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "floating")} |
| 231 | className="size-full" |
| 232 | /> |
| 233 | </div> |
| 234 | ) : ( |
| 235 | <> |
| 236 | <BrowserSurfaceSlot |
| 237 | tabId={runtimeTabId} |
| 238 | visible={Boolean(desktopOverlay?.hasWebContents)} |
| 239 | cornerRadius={PREVIEW_MINI_PLAYER_CORNER_RADIUS} |
| 240 | zIndex={PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX} |
| 241 | fitSourceContent |
| 242 | layoutVersion={`${frame.x}:${frame.y}`} |
| 243 | className="absolute inset-0" |
| 244 | /> |
| 245 | {!desktopOverlay?.hasWebContents ? ( |
| 246 | <div className="pointer-events-none absolute inset-0 z-[49] flex items-center justify-center rounded-[inherit] bg-muted text-xs text-muted-foreground"> |
| 247 | Reconnecting preview… |
| 248 | </div> |
| 249 | ) : null} |
| 250 | </> |
| 251 | ) |
| 252 | } |
| 253 | </MiniPlayerShell> |
| 254 | ); |
| 255 | } |
| 256 | |
| 257 | function DeviceMiniPlayer({ |
| 258 | threadRef, |
| 259 | source, |
| 260 | miniPlayer, |
| 261 | }: Props & { readonly source: Extract<PreviewMiniPlayerSource, { kind: "device" }> }) { |
| 262 | const { state: deviceState } = useDeviceState(threadRef.environmentId); |
| 263 | const [screen, setScreen] = useState<DeviceScreenSize | null>(null); |
| 264 | const sourceSize = resolveDeviceMiniPlayerSourceSize(source.platform, screen); |
| 265 | const device = deviceState.devices.find( |
| 266 | (entry) => entry.hostId === source.hostId && entry.id === source.deviceId, |
| 267 | ); |
| 268 | const hostLabel = |
| 269 | deviceState.hosts.find((host) => host.id === source.hostId)?.label ?? "Device host"; |
| 270 | const cornerRadius = useCallback( |
| 271 | (player: PreviewMiniPlayerSize) => resolveDeviceMiniPlayerCornerRadius(source.platform, player), |
| 272 | [source.platform], |
| 273 | ); |
| 274 | |
| 275 | const openInPanel = () => { |
| 276 | usePreviewMiniPlayerStore.getState().close(threadRef); |
| 277 | useRightPanelStore.getState().openDevice(threadRef, { |
| 278 | hostId: source.hostId, |
| 279 | deviceId: source.deviceId, |
| 280 | platform: source.platform, |
| 281 | name: source.name, |
| 282 | }); |
| 283 | }; |
| 284 | |
| 285 | return ( |
| 286 | <MiniPlayerShell |
| 287 | threadRef={threadRef} |
| 288 | miniPlayer={miniPlayer} |
| 289 | sourceSize={sourceSize} |
| 290 | label="Floating device preview" |
| 291 | onOpenInPanel={openInPanel} |
| 292 | cornerRadius={cornerRadius} |
| 293 | > |
| 294 | {() => ( |
| 295 | // The stream is DOM, so it takes the band the browser's native webview would. |
| 296 | <div |
| 297 | className="pointer-events-auto absolute inset-0 overflow-hidden rounded-[inherit]" |
| 298 | style={{ zIndex: PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX }} |
| 299 | > |
| 300 | <DeviceStreamView |
| 301 | environmentId={threadRef.environmentId} |
| 302 | platform={source.platform} |
| 303 | deviceId={source.deviceId} |
| 304 | hostId={source.hostId} |
| 305 | deviceName={device?.name ?? source.name} |
| 306 | deviceDescription={`${hostLabel} · ${device?.version ?? source.platform}`} |
| 307 | visible |
| 308 | onScreen={setScreen} |
| 309 | /> |
| 310 | </div> |
| 311 | )} |
| 312 | </MiniPlayerShell> |
| 313 | ); |
| 314 | } |
| 315 | |
| 316 | /** |
| 317 | * The frame, drag/resize gestures, and hover pill shared by every floating |
| 318 | * source. Native clipping and the DOM frame use the same radius so their |
| 319 | * separately composited edges stay aligned. |
| 320 | */ |
| 321 | function MiniPlayerShell({ |
| 322 | threadRef, |
| 323 | miniPlayer, |
| 324 | sourceSize, |
| 325 | label, |
| 326 | onOpenInPanel, |
| 327 | pillActions, |
| 328 | recording = false, |
| 329 | cornerRadius = frameCornerRadius, |
| 330 | children, |
| 331 | }: { |
| 332 | readonly threadRef: ScopedThreadRef; |
| 333 | readonly miniPlayer: PreviewMiniPlayerState; |
| 334 | readonly sourceSize: PreviewMiniPlayerSize; |
| 335 | readonly label: string; |
| 336 | readonly onOpenInPanel: () => void; |
| 337 | readonly pillActions?: ReactNode; |
| 338 | readonly recording?: boolean; |
| 339 | /** The clip radius for a given frame; the pill stays inside the curve. */ |
| 340 | readonly cornerRadius?: (frame: PreviewMiniPlayerSize) => number; |
| 341 | readonly children: (frame: PreviewMiniPlayerFrame) => ReactNode; |
| 342 | }) { |
| 343 | const canvas = useChatCanvas(); |
| 344 | const gestureRef = useRef<PointerGesture | null>(null); |
| 345 | // Touch has no hover, so tapping the handle toggles the pill instead. The |
| 346 | // toggle waits for the tap's click, which would otherwise land on the pill. |
| 347 | const [pillOpen, setPillOpen] = useState(false); |
| 348 | const handleTappedRef = useRef(false); |
| 349 | const handleRef = useRef<HTMLDivElement | null>(null); |
| 350 | // The pill covers the handle, so a tap anywhere else dismisses it. |
| 351 | useEffect(() => { |
| 352 | if (!pillOpen) return; |
| 353 | const dismiss = (event: PointerEvent) => { |
| 354 | if (event.target instanceof Node && handleRef.current?.contains(event.target)) return; |
| 355 | setPillOpen(false); |
| 356 | }; |
| 357 | document.addEventListener("pointerdown", dismiss, true); |
| 358 | return () => document.removeEventListener("pointerdown", dismiss, true); |
| 359 | }, [pillOpen]); |
| 360 | const container = canvas?.container ?? null; |
| 361 | const obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES; |
| 362 | const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source); |
| 363 | const frame = canvas?.previewKey === sourceKey ? canvas.layout.frame : null; |
| 364 | const { width: sourceWidth, height: sourceHeight } = sourceSize; |
| 365 | const reportPreview = canvas?.reportPreview; |
| 366 | const clearPreview = canvas?.clearPreview; |
| 367 | useLayoutEffect(() => { |
| 368 | reportPreview?.({ |
| 369 | key: sourceKey, |
| 370 | width: miniPlayer.width, |
| 371 | position: miniPlayer.position, |
| 372 | lastInteraction: miniPlayer.lastInteraction, |
| 373 | source: { width: sourceWidth, height: sourceHeight }, |
| 374 | }); |
| 375 | }, [ |
| 376 | reportPreview, |
| 377 | sourceKey, |
| 378 | miniPlayer.width, |
| 379 | miniPlayer.position, |
| 380 | miniPlayer.lastInteraction, |
| 381 | sourceWidth, |
| 382 | sourceHeight, |
| 383 | ]); |
| 384 | useLayoutEffect(() => () => clearPreview?.(sourceKey), [clearPreview, sourceKey]); |
| 385 | |
| 386 | const releaseGesture = useCallback(() => { |
| 387 | const gesture = gestureRef.current; |
| 388 | gestureRef.current = null; |
| 389 | handleTappedRef.current = false; |
| 390 | if (gesture?.target.hasPointerCapture(gesture.pointerId)) { |
| 391 | gesture.target.releasePointerCapture(gesture.pointerId); |
| 392 | } |
| 393 | }, []); |
| 394 | |
| 395 | useEffect(() => { |
| 396 | const onBlur = () => releaseGesture(); |
| 397 | window.addEventListener("blur", onBlur); |
| 398 | return () => { |
| 399 | window.removeEventListener("blur", onBlur); |
| 400 | releaseGesture(); |
| 401 | }; |
| 402 | }, [releaseGesture, sourceKey, threadRef.environmentId, threadRef.threadId]); |
| 403 | |
| 404 | const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS; |
| 405 | // Inside a wide curve the default 8px inset would land on the clipped-away corner. |
| 406 | const pillInset = Math.max(8, Math.round(radius * 0.55)); |
| 407 | |
| 408 | const close = () => { |
| 409 | usePreviewMiniPlayerStore.getState().close(threadRef); |
| 410 | }; |
| 411 | |
| 412 | const beginGesture = ( |
| 413 | event: ReactPointerEvent<HTMLElement>, |
| 414 | direction: BrowserViewportResizeDirection | null, |
| 415 | ) => { |
| 416 | if (event.button !== 0 || !frame || gestureRef.current || event.target !== event.currentTarget) |
| 417 | return; |
| 418 | gestureRef.current = { |
| 419 | target: event.currentTarget, |
| 420 | pointerId: event.pointerId, |
| 421 | pointerType: event.pointerType, |
| 422 | pointerX: event.clientX, |
| 423 | pointerY: event.clientY, |
| 424 | frame, |
| 425 | direction, |
| 426 | moved: false, |
| 427 | }; |
| 428 | try { |
| 429 | event.currentTarget.setPointerCapture(event.pointerId); |
| 430 | } catch { |
| 431 | gestureRef.current = null; |
| 432 | return; |
| 433 | } |
| 434 | event.preventDefault(); |
| 435 | event.stopPropagation(); |
| 436 | }; |
| 437 | |
| 438 | const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => { |
| 439 | const gesture = gestureRef.current; |
| 440 | if (!gesture || gesture.pointerId !== event.pointerId || !container) return; |
| 441 | const delta = { x: event.clientX - gesture.pointerX, y: event.clientY - gesture.pointerY }; |
| 442 | if (!gesture.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) return; |
| 443 | gesture.moved = true; |
| 444 | const store = usePreviewMiniPlayerStore.getState(); |
| 445 | if (gesture.direction === null) { |
| 446 | store.move( |
| 447 | threadRef, |
| 448 | sourceKey, |
| 449 | clampPreviewMiniPlayerPosition( |
| 450 | { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y }, |
| 451 | container, |
| 452 | gesture.frame, |
| 453 | obstacles, |
| 454 | ), |
| 455 | ); |
| 456 | return; |
| 457 | } |
| 458 | const next = resizePreviewMiniPlayer({ |
| 459 | start: gesture.frame, |
| 460 | direction: gesture.direction, |
| 461 | delta, |
| 462 | source: sourceSize, |
| 463 | container, |
| 464 | obstacles, |
| 465 | }); |
| 466 | store.resize(threadRef, sourceKey, next.width, { x: next.x, y: next.y }); |
| 467 | }; |
| 468 | |
| 469 | const endGesture = (event: ReactPointerEvent<HTMLElement>) => { |
| 470 | const gesture = gestureRef.current; |
| 471 | if (gesture?.pointerId !== event.pointerId) return; |
| 472 | if (event.type === "pointerup") handlePointerMove(event); |
| 473 | releaseGesture(); |
| 474 | handleTappedRef.current = |
| 475 | event.type === "pointerup" && |
| 476 | gesture.direction === null && |
| 477 | gesture.pointerType === "touch" && |
| 478 | !gesture.moved; |
| 479 | }; |
| 480 | |
| 481 | return ( |
| 482 | <div className="pointer-events-none absolute inset-0"> |
| 483 | {frame ? ( |
| 484 | <section |
| 485 | aria-label={label} |
| 486 | data-preview-mini-player={sourceKey} |
| 487 | className="pointer-events-none absolute select-none" |
| 488 | style={{ |
| 489 | left: frame.x, |
| 490 | top: frame.y, |
| 491 | width: frame.width, |
| 492 | height: frame.height, |
| 493 | borderRadius: radius, |
| 494 | }} |
| 495 | > |
| 496 | <div |
| 497 | ref={handleRef} |
| 498 | data-pill-open={pillOpen ? "" : undefined} |
| 499 | className="group pointer-events-auto absolute z-[49] size-3 touch-none cursor-grab active:cursor-grabbing pointer-coarse:-m-2.5 pointer-coarse:size-8" |
| 500 | style={{ right: pillInset, top: pillInset }} |
| 501 | onPointerDown={(event) => beginGesture(event, null)} |
| 502 | onPointerMove={handlePointerMove} |
| 503 | onPointerUp={endGesture} |
| 504 | onPointerCancel={endGesture} |
| 505 | onLostPointerCapture={endGesture} |
| 506 | onClick={() => { |
| 507 | if (!handleTappedRef.current) return; |
| 508 | handleTappedRef.current = false; |
| 509 | setPillOpen((open) => !open); |
| 510 | }} |
| 511 | > |
| 512 | <div |
| 513 | role={recording ? "status" : undefined} |
| 514 | aria-label={recording ? "Recording preview" : undefined} |
| 515 | aria-hidden={!recording} |
| 516 | className="absolute right-0 top-0 size-2 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0 group-data-pill-open:opacity-0 pointer-coarse:right-2.5 pointer-coarse:top-2.5" |
| 517 | > |
| 518 | <span |
| 519 | className={cn( |
| 520 | "block size-2 rounded-full shadow-sm ring-1 ring-background/70", |
| 521 | recording |
| 522 | ? "bg-destructive motion-safe:animate-status-pulse" |
| 523 | : "bg-foreground/25", |
| 524 | )} |
| 525 | /> |
| 526 | </div> |
| 527 | <div className="pointer-events-none absolute right-0 top-0 flex h-8 cursor-grab items-center gap-0.5 rounded-lg border border-border/80 bg-popover/92 p-0.5 opacity-0 shadow-lg/20 backdrop-blur-xl transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-data-pill-open:pointer-events-auto group-data-pill-open:opacity-100 active:cursor-grabbing pointer-coarse:right-2.5 pointer-coarse:top-2.5"> |
| 528 | {recording ? ( |
| 529 | <span aria-hidden className="flex size-6 shrink-0 items-center justify-center"> |
| 530 | <span className="size-2 rounded-full bg-destructive motion-safe:animate-status-pulse" /> |
| 531 | </span> |
| 532 | ) : null} |
| 533 | <Tooltip> |
| 534 | <TooltipTrigger |
| 535 | render={ |
| 536 | <Button |
| 537 | variant="ghost" |
| 538 | size="icon-xs" |
| 539 | aria-label="Open preview in right panel" |
| 540 | onPointerDown={(event) => event.stopPropagation()} |
| 541 | onClick={onOpenInPanel} |
| 542 | /> |
| 543 | } |
| 544 | > |
| 545 | <PanelRightIcon /> |
| 546 | </TooltipTrigger> |
| 547 | <TooltipPopup side="top">Open in right panel</TooltipPopup> |
| 548 | </Tooltip> |
| 549 | {pillActions} |
| 550 | <Tooltip> |
| 551 | <TooltipTrigger |
| 552 | render={ |
| 553 | <Button |
| 554 | variant="ghost" |
| 555 | size="icon-xs" |
| 556 | aria-label="Close floating preview" |
| 557 | onPointerDown={(event) => event.stopPropagation()} |
| 558 | onClick={close} |
| 559 | /> |
| 560 | } |
| 561 | > |
| 562 | <XIcon /> |
| 563 | </TooltipTrigger> |
| 564 | <TooltipPopup side="top">Close floating preview</TooltipPopup> |
| 565 | </Tooltip> |
| 566 | </div> |
| 567 | </div> |
| 568 | |
| 569 | <div className="absolute inset-0 z-[47] rounded-[inherit] bg-muted shadow-2xl/35" /> |
| 570 | {children(frame)} |
| 571 | <div className="pointer-events-none absolute inset-0 z-[49] rounded-[inherit] ring-1 ring-inset ring-border/80" /> |
| 572 | {RESIZE_HANDLES.map(({ direction, className }) => ( |
| 573 | <div |
| 574 | key={direction} |
| 575 | role="button" |
| 576 | tabIndex={0} |
| 577 | aria-label={`Resize floating preview from ${direction}`} |
| 578 | data-preview-mini-player-resize={direction} |
| 579 | className={cn( |
| 580 | "pointer-events-auto absolute z-[49] touch-none outline-none hover:bg-primary/15 focus-visible:bg-primary/20", |
| 581 | className, |
| 582 | )} |
| 583 | onPointerDown={(event) => beginGesture(event, direction)} |
| 584 | onPointerMove={handlePointerMove} |
| 585 | onPointerUp={endGesture} |
| 586 | onPointerCancel={endGesture} |
| 587 | onLostPointerCapture={endGesture} |
| 588 | onKeyDown={(event) => { |
| 589 | if (!container) return; |
| 590 | const step = event.shiftKey ? 40 : 10; |
| 591 | const delta = |
| 592 | event.key === "ArrowLeft" |
| 593 | ? { x: -step, y: 0 } |
| 594 | : event.key === "ArrowRight" |
| 595 | ? { x: step, y: 0 } |
| 596 | : event.key === "ArrowUp" |
| 597 | ? { x: 0, y: -step } |
| 598 | : event.key === "ArrowDown" |
| 599 | ? { x: 0, y: step } |
| 600 | : null; |
| 601 | if (!delta) return; |
| 602 | event.preventDefault(); |
| 603 | const next = resizePreviewMiniPlayer({ |
| 604 | start: frame, |
| 605 | direction, |
| 606 | delta, |
| 607 | source: sourceSize, |
| 608 | container, |
| 609 | obstacles, |
| 610 | }); |
| 611 | usePreviewMiniPlayerStore |
| 612 | .getState() |
| 613 | .resize(threadRef, sourceKey, next.width, { x: next.x, y: next.y }); |
| 614 | }} |
| 615 | /> |
| 616 | ))} |
| 617 | </section> |
| 618 | ) : null} |
| 619 | </div> |
| 620 | ); |
| 621 | } |