| 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,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 the | 1697 | // 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 resting | 1698 | // 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 | <div | 8660 | <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} |
| 8879 | 8875 |
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"; |
| 35 | import { previewBridge } from "./previewBridge"; | 35 | import { previewBridge } from "./previewBridge"; |
| 36 | import { | 36 | import { |
| 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 { |
| 56 | interface Props { | 58 | interface 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 | |||
| 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 | const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS; | 106 | const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS; |
| ... | @@ -77,7 +121,7 @@ const RESIZE_HANDLES: ReadonlyArray<{ | ... | @@ -77,7 +121,7 @@ const RESIZE_HANDLES: ReadonlyArray<{ |
| 77 | ]; | 121 | ]; |
| 78 | 122 | ||
| 79 | /** Floats the thread's browser tab or device stream over chat. */ | 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 | const { source } = miniPlayer; | 125 | const { source } = miniPlayer; |
| 82 | return source.kind === "browser" ? ( | 126 | return source.kind === "browser" ? ( |
| 83 | <BrowserMiniPlayer | 127 | <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 | <DeviceMiniPlayer | 135 | <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 = container | 337 | 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; |
| 300 | 346 | ||
| ... | @@ -306,22 +352,21 @@ function MiniPlayerShell({ | ... | @@ -306,22 +352,21 @@ function MiniPlayerShell({ |
| 306 | usePreviewMiniPlayerStore.getState().close(threadRef); | 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 | 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.clientHeight | 361 | 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]); |
| 325 | 370 | ||
| 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"; |
| 4 | import { | 4 | import { |
| 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 { |
| 13 | 14 | ||
| 14 | const container = { width: 1_000, height: 700 }; | 15 | const container = { width: 1_000, height: 700 }; |
| 15 | const source = { width: 1_600, height: 1_000 }; | 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 | describe("resolvePreviewMiniPlayerSourceSize", () => { | 23 | describe("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 | }); |
| 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 | 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 | }); |
| 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 | 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 | }); |
| 214 | 285 | ||
| 215 | describe("clampPreviewMiniPlayerPosition", () => { | 286 | describe("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 | }); |
| 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 | 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 | } |
| 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 | const availableArea = ( | 118 | const 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 | }); |
| 87 | 130 | ||
| 88 | /** | 131 | /** |
| ... | @@ -117,25 +160,68 @@ function defaultPreviewMiniPlayerWidth(source: PreviewMiniPlayerSize): number { | ... | @@ -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 | export function clampPreviewMiniPlayerPosition( | 196 | export 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 | } |
| 140 | 226 | ||
| 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 | } |
| 166 | 258 | ||
| 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: west | 293 | width: west |
| 192 | ? right - PREVIEW_MINI_PLAYER_EDGE_GAP | 294 | ? right - PREVIEW_MINI_PLAYER_EDGE_GAP |
| 193 | : east | 295 | : east |
| 194 | ? container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - start.x | 296 | ? container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - start.x |
| 195 | : available.width, | 297 | : container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, |
| 196 | height: north | 298 | height: north |
| 197 | ? bottom - PREVIEW_MINI_PLAYER_EDGE_GAP | 299 | ? bottom - PREVIEW_MINI_PLAYER_EDGE_GAP |
| 198 | : south | 300 | : south |
| 199 | ? container.height - Math.max(0, bottomInset) - PREVIEW_MINI_PLAYER_EDGE_GAP - start.y | 301 | ? 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 | } |