From e094155a669f3e8c9b33c681be850cf440864bf9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 20:47:54 -0700 Subject: [PATCH] fix(web): resize the floating preview from any edge The floating browser preview could only be resized from a single grip in its bottom-right corner, which is the corner that runs into the window edge, and it opened at a fixed 320x200 box that letterboxed the webview. Every edge and corner is now a grab zone, resizing holds the previewed viewport's aspect ratio with the opposite edge anchored, and the player opens at that ratio so the webview fills it with no padding. Width is the only stored dimension; the frame is derived per layout pass so a narrow window no longer overwrites the chosen size. Co-Authored-By: Claude Opus 5 (1M context) --- .../preview/ThreadPreviewMiniPlayer.tsx | 466 ++++++++---------- .../preview/previewMiniPlayerLayout.test.ts | 203 ++++++-- .../preview/previewMiniPlayerLayout.ts | 166 ++++++- apps/web/src/previewMiniPlayerStore.test.ts | 10 +- apps/web/src/previewMiniPlayerStore.ts | 14 +- 5 files changed, 528 insertions(+), 331 deletions(-) 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 }, }, }; }),