| author | |
| committer | |
| log | 18f7254e0adbd7d642f4d78f9b9c8e6cb569af56 |
| tree | 13e759d3d1e9233ab15d257d9b5916b8f8b241eb |
| parent | 8bbe2bf660c361e33db9c30f03fc72ab183eef3f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1694 | [], |
| 1695 | 1695 | ); |
| 1696 | 1696 | const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null); |
| 1697 | const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); | |
| 1698 | const composerOverlayHeightRef = useRef(0); | |
| 1699 | 1697 | // Space the timeline keeps clear above its end. Tracks the overlay while the |
| 1700 | 1698 | // composer is expanded and holds that height while it rests, so the resting |
| 1701 | 1699 | // composer never exposes rows that its expansion will cover. |
| ... | ... | @@ -5493,11 +5491,6 @@ export default function ChatView(props: ChatViewProps) { |
| 5493 | 5491 | (height: number) => { |
| 5494 | 5492 | const nextHeight = Math.ceil(height); |
| 5495 | 5493 | if (nextHeight <= 0) return; |
| 5496 | const previousHeight = composerOverlayHeightRef.current; | |
| 5497 | if (previousHeight !== nextHeight) { | |
| 5498 | composerOverlayHeightRef.current = nextHeight; | |
| 5499 | setComposerOverlayHeight(nextHeight); | |
| 5500 | } | |
| 5501 | 5494 | const nextInset = resolveComposerTimelineInset({ |
| 5502 | 5495 | currentInset: composerTimelineInsetRef.current, |
| 5503 | 5496 | overlayHeight: nextHeight, |
| ... | ... | @@ -8658,7 +8651,10 @@ export default function ChatView(props: ChatViewProps) { |
| 8658 | 8651 | ref={attachDraftHeroTransitionGroupRef} |
| 8659 | 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 | 8658 | {isDraftHeroState ? ( |
| 8663 | 8659 | <div className="absolute inset-x-0 bottom-full z-0"> |
| 8664 | 8660 | <div |
| ... | ... | @@ -8873,7 +8869,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8873 | 8869 | key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`} |
| 8874 | 8870 | threadRef={activeThreadRef} |
| 8875 | 8871 | miniPlayer={activePreviewMiniPlayer} |
| 8876 | bottomInset={isDraftHeroState ? 0 : composerOverlayHeight} | |
| 8872 | composerOverlayElement={isDraftHeroState ? null : composerOverlayElement} | |
| 8877 | 8873 | /> |
| 8878 | 8874 | ) : null} |
| 8879 | 8875 |
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+65-20| ... | ... | @@ -35,9 +35,11 @@ import type { DeviceScreenSize } from "../device/deviceStream"; |
| 35 | 35 | import { previewBridge } from "./previewBridge"; |
| 36 | 36 | import { |
| 37 | 37 | clampPreviewMiniPlayerPosition, |
| 38 | NO_PREVIEW_MINI_PLAYER_OBSTACLES, | |
| 38 | 39 | PREVIEW_MINI_PLAYER_CORNER_RADIUS, |
| 39 | 40 | PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, |
| 40 | 41 | type PreviewMiniPlayerFrame, |
| 42 | type PreviewMiniPlayerObstacles, | |
| 41 | 43 | resizePreviewMiniPlayer, |
| 42 | 44 | resolveDeviceMiniPlayerCornerRadius, |
| 43 | 45 | resolveDeviceMiniPlayerSourceSize, |
| ... | ... | @@ -56,7 +58,49 @@ interface PointerGesture { |
| 56 | 58 | interface Props { |
| 57 | 59 | readonly threadRef: ScopedThreadRef; |
| 58 | 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 | ||
| 65 | interface Layout { | |
| 66 | readonly container: PreviewMiniPlayerSize; | |
| 67 | readonly obstacles: PreviewMiniPlayerObstacles; | |
| 68 | } | |
| 69 | ||
| 70 | const 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 | */ | |
| 85 | function 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 | } |
| 61 | 105 | |
| 62 | 106 | const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS; |
| ... | ... | @@ -77,7 +121,7 @@ const RESIZE_HANDLES: ReadonlyArray<{ |
| 77 | 121 | ]; |
| 78 | 122 | |
| 79 | 123 | /** Floats the thread's browser tab or device stream over chat. */ |
| 80 | export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, bottomInset }: Props) { | |
| 124 | export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, composerOverlayElement }: Props) { | |
| 81 | 125 | const { source } = miniPlayer; |
| 82 | 126 | return source.kind === "browser" ? ( |
| 83 | 127 | <BrowserMiniPlayer |
| ... | ... | @@ -85,7 +129,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, bottomInset }: |
| 85 | 129 | threadRef={threadRef} |
| 86 | 130 | tabId={source.tabId} |
| 87 | 131 | miniPlayer={miniPlayer} |
| 88 | bottomInset={bottomInset} | |
| 132 | composerOverlayElement={composerOverlayElement} | |
| 89 | 133 | /> |
| 90 | 134 | ) : ( |
| 91 | 135 | <DeviceMiniPlayer |
| ... | ... | @@ -93,7 +137,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, bottomInset }: |
| 93 | 137 | threadRef={threadRef} |
| 94 | 138 | source={source} |
| 95 | 139 | miniPlayer={miniPlayer} |
| 96 | bottomInset={bottomInset} | |
| 140 | composerOverlayElement={composerOverlayElement} | |
| 97 | 141 | /> |
| 98 | 142 | ); |
| 99 | 143 | } |
| ... | ... | @@ -102,7 +146,7 @@ function BrowserMiniPlayer({ |
| 102 | 146 | threadRef, |
| 103 | 147 | tabId, |
| 104 | 148 | miniPlayer, |
| 105 | bottomInset, | |
| 149 | composerOverlayElement, | |
| 106 | 150 | }: Props & { readonly tabId: string }) { |
| 107 | 151 | const previewState = useThreadPreviewState(threadRef); |
| 108 | 152 | const snapshot = previewState.sessions[tabId] ?? null; |
| ... | ... | @@ -143,7 +187,7 @@ function BrowserMiniPlayer({ |
| 143 | 187 | threadRef={threadRef} |
| 144 | 188 | miniPlayer={miniPlayer} |
| 145 | 189 | sourceSize={sourceSize} |
| 146 | bottomInset={bottomInset} | |
| 190 | composerOverlayElement={composerOverlayElement} | |
| 147 | 191 | label="Floating browser preview" |
| 148 | 192 | onOpenInPanel={openInPanel} |
| 149 | 193 | pillActions={ |
| ... | ... | @@ -200,7 +244,7 @@ function DeviceMiniPlayer({ |
| 200 | 244 | threadRef, |
| 201 | 245 | source, |
| 202 | 246 | miniPlayer, |
| 203 | bottomInset, | |
| 247 | composerOverlayElement, | |
| 204 | 248 | }: Props & { readonly source: Extract<PreviewMiniPlayerSource, { kind: "device" }> }) { |
| 205 | 249 | const { state: deviceState } = useDeviceState(threadRef.environmentId); |
| 206 | 250 | const [screen, setScreen] = useState<DeviceScreenSize | null>(null); |
| ... | ... | @@ -230,7 +274,7 @@ function DeviceMiniPlayer({ |
| 230 | 274 | threadRef={threadRef} |
| 231 | 275 | miniPlayer={miniPlayer} |
| 232 | 276 | sourceSize={sourceSize} |
| 233 | bottomInset={bottomInset} | |
| 277 | composerOverlayElement={composerOverlayElement} | |
| 234 | 278 | label="Floating device preview" |
| 235 | 279 | onOpenInPanel={openInPanel} |
| 236 | 280 | cornerRadius={cornerRadius} |
| ... | ... | @@ -266,7 +310,7 @@ function MiniPlayerShell({ |
| 266 | 310 | threadRef, |
| 267 | 311 | miniPlayer, |
| 268 | 312 | sourceSize, |
| 269 | bottomInset, | |
| 313 | composerOverlayElement, | |
| 270 | 314 | label, |
| 271 | 315 | onOpenInPanel, |
| 272 | 316 | pillActions, |
| ... | ... | @@ -276,7 +320,7 @@ function MiniPlayerShell({ |
| 276 | 320 | readonly threadRef: ScopedThreadRef; |
| 277 | 321 | readonly miniPlayer: PreviewMiniPlayerState; |
| 278 | 322 | readonly sourceSize: PreviewMiniPlayerSize; |
| 279 | readonly bottomInset: number; | |
| 323 | readonly composerOverlayElement: HTMLElement | null; | |
| 280 | 324 | readonly label: string; |
| 281 | 325 | readonly onOpenInPanel: () => void; |
| 282 | 326 | readonly pillActions?: ReactNode; |
| ... | ... | @@ -286,7 +330,9 @@ function MiniPlayerShell({ |
| 286 | 330 | }) { |
| 287 | 331 | const containerRef = useRef<HTMLDivElement | null>(null); |
| 288 | 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 | 336 | const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source); |
| 291 | 337 | const frame = container |
| 292 | 338 | ? resolvePreviewMiniPlayerFrame({ |
| ... | ... | @@ -294,7 +340,7 @@ function MiniPlayerShell({ |
| 294 | 340 | position: miniPlayer.position, |
| 295 | 341 | source: sourceSize, |
| 296 | 342 | container, |
| 297 | bottomInset, | |
| 343 | obstacles, | |
| 298 | 344 | }) |
| 299 | 345 | : null; |
| 300 | 346 | |
| ... | ... | @@ -306,22 +352,21 @@ function MiniPlayerShell({ |
| 306 | 352 | usePreviewMiniPlayerStore.getState().close(threadRef); |
| 307 | 353 | }; |
| 308 | 354 | |
| 355 | // The composer grows on its own (drafts, banners), so it is observed alongside the column. | |
| 309 | 356 | useLayoutEffect(() => { |
| 310 | 357 | const element = containerRef.current; |
| 311 | 358 | if (!element) return; |
| 312 | 359 | 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)); | |
| 318 | 362 | }; |
| 319 | 363 | measure(); |
| 320 | 364 | if (typeof ResizeObserver === "undefined") return; |
| 321 | 365 | const observer = new ResizeObserver(measure); |
| 322 | 366 | observer.observe(element); |
| 367 | if (composerOverlayElement) observer.observe(composerOverlayElement); | |
| 323 | 368 | return () => observer.disconnect(); |
| 324 | }, []); | |
| 369 | }, [composerOverlayElement]); | |
| 325 | 370 | |
| 326 | 371 | const beginGesture = ( |
| 327 | 372 | event: ReactPointerEvent<HTMLElement>, |
| ... | ... | @@ -353,7 +398,7 @@ function MiniPlayerShell({ |
| 353 | 398 | { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y }, |
| 354 | 399 | container, |
| 355 | 400 | gesture.frame, |
| 356 | bottomInset, | |
| 401 | obstacles, | |
| 357 | 402 | ), |
| 358 | 403 | ); |
| 359 | 404 | return; |
| ... | ... | @@ -364,7 +409,7 @@ function MiniPlayerShell({ |
| 364 | 409 | delta, |
| 365 | 410 | source: sourceSize, |
| 366 | 411 | container, |
| 367 | bottomInset, | |
| 412 | obstacles, | |
| 368 | 413 | }); |
| 369 | 414 | store.resize(threadRef, sourceKey, next.width); |
| 370 | 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 | 4 | import { |
| 5 | 5 | clampPreviewMiniPlayerPosition, |
| 6 | 6 | PREVIEW_MINI_PLAYER_EDGE_GAP, |
| 7 | type PreviewMiniPlayerObstacles, | |
| 7 | 8 | resizePreviewMiniPlayer, |
| 8 | 9 | resolveDeviceMiniPlayerCornerRadius, |
| 9 | 10 | resolveDeviceMiniPlayerSourceSize, |
| ... | ... | @@ -13,6 +14,11 @@ import { |
| 13 | 14 | |
| 14 | 15 | const container = { width: 1_000, height: 700 }; |
| 15 | 16 | const source = { width: 1_600, height: 1_000 }; |
| 17 | const gap = PREVIEW_MINI_PLAYER_EDGE_GAP; | |
| 18 | // A centered composer stack with margins on each side. | |
| 19 | const composer = { left: 100, right: 900, height: 150 }; | |
| 20 | const obstacles: PreviewMiniPlayerObstacles = { composer }; | |
| 21 | const tallComposer: PreviewMiniPlayerObstacles = { composer: { ...composer, height: 300 } }; | |
| 16 | 22 | |
| 17 | 23 | describe("resolvePreviewMiniPlayerSourceSize", () => { |
| 18 | 24 | it("uses the device viewport scaled by zoom", () => { |
| ... | ... | @@ -106,11 +112,35 @@ describe("resolvePreviewMiniPlayerFrame", () => { |
| 106 | 112 | position: { x: 100, y: 80 }, |
| 107 | 113 | source, |
| 108 | 114 | container, |
| 109 | bottomInset: 300, | |
| 115 | obstacles: tallComposer, | |
| 110 | 116 | }); |
| 111 | 117 | expect(frame).toEqual({ x: 100, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 602, height: 376 }); |
| 112 | 118 | }); |
| 113 | 119 | |
| 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 | 144 | it("never grows past the source's own rendered size", () => { |
| 115 | 145 | expect( |
| 116 | 146 | resolvePreviewMiniPlayerFrame({ |
| ... | ... | @@ -194,11 +224,52 @@ describe("resizePreviewMiniPlayer", () => { |
| 194 | 224 | delta: { x: 300, y: 0 }, |
| 195 | 225 | source, |
| 196 | 226 | container, |
| 197 | bottomInset: 0, | |
| 198 | 227 | }), |
| 199 | 228 | ).toEqual({ x: 12, y: 300, width: 620, height: 388 }); |
| 200 | 229 | }); |
| 201 | 230 | |
| 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 | 273 | it("respects the minimum size", () => { |
| 203 | 274 | expect( |
| 204 | 275 | resizePreviewMiniPlayer({ |
| ... | ... | @@ -213,20 +284,53 @@ describe("resizePreviewMiniPlayer", () => { |
| 213 | 284 | }); |
| 214 | 285 | |
| 215 | 286 | describe("clampPreviewMiniPlayerPosition", () => { |
| 287 | const player = { width: 360, height: 240 }; | |
| 288 | ||
| 216 | 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 | 297 | 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 }); | |
| 220 | 302 | }); |
| 221 | 303 | |
| 222 | it("keeps the player above a growing composer inset", () => { | |
| 304 | it("lets the player drop into the margin beside the composer", () => { | |
| 223 | 305 | expect( |
| 224 | 306 | clampPreviewMiniPlayerPosition( |
| 225 | { x: 500, y: 448 }, | |
| 307 | { x: 20, y: 500 }, | |
| 226 | 308 | container, |
| 227 | { width: 360, height: 240 }, | |
| 228 | 160, | |
| 309 | { width: 60, height: 150 }, | |
| 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 | 77 | ); |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | interface 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 | */ | |
| 90 | export interface PreviewMiniPlayerObstacles { | |
| 91 | readonly composer: (HorizontalSpan & { readonly height: number }) | null; | |
| 92 | } | |
| 93 | ||
| 94 | export const NO_PREVIEW_MINI_PLAYER_OBSTACLES: PreviewMiniPlayerObstacles = { composer: null }; | |
| 95 | ||
| 96 | const spanOf = (x: number, width: number): HorizontalSpan => ({ left: x, right: x + width }); | |
| 97 | ||
| 98 | const 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. */ | |
| 101 | function 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 | */ | |
| 80 | 118 | const availableArea = ( |
| 81 | 119 | container: PreviewMiniPlayerSize, |
| 82 | bottomInset: number, | |
| 120 | obstacles: PreviewMiniPlayerObstacles, | |
| 121 | span: HorizontalSpan | null, | |
| 83 | 122 | ): PreviewMiniPlayerSize => ({ |
| 84 | 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 | }); |
| 87 | 130 | |
| 88 | 131 | /** |
| ... | ... | @@ -117,25 +160,68 @@ function defaultPreviewMiniPlayerWidth(source: PreviewMiniPlayerSize): number { |
| 117 | 160 | ); |
| 118 | 161 | } |
| 119 | 162 | |
| 163 | const 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 | ||
| 182 | const 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 | */ | |
| 120 | 196 | export function clampPreviewMiniPlayerPosition( |
| 121 | 197 | position: PreviewMiniPlayerPosition, |
| 122 | 198 | container: PreviewMiniPlayerSize, |
| 123 | 199 | player: PreviewMiniPlayerSize, |
| 124 | bottomInset = 0, | |
| 200 | obstacles: PreviewMiniPlayerObstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES, | |
| 125 | 201 | ): 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; | |
| 139 | 225 | } |
| 140 | 226 | |
| 141 | 227 | /** |
| ... | ... | @@ -149,19 +235,25 @@ export function resolvePreviewMiniPlayerFrame(input: { |
| 149 | 235 | readonly position: PreviewMiniPlayerPosition | null; |
| 150 | 236 | readonly source: PreviewMiniPlayerSize; |
| 151 | 237 | readonly container: PreviewMiniPlayerSize; |
| 152 | readonly bottomInset?: number; | |
| 238 | readonly obstacles?: PreviewMiniPlayerObstacles; | |
| 153 | 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 | 247 | const size = fitPreviewMiniPlayerWidth( |
| 156 | 248 | width ?? defaultPreviewMiniPlayerWidth(source), |
| 157 | 249 | source, |
| 158 | availableArea(container, bottomInset), | |
| 250 | availableArea(container, obstacles, position && width ? spanOf(position.x, width) : null), | |
| 159 | 251 | ); |
| 160 | 252 | const anchored = position ?? { |
| 161 | 253 | x: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - size.width, |
| 162 | 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 | } |
| 166 | 258 | |
| 167 | 259 | /** |
| ... | ... | @@ -177,27 +269,37 @@ export function resizePreviewMiniPlayer(input: { |
| 177 | 269 | readonly delta: PreviewMiniPlayerPosition; |
| 178 | 270 | readonly source: PreviewMiniPlayerSize; |
| 179 | 271 | readonly container: PreviewMiniPlayerSize; |
| 180 | readonly bottomInset?: number; | |
| 272 | readonly obstacles?: PreviewMiniPlayerObstacles; | |
| 181 | 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 | 282 | const east = direction.includes("east"); |
| 184 | 283 | const west = direction.includes("west"); |
| 185 | 284 | const north = direction.includes("north"); |
| 186 | 285 | const south = direction.includes("south"); |
| 187 | const available = availableArea(container, bottomInset); | |
| 188 | 286 | const right = start.x + start.width; |
| 189 | 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 | 292 | const max = { |
| 191 | 293 | width: west |
| 192 | 294 | ? right - PREVIEW_MINI_PLAYER_EDGE_GAP |
| 193 | 295 | : east |
| 194 | 296 | ? container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - start.x |
| 195 | : available.width, | |
| 297 | : container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, | |
| 196 | 298 | height: north |
| 197 | 299 | ? bottom - PREVIEW_MINI_PLAYER_EDGE_GAP |
| 198 | 300 | : 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, | |
| 201 | 303 | }; |
| 202 | 304 | const desiredWidth = start.width + (east ? delta.x : west ? -delta.x : 0); |
| 203 | 305 | const desiredHeight = start.height + (south ? delta.y : north ? -delta.y : 0); |
| ... | ... | @@ -219,7 +321,7 @@ export function resizePreviewMiniPlayer(input: { |
| 219 | 321 | { x: west ? right - size.width : start.x, y: north ? bottom - size.height : start.y }, |
| 220 | 322 | container, |
| 221 | 323 | size, |
| 222 | bottomInset, | |
| 324 | obstacles, | |
| 223 | 325 | ); |
| 224 | 326 | return { ...position, ...size }; |
| 225 | 327 | } |