From 27e5cfc72bbeb83dc8f6dfad2b2bd15df8b4651b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:44:54 +0000 Subject: [PATCH] fix(web): floating preview resize holds its shape and ends when interrupted A resize of the floating preview laid the frame out from the live source size, so a source that changed shape under a held handle resized the player by itself. A release the handle never saw (another window, lost capture) left the gesture active, so later hover kept resizing or moving the player. The frame now lays out from the source size captured at pointerdown until the gesture ends, then settles to the new shape with the edges opposite the handle kept in place. Gestures end on release, cancel, lost capture, window blur, and a move without the primary button. Fixes pingdotgg/t3code#22988 Co-Authored-By: Claude Fable 5.2 --- .../ThreadPreviewMiniPlayer.browser.test.tsx | 121 +++++++++++++++ .../preview/ThreadPreviewMiniPlayer.tsx | 142 ++++++++++++------ .../preview/previewMiniPlayerGesture.test.ts | 77 ++++++++++ .../preview/previewMiniPlayerGesture.ts | 93 ++++++++++++ 4 files changed, 388 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/components/preview/ThreadPreviewMiniPlayer.browser.test.tsx create mode 100644 apps/web/src/components/preview/previewMiniPlayerGesture.test.ts create mode 100644 apps/web/src/components/preview/previewMiniPlayerGesture.ts diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.browser.test.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.browser.test.tsx new file mode 100644 index 000000000000..9bb9e746ea12 --- /dev/null +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.browser.test.tsx @@ -0,0 +1,121 @@ +import "~/index.css"; + +import { RegistryContext } from "@effect/atom-react"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { type DeviceServiceState, type EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { useState } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; +import { + type PreviewMiniPlayerSource, + selectThreadPreviewMiniPlayer, + usePreviewMiniPlayerStore, +} from "../../previewMiniPlayerStore"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { deviceEnvironment } from "../../state/device"; +import { ChatCanvas } from "../chat/ChatCanvas"; +import { createChatCanvasStore } from "../chat/canvasStore"; +import { deviceStreamScreenAtom } from "../device/deviceStreamSession"; +import { ThreadPreviewMiniPlayer, useThreadPreviewCanvasSource } from "./ThreadPreviewMiniPlayer"; + +vi.mock(import("../device/DeviceStreamView"), () => ({ + DeviceStreamView: (({ viewId }: { viewId: string }) => ( +
+ )) as never, +})); + +const threadRef = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-1")); +const phone: PreviewMiniPlayerSource = { + kind: "device", + hostId: "local", + deviceId: "phone", + platform: "ios", + name: "Phone", +}; + +function Harness() { + const [store] = useState(createChatCanvasStore); + const miniPlayer = usePreviewMiniPlayerStore((state) => + selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), + ); + const preview = useThreadPreviewCanvasSource(threadRef, miniPlayer); + return ( + + {miniPlayer ? ( + + ) : null} + + ); +} + +it("holds its frame through a source change and stops when the gesture is interrupted", async () => { + using _state = vi + .spyOn(deviceEnvironment, "state") + .mockReturnValue(Atom.make>(AsyncResult.initial())); + using _store = { + [Symbol.dispose]: () => usePreviewMiniPlayerStore.getState().removeThread(threadRef), + }; + usePreviewMiniPlayerStore.getState().open(threadRef, phone); + const container = document.createElement("div"); + container.style.cssText = "display:flex;position:fixed;inset:0;width:1200px;height:800px"; + await using screen = await render( + + + , + { container }, + ); + const player = screen.container.querySelector("[data-preview-mini-player]")!; + const handle = (direction: string) => + player.querySelector(`[data-preview-mini-player-resize="${direction}"]`)!; + const rect = () => { + const { left, top, right, bottom } = player.getBoundingClientRect(); + return { left, top, right, bottom }; + }; + const point = (target: HTMLElement, type: string, x: number, y: number, buttons: number) => + act(() => + target.dispatchEvent( + new PointerEvent(type, { + pointerId: 1, + pointerType: "mouse", + isPrimary: true, + button: type === "pointermove" ? -1 : 0, + buttons, + clientX: x, + clientY: y, + bubbles: true, + cancelable: true, + }), + ), + ); + const viewId = screen.getByTestId("stream").element().getAttribute("data-view-id")!; + const portrait = rect(); + + // Held without moving while the device rotates: the frame waits for the release. + await point(handle("northwest"), "pointerdown", portrait.left, portrait.top, 1); + await act(() => + appAtomRegistry.set(deviceStreamScreenAtom(viewId), { + width: 1_000, + height: 2_000, + orientation: "landscape_left", + }), + ); + expect(rect()).toEqual(portrait); + + // The release never arrived, so the next buttonless move ends the gesture where it was held. + await point(handle("northwest"), "pointermove", portrait.left - 200, portrait.top - 200, 0); + const landscape = rect(); + expect({ right: landscape.right, bottom: landscape.bottom }).toEqual({ + right: portrait.right, + bottom: portrait.bottom, + }); + expect(landscape.right - landscape.left).toBe(2 * (landscape.bottom - landscape.top)); + await point(handle("northwest"), "pointermove", portrait.left - 300, portrait.top - 300, 1); + expect(rect()).toEqual(landscape); + + await point(handle("west"), "pointerdown", landscape.left, landscape.top + 10, 1); + await act(() => window.dispatchEvent(new Event("blur"))); + await point(handle("west"), "pointermove", landscape.left - 100, landscape.top + 10, 1); + expect(rect()).toEqual(landscape); +}); diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 8e6c8fc42068..3e4dba8d879c 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -6,6 +6,7 @@ import { type PointerEvent as ReactPointerEvent, type ReactNode, useEffect, + useEffectEvent, useRef, useState, } from "react"; @@ -59,28 +60,19 @@ import { previewBridge } from "./previewBridge"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { showPreviewPopup } from "./showPreviewPopup"; import { - clampPreviewMiniPlayerPosition, + type PreviewMiniPlayerGesture, + settlePreviewMiniPlayerResize, + stepPreviewMiniPlayerGesture, +} from "./previewMiniPlayerGesture"; +import { PREVIEW_MINI_PLAYER_CORNER_RADIUS, PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, type PreviewMiniPlayerFrame, - resizePreviewMiniPlayer, resolveDeviceMiniPlayerCornerRadius, resolveDeviceMiniPlayerSourceSize, resolvePreviewMiniPlayerSourceSize, } from "./previewMiniPlayerLayout"; -interface PointerGesture { - readonly pointerId: number; - readonly pointerType: string; - readonly pointerX: number; - readonly pointerY: number; - readonly frame: PreviewMiniPlayerFrame; - readonly direction: BrowserViewportResizeDirection | null; - moved: boolean; -} - -const HANDLE_TAP_SLOP_PX = 6; - interface Props { readonly threadRef: ScopedThreadRef; readonly miniPlayer: PreviewMiniPlayerState; @@ -111,6 +103,13 @@ const useStreamedSourceSize = create<{ readonly viewport: PreviewStreamViewport; } | null>(() => null); +// The source size a resize in progress lays the frame out from, so a source that changes shape +// under a held handle cannot resize the player by itself. Same single slot as above. +const useHeldSourceSize = create<{ + readonly key: string; + readonly source: PreviewMiniPlayerSize; +} | null>(() => null); + const NO_DEVICE_SCREEN_ATOM = Atom.make(null).pipe( Atom.withLabel("device-stream-screen:none"), ); @@ -178,6 +177,11 @@ export function useThreadPreviewCanvasSource( ? deviceStreamScreenAtom(miniPlayerDeviceViewId(threadRef, source)) : NO_DEVICE_SCREEN_ATOM, ); + const heldSource = useHeldSourceSize((state) => + threadRef && sourceKey !== null && state?.key === streamedSourceKey(threadRef, sourceKey) + ? state.source + : null, + ); if (!miniPlayer || !source || sourceKey === null) return null; if (source.kind === "browser" && !browser.snapshot) return null; return { @@ -186,9 +190,10 @@ export function useThreadPreviewCanvasSource( position: miniPlayer.position, lastInteraction: miniPlayer.lastInteraction, source: - source.kind === "browser" + heldSource ?? + (source.kind === "browser" ? browser.sourceSize - : resolveDeviceMiniPlayerSourceSize(source.platform, screen), + : resolveDeviceMiniPlayerSourceSize(source.platform, screen)), }; } @@ -446,7 +451,8 @@ function MiniPlayerShell({ readonly children: (frame: PreviewMiniPlayerFrame) => ReactNode; }) { const canvas = useChatCanvas(); - const gestureRef = useRef(null); + const gestureRef = useRef(null); + const captureRef = useRef(null); // Touch toggles the pill after the tap's click so that click cannot land on the newly opened pill. const [pillOpen, setPillOpen] = useState(false); const handleTappedRef = useRef(false); @@ -463,6 +469,7 @@ function MiniPlayerShell({ }, [pillOpen]); const container = canvas?.container ?? null; const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source); + const heldSourceKey = streamedSourceKey(threadRef, sourceKey); const frame = canvas?.previewKey === sourceKey ? canvas.layout.frame : null; const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS; @@ -472,11 +479,54 @@ function MiniPlayerShell({ usePreviewMiniPlayerStore.getState().close(threadRef); }; + /** Every way a gesture stops comes through here; returns the gesture it ended. */ + const finishGesture = () => { + const gesture = gestureRef.current; + if (!gesture) return null; + gestureRef.current = null; + handleTappedRef.current = false; + const handle = captureRef.current; + captureRef.current = null; + if (handle?.hasPointerCapture(gesture.pointerId)) { + handle.releasePointerCapture(gesture.pointerId); + } + if (gesture.direction === null) return gesture; + useHeldSourceSize.setState((state) => (state?.key === heldSourceKey ? null : state), true); + const settled = + frame && container + ? settlePreviewMiniPlayerResize({ + frame, + direction: gesture.direction, + held: gesture.source, + source: sourceSize, + container, + }) + : null; + if (settled) { + usePreviewMiniPlayerStore + .getState() + .resize(threadRef, sourceKey, settled.width, { x: settled.x, y: settled.y }); + } + return gesture; + }; + const endInterruptedGesture = useEffectEvent(() => { + finishGesture(); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window blur ends a gesture whose release went to another window, and unmount ends one in progress + useEffect(() => { + window.addEventListener("blur", endInterruptedGesture); + return () => { + window.removeEventListener("blur", endInterruptedGesture); + useHeldSourceSize.setState((state) => (state?.key === heldSourceKey ? null : state), true); + }; + }, [heldSourceKey]); + const beginGesture = ( event: ReactPointerEvent, direction: BrowserViewportResizeDirection | null, ) => { if (event.button !== 0 || !frame) return; + finishGesture(); gestureRef.current = { pointerId: event.pointerId, pointerType: event.pointerType, @@ -484,8 +534,13 @@ function MiniPlayerShell({ pointerY: event.clientY, frame, direction, + source: sourceSize, moved: false, }; + captureRef.current = event.currentTarget; + if (direction !== null) { + useHeldSourceSize.setState({ key: heldSourceKey, source: sourceSize }, true); + } event.currentTarget.setPointerCapture(event.pointerId); event.preventDefault(); event.stopPropagation(); @@ -493,44 +548,39 @@ function MiniPlayerShell({ const handlePointerMove = (event: ReactPointerEvent) => { const gesture = gestureRef.current; - if (!gesture || gesture.pointerId !== event.pointerId || !container) return; - const delta = { x: event.clientX - gesture.pointerX, y: event.clientY - gesture.pointerY }; - if (!gesture.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) return; - gesture.moved = true; + if (!gesture || !container) return; + const step = stepPreviewMiniPlayerGesture(gesture, event, container); + if (step.kind === "ignore") return; + if (step.kind === "end") { + finishGesture(); + return; + } + if (!gesture.moved) gestureRef.current = { ...gesture, moved: true }; const store = usePreviewMiniPlayerStore.getState(); - if (gesture.direction === null) { - store.move( - threadRef, - sourceKey, - clampPreviewMiniPlayerPosition( - { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y }, - container, - gesture.frame, - ), - ); + if (step.kind === "move") { + store.move(threadRef, sourceKey, step.position); return; } - const next = resizePreviewMiniPlayer({ - start: gesture.frame, - direction: gesture.direction, - delta, - source: sourceSize, - container, - }); - store.resize(threadRef, sourceKey, next.width, { x: next.x, y: next.y }); + store.resize(threadRef, sourceKey, step.frame.width, { x: step.frame.x, y: step.frame.y }); }; const endGesture = (event: ReactPointerEvent) => { - const gesture = gestureRef.current; - if (gesture?.pointerId !== event.pointerId) return; - gestureRef.current = null; + if (gestureRef.current?.pointerId !== event.pointerId) return; + const gesture = finishGesture(); handleTappedRef.current = event.type === "pointerup" && - gesture.direction === null && + gesture?.direction === null && gesture.pointerType === "touch" && !gesture.moved; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); + }; + + // Capture can go without a release or cancel: the handle is replaced, or the platform takes it. + const handleLostCapture = (event: ReactPointerEvent) => { + if ( + gestureRef.current?.pointerId === event.pointerId && + captureRef.current === event.currentTarget + ) { + finishGesture(); } }; @@ -558,6 +608,7 @@ function MiniPlayerShell({ onPointerMove={handlePointerMove} onPointerUp={endGesture} onPointerCancel={endGesture} + onLostPointerCapture={handleLostCapture} onClick={() => { if (!handleTappedRef.current) return; handleTappedRef.current = false; @@ -634,6 +685,7 @@ function MiniPlayerShell({ onPointerMove={handlePointerMove} onPointerUp={endGesture} onPointerCancel={endGesture} + onLostPointerCapture={handleLostCapture} /> ))} diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts new file mode 100644 index 000000000000..1d9f9035ebf2 --- /dev/null +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveChatCanvasLayout } from "../chat/chatCanvasLayout"; +import { + type PreviewMiniPlayerGesture, + settlePreviewMiniPlayerResize, + stepPreviewMiniPlayerGesture, +} from "./previewMiniPlayerGesture"; + +const container = { width: 1_440, height: 900 }; +const frame = { x: 996, y: 433, width: 268, height: 179 }; +const gesture: PreviewMiniPlayerGesture = { + pointerId: 1, + pointerType: "mouse", + pointerX: 996, + pointerY: 433, + frame, + direction: "northwest", + source: { width: 1_200, height: 800 }, + moved: true, +}; +const pointer = { pointerId: 1, buttons: 1, clientX: 936, clientY: 393 }; + +describe("stepPreviewMiniPlayerGesture", () => { + it("ends on a move without the primary button instead of following the hover", () => { + expect(stepPreviewMiniPlayerGesture(gesture, pointer, container).kind).toBe("resize"); + expect(stepPreviewMiniPlayerGesture(gesture, { ...pointer, buttons: 0 }, container)).toEqual({ + kind: "end", + }); + expect(stepPreviewMiniPlayerGesture(gesture, { ...pointer, buttons: 2 }, container)).toEqual({ + kind: "end", + }); + expect(stepPreviewMiniPlayerGesture(gesture, { ...pointer, pointerId: 2 }, container)).toEqual({ + kind: "ignore", + }); + }); + + it("resizes at the shape the gesture started with", () => { + const step = stepPreviewMiniPlayerGesture(gesture, pointer, container); + expect(step).toEqual({ kind: "resize", frame: { x: 936, y: 393, width: 328, height: 219 } }); + }); +}); + +describe("settlePreviewMiniPlayerResize", () => { + it("keeps the corner opposite the handle when the source changed shape mid-resize", () => { + const source = { width: 1_200, height: 1_000 }; + const settled = settlePreviewMiniPlayerResize({ + frame, + direction: "northwest", + held: gesture.source, + source, + container, + }); + expect(settled).toEqual({ x: 1_018, y: 407, width: 246, height: 205 }); + expect( + resolveChatCanvasLayout({ + container, + preview: { + key: "browser:tab", + width: settled!.width, + position: settled, + source, + lastInteraction: "resize", + }, + }).frame, + ).toEqual(settled); + expect( + settlePreviewMiniPlayerResize({ + frame, + direction: "northwest", + held: gesture.source, + source: { width: 600, height: 400 }, + container, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.ts new file mode 100644 index 000000000000..6c5892eab73f --- /dev/null +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.ts @@ -0,0 +1,93 @@ +import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; +import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore"; + +import { + clampPreviewMiniPlayerPosition, + type PreviewMiniPlayerFrame, + resizePreviewMiniPlayer, +} from "./previewMiniPlayerLayout"; + +const HANDLE_TAP_SLOP_PX = 6; + +export interface PreviewMiniPlayerGesture { + readonly pointerId: number; + readonly pointerType: string; + readonly pointerX: number; + readonly pointerY: number; + readonly frame: PreviewMiniPlayerFrame; + /** Null drags the player; a direction resizes it from that handle. */ + readonly direction: BrowserViewportResizeDirection | null; + /** The source size a resize keeps the frame's shape to until it ends. */ + readonly source: PreviewMiniPlayerSize; + readonly moved: boolean; +} + +interface GesturePointer { + readonly pointerId: number; + readonly buttons: number; + readonly clientX: number; + readonly clientY: number; +} + +export type PreviewMiniPlayerGestureStep = + | { readonly kind: "ignore" } + | { readonly kind: "end" } + | { readonly kind: "move"; readonly position: PreviewMiniPlayerPosition } + | { readonly kind: "resize"; readonly frame: PreviewMiniPlayerFrame }; + +/** What a pointer move does to the gesture in progress. */ +export function stepPreviewMiniPlayerGesture( + gesture: PreviewMiniPlayerGesture, + pointer: GesturePointer, + container: PreviewMiniPlayerSize, +): PreviewMiniPlayerGestureStep { + if (pointer.pointerId !== gesture.pointerId) return { kind: "ignore" }; + // A release the handle never saw (over a native view, another window) leaves only this trace. + if ((pointer.buttons & 1) === 0) return { kind: "end" }; + const delta = { x: pointer.clientX - gesture.pointerX, y: pointer.clientY - gesture.pointerY }; + if (!gesture.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) { + return { kind: "ignore" }; + } + if (gesture.direction === null) { + return { + kind: "move", + position: clampPreviewMiniPlayerPosition( + { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y }, + container, + gesture.frame, + ), + }; + } + return { + kind: "resize", + frame: resizePreviewMiniPlayer({ + start: gesture.frame, + direction: gesture.direction, + delta, + source: gesture.source, + container, + }), + }; +} + +/** + * The frame a finished resize leaves when the source changed shape under it: the new shape, with + * the edges opposite the handle where the gesture left them. Null when the shape is unchanged. + */ +export function settlePreviewMiniPlayerResize(input: { + readonly frame: PreviewMiniPlayerFrame; + readonly direction: BrowserViewportResizeDirection; + readonly held: PreviewMiniPlayerSize; + readonly source: PreviewMiniPlayerSize; + readonly container: PreviewMiniPlayerSize; +}): PreviewMiniPlayerFrame | null { + const { frame, direction, held, source, container } = input; + if (held.width * source.height === source.width * held.height) return null; + return resizePreviewMiniPlayer({ + start: frame, + direction, + delta: { x: 0, y: 0 }, + source, + container, + }); +}