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) {
16941694 [],
16951695 );
16961696 const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null);
1697 const [composerOverlayHeight, setComposerOverlayHeight] = useState(0);
1698 const composerOverlayHeightRef = useRef(0);
16991697 // Space the timeline keeps clear above its end. Tracks the overlay while the
17001698 // composer is expanded and holds that height while it rests, so the resting
17011699 // composer never exposes rows that its expansion will cover.
......@@ -5493,11 +5491,6 @@ export default function ChatView(props: ChatViewProps) {
54935491 (height: number) => {
54945492 const nextHeight = Math.ceil(height);
54955493 if (nextHeight <= 0) return;
5496 const previousHeight = composerOverlayHeightRef.current;
5497 if (previousHeight !== nextHeight) {
5498 composerOverlayHeightRef.current = nextHeight;
5499 setComposerOverlayHeight(nextHeight);
5500 }
55015494 const nextInset = resolveComposerTimelineInset({
55025495 currentInset: composerTimelineInsetRef.current,
55035496 overlayHeight: nextHeight,
......@@ -8658,7 +8651,10 @@ export default function ChatView(props: ChatViewProps) {
86588651 ref={attachDraftHeroTransitionGroupRef}
86598652 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)]"
86608653 >
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 >
86628658 {isDraftHeroState ? (
86638659 <div className="absolute inset-x-0 bottom-full z-0">
86648660 <div
......@@ -8873,7 +8869,7 @@ export default function ChatView(props: ChatViewProps) {
88738869 key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`}
88748870 threadRef={activeThreadRef}
88758871 miniPlayer={activePreviewMiniPlayer}
8876 bottomInset={isDraftHeroState ? 0 : composerOverlayHeight}
8872 composerOverlayElement={isDraftHeroState ? null : composerOverlayElement}
88778873 />
88788874 ) : null}
88798875
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+65-20
......@@ -35,9 +35,11 @@ import type { DeviceScreenSize } from "../device/deviceStream";
3535import { previewBridge } from "./previewBridge";
3636import {
3737 clampPreviewMiniPlayerPosition,
38 NO_PREVIEW_MINI_PLAYER_OBSTACLES,
3839 PREVIEW_MINI_PLAYER_CORNER_RADIUS,
3940 PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX,
4041 type PreviewMiniPlayerFrame,
42 type PreviewMiniPlayerObstacles,
4143 resizePreviewMiniPlayer,
4244 resolveDeviceMiniPlayerCornerRadius,
4345 resolveDeviceMiniPlayerSourceSize,
......@@ -56,7 +58,49 @@ interface PointerGesture {
5658interface Props {
5759 readonly threadRef: ScopedThreadRef;
5860 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 };
60104}
61105
62106const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS;
......@@ -77,7 +121,7 @@ const RESIZE_HANDLES: ReadonlyArray<{
77121];
78122
79123/** 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) {
81125 const { source } = miniPlayer;
82126 return source.kind === "browser" ? (
83127 <BrowserMiniPlayer
......@@ -85,7 +129,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, bottomInset }:
85129 threadRef={threadRef}
86130 tabId={source.tabId}
87131 miniPlayer={miniPlayer}
88 bottomInset={bottomInset}
132 composerOverlayElement={composerOverlayElement}
89133 />
90134 ) : (
91135 <DeviceMiniPlayer
......@@ -93,7 +137,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, bottomInset }:
93137 threadRef={threadRef}
94138 source={source}
95139 miniPlayer={miniPlayer}
96 bottomInset={bottomInset}
140 composerOverlayElement={composerOverlayElement}
97141 />
98142 );
99143}
......@@ -102,7 +146,7 @@ function BrowserMiniPlayer({
102146 threadRef,
103147 tabId,
104148 miniPlayer,
105 bottomInset,
149 composerOverlayElement,
106150}: Props & { readonly tabId: string }) {
107151 const previewState = useThreadPreviewState(threadRef);
108152 const snapshot = previewState.sessions[tabId] ?? null;
......@@ -143,7 +187,7 @@ function BrowserMiniPlayer({
143187 threadRef={threadRef}
144188 miniPlayer={miniPlayer}
145189 sourceSize={sourceSize}
146 bottomInset={bottomInset}
190 composerOverlayElement={composerOverlayElement}
147191 label="Floating browser preview"
148192 onOpenInPanel={openInPanel}
149193 pillActions={
......@@ -200,7 +244,7 @@ function DeviceMiniPlayer({
200244 threadRef,
201245 source,
202246 miniPlayer,
203 bottomInset,
247 composerOverlayElement,
204248}: Props & { readonly source: Extract<PreviewMiniPlayerSource, { kind: "device" }> }) {
205249 const { state: deviceState } = useDeviceState(threadRef.environmentId);
206250 const [screen, setScreen] = useState<DeviceScreenSize | null>(null);
......@@ -230,7 +274,7 @@ function DeviceMiniPlayer({
230274 threadRef={threadRef}
231275 miniPlayer={miniPlayer}
232276 sourceSize={sourceSize}
233 bottomInset={bottomInset}
277 composerOverlayElement={composerOverlayElement}
234278 label="Floating device preview"
235279 onOpenInPanel={openInPanel}
236280 cornerRadius={cornerRadius}
......@@ -266,7 +310,7 @@ function MiniPlayerShell({
266310 threadRef,
267311 miniPlayer,
268312 sourceSize,
269 bottomInset,
313 composerOverlayElement,
270314 label,
271315 onOpenInPanel,
272316 pillActions,
......@@ -276,7 +320,7 @@ function MiniPlayerShell({
276320 readonly threadRef: ScopedThreadRef;
277321 readonly miniPlayer: PreviewMiniPlayerState;
278322 readonly sourceSize: PreviewMiniPlayerSize;
279 readonly bottomInset: number;
323 readonly composerOverlayElement: HTMLElement | null;
280324 readonly label: string;
281325 readonly onOpenInPanel: () => void;
282326 readonly pillActions?: ReactNode;
......@@ -286,7 +330,9 @@ function MiniPlayerShell({
286330}) {
287331 const containerRef = useRef<HTMLDivElement | null>(null);
288332 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;
290336 const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source);
291337 const frame = container
292338 ? resolvePreviewMiniPlayerFrame({
......@@ -294,7 +340,7 @@ function MiniPlayerShell({
294340 position: miniPlayer.position,
295341 source: sourceSize,
296342 container,
297 bottomInset,
343 obstacles,
298344 })
299345 : null;
300346
......@@ -306,22 +352,21 @@ function MiniPlayerShell({
306352 usePreviewMiniPlayerStore.getState().close(threadRef);
307353 };
308354
355 // The composer grows on its own (drafts, banners), so it is observed alongside the column.
309356 useLayoutEffect(() => {
310357 const element = containerRef.current;
311358 if (!element) return;
312359 const measure = () => {
313 setContainer((current) =>
314 current?.width === element.clientWidth && current.height === element.clientHeight
315 ? current
316 : { width: element.clientWidth, height: element.clientHeight },
317 );
360 const next = measureLayout(element, composerOverlayElement);
361 setLayout((current) => (current && sameLayout(current, next) ? current : next));
318362 };
319363 measure();
320364 if (typeof ResizeObserver === "undefined") return;
321365 const observer = new ResizeObserver(measure);
322366 observer.observe(element);
367 if (composerOverlayElement) observer.observe(composerOverlayElement);
323368 return () => observer.disconnect();
324 }, []);
369 }, [composerOverlayElement]);
325370
326371 const beginGesture = (
327372 event: ReactPointerEvent<HTMLElement>,
......@@ -353,7 +398,7 @@ function MiniPlayerShell({
353398 { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y },
354399 container,
355400 gesture.frame,
356 bottomInset,
401 obstacles,
357402 ),
358403 );
359404 return;
......@@ -364,7 +409,7 @@ function MiniPlayerShell({
364409 delta,
365410 source: sourceSize,
366411 container,
367 bottomInset,
412 obstacles,
368413 });
369414 store.resize(threadRef, sourceKey, next.width);
370415 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";
44import {
55 clampPreviewMiniPlayerPosition,
66 PREVIEW_MINI_PLAYER_EDGE_GAP,
7 type PreviewMiniPlayerObstacles,
78 resizePreviewMiniPlayer,
89 resolveDeviceMiniPlayerCornerRadius,
910 resolveDeviceMiniPlayerSourceSize,
......@@ -13,6 +14,11 @@ import {
1314
1415const container = { width: 1_000, height: 700 };
1516const 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
1723describe("resolvePreviewMiniPlayerSourceSize", () => {
1824 it("uses the device viewport scaled by zoom", () => {
......@@ -106,11 +112,35 @@ describe("resolvePreviewMiniPlayerFrame", () => {
106112 position: { x: 100, y: 80 },
107113 source,
108114 container,
109 bottomInset: 300,
115 obstacles: tallComposer,
110116 });
111117 expect(frame).toEqual({ x: 100, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 602, height: 376 });
112118 });
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
114144 it("never grows past the source's own rendered size", () => {
115145 expect(
116146 resolvePreviewMiniPlayerFrame({
......@@ -194,11 +224,52 @@ describe("resizePreviewMiniPlayer", () => {
194224 delta: { x: 300, y: 0 },
195225 source,
196226 container,
197 bottomInset: 0,
198227 }),
199228 ).toEqual({ x: 12, y: 300, width: 620, height: 388 });
200229 });
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
202273 it("respects the minimum size", () => {
203274 expect(
204275 resizePreviewMiniPlayer({
......@@ -213,20 +284,53 @@ describe("resizePreviewMiniPlayer", () => {
213284});
214285
215286describe("clampPreviewMiniPlayerPosition", () => {
287 const player = { width: 360, height: 240 };
288
216289 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", () => {
217297 expect(
218 clampPreviewMiniPlayerPosition({ x: 900, y: -40 }, container, { width: 360, height: 240 }),
219 ).toEqual({ x: 628, y: PREVIEW_MINI_PLAYER_EDGE_GAP });
298 clampPreviewMiniPlayerPosition({ x: 500, y: 448 }, container, player, {
299 composer: { ...composer, height: 160 },
300 }),
301 ).toEqual({ x: 500, y: 288 });
220302 });
221303
222 it("keeps the player above a growing composer inset", () => {
304 it("lets the player drop into the margin beside the composer", () => {
223305 expect(
224306 clampPreviewMiniPlayerPosition(
225 { x: 500, y: 448 },
307 { x: 20, y: 500 },
226308 container,
227 { width: 360, height: 240 },
228 160,
309 { width: 60, height: 150 },
310 obstacles,
229311 ),
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 });
231335 });
232336});
apps/web/src/components/preview/previewMiniPlayerLayout.ts+129-27
......@@ -77,12 +77,55 @@ export function resolveDeviceMiniPlayerCornerRadius(
7777 );
7878}
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 */
80118const availableArea = (
81119 container: PreviewMiniPlayerSize,
82 bottomInset: number,
120 obstacles: PreviewMiniPlayerObstacles,
121 span: HorizontalSpan | null,
83122): PreviewMiniPlayerSize => ({
84123 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,
86129});
87130
88131/**
......@@ -117,25 +160,68 @@ function defaultPreviewMiniPlayerWidth(source: PreviewMiniPlayerSize): number {
117160 );
118161}
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 */
120196export function clampPreviewMiniPlayerPosition(
121197 position: PreviewMiniPlayerPosition,
122198 container: PreviewMiniPlayerSize,
123199 player: PreviewMiniPlayerSize,
124 bottomInset = 0,
200 obstacles: PreviewMiniPlayerObstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES,
125201): PreviewMiniPlayerPosition {
126 const reservedBottomSpace = Math.max(0, bottomInset);
127 const maxX = Math.max(
128 PREVIEW_MINI_PLAYER_EDGE_GAP,
129 container.width - player.width - PREVIEW_MINI_PLAYER_EDGE_GAP,
130 );
131 const maxY = Math.max(
132 PREVIEW_MINI_PLAYER_EDGE_GAP,
133 container.height - reservedBottomSpace - player.height - PREVIEW_MINI_PLAYER_EDGE_GAP,
134 );
135 return {
136 x: Math.min(Math.max(position.x, PREVIEW_MINI_PLAYER_EDGE_GAP), maxX),
137 y: Math.min(Math.max(position.y, PREVIEW_MINI_PLAYER_EDGE_GAP), maxY),
138 };
202 const inside = clampToContainer(position, container, player);
203 const { composer } = obstacles;
204 if (!composer || !overlapsObstacle(inside, player, container, obstacles)) return inside;
205 const gap = PREVIEW_MINI_PLAYER_EDGE_GAP;
206 const above = { x: inside.x, y: container.height - composer.height - gap - player.height };
207 const beside = [
208 { x: composer.left - gap - player.width, y: inside.y },
209 { x: composer.right + gap, y: inside.y },
210 ];
211 let best = clampToContainer(above, container, player);
212 let bestDistance = Math.abs(best.y - inside.y);
213 for (const candidate of beside) {
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;
139225}
140226
141227/**
......@@ -149,19 +235,25 @@ export function resolvePreviewMiniPlayerFrame(input: {
149235 readonly position: PreviewMiniPlayerPosition | null;
150236 readonly source: PreviewMiniPlayerSize;
151237 readonly container: PreviewMiniPlayerSize;
152 readonly bottomInset?: number;
238 readonly obstacles?: PreviewMiniPlayerObstacles;
153239}): 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;
155247 const size = fitPreviewMiniPlayerWidth(
156248 width ?? defaultPreviewMiniPlayerWidth(source),
157249 source,
158 availableArea(container, bottomInset),
250 availableArea(container, obstacles, position && width ? spanOf(position.x, width) : null),
159251 );
160252 const anchored = position ?? {
161253 x: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - size.width,
162254 y: PREVIEW_MINI_PLAYER_EDGE_GAP,
163255 };
164 return { ...clampPreviewMiniPlayerPosition(anchored, container, size, bottomInset), ...size };
256 return { ...clampPreviewMiniPlayerPosition(anchored, container, size, obstacles), ...size };
165257}
166258
167259/**
......@@ -177,27 +269,37 @@ export function resizePreviewMiniPlayer(input: {
177269 readonly delta: PreviewMiniPlayerPosition;
178270 readonly source: PreviewMiniPlayerSize;
179271 readonly container: PreviewMiniPlayerSize;
180 readonly bottomInset?: number;
272 readonly obstacles?: PreviewMiniPlayerObstacles;
181273}): 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;
183282 const east = direction.includes("east");
184283 const west = direction.includes("west");
185284 const north = direction.includes("north");
186285 const south = direction.includes("south");
187 const available = availableArea(container, bottomInset);
188286 const right = start.x + start.width;
189287 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);
190292 const max = {
191293 width: west
192294 ? right - PREVIEW_MINI_PLAYER_EDGE_GAP
193295 : east
194296 ? container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - start.x
195 : available.width,
297 : container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,
196298 height: north
197299 ? bottom - PREVIEW_MINI_PLAYER_EDGE_GAP
198300 : south
199 ? container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP - start.y
200 : available.height,
301 ? floor - PREVIEW_MINI_PLAYER_EDGE_GAP - start.y
302 : floor - PREVIEW_MINI_PLAYER_EDGE_GAP * 2,
201303 };
202304 const desiredWidth = start.width + (east ? delta.x : west ? -delta.x : 0);
203305 const desiredHeight = start.height + (south ? delta.y : north ? -delta.y : 0);
......@@ -219,7 +321,7 @@ export function resizePreviewMiniPlayer(input: {
219321 { x: west ? right - size.width : start.x, y: north ? bottom - size.height : start.y },
220322 container,
221323 size,
222 bottomInset,
324 obstacles,
223325 );
224326 return { ...position, ...size };
225327}