authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-07 01:05:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 01:05:12-07:00
logf57d3832c0219b4f6fbe2e86f824fedd232492c0
tree227c95f4e07545ae0772767555dc296f5dbac936
parentb919d6389929698ddf223d35d20139f01c8bc899
signature Signed by PGP key B5690EEEBB952194

fix(web): resize the floating preview from any edge (#10467)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

5 files changed, 528 insertions(+), 331 deletions(-)

apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+208-258
......@@ -1,43 +1,41 @@
11"use client";
22
3import type { ScopedThreadRef } from "@t3tools/contracts";
3import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts";
44import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react";
55import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef, useState } from "react";
66
77import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
8import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
9import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout";
810import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
911import { Button } from "~/components/ui/button";
1012import { toastManager } from "~/components/ui/toast";
1113import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
14import { cn } from "~/lib/utils";
1215import { useThreadPreviewState } from "~/previewStateStore";
13import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
16import {
17 type PreviewMiniPlayerSize,
18 selectThreadPreviewMiniPlayer,
19 usePreviewMiniPlayerStore,
20} from "~/previewMiniPlayerStore";
1421import { useRightPanelStore } from "~/rightPanelStore";
1522
1623import { previewBridge } from "./previewBridge";
1724import {
1825 clampPreviewMiniPlayerPosition,
19 clampPreviewMiniPlayerSize,
20 PREVIEW_MINI_PLAYER_DEFAULT_SIZE,
21 PREVIEW_MINI_PLAYER_EDGE_GAP,
2226 PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX,
27 type PreviewMiniPlayerFrame,
28 resizePreviewMiniPlayer,
29 resolvePreviewMiniPlayerFrame,
30 resolvePreviewMiniPlayerSourceSize,
2331} from "./previewMiniPlayerLayout";
2432
25interface DragState {
26 readonly pointerId: number;
27 readonly pointerX: number;
28 readonly pointerY: number;
29 readonly playerX: number;
30 readonly playerY: number;
31}
32
33interface ResizeState {
33interface PointerGesture {
3434 readonly pointerId: number;
3535 readonly pointerX: number;
3636 readonly pointerY: number;
37 readonly playerX: number;
38 readonly playerY: number;
39 readonly width: number;
40 readonly height: number;
37 readonly frame: PreviewMiniPlayerFrame;
38 readonly direction: BrowserViewportResizeDirection | null;
4139}
4240
4341interface Props {
......@@ -46,11 +44,25 @@ interface Props {
4644 readonly bottomInset: number;
4745}
4846
47// Invisible grab zones straddling each edge; the cursor is the only affordance.
48const RESIZE_HANDLES: ReadonlyArray<{
49 readonly direction: BrowserViewportResizeDirection;
50 readonly className: string;
51}> = [
52 { direction: "north", className: "inset-x-0 -top-1 h-2 cursor-ns-resize" },
53 { direction: "south", className: "inset-x-0 -bottom-1 h-2 cursor-ns-resize" },
54 { direction: "west", className: "inset-y-0 -left-1 w-2 cursor-ew-resize" },
55 { direction: "east", className: "inset-y-0 -right-1 w-2 cursor-ew-resize" },
56 { direction: "northwest", className: "-left-2 -top-2 size-4 cursor-nwse-resize" },
57 { direction: "northeast", className: "-right-2 -top-2 size-4 cursor-nesw-resize" },
58 { direction: "southwest", className: "-bottom-2 -left-2 size-4 cursor-nesw-resize" },
59 { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" },
60];
61
4962export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props) {
50 const rootRef = useRef<HTMLElement | null>(null);
51 const dragRef = useRef<DragState | null>(null);
52 const resizeRef = useRef<ResizeState | null>(null);
53 const [defaultLayoutVersion, setDefaultLayoutVersion] = useState("");
63 const containerRef = useRef<HTMLDivElement | null>(null);
64 const gestureRef = useRef<PointerGesture | null>(null);
65 const [container, setContainer] = useState<PreviewMiniPlayerSize | null>(null);
5466 const miniPlayer = usePreviewMiniPlayerStore((state) =>
5567 selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef),
5668 );
......@@ -58,11 +70,25 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
5870 const snapshot = previewState.sessions[tabId] ?? null;
5971 const runtimeTabId = previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId);
6072 const desktopOverlay = previewState.desktopByTabId[tabId] ?? null;
61 const position = miniPlayer?.tabId === tabId ? miniPlayer.position : null;
62 const size =
63 miniPlayer?.tabId === tabId && miniPlayer.size
64 ? miniPlayer.size
65 : PREVIEW_MINI_PLAYER_DEFAULT_SIZE;
73 const fittedSourceContent = useBrowserSurfaceStore(
74 (state) => state.byTabId[runtimeTabId]?.fittedSourceContent ?? null,
75 );
76 const source = resolvePreviewMiniPlayerSourceSize(
77 snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT,
78 fittedSourceContent,
79 desktopOverlay?.zoomFactor ?? 1,
80 );
81 const frame =
82 container && miniPlayer?.tabId === tabId
83 ? resolvePreviewMiniPlayerFrame({
84 width: miniPlayer.width,
85 position: miniPlayer.position,
86 source,
87 container,
88 bottomInset,
89 })
90 : null;
91
6692 const close = () => {
6793 usePreviewMiniPlayerStore.getState().close(threadRef);
6894 };
......@@ -87,139 +113,72 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
87113 };
88114
89115 useLayoutEffect(() => {
90 const clampAndMove = () => {
91 const root = rootRef.current;
92 const parent = root?.offsetParent;
93 if (!root || !(parent instanceof HTMLElement)) return;
94 const nextSize = clampPreviewMiniPlayerSize(
95 { width: root.offsetWidth, height: root.offsetHeight },
96 { width: parent.clientWidth, height: parent.clientHeight },
97 bottomInset,
116 const element = containerRef.current;
117 if (!element) return;
118 const measure = () => {
119 setContainer((current) =>
120 current?.width === element.clientWidth && current.height === element.clientHeight
121 ? current
122 : { width: element.clientWidth, height: element.clientHeight },
98123 );
99 usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize);
100 if (!position) {
101 setDefaultLayoutVersion(`${parent.clientWidth}:${parent.clientHeight}`);
102 return;
103 }
104 const next = clampPreviewMiniPlayerPosition(
105 position,
106 { width: parent.clientWidth, height: parent.clientHeight },
107 nextSize,
108 bottomInset,
109 );
110 usePreviewMiniPlayerStore.getState().move(threadRef, tabId, next);
111124 };
112 clampAndMove();
113 const root = rootRef.current;
114 const parent = root?.offsetParent;
115 if (!root || !(parent instanceof HTMLElement) || typeof ResizeObserver === "undefined") {
116 return;
117 }
118 const observer = new ResizeObserver(clampAndMove);
119 observer.observe(root);
120 observer.observe(parent);
125 measure();
126 if (typeof ResizeObserver === "undefined") return;
127 const observer = new ResizeObserver(measure);
128 observer.observe(element);
121129 return () => observer.disconnect();
122 }, [bottomInset, position, tabId, threadRef]);
123
124 const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
125 if (event.button !== 0) return;
126 const root = rootRef.current;
127 const parent = root?.offsetParent;
128 if (!root || !(parent instanceof HTMLElement)) return;
129 const rootRect = root.getBoundingClientRect();
130 const parentRect = parent.getBoundingClientRect();
131 dragRef.current = {
132 pointerId: event.pointerId,
133 pointerX: event.clientX,
134 pointerY: event.clientY,
135 playerX: rootRect.left - parentRect.left,
136 playerY: rootRect.top - parentRect.top,
137 };
138 event.currentTarget.setPointerCapture(event.pointerId);
139 event.preventDefault();
140 };
141
142 const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
143 const drag = dragRef.current;
144 const root = rootRef.current;
145 const parent = root?.offsetParent;
146 if (!drag || drag.pointerId !== event.pointerId || !root || !(parent instanceof HTMLElement)) {
147 return;
148 }
149 const next = clampPreviewMiniPlayerPosition(
150 {
151 x: drag.playerX + event.clientX - drag.pointerX,
152 y: drag.playerY + event.clientY - drag.pointerY,
153 },
154 { width: parent.clientWidth, height: parent.clientHeight },
155 { width: root.offsetWidth, height: root.offsetHeight },
156 bottomInset,
157 );
158 usePreviewMiniPlayerStore.getState().move(threadRef, tabId, next);
159 };
160
161 const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
162 if (dragRef.current?.pointerId !== event.pointerId) return;
163 dragRef.current = null;
164 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
165 event.currentTarget.releasePointerCapture(event.pointerId);
166 }
167 };
130 }, []);
168131
169 const handleResizePointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
170 if (event.button !== 0) return;
171 const root = rootRef.current;
172 const parent = root?.offsetParent;
173 if (!root || !(parent instanceof HTMLElement)) return;
174 const rootRect = root.getBoundingClientRect();
175 const parentRect = parent.getBoundingClientRect();
176 resizeRef.current = {
132 const beginGesture = (
133 event: ReactPointerEvent<HTMLElement>,
134 direction: BrowserViewportResizeDirection | null,
135 ) => {
136 if (event.button !== 0 || !frame) return;
137 gestureRef.current = {
177138 pointerId: event.pointerId,
178139 pointerX: event.clientX,
179140 pointerY: event.clientY,
180 playerX: rootRect.left - parentRect.left,
181 playerY: rootRect.top - parentRect.top,
182 width: root.offsetWidth,
183 height: root.offsetHeight,
141 frame,
142 direction,
184143 };
185144 event.currentTarget.setPointerCapture(event.pointerId);
186145 event.preventDefault();
187146 event.stopPropagation();
188147 };
189148
190 const handleResizePointerMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
191 const resize = resizeRef.current;
192 const root = rootRef.current;
193 const parent = root?.offsetParent;
194 if (
195 !resize ||
196 resize.pointerId !== event.pointerId ||
197 !root ||
198 !(parent instanceof HTMLElement)
199 ) {
149 const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
150 const gesture = gestureRef.current;
151 if (!gesture || gesture.pointerId !== event.pointerId || !container) return;
152 const delta = { x: event.clientX - gesture.pointerX, y: event.clientY - gesture.pointerY };
153 const store = usePreviewMiniPlayerStore.getState();
154 if (gesture.direction === null) {
155 store.move(
156 threadRef,
157 tabId,
158 clampPreviewMiniPlayerPosition(
159 { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y },
160 container,
161 gesture.frame,
162 bottomInset,
163 ),
164 );
200165 return;
201166 }
202 const nextSize = clampPreviewMiniPlayerSize(
203 {
204 width: resize.width + event.clientX - resize.pointerX,
205 height: resize.height + event.clientY - resize.pointerY,
206 },
207 { width: parent.clientWidth, height: parent.clientHeight },
208 bottomInset,
209 );
210 usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize);
211 const nextPosition = clampPreviewMiniPlayerPosition(
212 { x: resize.playerX, y: resize.playerY },
213 { width: parent.clientWidth, height: parent.clientHeight },
214 nextSize,
167 const next = resizePreviewMiniPlayer({
168 start: gesture.frame,
169 direction: gesture.direction,
170 delta,
171 source,
172 container,
215173 bottomInset,
216 );
217 usePreviewMiniPlayerStore.getState().move(threadRef, tabId, nextPosition);
174 });
175 store.resize(threadRef, tabId, next.width);
176 store.move(threadRef, tabId, { x: next.x, y: next.y });
218177 };
219178
220 const endResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
221 if (resizeRef.current?.pointerId !== event.pointerId) return;
222 resizeRef.current = null;
179 const endGesture = (event: ReactPointerEvent<HTMLElement>) => {
180 if (gestureRef.current?.pointerId !== event.pointerId) return;
181 gestureRef.current = null;
223182 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
224183 event.currentTarget.releasePointerCapture(event.pointerId);
225184 }
......@@ -228,125 +187,116 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
228187 if (!snapshot || miniPlayer?.tabId !== tabId) return null;
229188
230189 return (
231 <section
232 ref={rootRef}
233 aria-label="Floating browser preview"
234 data-preview-mini-player={tabId}
235 className="pointer-events-none absolute select-none"
236 style={
237 position
238 ? { left: position.x, top: position.y, width: size.width, height: size.height }
239 : {
240 right: PREVIEW_MINI_PLAYER_EDGE_GAP,
241 top: PREVIEW_MINI_PLAYER_EDGE_GAP,
242 width: size.width,
243 height: size.height,
244 }
245 }
246 >
247 <div className="group pointer-events-auto absolute right-2 top-2 z-[49] size-3">
248 <div
249 aria-hidden="true"
250 className="absolute right-0 top-0 size-2 rounded-full bg-foreground/25 shadow-sm ring-1 ring-background/70 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
251 />
252 <div
253 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 active:cursor-grabbing"
254 onPointerDown={handlePointerDown}
255 onPointerMove={handlePointerMove}
256 onPointerUp={endDrag}
257 onPointerCancel={endDrag}
190 <div ref={containerRef} className="pointer-events-none absolute inset-0">
191 {frame ? (
192 <section
193 aria-label="Floating browser preview"
194 data-preview-mini-player={tabId}
195 className="pointer-events-none absolute select-none"
196 style={{ left: frame.x, top: frame.y, width: frame.width, height: frame.height }}
258197 >
259 <Tooltip>
260 <TooltipTrigger
261 render={
262 <Button
263 variant="ghost"
264 size="icon-xs"
265 aria-label="Open preview in right panel"
266 onPointerDown={(event) => event.stopPropagation()}
267 onClick={openInPanel}
268 />
269 }
198 <div className="group pointer-events-auto absolute right-2 top-2 z-[49] size-3">
199 <div
200 aria-hidden="true"
201 className="absolute right-0 top-0 size-2 rounded-full bg-foreground/25 shadow-sm ring-1 ring-background/70 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
202 />
203 <div
204 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 active:cursor-grabbing"
205 onPointerDown={(event) => beginGesture(event, null)}
206 onPointerMove={handlePointerMove}
207 onPointerUp={endGesture}
208 onPointerCancel={endGesture}
270209 >
271 <PanelRightIcon />
272 </TooltipTrigger>
273 <TooltipPopup side="top">Open in right panel</TooltipPopup>
274 </Tooltip>
275 <Tooltip>
276 <TooltipTrigger
277 render={
278 <Button
279 variant={desktopOverlay?.pictureInPicture ? "secondary" : "ghost"}
280 size="icon-xs"
281 aria-label={
282 desktopOverlay?.pictureInPicture
283 ? "Close popped-out preview"
284 : "Pop preview into separate window"
210 <Tooltip>
211 <TooltipTrigger
212 render={
213 <Button
214 variant="ghost"
215 size="icon-xs"
216 aria-label="Open preview in right panel"
217 onPointerDown={(event) => event.stopPropagation()}
218 onClick={openInPanel}
219 />
285220 }
286 disabled={!desktopOverlay?.hasWebContents}
287 onPointerDown={(event) => event.stopPropagation()}
288 onClick={toggleNativePictureInPicture}
289 />
290 }
291 >
292 <PictureInPicture2 />
293 </TooltipTrigger>
294 <TooltipPopup side="top">
295 {desktopOverlay?.pictureInPicture
296 ? "Close separate window"
297 : "Pop into separate window"}
298 </TooltipPopup>
299 </Tooltip>
300 <Tooltip>
301 <TooltipTrigger
302 render={
303 <Button
304 variant="ghost"
305 size="icon-xs"
306 aria-label="Close floating preview"
307 onPointerDown={(event) => event.stopPropagation()}
308 onClick={close}
309 />
310 }
311 >
312 <XIcon />
313 </TooltipTrigger>
314 <TooltipPopup side="top">Close floating preview</TooltipPopup>
315 </Tooltip>
316 </div>
317 </div>
318
319 <div className="relative h-full min-h-0">
320 <div className="absolute inset-0 z-[47] rounded-xl bg-muted shadow-2xl/35" />
321 <BrowserSurfaceSlot
322 tabId={runtimeTabId}
323 visible={Boolean(desktopOverlay?.hasWebContents)}
324 cornerRadius={12}
325 zIndex={PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX}
326 fitSourceContent
327 layoutVersion={
328 position
329 ? `${position.x}:${position.y}`
330 : `initial:${bottomInset}:${defaultLayoutVersion}`
331 }
332 className="absolute inset-0"
333 />
334 <div className="pointer-events-none absolute inset-0 z-[49] rounded-xl ring-1 ring-inset ring-border/80" />
335 {!desktopOverlay?.hasWebContents ? (
336 <div className="pointer-events-none absolute inset-0 z-[49] flex items-center justify-center rounded-xl bg-muted text-xs text-muted-foreground">
337 Reconnecting preview…
221 >
222 <PanelRightIcon />
223 </TooltipTrigger>
224 <TooltipPopup side="top">Open in right panel</TooltipPopup>
225 </Tooltip>
226 <Tooltip>
227 <TooltipTrigger
228 render={
229 <Button
230 variant={desktopOverlay?.pictureInPicture ? "secondary" : "ghost"}
231 size="icon-xs"
232 aria-label={
233 desktopOverlay?.pictureInPicture
234 ? "Close popped-out preview"
235 : "Pop preview into separate window"
236 }
237 disabled={!desktopOverlay?.hasWebContents}
238 onPointerDown={(event) => event.stopPropagation()}
239 onClick={toggleNativePictureInPicture}
240 />
241 }
242 >
243 <PictureInPicture2 />
244 </TooltipTrigger>
245 <TooltipPopup side="top">
246 {desktopOverlay?.pictureInPicture
247 ? "Close separate window"
248 : "Pop into separate window"}
249 </TooltipPopup>
250 </Tooltip>
251 <Tooltip>
252 <TooltipTrigger
253 render={
254 <Button
255 variant="ghost"
256 size="icon-xs"
257 aria-label="Close floating preview"
258 onPointerDown={(event) => event.stopPropagation()}
259 onClick={close}
260 />
261 }
262 >
263 <XIcon />
264 </TooltipTrigger>
265 <TooltipPopup side="top">Close floating preview</TooltipPopup>
266 </Tooltip>
267 </div>
338268 </div>
339 ) : null}
340 <button
341 type="button"
342 aria-label="Resize floating preview"
343 className="pointer-events-auto absolute bottom-0 right-0 z-[49] size-5 cursor-nwse-resize rounded-br-xl after:absolute after:bottom-1 after:right-1 after:size-2 after:border-b after:border-r after:border-foreground/45"
344 onPointerDown={handleResizePointerDown}
345 onPointerMove={handleResizePointerMove}
346 onPointerUp={endResize}
347 onPointerCancel={endResize}
348 />
349 </div>
350 </section>
269
270 <div className="absolute inset-0 z-[47] rounded-xl bg-muted shadow-2xl/35" />
271 <BrowserSurfaceSlot
272 tabId={runtimeTabId}
273 visible={Boolean(desktopOverlay?.hasWebContents)}
274 cornerRadius={12}
275 zIndex={PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX}
276 fitSourceContent
277 layoutVersion={`${frame.x}:${frame.y}`}
278 className="absolute inset-0"
279 />
280 <div className="pointer-events-none absolute inset-0 z-[49] rounded-xl ring-1 ring-inset ring-border/80" />
281 {!desktopOverlay?.hasWebContents ? (
282 <div className="pointer-events-none absolute inset-0 z-[49] flex items-center justify-center rounded-xl bg-muted text-xs text-muted-foreground">
283 Reconnecting preview…
284 </div>
285 ) : null}
286 {RESIZE_HANDLES.map(({ direction, className }) => (
287 <div
288 key={direction}
289 role="presentation"
290 data-preview-mini-player-resize={direction}
291 className={cn("pointer-events-auto absolute z-[49] touch-none", className)}
292 onPointerDown={(event) => beginGesture(event, direction)}
293 onPointerMove={handlePointerMove}
294 onPointerUp={endGesture}
295 onPointerCancel={endGesture}
296 />
297 ))}
298 </section>
299 ) : null}
300 </div>
351301 );
352302}
apps/web/src/components/preview/previewMiniPlayerLayout.test.ts+158-45
......@@ -1,73 +1,186 @@
1import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";
12import { describe, expect, it } from "vite-plus/test";
23
34import {
45 clampPreviewMiniPlayerPosition,
5 clampPreviewMiniPlayerSize,
66 PREVIEW_MINI_PLAYER_EDGE_GAP,
7 resizePreviewMiniPlayer,
8 resolvePreviewMiniPlayerFrame,
9 resolvePreviewMiniPlayerSourceSize,
710} from "./previewMiniPlayerLayout";
811
9describe("clampPreviewMiniPlayerPosition", () => {
10 it("keeps a dragged player within the chat viewport", () => {
12const container = { width: 1_000, height: 700 };
13const source = { width: 1_600, height: 1_000 };
14
15describe("resolvePreviewMiniPlayerSourceSize", () => {
16 it("uses the device viewport scaled by zoom", () => {
1117 expect(
12 clampPreviewMiniPlayerPosition(
13 { x: 900, y: -40 },
14 { width: 1_000, height: 700 },
15 { width: 360, height: 240 },
16 ),
17 ).toEqual({
18 x: 628,
19 y: PREVIEW_MINI_PLAYER_EDGE_GAP,
20 });
18 resolvePreviewMiniPlayerSourceSize({ _tag: "freeform", width: 390, height: 844 }, null, 2),
19 ).toEqual({ width: 780, height: 1_688 });
2120 });
2221
23 it("keeps an edge gap when the player is larger than its container", () => {
22 it("uses the size the fill viewport had when it was floated", () => {
2423 expect(
25 clampPreviewMiniPlayerPosition(
26 { x: 20, y: 30 },
27 { width: 200, height: 160 },
28 { width: 360, height: 240 },
24 resolvePreviewMiniPlayerSourceSize(
25 FILL_PREVIEW_VIEWPORT,
26 { x: 0, y: 0, width: 640, height: 400, scale: 0.5, scrollLeft: 0, scrollTop: 0 },
27 1,
2928 ),
30 ).toEqual({
31 x: PREVIEW_MINI_PLAYER_EDGE_GAP,
32 y: PREVIEW_MINI_PLAYER_EDGE_GAP,
33 });
29 ).toEqual({ width: 1_280, height: 800 });
3430 });
31});
3532
36 it("keeps the player above a growing composer inset", () => {
33describe("resolvePreviewMiniPlayerFrame", () => {
34 it("opens at the source aspect ratio in the top-right corner", () => {
3735 expect(
38 clampPreviewMiniPlayerPosition(
39 { x: 500, y: 448 },
40 { width: 1_000, height: 700 },
41 { width: 360, height: 240 },
42 160,
43 ),
44 ).toEqual({
45 x: 500,
46 y: 288,
36 resolvePreviewMiniPlayerFrame({ width: null, position: null, source, container }),
37 ).toEqual({ x: 668, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 320, height: 200 });
38 });
39
40 it("keeps a tall source at the minimum width instead of the default box", () => {
41 expect(
42 resolvePreviewMiniPlayerFrame({
43 width: null,
44 position: null,
45 source: { width: 390, height: 844 },
46 container,
47 }),
48 ).toEqual({ x: 748, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 240, height: 519 });
49 });
50
51 it("derives height from the stored width", () => {
52 expect(
53 resolvePreviewMiniPlayerFrame({ width: 480, position: { x: 100, y: 80 }, source, container }),
54 ).toEqual({ x: 100, y: 80, width: 480, height: 300 });
55 });
56
57 it("shrinks to the space above the composer without losing the stored width", () => {
58 const frame = resolvePreviewMiniPlayerFrame({
59 width: 800,
60 position: { x: 100, y: 80 },
61 source,
62 container,
63 bottomInset: 300,
4764 });
65 expect(frame).toEqual({ x: 100, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 602, height: 376 });
66 });
67
68 it("never grows past the source's own rendered size", () => {
69 expect(
70 resolvePreviewMiniPlayerFrame({
71 width: 900,
72 position: { x: 12, y: 12 },
73 source: { width: 480, height: 320 },
74 container,
75 }),
76 ).toMatchObject({ width: 480, height: 320 });
4877 });
4978});
5079
51describe("clampPreviewMiniPlayerSize", () => {
52 it("allows resizing within the available chat viewport", () => {
80describe("resizePreviewMiniPlayer", () => {
81 const start = { x: 300, y: 200, width: 320, height: 200 };
82
83 it("keeps the aspect ratio when dragging the right edge", () => {
5384 expect(
54 clampPreviewMiniPlayerSize({ width: 520, height: 360 }, { width: 1_000, height: 700 }, 120),
55 ).toEqual({ width: 520, height: 360 });
85 resizePreviewMiniPlayer({
86 start,
87 direction: "east",
88 delta: { x: 160, y: 0 },
89 source,
90 container,
91 }),
92 ).toEqual({ x: 300, y: 200, width: 480, height: 300 });
5693 });
5794
58 it("bounds oversized players above the composer", () => {
95 it("anchors the right edge when dragging from the left", () => {
5996 expect(
60 clampPreviewMiniPlayerSize(
61 { width: 2_000, height: 2_000 },
62 { width: 1_000, height: 700 },
63 120,
64 ),
65 ).toEqual({ width: 976, height: 556 });
97 resizePreviewMiniPlayer({
98 start,
99 direction: "west",
100 delta: { x: -160, y: 0 },
101 source,
102 container,
103 }),
104 ).toEqual({ x: 140, y: 200, width: 480, height: 300 });
105 });
106
107 it("anchors the bottom edge when dragging the top up", () => {
108 expect(
109 resizePreviewMiniPlayer({
110 start,
111 direction: "north",
112 delta: { x: 0, y: -100 },
113 source,
114 container,
115 }),
116 ).toEqual({ x: 300, y: 100, width: 480, height: 300 });
117 });
118
119 it("follows the dominant axis on a corner drag", () => {
120 expect(
121 resizePreviewMiniPlayer({
122 start,
123 direction: "southeast",
124 delta: { x: 20, y: 100 },
125 source,
126 container,
127 }),
128 ).toEqual({ x: 300, y: 200, width: 480, height: 300 });
129 });
130
131 it("stops at the container edge in the drag direction", () => {
132 expect(
133 resizePreviewMiniPlayer({
134 start: { x: 600, y: 12, width: 320, height: 200 },
135 direction: "east",
136 delta: { x: 500, y: 0 },
137 source,
138 container,
139 }),
140 ).toEqual({ x: 600, y: 12, width: 388, height: 243 });
141 });
142
143 it("shifts the player when the free axis would overflow", () => {
144 expect(
145 resizePreviewMiniPlayer({
146 start: { x: 12, y: 400, width: 320, height: 200 },
147 direction: "east",
148 delta: { x: 300, y: 0 },
149 source,
150 container,
151 bottomInset: 0,
152 }),
153 ).toEqual({ x: 12, y: 300, width: 620, height: 388 });
154 });
155
156 it("respects the minimum size", () => {
157 expect(
158 resizePreviewMiniPlayer({
159 start,
160 direction: "southeast",
161 delta: { x: -300, y: -300 },
162 source,
163 container,
164 }),
165 ).toEqual({ x: 300, y: 200, width: 240, height: 150 });
166 });
167});
168
169describe("clampPreviewMiniPlayerPosition", () => {
170 it("keeps a dragged player within the chat viewport", () => {
171 expect(
172 clampPreviewMiniPlayerPosition({ x: 900, y: -40 }, container, { width: 360, height: 240 }),
173 ).toEqual({ x: 628, y: PREVIEW_MINI_PLAYER_EDGE_GAP });
66174 });
67175
68 it("lets a tiny container win over the preferred minimum", () => {
176 it("keeps the player above a growing composer inset", () => {
69177 expect(
70 clampPreviewMiniPlayerSize({ width: 360, height: 239 }, { width: 250, height: 180 }, 20),
71 ).toEqual({ width: 226, height: 136 });
178 clampPreviewMiniPlayerPosition(
179 { x: 500, y: 448 },
180 container,
181 { width: 360, height: 240 },
182 160,
183 ),
184 ).toEqual({ x: 500, y: 288 });
72185 });
73186});
apps/web/src/components/preview/previewMiniPlayerLayout.ts+150-16
......@@ -1,31 +1,79 @@
1import type { PreviewViewportSetting } from "@t3tools/contracts";
2
3import type { BrowserSurfaceContentPresentation } from "~/browser/browserSurfaceStore";
4import {
5 resolveFittedBrowserViewport,
6 type BrowserViewportResizeDirection,
7} from "~/browser/browserViewportLayout";
18import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore";
29
310export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12;
411// The mini-player shell straddles this webview at 47 and 49; dialogs begin at 50.
512export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48;
6export const PREVIEW_MINI_PLAYER_DEFAULT_SIZE = { width: 320, height: 200 } as const;
13// A fresh player is the largest box at the source aspect ratio that fits here.
14const PREVIEW_MINI_PLAYER_DEFAULT_BOX = { width: 320, height: 320 } as const;
715const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const;
816
9export function clampPreviewMiniPlayerSize(
10 size: PreviewMiniPlayerSize,
11 container: PreviewMiniPlayerSize,
12 bottomInset = 0,
17export interface PreviewMiniPlayerFrame extends PreviewMiniPlayerPosition, PreviewMiniPlayerSize {}
18
19/**
20 * The rendered size of what the floating player mirrors: the device viewport
21 * when one is set, otherwise the size the webview had when it was floated
22 * (`fittedSourceContent`), which the hosted webview keeps as its CSS viewport.
23 */
24export function resolvePreviewMiniPlayerSourceSize(
25 viewport: PreviewViewportSetting,
26 fittedSourceContent: BrowserSurfaceContentPresentation | null,
27 zoomFactor: number,
1328): PreviewMiniPlayerSize {
14 const availableWidth = Math.max(1, container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2);
15 const availableHeight = Math.max(
16 1,
17 container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,
18 );
29 const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
30 const fitted = resolveFittedBrowserViewport(viewport, fittedSourceContent, normalizedZoomFactor);
1931 return {
20 width: Math.round(
21 Math.min(Math.max(PREVIEW_MINI_PLAYER_MIN_SIZE.width, size.width), availableWidth),
22 ),
23 height: Math.round(
24 Math.min(Math.max(PREVIEW_MINI_PLAYER_MIN_SIZE.height, size.height), availableHeight),
25 ),
32 width: fitted.width * normalizedZoomFactor,
33 height: fitted.height * normalizedZoomFactor,
2634 };
2735}
2836
37const availableArea = (
38 container: PreviewMiniPlayerSize,
39 bottomInset: number,
40): PreviewMiniPlayerSize => ({
41 width: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,
42 height: container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,
43});
44
45/**
46 * Width is the player's only free dimension; height always follows the source
47 * aspect ratio so the webview fills the box without letterboxing. The player
48 * never grows past the source's own size (the guest keeps its CSS viewport, so
49 * going bigger would only upscale), and a tight container wins over the minimum.
50 */
51function fitPreviewMiniPlayerWidth(
52 desiredWidth: number,
53 source: PreviewMiniPlayerSize,
54 max: PreviewMiniPlayerSize,
55): PreviewMiniPlayerSize {
56 const aspectRatio = source.width / source.height;
57 const width = Math.min(
58 Math.max(
59 desiredWidth,
60 PREVIEW_MINI_PLAYER_MIN_SIZE.width,
61 PREVIEW_MINI_PLAYER_MIN_SIZE.height * aspectRatio,
62 ),
63 source.width,
64 Math.max(1, max.width),
65 Math.max(1, max.height * aspectRatio),
66 );
67 return { width: Math.round(width), height: Math.round(width / aspectRatio) };
68}
69
70function defaultPreviewMiniPlayerWidth(source: PreviewMiniPlayerSize): number {
71 return Math.min(
72 PREVIEW_MINI_PLAYER_DEFAULT_BOX.width,
73 (PREVIEW_MINI_PLAYER_DEFAULT_BOX.height * source.width) / source.height,
74 );
75}
76
2977export function clampPreviewMiniPlayerPosition(
3078 position: PreviewMiniPlayerPosition,
3179 container: PreviewMiniPlayerSize,
......@@ -46,3 +94,89 @@ export function clampPreviewMiniPlayerPosition(
4694 y: Math.min(Math.max(position.y, PREVIEW_MINI_PLAYER_EDGE_GAP), maxY),
4795 };
4896}
97
98/**
99 * Resolves the on-screen frame from the stored width and position. Clamping
100 * happens here on every layout pass instead of being written back to the
101 * store, so a temporarily narrow container never destroys the user's chosen
102 * width. A player without a position sits in the top-right corner.
103 */
104export function resolvePreviewMiniPlayerFrame(input: {
105 readonly width: number | null;
106 readonly position: PreviewMiniPlayerPosition | null;
107 readonly source: PreviewMiniPlayerSize;
108 readonly container: PreviewMiniPlayerSize;
109 readonly bottomInset?: number;
110}): PreviewMiniPlayerFrame {
111 const { width, position, source, container, bottomInset = 0 } = input;
112 const size = fitPreviewMiniPlayerWidth(
113 width ?? defaultPreviewMiniPlayerWidth(source),
114 source,
115 availableArea(container, bottomInset),
116 );
117 const anchored = position ?? {
118 x: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - size.width,
119 y: PREVIEW_MINI_PLAYER_EDGE_GAP,
120 };
121 return { ...clampPreviewMiniPlayerPosition(anchored, container, size, bottomInset), ...size };
122}
123
124/**
125 * Resizes from any edge or corner while holding the aspect ratio. The edge
126 * opposite the dragged one stays anchored, so growth stops at the container
127 * on that axis and the pointer keeps tracking the grabbed edge. On a plain edge
128 * drag the perpendicular axis may use the whole container, and the player
129 * shifts as needed to stay inside.
130 */
131export function resizePreviewMiniPlayer(input: {
132 readonly start: PreviewMiniPlayerFrame;
133 readonly direction: BrowserViewportResizeDirection;
134 readonly delta: PreviewMiniPlayerPosition;
135 readonly source: PreviewMiniPlayerSize;
136 readonly container: PreviewMiniPlayerSize;
137 readonly bottomInset?: number;
138}): PreviewMiniPlayerFrame {
139 const { start, direction, delta, source, container, bottomInset = 0 } = input;
140 const east = direction.includes("east");
141 const west = direction.includes("west");
142 const north = direction.includes("north");
143 const south = direction.includes("south");
144 const available = availableArea(container, bottomInset);
145 const right = start.x + start.width;
146 const bottom = start.y + start.height;
147 const max = {
148 width: west
149 ? right - PREVIEW_MINI_PLAYER_EDGE_GAP
150 : east
151 ? container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - start.x
152 : available.width,
153 height: north
154 ? bottom - PREVIEW_MINI_PLAYER_EDGE_GAP
155 : south
156 ? container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP - start.y
157 : available.height,
158 };
159 const desiredWidth = start.width + (east ? delta.x : west ? -delta.x : 0);
160 const desiredHeight = start.height + (south ? delta.y : north ? -delta.y : 0);
161 const horizontal = east || west;
162 const vertical = north || south;
163 const widthLeads =
164 horizontal && !vertical
165 ? true
166 : vertical && !horizontal
167 ? false
168 : Math.abs(desiredWidth - start.width) / start.width >=
169 Math.abs(desiredHeight - start.height) / start.height;
170 const size = fitPreviewMiniPlayerWidth(
171 widthLeads ? desiredWidth : (desiredHeight * source.width) / source.height,
172 source,
173 max,
174 );
175 const position = clampPreviewMiniPlayerPosition(
176 { x: west ? right - size.width : start.x, y: north ? bottom - size.height : start.y },
177 container,
178 size,
179 bottomInset,
180 );
181 return { ...position, ...size };
182}
apps/web/src/previewMiniPlayerStore.test.ts+5-5
......@@ -34,7 +34,7 @@ describe("previewMiniPlayerStore", () => {
3434 ).toEqual({
3535 tabId: "tab-b",
3636 position: { x: 24, y: 48 },
37 size: null,
37 width: null,
3838 });
3939 });
4040
......@@ -48,17 +48,17 @@ describe("previewMiniPlayerStore", () => {
4848 ).toEqual({
4949 tabId: "tab-b",
5050 position: null,
51 size: null,
51 width: null,
5252 });
5353 });
5454
55 it("preserves a thread-bound size while switching tabs", () => {
55 it("preserves a thread-bound width while switching tabs", () => {
5656 usePreviewMiniPlayerStore.getState().open(refA, "tab-a");
57 usePreviewMiniPlayerStore.getState().resize(refA, "tab-a", { width: 480, height: 320 });
57 usePreviewMiniPlayerStore.getState().resize(refA, "tab-a", 480);
5858 usePreviewMiniPlayerStore.getState().open(refA, "tab-b");
5959
6060 expect(
6161 selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, refA),
62 ).toMatchObject({ tabId: "tab-b", size: { width: 480, height: 320 } });
62 ).toMatchObject({ tabId: "tab-b", width: 480 });
6363 });
6464});
apps/web/src/previewMiniPlayerStore.ts+7-7
......@@ -15,7 +15,8 @@ export interface PreviewMiniPlayerSize {
1515export interface PreviewMiniPlayerState {
1616 readonly tabId: string;
1717 readonly position: PreviewMiniPlayerPosition | null;
18 readonly size: PreviewMiniPlayerSize | null;
18 /** Height always follows the previewed viewport's aspect ratio. */
19 readonly width: number | null;
1920}
2021
2122interface PreviewMiniPlayerStoreState {
......@@ -23,7 +24,7 @@ interface PreviewMiniPlayerStoreState {
2324 readonly open: (ref: ScopedThreadRef, tabId: string) => void;
2425 readonly close: (ref: ScopedThreadRef) => void;
2526 readonly move: (ref: ScopedThreadRef, tabId: string, position: PreviewMiniPlayerPosition) => void;
26 readonly resize: (ref: ScopedThreadRef, tabId: string, size: PreviewMiniPlayerSize) => void;
27 readonly resize: (ref: ScopedThreadRef, tabId: string, width: number) => void;
2728 readonly removeThread: (ref: ScopedThreadRef) => void;
2829}
2930
......@@ -40,7 +41,7 @@ export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()((
4041 [threadKey]: {
4142 tabId,
4243 position: current?.position ?? null,
43 size: current?.size ?? null,
44 width: current?.width ?? null,
4445 },
4546 },
4647 };
......@@ -65,16 +66,15 @@ export const usePreviewMiniPlayerStore = create<PreviewMiniPlayerStoreState>()((
6566 },
6667 };
6768 }),
68 resize: (ref, tabId, size) =>
69 resize: (ref, tabId, width) =>
6970 set((state) => {
7071 const threadKey = scopedThreadKey(ref);
7172 const current = state.byThreadKey[threadKey];
72 if (!current || current.tabId !== tabId) return state;
73 if (current.size?.width === size.width && current.size.height === size.height) return state;
73 if (!current || current.tabId !== tabId || current.width === width) return state;
7474 return {
7575 byThreadKey: {
7676 ...state.byThreadKey,
77 [threadKey]: { ...current, size },
77 [threadKey]: { ...current, width },
7878 },
7979 };
8080 }),