diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index dc2d9f5a96ea..9e59e56a24f0 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -1,43 +1,41 @@ "use client"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +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 { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { + type PreviewMiniPlayerSize, + selectThreadPreviewMiniPlayer, + usePreviewMiniPlayerStore, +} from "~/previewMiniPlayerStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { previewBridge } from "./previewBridge"; import { clampPreviewMiniPlayerPosition, - clampPreviewMiniPlayerSize, - PREVIEW_MINI_PLAYER_DEFAULT_SIZE, - PREVIEW_MINI_PLAYER_EDGE_GAP, PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, + type PreviewMiniPlayerFrame, + resizePreviewMiniPlayer, + resolvePreviewMiniPlayerFrame, + resolvePreviewMiniPlayerSourceSize, } from "./previewMiniPlayerLayout"; -interface DragState { - readonly pointerId: number; - readonly pointerX: number; - readonly pointerY: number; - readonly playerX: number; - readonly playerY: number; -} - -interface ResizeState { +interface PointerGesture { readonly pointerId: number; readonly pointerX: number; readonly pointerY: number; - readonly playerX: number; - readonly playerY: number; - readonly width: number; - readonly height: number; + readonly frame: PreviewMiniPlayerFrame; + readonly direction: BrowserViewportResizeDirection | null; } interface Props { @@ -46,11 +44,25 @@ interface Props { readonly bottomInset: number; } +// 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" }, +]; + export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props) { - const rootRef = useRef(null); - const dragRef = useRef(null); - const resizeRef = useRef(null); - const [defaultLayoutVersion, setDefaultLayoutVersion] = useState(""); + const containerRef = useRef(null); + const gestureRef = useRef(null); + const [container, setContainer] = useState(null); const miniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), ); @@ -58,11 +70,25 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props const snapshot = previewState.sessions[tabId] ?? null; const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId); const desktopOverlay = previewState.desktopByTabId[tabId] ?? null; - const position = miniPlayer?.tabId === tabId ? miniPlayer.position : null; - const size = - miniPlayer?.tabId === tabId && miniPlayer.size - ? miniPlayer.size - : PREVIEW_MINI_PLAYER_DEFAULT_SIZE; + 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); }; @@ -87,139 +113,72 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props }; useLayoutEffect(() => { - const clampAndMove = () => { - const root = rootRef.current; - const parent = root?.offsetParent; - if (!root || !(parent instanceof HTMLElement)) return; - const nextSize = clampPreviewMiniPlayerSize( - { width: root.offsetWidth, height: root.offsetHeight }, - { width: parent.clientWidth, height: parent.clientHeight }, - bottomInset, + 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 }, ); - usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); - if (!position) { - setDefaultLayoutVersion(`${parent.clientWidth}:${parent.clientHeight}`); - return; - } - const next = clampPreviewMiniPlayerPosition( - position, - { width: parent.clientWidth, height: parent.clientHeight }, - nextSize, - bottomInset, - ); - usePreviewMiniPlayerStore.getState().move(threadRef, tabId, next); }; - clampAndMove(); - const root = rootRef.current; - const parent = root?.offsetParent; - if (!root || !(parent instanceof HTMLElement) || typeof ResizeObserver === "undefined") { - return; - } - const observer = new ResizeObserver(clampAndMove); - observer.observe(root); - observer.observe(parent); + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(element); return () => observer.disconnect(); - }, [bottomInset, position, tabId, threadRef]); - - const handlePointerDown = (event: ReactPointerEvent) => { - if (event.button !== 0) return; - const root = rootRef.current; - const parent = root?.offsetParent; - if (!root || !(parent instanceof HTMLElement)) return; - const rootRect = root.getBoundingClientRect(); - const parentRect = parent.getBoundingClientRect(); - dragRef.current = { - pointerId: event.pointerId, - pointerX: event.clientX, - pointerY: event.clientY, - playerX: rootRect.left - parentRect.left, - playerY: rootRect.top - parentRect.top, - }; - event.currentTarget.setPointerCapture(event.pointerId); - event.preventDefault(); - }; - - const handlePointerMove = (event: ReactPointerEvent) => { - const drag = dragRef.current; - const root = rootRef.current; - const parent = root?.offsetParent; - if (!drag || drag.pointerId !== event.pointerId || !root || !(parent instanceof HTMLElement)) { - return; - } - const next = clampPreviewMiniPlayerPosition( - { - x: drag.playerX + event.clientX - drag.pointerX, - y: drag.playerY + event.clientY - drag.pointerY, - }, - { width: parent.clientWidth, height: parent.clientHeight }, - { width: root.offsetWidth, height: root.offsetHeight }, - bottomInset, - ); - usePreviewMiniPlayerStore.getState().move(threadRef, tabId, next); - }; - - const endDrag = (event: ReactPointerEvent) => { - if (dragRef.current?.pointerId !== event.pointerId) return; - dragRef.current = null; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - }; + }, []); - const handleResizePointerDown = (event: ReactPointerEvent) => { - if (event.button !== 0) return; - const root = rootRef.current; - const parent = root?.offsetParent; - if (!root || !(parent instanceof HTMLElement)) return; - const rootRect = root.getBoundingClientRect(); - const parentRect = parent.getBoundingClientRect(); - resizeRef.current = { + const beginGesture = ( + event: ReactPointerEvent, + direction: BrowserViewportResizeDirection | null, + ) => { + if (event.button !== 0 || !frame) return; + gestureRef.current = { pointerId: event.pointerId, pointerX: event.clientX, pointerY: event.clientY, - playerX: rootRect.left - parentRect.left, - playerY: rootRect.top - parentRect.top, - width: root.offsetWidth, - height: root.offsetHeight, + frame, + direction, }; event.currentTarget.setPointerCapture(event.pointerId); event.preventDefault(); event.stopPropagation(); }; - const handleResizePointerMove = (event: ReactPointerEvent) => { - const resize = resizeRef.current; - const root = rootRef.current; - const parent = root?.offsetParent; - if ( - !resize || - resize.pointerId !== event.pointerId || - !root || - !(parent instanceof HTMLElement) - ) { + 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 nextSize = clampPreviewMiniPlayerSize( - { - width: resize.width + event.clientX - resize.pointerX, - height: resize.height + event.clientY - resize.pointerY, - }, - { width: parent.clientWidth, height: parent.clientHeight }, - bottomInset, - ); - usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); - const nextPosition = clampPreviewMiniPlayerPosition( - { x: resize.playerX, y: resize.playerY }, - { width: parent.clientWidth, height: parent.clientHeight }, - nextSize, + const next = resizePreviewMiniPlayer({ + start: gesture.frame, + direction: gesture.direction, + delta, + source, + container, bottomInset, - ); - usePreviewMiniPlayerStore.getState().move(threadRef, tabId, nextPosition); + }); + store.resize(threadRef, tabId, next.width); + store.move(threadRef, tabId, { x: next.x, y: next.y }); }; - const endResize = (event: ReactPointerEvent) => { - if (resizeRef.current?.pointerId !== event.pointerId) return; - resizeRef.current = null; + 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); } @@ -228,125 +187,116 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props if (!snapshot || miniPlayer?.tabId !== tabId) return null; return ( -
-
-
+ ) : null} + ); } diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts index 98b26e8a0b2b..20094ad1a5f7 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts @@ -1,73 +1,186 @@ +import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { clampPreviewMiniPlayerPosition, - clampPreviewMiniPlayerSize, PREVIEW_MINI_PLAYER_EDGE_GAP, + resizePreviewMiniPlayer, + resolvePreviewMiniPlayerFrame, + resolvePreviewMiniPlayerSourceSize, } from "./previewMiniPlayerLayout"; -describe("clampPreviewMiniPlayerPosition", () => { - it("keeps a dragged player within the chat viewport", () => { +const container = { width: 1_000, height: 700 }; +const source = { width: 1_600, height: 1_000 }; + +describe("resolvePreviewMiniPlayerSourceSize", () => { + it("uses the device viewport scaled by zoom", () => { expect( - clampPreviewMiniPlayerPosition( - { x: 900, y: -40 }, - { width: 1_000, height: 700 }, - { width: 360, height: 240 }, - ), - ).toEqual({ - x: 628, - y: PREVIEW_MINI_PLAYER_EDGE_GAP, - }); + resolvePreviewMiniPlayerSourceSize({ _tag: "freeform", width: 390, height: 844 }, null, 2), + ).toEqual({ width: 780, height: 1_688 }); }); - it("keeps an edge gap when the player is larger than its container", () => { + it("uses the size the fill viewport had when it was floated", () => { expect( - clampPreviewMiniPlayerPosition( - { x: 20, y: 30 }, - { width: 200, height: 160 }, - { width: 360, height: 240 }, + resolvePreviewMiniPlayerSourceSize( + FILL_PREVIEW_VIEWPORT, + { x: 0, y: 0, width: 640, height: 400, scale: 0.5, scrollLeft: 0, scrollTop: 0 }, + 1, ), - ).toEqual({ - x: PREVIEW_MINI_PLAYER_EDGE_GAP, - y: PREVIEW_MINI_PLAYER_EDGE_GAP, - }); + ).toEqual({ width: 1_280, height: 800 }); }); +}); - it("keeps the player above a growing composer inset", () => { +describe("resolvePreviewMiniPlayerFrame", () => { + it("opens at the source aspect ratio in the top-right corner", () => { expect( - clampPreviewMiniPlayerPosition( - { x: 500, y: 448 }, - { width: 1_000, height: 700 }, - { width: 360, height: 240 }, - 160, - ), - ).toEqual({ - x: 500, - y: 288, + resolvePreviewMiniPlayerFrame({ width: null, position: null, source, container }), + ).toEqual({ x: 668, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 320, height: 200 }); + }); + + it("keeps a tall source at the minimum width instead of the default box", () => { + expect( + resolvePreviewMiniPlayerFrame({ + width: null, + position: null, + source: { width: 390, height: 844 }, + container, + }), + ).toEqual({ x: 748, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 240, height: 519 }); + }); + + it("derives height from the stored width", () => { + expect( + resolvePreviewMiniPlayerFrame({ width: 480, position: { x: 100, y: 80 }, source, container }), + ).toEqual({ x: 100, y: 80, width: 480, height: 300 }); + }); + + it("shrinks to the space above the composer without losing the stored width", () => { + const frame = resolvePreviewMiniPlayerFrame({ + width: 800, + position: { x: 100, y: 80 }, + source, + container, + bottomInset: 300, }); + expect(frame).toEqual({ x: 100, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 602, height: 376 }); + }); + + it("never grows past the source's own rendered size", () => { + expect( + resolvePreviewMiniPlayerFrame({ + width: 900, + position: { x: 12, y: 12 }, + source: { width: 480, height: 320 }, + container, + }), + ).toMatchObject({ width: 480, height: 320 }); }); }); -describe("clampPreviewMiniPlayerSize", () => { - it("allows resizing within the available chat viewport", () => { +describe("resizePreviewMiniPlayer", () => { + const start = { x: 300, y: 200, width: 320, height: 200 }; + + it("keeps the aspect ratio when dragging the right edge", () => { expect( - clampPreviewMiniPlayerSize({ width: 520, height: 360 }, { width: 1_000, height: 700 }, 120), - ).toEqual({ width: 520, height: 360 }); + resizePreviewMiniPlayer({ + start, + direction: "east", + delta: { x: 160, y: 0 }, + source, + container, + }), + ).toEqual({ x: 300, y: 200, width: 480, height: 300 }); }); - it("bounds oversized players above the composer", () => { + it("anchors the right edge when dragging from the left", () => { expect( - clampPreviewMiniPlayerSize( - { width: 2_000, height: 2_000 }, - { width: 1_000, height: 700 }, - 120, - ), - ).toEqual({ width: 976, height: 556 }); + resizePreviewMiniPlayer({ + start, + direction: "west", + delta: { x: -160, y: 0 }, + source, + container, + }), + ).toEqual({ x: 140, y: 200, width: 480, height: 300 }); + }); + + it("anchors the bottom edge when dragging the top up", () => { + expect( + resizePreviewMiniPlayer({ + start, + direction: "north", + delta: { x: 0, y: -100 }, + source, + container, + }), + ).toEqual({ x: 300, y: 100, width: 480, height: 300 }); + }); + + it("follows the dominant axis on a corner drag", () => { + expect( + resizePreviewMiniPlayer({ + start, + direction: "southeast", + delta: { x: 20, y: 100 }, + source, + container, + }), + ).toEqual({ x: 300, y: 200, width: 480, height: 300 }); + }); + + it("stops at the container edge in the drag direction", () => { + expect( + resizePreviewMiniPlayer({ + start: { x: 600, y: 12, width: 320, height: 200 }, + direction: "east", + delta: { x: 500, y: 0 }, + source, + container, + }), + ).toEqual({ x: 600, y: 12, width: 388, height: 243 }); + }); + + it("shifts the player when the free axis would overflow", () => { + expect( + resizePreviewMiniPlayer({ + start: { x: 12, y: 400, width: 320, height: 200 }, + direction: "east", + delta: { x: 300, y: 0 }, + source, + container, + bottomInset: 0, + }), + ).toEqual({ x: 12, y: 300, width: 620, height: 388 }); + }); + + it("respects the minimum size", () => { + expect( + resizePreviewMiniPlayer({ + start, + direction: "southeast", + delta: { x: -300, y: -300 }, + source, + container, + }), + ).toEqual({ x: 300, y: 200, width: 240, height: 150 }); + }); +}); + +describe("clampPreviewMiniPlayerPosition", () => { + it("keeps a dragged player within the chat viewport", () => { + expect( + clampPreviewMiniPlayerPosition({ x: 900, y: -40 }, container, { width: 360, height: 240 }), + ).toEqual({ x: 628, y: PREVIEW_MINI_PLAYER_EDGE_GAP }); }); - it("lets a tiny container win over the preferred minimum", () => { + it("keeps the player above a growing composer inset", () => { expect( - clampPreviewMiniPlayerSize({ width: 360, height: 239 }, { width: 250, height: 180 }, 20), - ).toEqual({ width: 226, height: 136 }); + clampPreviewMiniPlayerPosition( + { x: 500, y: 448 }, + container, + { width: 360, height: 240 }, + 160, + ), + ).toEqual({ x: 500, y: 288 }); }); }); diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.ts index 10723cedaa8a..372ed71c6b0d 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.ts @@ -1,31 +1,79 @@ +import type { PreviewViewportSetting } from "@t3tools/contracts"; + +import type { BrowserSurfaceContentPresentation } from "~/browser/browserSurfaceStore"; +import { + resolveFittedBrowserViewport, + type BrowserViewportResizeDirection, +} from "~/browser/browserViewportLayout"; import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore"; export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12; // The mini-player shell straddles this webview at 47 and 49; dialogs begin at 50. export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48; -export const PREVIEW_MINI_PLAYER_DEFAULT_SIZE = { width: 320, height: 200 } as const; +// A fresh player is the largest box at the source aspect ratio that fits here. +const PREVIEW_MINI_PLAYER_DEFAULT_BOX = { width: 320, height: 320 } as const; const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const; -export function clampPreviewMiniPlayerSize( - size: PreviewMiniPlayerSize, - container: PreviewMiniPlayerSize, - bottomInset = 0, +export interface PreviewMiniPlayerFrame extends PreviewMiniPlayerPosition, PreviewMiniPlayerSize {} + +/** + * The rendered size of what the floating player mirrors: the device viewport + * when one is set, otherwise the size the webview had when it was floated + * (`fittedSourceContent`), which the hosted webview keeps as its CSS viewport. + */ +export function resolvePreviewMiniPlayerSourceSize( + viewport: PreviewViewportSetting, + fittedSourceContent: BrowserSurfaceContentPresentation | null, + zoomFactor: number, ): PreviewMiniPlayerSize { - const availableWidth = Math.max(1, container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2); - const availableHeight = Math.max( - 1, - container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, - ); + const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; + const fitted = resolveFittedBrowserViewport(viewport, fittedSourceContent, normalizedZoomFactor); return { - width: Math.round( - Math.min(Math.max(PREVIEW_MINI_PLAYER_MIN_SIZE.width, size.width), availableWidth), - ), - height: Math.round( - Math.min(Math.max(PREVIEW_MINI_PLAYER_MIN_SIZE.height, size.height), availableHeight), - ), + width: fitted.width * normalizedZoomFactor, + height: fitted.height * normalizedZoomFactor, }; } +const availableArea = ( + container: PreviewMiniPlayerSize, + bottomInset: number, +): PreviewMiniPlayerSize => ({ + width: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, + height: container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, +}); + +/** + * Width is the player's only free dimension; height always follows the source + * aspect ratio so the webview fills the box without letterboxing. The player + * never grows past the source's own size (the guest keeps its CSS viewport, so + * going bigger would only upscale), and a tight container wins over the minimum. + */ +function fitPreviewMiniPlayerWidth( + desiredWidth: number, + source: PreviewMiniPlayerSize, + max: PreviewMiniPlayerSize, +): PreviewMiniPlayerSize { + const aspectRatio = source.width / source.height; + const width = Math.min( + Math.max( + desiredWidth, + PREVIEW_MINI_PLAYER_MIN_SIZE.width, + PREVIEW_MINI_PLAYER_MIN_SIZE.height * aspectRatio, + ), + source.width, + Math.max(1, max.width), + Math.max(1, max.height * aspectRatio), + ); + return { width: Math.round(width), height: Math.round(width / aspectRatio) }; +} + +function defaultPreviewMiniPlayerWidth(source: PreviewMiniPlayerSize): number { + return Math.min( + PREVIEW_MINI_PLAYER_DEFAULT_BOX.width, + (PREVIEW_MINI_PLAYER_DEFAULT_BOX.height * source.width) / source.height, + ); +} + export function clampPreviewMiniPlayerPosition( position: PreviewMiniPlayerPosition, container: PreviewMiniPlayerSize, @@ -46,3 +94,89 @@ export function clampPreviewMiniPlayerPosition( y: Math.min(Math.max(position.y, PREVIEW_MINI_PLAYER_EDGE_GAP), maxY), }; } + +/** + * Resolves the on-screen frame from the stored width and position. Clamping + * happens here on every layout pass instead of being written back to the + * store, so a temporarily narrow container never destroys the user's chosen + * width. A player without a position sits in the top-right corner. + */ +export function resolvePreviewMiniPlayerFrame(input: { + readonly width: number | null; + readonly position: PreviewMiniPlayerPosition | null; + readonly source: PreviewMiniPlayerSize; + readonly container: PreviewMiniPlayerSize; + readonly bottomInset?: number; +}): PreviewMiniPlayerFrame { + const { width, position, source, container, bottomInset = 0 } = input; + const size = fitPreviewMiniPlayerWidth( + width ?? defaultPreviewMiniPlayerWidth(source), + source, + availableArea(container, bottomInset), + ); + const anchored = position ?? { + x: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - size.width, + y: PREVIEW_MINI_PLAYER_EDGE_GAP, + }; + return { ...clampPreviewMiniPlayerPosition(anchored, container, size, bottomInset), ...size }; +} + +/** + * Resizes from any edge or corner while holding the aspect ratio. The edge + * opposite the dragged one stays anchored, so growth stops at the container + * on that axis and the pointer keeps tracking the grabbed edge. On a plain edge + * drag the perpendicular axis may use the whole container, and the player + * shifts as needed to stay inside. + */ +export function resizePreviewMiniPlayer(input: { + readonly start: PreviewMiniPlayerFrame; + readonly direction: BrowserViewportResizeDirection; + readonly delta: PreviewMiniPlayerPosition; + readonly source: PreviewMiniPlayerSize; + readonly container: PreviewMiniPlayerSize; + readonly bottomInset?: number; +}): PreviewMiniPlayerFrame { + const { start, direction, delta, source, container, bottomInset = 0 } = input; + const east = direction.includes("east"); + const west = direction.includes("west"); + const north = direction.includes("north"); + const south = direction.includes("south"); + const available = availableArea(container, bottomInset); + const right = start.x + start.width; + const bottom = start.y + start.height; + const max = { + width: west + ? right - PREVIEW_MINI_PLAYER_EDGE_GAP + : east + ? container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - start.x + : available.width, + height: north + ? bottom - PREVIEW_MINI_PLAYER_EDGE_GAP + : south + ? container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP - start.y + : available.height, + }; + const desiredWidth = start.width + (east ? delta.x : west ? -delta.x : 0); + const desiredHeight = start.height + (south ? delta.y : north ? -delta.y : 0); + const horizontal = east || west; + const vertical = north || south; + const widthLeads = + horizontal && !vertical + ? true + : vertical && !horizontal + ? false + : Math.abs(desiredWidth - start.width) / start.width >= + Math.abs(desiredHeight - start.height) / start.height; + const size = fitPreviewMiniPlayerWidth( + widthLeads ? desiredWidth : (desiredHeight * source.width) / source.height, + source, + max, + ); + const position = clampPreviewMiniPlayerPosition( + { x: west ? right - size.width : start.x, y: north ? bottom - size.height : start.y }, + container, + size, + bottomInset, + ); + return { ...position, ...size }; +} diff --git a/apps/web/src/previewMiniPlayerStore.test.ts b/apps/web/src/previewMiniPlayerStore.test.ts index d6ec64bf0695..32c84f382941 100644 --- a/apps/web/src/previewMiniPlayerStore.test.ts +++ b/apps/web/src/previewMiniPlayerStore.test.ts @@ -34,7 +34,7 @@ describe("previewMiniPlayerStore", () => { ).toEqual({ tabId: "tab-b", position: { x: 24, y: 48 }, - size: null, + width: null, }); }); @@ -48,17 +48,17 @@ describe("previewMiniPlayerStore", () => { ).toEqual({ tabId: "tab-b", position: null, - size: null, + width: null, }); }); - it("preserves a thread-bound size while switching tabs", () => { + it("preserves a thread-bound width while switching tabs", () => { usePreviewMiniPlayerStore.getState().open(refA, "tab-a"); - usePreviewMiniPlayerStore.getState().resize(refA, "tab-a", { width: 480, height: 320 }); + usePreviewMiniPlayerStore.getState().resize(refA, "tab-a", 480); usePreviewMiniPlayerStore.getState().open(refA, "tab-b"); expect( selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA), - ).toMatchObject({ tabId: "tab-b", size: { width: 480, height: 320 } }); + ).toMatchObject({ tabId: "tab-b", width: 480 }); }); }); diff --git a/apps/web/src/previewMiniPlayerStore.ts b/apps/web/src/previewMiniPlayerStore.ts index d1a1fde5effb..aed9376af156 100644 --- a/apps/web/src/previewMiniPlayerStore.ts +++ b/apps/web/src/previewMiniPlayerStore.ts @@ -15,7 +15,8 @@ export interface PreviewMiniPlayerSize { export interface PreviewMiniPlayerState { readonly tabId: string; readonly position: PreviewMiniPlayerPosition | null; - readonly size: PreviewMiniPlayerSize | null; + /** Height always follows the previewed viewport's aspect ratio. */ + readonly width: number | null; } interface PreviewMiniPlayerStoreState { @@ -23,7 +24,7 @@ interface PreviewMiniPlayerStoreState { readonly open: (ref: ScopedThreadRef, tabId: string) => void; readonly close: (ref: ScopedThreadRef) => void; readonly move: (ref: ScopedThreadRef, tabId: string, position: PreviewMiniPlayerPosition) => void; - readonly resize: (ref: ScopedThreadRef, tabId: string, size: PreviewMiniPlayerSize) => void; + readonly resize: (ref: ScopedThreadRef, tabId: string, width: number) => void; readonly removeThread: (ref: ScopedThreadRef) => void; } @@ -40,7 +41,7 @@ export const usePreviewMiniPlayerStore = create()(( [threadKey]: { tabId, position: current?.position ?? null, - size: current?.size ?? null, + width: current?.width ?? null, }, }, }; @@ -65,16 +66,15 @@ export const usePreviewMiniPlayerStore = create()(( }, }; }), - resize: (ref, tabId, size) => + resize: (ref, tabId, width) => set((state) => { const threadKey = scopedThreadKey(ref); const current = state.byThreadKey[threadKey]; - if (!current || current.tabId !== tabId) return state; - if (current.size?.width === size.width && current.size.height === size.height) return state; + if (!current || current.tabId !== tabId || current.width === width) return state; return { byThreadKey: { ...state.byThreadKey, - [threadKey]: { ...current, size }, + [threadKey]: { ...current, width }, }, }; }),