authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-11 14:16:51-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 14:16:51-07:00
log18f7254e0adbd7d642f4d78f9b9c8e6cb569af56
tree13e759d3d1e9233ab15d257d9b5916b8f8b241eb
parent8bbe2bf660c361e33db9c30f03fc72ab183eef3f
signature Signed by PGP key B5690EEEBB952194

fix(web): floating preview can use the margins beside the composer (#11290)

Co-authored-by: Claude Code <noreply@anthropic.com>

4 files changed, 312 insertions(+), 65 deletions(-)

apps/web/src/components/ChatView.tsx+5-9
...@@ -1694,8 +1694,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -1694,8 +1694,6 @@ export default function ChatView(props: ChatViewProps) {
1694 [],1694 [],
1695 );1695 );
1696 const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null);1696 const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null);
1697 const [composerOverlayHeight, setComposerOverlayHeight] = useState(0);
1698 const composerOverlayHeightRef = useRef(0);
1699 // Space the timeline keeps clear above its end. Tracks the overlay while the1697 // Space the timeline keeps clear above its end. Tracks the overlay while the
1700 // composer is expanded and holds that height while it rests, so the resting1698 // composer is expanded and holds that height while it rests, so the resting
1701 // composer never exposes rows that its expansion will cover.1699 // composer never exposes rows that its expansion will cover.
...@@ -5493,11 +5491,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -5493,11 +5491,6 @@ export default function ChatView(props: ChatViewProps) {
5493 (height: number) => {5491 (height: number) => {
5494 const nextHeight = Math.ceil(height);5492 const nextHeight = Math.ceil(height);
5495 if (nextHeight <= 0) return;5493 if (nextHeight <= 0) return;
5496 const previousHeight = composerOverlayHeightRef.current;
5497 if (previousHeight !== nextHeight) {
5498 composerOverlayHeightRef.current = nextHeight;
5499 setComposerOverlayHeight(nextHeight);
5500 }
5501 const nextInset = resolveComposerTimelineInset({5494 const nextInset = resolveComposerTimelineInset({
5502 currentInset: composerTimelineInsetRef.current,5495 currentInset: composerTimelineInsetRef.current,
5503 overlayHeight: nextHeight,5496 overlayHeight: nextHeight,
...@@ -8658,7 +8651,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -8658,7 +8651,10 @@ export default function ChatView(props: ChatViewProps) {
8658 ref={attachDraftHeroTransitionGroupRef}8651 ref={attachDraftHeroTransitionGroupRef}
8659 className="w-full ps-[calc(env(safe-area-inset-left)+0.75rem)] pe-[calc(env(safe-area-inset-right)+0.75rem)] sm:ps-[calc(env(safe-area-inset-left)+1.25rem)] sm:pe-[calc(env(safe-area-inset-right)+1.25rem)]"8652 className="w-full ps-[calc(env(safe-area-inset-left)+0.75rem)] pe-[calc(env(safe-area-inset-right)+0.75rem)] sm:ps-[calc(env(safe-area-inset-left)+1.25rem)] sm:pe-[calc(env(safe-area-inset-right)+1.25rem)]"
8660 >8653 >
8661 <div className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-3xl">8654 <div
8655 data-chat-composer-stack="true"
8656 className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-3xl"
8657 >
8662 {isDraftHeroState ? (8658 {isDraftHeroState ? (
8663 <div className="absolute inset-x-0 bottom-full z-0">8659 <div className="absolute inset-x-0 bottom-full z-0">
8664 <div8660 <div
...@@ -8873,7 +8869,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8873,7 +8869,7 @@ export default function ChatView(props: ChatViewProps) {
8873 key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`}8869 key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`}
8874 threadRef={activeThreadRef}8870 threadRef={activeThreadRef}
8875 miniPlayer={activePreviewMiniPlayer}8871 miniPlayer={activePreviewMiniPlayer}
8876 bottomInset={isDraftHeroState ? 0 : composerOverlayHeight}8872 composerOverlayElement={isDraftHeroState ? null : composerOverlayElement}
8877 />8873 />
8878 ) : null}8874 ) : null}
88798875
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+65-20
...@@ -35,9 +35,11 @@ import type { DeviceScreenSize } from "../device/deviceStream";...@@ -35,9 +35,11 @@ import type { DeviceScreenSize } from "../device/deviceStream";
35import { previewBridge } from "./previewBridge";35import { previewBridge } from "./previewBridge";
36import {36import {
37 clampPreviewMiniPlayerPosition,37 clampPreviewMiniPlayerPosition,
38 NO_PREVIEW_MINI_PLAYER_OBSTACLES,
38 PREVIEW_MINI_PLAYER_CORNER_RADIUS,39 PREVIEW_MINI_PLAYER_CORNER_RADIUS,
39 PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX,40 PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX,
40 type PreviewMiniPlayerFrame,41 type PreviewMiniPlayerFrame,
42 type PreviewMiniPlayerObstacles,
41 resizePreviewMiniPlayer,43 resizePreviewMiniPlayer,
42 resolveDeviceMiniPlayerCornerRadius,44 resolveDeviceMiniPlayerCornerRadius,
43 resolveDeviceMiniPlayerSourceSize,45 resolveDeviceMiniPlayerSourceSize,
...@@ -56,7 +58,49 @@ interface PointerGesture {...@@ -56,7 +58,49 @@ interface PointerGesture {
56interface Props {58interface Props {
57 readonly threadRef: ScopedThreadRef;59 readonly threadRef: ScopedThreadRef;
58 readonly miniPlayer: PreviewMiniPlayerState;60 readonly miniPlayer: PreviewMiniPlayerState;
59 readonly bottomInset: number;61 /** The docked composer overlay; null while the composer floats mid-screen. */
62 readonly composerOverlayElement: HTMLElement | null;
63}
64
65interface Layout {
66 readonly container: PreviewMiniPlayerSize;
67 readonly obstacles: PreviewMiniPlayerObstacles;
68}
69
70const sameLayout = (a: Layout, b: Layout) =>
71 a.container.width === b.container.width &&
72 a.container.height === b.container.height &&
73 (a.obstacles.composer === b.obstacles.composer ||
74 (a.obstacles.composer !== null &&
75 b.obstacles.composer !== null &&
76 a.obstacles.composer.left === b.obstacles.composer.left &&
77 a.obstacles.composer.right === b.obstacles.composer.right &&
78 a.obstacles.composer.height === b.obstacles.composer.height));
79
80/**
81 * Measures the chat column and the composer in the column's coordinates. The
82 * composer's columns come from its centered stack, not the full-width overlay,
83 * so the margins beside it stay open to the player.
84 */
85function measureLayout(container: HTMLElement, composerOverlay: HTMLElement | null): Layout {
86 const containerRect = container.getBoundingClientRect();
87 const stackRect = composerOverlay
88 ?.querySelector('[data-chat-composer-stack="true"]')
89 ?.getBoundingClientRect();
90 const overlayRect = composerOverlay?.getBoundingClientRect();
91 return {
92 container: { width: container.clientWidth, height: container.clientHeight },
93 obstacles: {
94 composer:
95 overlayRect && stackRect && overlayRect.height > 0
96 ? {
97 left: Math.floor(stackRect.left - containerRect.left),
98 right: Math.ceil(stackRect.right - containerRect.left),
99 height: Math.ceil(overlayRect.height),
100 }
101 : null,
102 },
103 };
60}104}
61105
62const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS;106const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS;
...@@ -77,7 +121,7 @@ const RESIZE_HANDLES: ReadonlyArray<{...@@ -77,7 +121,7 @@ const RESIZE_HANDLES: ReadonlyArray<{
77];121];
78122
79/** Floats the thread's browser tab or device stream over chat. */123/** Floats the thread's browser tab or device stream over chat. */
80export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, bottomInset }: Props) {124export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, composerOverlayElement }: Props) {
81 const { source } = miniPlayer;125 const { source } = miniPlayer;
82 return source.kind === "browser" ? (126 return source.kind === "browser" ? (
83 <BrowserMiniPlayer127 <BrowserMiniPlayer
...@@ -85,7 +129,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, bottomInset }:...@@ -85,7 +129,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, bottomInset }:
85 threadRef={threadRef}129 threadRef={threadRef}
86 tabId={source.tabId}130 tabId={source.tabId}
87 miniPlayer={miniPlayer}131 miniPlayer={miniPlayer}
88 bottomInset={bottomInset}132 composerOverlayElement={composerOverlayElement}
89 />133 />
90 ) : (134 ) : (
91 <DeviceMiniPlayer135 <DeviceMiniPlayer
...@@ -93,7 +137,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, bottomInset }:...@@ -93,7 +137,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, bottomInset }:
93 threadRef={threadRef}137 threadRef={threadRef}
94 source={source}138 source={source}
95 miniPlayer={miniPlayer}139 miniPlayer={miniPlayer}
96 bottomInset={bottomInset}140 composerOverlayElement={composerOverlayElement}
97 />141 />
98 );142 );
99}143}
...@@ -102,7 +146,7 @@ function BrowserMiniPlayer({...@@ -102,7 +146,7 @@ function BrowserMiniPlayer({
102 threadRef,146 threadRef,
103 tabId,147 tabId,
104 miniPlayer,148 miniPlayer,
105 bottomInset,149 composerOverlayElement,
106}: Props & { readonly tabId: string }) {150}: Props & { readonly tabId: string }) {
107 const previewState = useThreadPreviewState(threadRef);151 const previewState = useThreadPreviewState(threadRef);
108 const snapshot = previewState.sessions[tabId] ?? null;152 const snapshot = previewState.sessions[tabId] ?? null;
...@@ -143,7 +187,7 @@ function BrowserMiniPlayer({...@@ -143,7 +187,7 @@ function BrowserMiniPlayer({
143 threadRef={threadRef}187 threadRef={threadRef}
144 miniPlayer={miniPlayer}188 miniPlayer={miniPlayer}
145 sourceSize={sourceSize}189 sourceSize={sourceSize}
146 bottomInset={bottomInset}190 composerOverlayElement={composerOverlayElement}
147 label="Floating browser preview"191 label="Floating browser preview"
148 onOpenInPanel={openInPanel}192 onOpenInPanel={openInPanel}
149 pillActions={193 pillActions={
...@@ -200,7 +244,7 @@ function DeviceMiniPlayer({...@@ -200,7 +244,7 @@ function DeviceMiniPlayer({
200 threadRef,244 threadRef,
201 source,245 source,
202 miniPlayer,246 miniPlayer,
203 bottomInset,247 composerOverlayElement,
204}: Props & { readonly source: Extract<PreviewMiniPlayerSource, { kind: "device" }> }) {248}: Props & { readonly source: Extract<PreviewMiniPlayerSource, { kind: "device" }> }) {
205 const { state: deviceState } = useDeviceState(threadRef.environmentId);249 const { state: deviceState } = useDeviceState(threadRef.environmentId);
206 const [screen, setScreen] = useState<DeviceScreenSize | null>(null);250 const [screen, setScreen] = useState<DeviceScreenSize | null>(null);
...@@ -230,7 +274,7 @@ function DeviceMiniPlayer({...@@ -230,7 +274,7 @@ function DeviceMiniPlayer({
230 threadRef={threadRef}274 threadRef={threadRef}
231 miniPlayer={miniPlayer}275 miniPlayer={miniPlayer}
232 sourceSize={sourceSize}276 sourceSize={sourceSize}
233 bottomInset={bottomInset}277 composerOverlayElement={composerOverlayElement}
234 label="Floating device preview"278 label="Floating device preview"
235 onOpenInPanel={openInPanel}279 onOpenInPanel={openInPanel}
236 cornerRadius={cornerRadius}280 cornerRadius={cornerRadius}
...@@ -266,7 +310,7 @@ function MiniPlayerShell({...@@ -266,7 +310,7 @@ function MiniPlayerShell({
266 threadRef,310 threadRef,
267 miniPlayer,311 miniPlayer,
268 sourceSize,312 sourceSize,
269 bottomInset,313 composerOverlayElement,
270 label,314 label,
271 onOpenInPanel,315 onOpenInPanel,
272 pillActions,316 pillActions,
...@@ -276,7 +320,7 @@ function MiniPlayerShell({...@@ -276,7 +320,7 @@ function MiniPlayerShell({
276 readonly threadRef: ScopedThreadRef;320 readonly threadRef: ScopedThreadRef;
277 readonly miniPlayer: PreviewMiniPlayerState;321 readonly miniPlayer: PreviewMiniPlayerState;
278 readonly sourceSize: PreviewMiniPlayerSize;322 readonly sourceSize: PreviewMiniPlayerSize;
279 readonly bottomInset: number;323 readonly composerOverlayElement: HTMLElement | null;
280 readonly label: string;324 readonly label: string;
281 readonly onOpenInPanel: () => void;325 readonly onOpenInPanel: () => void;
282 readonly pillActions?: ReactNode;326 readonly pillActions?: ReactNode;
...@@ -286,7 +330,9 @@ function MiniPlayerShell({...@@ -286,7 +330,9 @@ function MiniPlayerShell({
286}) {330}) {
287 const containerRef = useRef<HTMLDivElement | null>(null);331 const containerRef = useRef<HTMLDivElement | null>(null);
288 const gestureRef = useRef<PointerGesture | null>(null);332 const gestureRef = useRef<PointerGesture | null>(null);
289 const [container, setContainer] = useState<PreviewMiniPlayerSize | null>(null);333 const [layout, setLayout] = useState<Layout | null>(null);
334 const container = layout?.container ?? null;
335 const obstacles = layout?.obstacles ?? NO_PREVIEW_MINI_PLAYER_OBSTACLES;
290 const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source);336 const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source);
291 const frame = container337 const frame = container
292 ? resolvePreviewMiniPlayerFrame({338 ? resolvePreviewMiniPlayerFrame({
...@@ -294,7 +340,7 @@ function MiniPlayerShell({...@@ -294,7 +340,7 @@ function MiniPlayerShell({
294 position: miniPlayer.position,340 position: miniPlayer.position,
295 source: sourceSize,341 source: sourceSize,
296 container,342 container,
297 bottomInset,343 obstacles,
298 })344 })
299 : null;345 : null;
300346
...@@ -306,22 +352,21 @@ function MiniPlayerShell({...@@ -306,22 +352,21 @@ function MiniPlayerShell({
306 usePreviewMiniPlayerStore.getState().close(threadRef);352 usePreviewMiniPlayerStore.getState().close(threadRef);
307 };353 };
308354
355 // The composer grows on its own (drafts, banners), so it is observed alongside the column.
309 useLayoutEffect(() => {356 useLayoutEffect(() => {
310 const element = containerRef.current;357 const element = containerRef.current;
311 if (!element) return;358 if (!element) return;
312 const measure = () => {359 const measure = () => {
313 setContainer((current) =>360 const next = measureLayout(element, composerOverlayElement);
314 current?.width === element.clientWidth && current.height === element.clientHeight361 setLayout((current) => (current && sameLayout(current, next) ? current : next));
315 ? current
316 : { width: element.clientWidth, height: element.clientHeight },
317 );
318 };362 };
319 measure();363 measure();
320 if (typeof ResizeObserver === "undefined") return;364 if (typeof ResizeObserver === "undefined") return;
321 const observer = new ResizeObserver(measure);365 const observer = new ResizeObserver(measure);
322 observer.observe(element);366 observer.observe(element);
367 if (composerOverlayElement) observer.observe(composerOverlayElement);
323 return () => observer.disconnect();368 return () => observer.disconnect();
324 }, []);369 }, [composerOverlayElement]);
325370
326 const beginGesture = (371 const beginGesture = (
327 event: ReactPointerEvent<HTMLElement>,372 event: ReactPointerEvent<HTMLElement>,
...@@ -353,7 +398,7 @@ function MiniPlayerShell({...@@ -353,7 +398,7 @@ function MiniPlayerShell({
353 { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y },398 { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y },
354 container,399 container,
355 gesture.frame,400 gesture.frame,
356 bottomInset,401 obstacles,
357 ),402 ),
358 );403 );
359 return;404 return;
...@@ -364,7 +409,7 @@ function MiniPlayerShell({...@@ -364,7 +409,7 @@ function MiniPlayerShell({
364 delta,409 delta,
365 source: sourceSize,410 source: sourceSize,
366 container,411 container,
367 bottomInset,412 obstacles,
368 });413 });
369 store.resize(threadRef, sourceKey, next.width);414 store.resize(threadRef, sourceKey, next.width);
370 store.move(threadRef, sourceKey, { x: next.x, y: next.y });415 store.move(threadRef, sourceKey, { x: next.x, y: next.y });
apps/web/src/components/preview/previewMiniPlayerLayout.test.ts+113-9
...@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";
4import {4import {
5 clampPreviewMiniPlayerPosition,5 clampPreviewMiniPlayerPosition,
6 PREVIEW_MINI_PLAYER_EDGE_GAP,6 PREVIEW_MINI_PLAYER_EDGE_GAP,
7 type PreviewMiniPlayerObstacles,
7 resizePreviewMiniPlayer,8 resizePreviewMiniPlayer,
8 resolveDeviceMiniPlayerCornerRadius,9 resolveDeviceMiniPlayerCornerRadius,
9 resolveDeviceMiniPlayerSourceSize,10 resolveDeviceMiniPlayerSourceSize,
...@@ -13,6 +14,11 @@ import {...@@ -13,6 +14,11 @@ import {
1314
14const container = { width: 1_000, height: 700 };15const container = { width: 1_000, height: 700 };
15const source = { width: 1_600, height: 1_000 };16const source = { width: 1_600, height: 1_000 };
17const gap = PREVIEW_MINI_PLAYER_EDGE_GAP;
18// A centered composer stack with margins on each side.
19const composer = { left: 100, right: 900, height: 150 };
20const obstacles: PreviewMiniPlayerObstacles = { composer };
21const tallComposer: PreviewMiniPlayerObstacles = { composer: { ...composer, height: 300 } };
1622
17describe("resolvePreviewMiniPlayerSourceSize", () => {23describe("resolvePreviewMiniPlayerSourceSize", () => {
18 it("uses the device viewport scaled by zoom", () => {24 it("uses the device viewport scaled by zoom", () => {
...@@ -106,11 +112,35 @@ describe("resolvePreviewMiniPlayerFrame", () => {...@@ -106,11 +112,35 @@ describe("resolvePreviewMiniPlayerFrame", () => {
106 position: { x: 100, y: 80 },112 position: { x: 100, y: 80 },
107 source,113 source,
108 container,114 container,
109 bottomInset: 300,115 obstacles: tallComposer,
110 });116 });
111 expect(frame).toEqual({ x: 100, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 602, height: 376 });117 expect(frame).toEqual({ x: 100, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 602, height: 376 });
112 });118 });
113119
120 it("keeps a tall frame parked beside the composer across layout passes", () => {
121 // The frame an edge resize produced in the left margin, resolved again from
122 // the stored width and position on the next render.
123 const phone = { width: 390, height: 844 };
124 const beside = { composer: { left: 300, right: 900, height: 300 } };
125 const resized = resizePreviewMiniPlayer({
126 start: { x: 12, y: 100, width: 240, height: 519 },
127 direction: "east",
128 delta: { x: 10, y: 0 },
129 source: phone,
130 container,
131 obstacles: beside,
132 });
133 expect(
134 resolvePreviewMiniPlayerFrame({
135 width: resized.width,
136 position: { x: resized.x, y: resized.y },
137 source: phone,
138 container,
139 obstacles: beside,
140 }),
141 ).toEqual(resized);
142 });
143
114 it("never grows past the source's own rendered size", () => {144 it("never grows past the source's own rendered size", () => {
115 expect(145 expect(
116 resolvePreviewMiniPlayerFrame({146 resolvePreviewMiniPlayerFrame({
...@@ -194,11 +224,52 @@ describe("resizePreviewMiniPlayer", () => {...@@ -194,11 +224,52 @@ describe("resizePreviewMiniPlayer", () => {
194 delta: { x: 300, y: 0 },224 delta: { x: 300, y: 0 },
195 source,225 source,
196 container,226 container,
197 bottomInset: 0,
198 }),227 }),
199 ).toEqual({ x: 12, y: 300, width: 620, height: 388 });228 ).toEqual({ x: 12, y: 300, width: 620, height: 388 });
200 });229 });
201230
231 it("lets a player beside a tall composer keep its height on an edge drag", () => {
232 // A portrait player parked in the margin left of the composer, already
233 // taller than the rows above the composer, nudged from its right edge.
234 const phone = { width: 390, height: 844 };
235 const start = { x: 12, y: 100, width: 240, height: 519 };
236 const beside = { composer: { left: 300, right: 900, height: 300 } };
237 expect(
238 resizePreviewMiniPlayer({
239 start,
240 direction: "east",
241 delta: { x: 10, y: 0 },
242 source: phone,
243 container,
244 obstacles: beside,
245 }),
246 ).toEqual({ x: 12, y: 100, width: 250, height: 541 });
247 // The same drag with the composer under the player is still held above it.
248 expect(
249 resizePreviewMiniPlayer({
250 start: { ...start, x: 400 },
251 direction: "east",
252 delta: { x: 10, y: 0 },
253 source: phone,
254 container,
255 obstacles: beside,
256 }),
257 ).toMatchObject({ height: 376 });
258 });
259
260 it("stops growing downward at the composer beneath the player's columns", () => {
261 expect(
262 resizePreviewMiniPlayer({
263 start: { x: 300, y: 100, width: 320, height: 200 },
264 direction: "south",
265 delta: { x: 0, y: 400 },
266 source,
267 container,
268 obstacles: tallComposer,
269 }),
270 ).toEqual({ x: 300, y: 100, width: 461, height: 288 });
271 });
272
202 it("respects the minimum size", () => {273 it("respects the minimum size", () => {
203 expect(274 expect(
204 resizePreviewMiniPlayer({275 resizePreviewMiniPlayer({
...@@ -213,20 +284,53 @@ describe("resizePreviewMiniPlayer", () => {...@@ -213,20 +284,53 @@ describe("resizePreviewMiniPlayer", () => {
213});284});
214285
215describe("clampPreviewMiniPlayerPosition", () => {286describe("clampPreviewMiniPlayerPosition", () => {
287 const player = { width: 360, height: 240 };
288
216 it("keeps a dragged player within the chat viewport", () => {289 it("keeps a dragged player within the chat viewport", () => {
290 expect(clampPreviewMiniPlayerPosition({ x: 900, y: -40 }, container, player)).toEqual({
291 x: 628,
292 y: gap,
293 });
294 });
295
296 it("keeps the player above a growing composer", () => {
217 expect(297 expect(
218 clampPreviewMiniPlayerPosition({ x: 900, y: -40 }, container, { width: 360, height: 240 }),298 clampPreviewMiniPlayerPosition({ x: 500, y: 448 }, container, player, {
219 ).toEqual({ x: 628, y: PREVIEW_MINI_PLAYER_EDGE_GAP });299 composer: { ...composer, height: 160 },
300 }),
301 ).toEqual({ x: 500, y: 288 });
220 });302 });
221303
222 it("keeps the player above a growing composer inset", () => {304 it("lets the player drop into the margin beside the composer", () => {
223 expect(305 expect(
224 clampPreviewMiniPlayerPosition(306 clampPreviewMiniPlayerPosition(
225 { x: 500, y: 448 },307 { x: 20, y: 500 },
226 container,308 container,
227 { width: 360, height: 240 },309 { width: 60, height: 150 },
228 160,310 obstacles,
229 ),311 ),
230 ).toEqual({ x: 500, y: 288 });312 ).toEqual({ x: 20, y: 500 });
313 });
314
315 it("slides sideways past the composer when that is the shorter move", () => {
316 expect(
317 clampPreviewMiniPlayerPosition(
318 { x: 850, y: 500 },
319 container,
320 { width: 60, height: 150 },
321 obstacles,
322 ),
323 ).toEqual({ x: composer.right + gap, y: 500 });
324 });
325
326 it("sits above the composer when it is too wide for either margin", () => {
327 expect(
328 clampPreviewMiniPlayerPosition(
329 { x: 100, y: 100 },
330 container,
331 { width: 976, height: 500 },
332 obstacles,
333 ),
334 ).toEqual({ x: gap, y: 700 - 150 - gap - 500 });
231 });335 });
232});336});
apps/web/src/components/preview/previewMiniPlayerLayout.ts+129-27
...@@ -77,12 +77,55 @@ export function resolveDeviceMiniPlayerCornerRadius(...@@ -77,12 +77,55 @@ export function resolveDeviceMiniPlayerCornerRadius(
77 );77 );
78}78}
7979
80interface HorizontalSpan {
81 readonly left: number;
82 readonly right: number;
83}
84
85/**
86 * The composer stack docked to the bottom edge, in container coordinates. It
87 * only reserves the columns it covers, so the margins beside it stay open all
88 * the way down.
89 */
90export interface PreviewMiniPlayerObstacles {
91 readonly composer: (HorizontalSpan & { readonly height: number }) | null;
92}
93
94export const NO_PREVIEW_MINI_PLAYER_OBSTACLES: PreviewMiniPlayerObstacles = { composer: null };
95
96const spanOf = (x: number, width: number): HorizontalSpan => ({ left: x, right: x + width });
97
98const spansOverlap = (a: HorizontalSpan, b: HorizontalSpan) => a.left < b.right && a.right > b.left;
99
100/** The lowest row (before the edge gap) open to a player covering these columns. */
101function floorFor(
102 span: HorizontalSpan,
103 container: PreviewMiniPlayerSize,
104 obstacles: PreviewMiniPlayerObstacles,
105): number {
106 const { composer } = obstacles;
107 return composer && spansOverlap(span, composer)
108 ? container.height - Math.max(0, composer.height)
109 : container.height;
110}
111
112/**
113 * The box a stored size is fitted into. A player with a position keeps the
114 * rows its own columns have, so a tall frame parked beside the composer
115 * survives the next layout pass; without one it takes the rows above the
116 * composer, which every column has.
117 */
80const availableArea = (118const availableArea = (
81 container: PreviewMiniPlayerSize,119 container: PreviewMiniPlayerSize,
82 bottomInset: number,120 obstacles: PreviewMiniPlayerObstacles,
121 span: HorizontalSpan | null,
83): PreviewMiniPlayerSize => ({122): PreviewMiniPlayerSize => ({
84 width: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,123 width: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,
85 height: container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,124 height:
125 (span
126 ? floorFor(span, container, obstacles)
127 : container.height - Math.max(0, obstacles.composer?.height ?? 0)) -
128 PREVIEW_MINI_PLAYER_EDGE_GAP * 2,
86});129});
87130
88/**131/**
...@@ -117,25 +160,68 @@ function defaultPreviewMiniPlayerWidth(source: PreviewMiniPlayerSize): number {...@@ -117,25 +160,68 @@ function defaultPreviewMiniPlayerWidth(source: PreviewMiniPlayerSize): number {
117 );160 );
118}161}
119162
163const clampToContainer = (
164 position: PreviewMiniPlayerPosition,
165 container: PreviewMiniPlayerSize,
166 player: PreviewMiniPlayerSize,
167 bottom = container.height,
168): PreviewMiniPlayerPosition => ({
169 x: Math.min(
170 Math.max(position.x, PREVIEW_MINI_PLAYER_EDGE_GAP),
171 Math.max(
172 PREVIEW_MINI_PLAYER_EDGE_GAP,
173 container.width - player.width - PREVIEW_MINI_PLAYER_EDGE_GAP,
174 ),
175 ),
176 y: Math.min(
177 Math.max(position.y, PREVIEW_MINI_PLAYER_EDGE_GAP),
178 Math.max(PREVIEW_MINI_PLAYER_EDGE_GAP, bottom - player.height - PREVIEW_MINI_PLAYER_EDGE_GAP),
179 ),
180});
181
182const overlapsObstacle = (
183 position: PreviewMiniPlayerPosition,
184 player: PreviewMiniPlayerSize,
185 container: PreviewMiniPlayerSize,
186 obstacles: PreviewMiniPlayerObstacles,
187): boolean =>
188 position.y + player.height > floorFor(spanOf(position.x, player.width), container, obstacles);
189
190/**
191 * Keeps the player inside the container and off the composer. An overlapping
192 * player is pushed out along whichever side needs the smaller move, so a drag
193 * slides along the composer into the margin beside it instead of stopping at
194 * its top edge; when no side leaves it fully clear it sits above the composer.
195 */
120export function clampPreviewMiniPlayerPosition(196export function clampPreviewMiniPlayerPosition(
121 position: PreviewMiniPlayerPosition,197 position: PreviewMiniPlayerPosition,
122 container: PreviewMiniPlayerSize,198 container: PreviewMiniPlayerSize,
123 player: PreviewMiniPlayerSize,199 player: PreviewMiniPlayerSize,
124 bottomInset = 0,200 obstacles: PreviewMiniPlayerObstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES,
125): PreviewMiniPlayerPosition {201): PreviewMiniPlayerPosition {
126 const reservedBottomSpace = Math.max(0, bottomInset);202 const inside = clampToContainer(position, container, player);
127 const maxX = Math.max(203 const { composer } = obstacles;
128 PREVIEW_MINI_PLAYER_EDGE_GAP,204 if (!composer || !overlapsObstacle(inside, player, container, obstacles)) return inside;
129 container.width - player.width - PREVIEW_MINI_PLAYER_EDGE_GAP,205 const gap = PREVIEW_MINI_PLAYER_EDGE_GAP;
130 );206 const above = { x: inside.x, y: container.height - composer.height - gap - player.height };
131 const maxY = Math.max(207 const beside = [
132 PREVIEW_MINI_PLAYER_EDGE_GAP,208 { x: composer.left - gap - player.width, y: inside.y },
133 container.height - reservedBottomSpace - player.height - PREVIEW_MINI_PLAYER_EDGE_GAP,209 { x: composer.right + gap, y: inside.y },
134 );210 ];
135 return {211 let best = clampToContainer(above, container, player);
136 x: Math.min(Math.max(position.x, PREVIEW_MINI_PLAYER_EDGE_GAP), maxX),212 let bestDistance = Math.abs(best.y - inside.y);
137 y: Math.min(Math.max(position.y, PREVIEW_MINI_PLAYER_EDGE_GAP), maxY),213 for (const candidate of beside) {
138 };214 const clamped = clampToContainer(candidate, container, player);
215 if (clamped.x !== candidate.x || overlapsObstacle(candidate, player, container, obstacles)) {
216 continue;
217 }
218 const distance = Math.abs(candidate.x - inside.x);
219 if (distance < bestDistance) {
220 best = candidate;
221 bestDistance = distance;
222 }
223 }
224 return best;
139}225}
140226
141/**227/**
...@@ -149,19 +235,25 @@ export function resolvePreviewMiniPlayerFrame(input: {...@@ -149,19 +235,25 @@ export function resolvePreviewMiniPlayerFrame(input: {
149 readonly position: PreviewMiniPlayerPosition | null;235 readonly position: PreviewMiniPlayerPosition | null;
150 readonly source: PreviewMiniPlayerSize;236 readonly source: PreviewMiniPlayerSize;
151 readonly container: PreviewMiniPlayerSize;237 readonly container: PreviewMiniPlayerSize;
152 readonly bottomInset?: number;238 readonly obstacles?: PreviewMiniPlayerObstacles;
153}): PreviewMiniPlayerFrame {239}): PreviewMiniPlayerFrame {
154 const { width, position, source, container, bottomInset = 0 } = input;240 const {
241 width,
242 position,
243 source,
244 container,
245 obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES,
246 } = input;
155 const size = fitPreviewMiniPlayerWidth(247 const size = fitPreviewMiniPlayerWidth(
156 width ?? defaultPreviewMiniPlayerWidth(source),248 width ?? defaultPreviewMiniPlayerWidth(source),
157 source,249 source,
158 availableArea(container, bottomInset),250 availableArea(container, obstacles, position && width ? spanOf(position.x, width) : null),
159 );251 );
160 const anchored = position ?? {252 const anchored = position ?? {
161 x: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - size.width,253 x: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - size.width,
162 y: PREVIEW_MINI_PLAYER_EDGE_GAP,254 y: PREVIEW_MINI_PLAYER_EDGE_GAP,
163 };255 };
164 return { ...clampPreviewMiniPlayerPosition(anchored, container, size, bottomInset), ...size };256 return { ...clampPreviewMiniPlayerPosition(anchored, container, size, obstacles), ...size };
165}257}
166258
167/**259/**
...@@ -177,27 +269,37 @@ export function resizePreviewMiniPlayer(input: {...@@ -177,27 +269,37 @@ export function resizePreviewMiniPlayer(input: {
177 readonly delta: PreviewMiniPlayerPosition;269 readonly delta: PreviewMiniPlayerPosition;
178 readonly source: PreviewMiniPlayerSize;270 readonly source: PreviewMiniPlayerSize;
179 readonly container: PreviewMiniPlayerSize;271 readonly container: PreviewMiniPlayerSize;
180 readonly bottomInset?: number;272 readonly obstacles?: PreviewMiniPlayerObstacles;
181}): PreviewMiniPlayerFrame {273}): PreviewMiniPlayerFrame {
182 const { start, direction, delta, source, container, bottomInset = 0 } = input;274 const {
275 start,
276 direction,
277 delta,
278 source,
279 container,
280 obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES,
281 } = input;
183 const east = direction.includes("east");282 const east = direction.includes("east");
184 const west = direction.includes("west");283 const west = direction.includes("west");
185 const north = direction.includes("north");284 const north = direction.includes("north");
186 const south = direction.includes("south");285 const south = direction.includes("south");
187 const available = availableArea(container, bottomInset);
188 const right = start.x + start.width;286 const right = start.x + start.width;
189 const bottom = start.y + start.height;287 const bottom = start.y + start.height;
288 // Growth stops where the player's current columns meet the composer, and a
289 // plain edge drag lets the free axis use everything those columns have. A
290 // wider player may reach new columns; the clamp below slides it clear.
291 const floor = floorFor(spanOf(start.x, start.width), container, obstacles);
190 const max = {292 const max = {
191 width: west293 width: west
192 ? right - PREVIEW_MINI_PLAYER_EDGE_GAP294 ? right - PREVIEW_MINI_PLAYER_EDGE_GAP
193 : east295 : east
194 ? container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - start.x296 ? container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - start.x
195 : available.width,297 : container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,
196 height: north298 height: north
197 ? bottom - PREVIEW_MINI_PLAYER_EDGE_GAP299 ? bottom - PREVIEW_MINI_PLAYER_EDGE_GAP
198 : south300 : south
199 ? container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP - start.y301 ? floor - PREVIEW_MINI_PLAYER_EDGE_GAP - start.y
200 : available.height,302 : floor - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,
201 };303 };
202 const desiredWidth = start.width + (east ? delta.x : west ? -delta.x : 0);304 const desiredWidth = start.width + (east ? delta.x : west ? -delta.x : 0);
203 const desiredHeight = start.height + (south ? delta.y : north ? -delta.y : 0);305 const desiredHeight = start.height + (south ? delta.y : north ? -delta.y : 0);
...@@ -219,7 +321,7 @@ export function resizePreviewMiniPlayer(input: {...@@ -219,7 +321,7 @@ export function resizePreviewMiniPlayer(input: {
219 { x: west ? right - size.width : start.x, y: north ? bottom - size.height : start.y },321 { x: west ? right - size.width : start.x, y: north ? bottom - size.height : start.y },
220 container,322 container,
221 size,323 size,
222 bottomInset,324 obstacles,
223 );325 );
224 return { ...position, ...size };326 return { ...position, ...size };
225}327}