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, + }); +}