From 41efb6e29b2f18daa747e9d19ee4840b1393da05 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 16:50:49 +0000 Subject: [PATCH 1/8] fix(web): terminate floating preview gestures reliably --- .../preview/ThreadPreviewMiniPlayer.test.tsx | 237 ++++++++++++++++++ .../preview/ThreadPreviewMiniPlayer.tsx | 88 +++++-- 2 files changed, 308 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/preview/ThreadPreviewMiniPlayer.test.tsx diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.test.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.test.tsx new file mode 100644 index 0000000000000..da6fe3700e458 --- /dev/null +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.test.tsx @@ -0,0 +1,237 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { PreviewMiniPlayerState } from "~/previewMiniPlayerStore"; +import type { PreviewMiniPlayerFrame } from "./previewMiniPlayerLayout"; + +const hooks = vi.hoisted(() => ({ + effects: [] as Array<() => void | (() => void)>, + canvas: { + container: { width: 1000, height: 800 }, + previewKey: "device:host:device", + layout: { frame: { x: 100, y: 100, width: 300, height: 650 } }, + reportPreview: () => {}, + clearPreview: () => {}, + }, +})); +vi.mock("react", async (original) => ({ + ...(await original()), + useRef: (current: T) => ({ current }), + useState: (initial: T) => [initial, vi.fn()], + useEffect: (effect: () => void | (() => void)) => hooks.effects.push(effect), + useLayoutEffect: (effect: () => void | (() => void)) => hooks.effects.push(effect), +})); +vi.mock("../chat/ChatCanvasContext", () => ({ useChatCanvas: () => hooks.canvas })); +vi.mock("~/state/device", () => ({ + useDeviceState: () => ({ state: { devices: [], hosts: [] } }), +})); +vi.mock("~/state/environments", () => ({ useEnvironmentHttpBaseUrl: () => "" })); +vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: "BrowserSurfaceSlot" })); +vi.mock("~/browser/browserRecording", () => ({})); +vi.mock("~/browser/browserSurfaceStore", () => ({})); +vi.mock("~/browser/previewRuntime", () => ({})); +vi.mock("~/browser/ServerBrowserSurface", () => ({ ServerBrowserSurface: "ServerBrowserSurface" })); +vi.mock("~/browser/serverPictureInPicture", () => ({})); +vi.mock("~/browser/useBrowserLayoutZoomFactor", () => ({})); +vi.mock("~/components/ui/button", () => ({ Button: "Button" })); +vi.mock("~/components/ui/toast", () => ({})); +vi.mock("~/components/ui/tooltip", () => ({ + Tooltip: "Tooltip", + TooltipTrigger: "TooltipTrigger", + TooltipPopup: "TooltipPopup", +})); +vi.mock("~/previewStateStore", () => ({})); +vi.mock("~/rightPanelStore", () => ({})); +vi.mock("../device/DeviceStreamView", () => ({ DeviceStreamView: "DeviceStreamView" })); +vi.mock("./previewBridge", () => ({ previewBridge: null })); +vi.mock("./showPreviewPopup", () => ({})); + +import { ThreadPreviewMiniPlayer } from "./ThreadPreviewMiniPlayer"; +import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; + +const threadRef = { + environmentId: EnvironmentId.make("gesture-test"), + threadId: ThreadId.make("thread"), +}; +const source = { + kind: "device", + hostId: "host", + deviceId: "device", + platform: "ios", + name: "Phone", +} as const; +type HandlerProps = { + children?: ReactNode; + onPointerDown?: (event: unknown) => void; + onPointerMove?: (event: unknown) => void; + onPointerUp?: (event: unknown) => void; + onPointerCancel?: (event: unknown) => void; + onLostPointerCapture?: (event: unknown) => void; + "data-preview-mini-player-resize"?: string; +}; + +function invoke(element: ReactElement): ReactElement { + if (typeof element.type !== "function") throw new Error("Expected component"); + return (element.type as (props: unknown) => ReactElement)(element.props); +} +function find( + node: ReactNode, + predicate: (props: HandlerProps) => boolean, +): HandlerProps | undefined { + if (Array.isArray(node)) return node.map((child) => find(child, predicate)).find(Boolean); + if (!isValidElement(node)) return undefined; + return predicate(node.props) ? node.props : find(node.props.children, predicate); +} + +function setup(direction: string | null, ownFrame?: PreviewMiniPlayerFrame) { + hooks.effects = []; + hooks.canvas.container = { width: 1000, height: 800 }; + hooks.canvas.layout.frame = { x: 100, y: 100, width: 300, height: 650 }; + const frame = ownFrame ?? hooks.canvas.layout.frame; + const miniPlayer: PreviewMiniPlayerState = { + source, + width: frame.width, + position: { x: frame.x, y: frame.y }, + lastInteraction: "drag", + }; + usePreviewMiniPlayerStore.setState({ byThreadKey: { [scopedThreadKey(threadRef)]: miniPlayer } }); + const windowEvents = new EventTarget(); + vi.stubGlobal("window", windowEvents); + const shell = invoke(invoke(ThreadPreviewMiniPlayer({ threadRef, miniPlayer }))); + const handlers = find(shell, (props) => + direction === null + ? Boolean(props.onPointerDown) && props["data-preview-mini-player-resize"] === undefined + : props["data-preview-mini-player-resize"] === direction, + ); + if (!handlers) throw new Error("Gesture handle missing"); + const cleanups = hooks.effects.map((effect) => effect()); + const captured = new Set(); + const rect = { + ...frame, + left: frame.x, + top: frame.y, + right: frame.x + frame.width, + bottom: frame.y + frame.height, + }; + const preview = { + getBoundingClientRect: () => rect, + offsetWidth: frame.width, + offsetHeight: frame.height, + }; + const target = { + parentElement: preview, + closest: () => preview, + getBoundingClientRect: () => ({ + left: rect.right - 8, + top: rect.bottom - 8, + width: 16, + height: 16, + }), + setPointerCapture: (id: number) => captured.add(id), + hasPointerCapture: (id: number) => captured.has(id), + releasePointerCapture: (id: number) => captured.delete(id), + }; + const dispatch = (type: string, x = 400, y = 750, buttons = 1) => { + const event = new Event(type, { cancelable: true }); + Object.assign(event, { + pointerId: 1, + pointerType: "mouse", + button: 0, + buttons, + clientX: x, + clientY: y, + }); + Object.defineProperty(event, "currentTarget", { value: target }); + const eventHandlers: Record void) | undefined> = { + pointerdown: handlers.onPointerDown, + pointermove: handlers.onPointerMove, + pointerup: handlers.onPointerUp, + pointercancel: handlers.onPointerCancel, + lostpointercapture: handlers.onLostPointerCapture, + }; + eventHandlers[type]?.(event); + }; + return { + dispatch, + blur: () => windowEvents.dispatchEvent(new Event("blur")), + state: () => usePreviewMiniPlayerStore.getState().byThreadKey[scopedThreadKey(threadRef)]!, + [Symbol.dispose]() { + for (const cleanup of cleanups.toReversed()) cleanup?.(); + }, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("floating preview pointer gestures", () => { + it.each([ + { direction: "northwest", dx: -12, dy: -26 }, + { direction: "northeast", dx: 12, dy: -26 }, + { direction: "southwest", dx: -12, dy: 26 }, + { direction: "southeast", dx: 12, dy: 26 }, + ])( + "$direction: resizes from the preview element's own size at pointerdown without a scale jump", + ({ direction, dx, dy }) => { + // The canvas can still hold the previous layout when the rendered preview has changed size. + using gesture = setup(direction, { x: 100, y: 100, width: 240, height: 520 }); + gesture.dispatch("pointerdown"); + gesture.dispatch("pointermove", 400 + dx, 750 + dy); + expect(gesture.state().width).toBe(252); + expect(gesture.state().position).toEqual({ x: dx < 0 ? 88 : 100, y: dy < 0 ? 74 : 100 }); + }, + ); + + it.each([null, "southeast"])( + "%s: idle hover and pointermove leave state unchanged", + (direction) => { + using gesture = setup(direction); + const initial = gesture.state(); + gesture.dispatch("pointermove", 450, 700, 0); + gesture.dispatch("pointermove", 500, 600, 1); + expect(gesture.state()).toBe(initial); + }, + ); + const endings = [null, "southeast"].flatMap((direction) => + ["pointerup", "pointercancel", "lostpointercapture", "blur", "buttons=0"].map((ending) => ({ + direction, + ending, + })), + ); + it.each(endings)( + "$direction: ends on $ending and ignores later pointermove", + ({ direction, ending }) => { + using gesture = setup(direction); + gesture.dispatch("pointerdown"); + gesture.dispatch("pointermove", 420, 710); + if (ending === "blur") gesture.blur(); + else if (ending === "buttons=0") gesture.dispatch("pointermove", 420, 710, 0); + else gesture.dispatch(ending, 420, 710, 0); + const ended = gesture.state(); + gesture.dispatch("pointermove", 600, 600); + expect(gesture.state()).toEqual(ended); + expect(ended.lastInteraction).toBe(direction === null ? "drag" : "resize"); + }, + ); + it.each(endings)( + "$direction: $ending clamps the final frame into a viewport that shrank during the gesture", + ({ direction, ending }) => { + using gesture = setup(direction); + gesture.dispatch("pointerdown"); + gesture.dispatch("pointermove", 600, 750); + hooks.canvas.container.width = 500; + hooks.canvas.container.height = 700; + if (ending === "blur") gesture.blur(); + else if (ending === "buttons=0") gesture.dispatch("pointermove", 600, 750, 0); + else gesture.dispatch(ending, 600, 750, 0); + const final = gesture.state(); + expect(final.position!.x).toBeGreaterThanOrEqual(12); + expect(final.position!.y).toBeGreaterThanOrEqual(12); + expect(final.position!.x + final.width!).toBeLessThanOrEqual(488); + expect(final.position!.y + final.width! * (650 / 300)).toBeLessThanOrEqual(688); + }, + ); +}); diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index b4fe378354fd8..e0773691be06f 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -57,6 +57,7 @@ import { PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, type PreviewMiniPlayerFrame, resizePreviewMiniPlayer, + resolvePreviewMiniPlayerFrame, resolveDeviceMiniPlayerCornerRadius, resolveDeviceMiniPlayerSourceSize, resolvePreviewMiniPlayerSourceSize, @@ -65,10 +66,12 @@ import { interface PointerGesture { readonly pointerId: number; readonly pointerType: string; + readonly target: HTMLElement; readonly pointerX: number; readonly pointerY: number; readonly frame: PreviewMiniPlayerFrame; readonly direction: BrowserViewportResizeDirection | null; + currentFrame: PreviewMiniPlayerFrame; moved: boolean; } @@ -390,21 +393,70 @@ function MiniPlayerShell({ usePreviewMiniPlayerStore.getState().close(threadRef); }; + const finishGesture = () => { + const gesture = gestureRef.current; + if (!gesture) return; + gestureRef.current = null; + handleTappedRef.current = false; + if (container && gesture.moved) { + const next = resolvePreviewMiniPlayerFrame({ + width: gesture.currentFrame.width, + position: gesture.currentFrame, + source: gesture.frame, + container, + }); + const store = usePreviewMiniPlayerStore.getState(); + const position = { x: next.x, y: next.y }; + if (gesture.direction === null) store.move(threadRef, sourceKey, position); + else store.resize(threadRef, sourceKey, next.width, position); + } + if (gesture.target.hasPointerCapture(gesture.pointerId)) { + gesture.target.releasePointerCapture(gesture.pointerId); + } + }; + + const finishGestureRef = useRef(finishGesture); + useLayoutEffect(() => { + finishGestureRef.current = finishGesture; + }); + useEffect(() => { + const handleBlur = () => finishGestureRef.current(); + window.addEventListener("blur", handleBlur); + return () => window.removeEventListener("blur", handleBlur); + }, []); + const beginGesture = ( event: ReactPointerEvent, direction: BrowserViewportResizeDirection | null, ) => { - if (event.button !== 0 || !frame) return; + if (event.button !== 0 || !frame || gestureRef.current) return; + const preview = event.currentTarget.closest("[data-preview-mini-player]"); + if (!preview) return; + const rect = preview.getBoundingClientRect(); + // The canvas can lag the rendered element after a resize. + const start = { + x: preview.offsetLeft ?? rect.left, + y: preview.offsetTop ?? rect.top, + width: preview.offsetWidth, + height: preview.offsetHeight, + }; + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + return; + } + handleTappedRef.current = false; gestureRef.current = { pointerId: event.pointerId, pointerType: event.pointerType, + target: event.currentTarget, pointerX: event.clientX, pointerY: event.clientY, - frame, + frame: start, + currentFrame: start, direction, moved: false, }; - event.currentTarget.setPointerCapture(event.pointerId); event.preventDefault(); event.stopPropagation(); }; @@ -412,46 +464,46 @@ function MiniPlayerShell({ const handlePointerMove = (event: ReactPointerEvent) => { const gesture = gestureRef.current; if (!gesture || gesture.pointerId !== event.pointerId || !container) return; + if ((event.buttons & 1) === 0) { + finishGesture(); + 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; 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, - obstacles, - ), + const position = clampPreviewMiniPlayerPosition( + { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y }, + container, + gesture.frame, + obstacles, ); + gesture.currentFrame = { ...gesture.frame, ...position }; + store.move(threadRef, sourceKey, position); return; } const next = resizePreviewMiniPlayer({ start: gesture.frame, direction: gesture.direction, delta, - source: sourceSize, + source: gesture.frame, container, obstacles, }); + gesture.currentFrame = next; store.resize(threadRef, sourceKey, next.width, { x: next.x, y: next.y }); }; const endGesture = (event: ReactPointerEvent) => { const gesture = gestureRef.current; if (gesture?.pointerId !== event.pointerId) return; - gestureRef.current = null; + finishGesture(); handleTappedRef.current = event.type === "pointerup" && gesture.direction === null && gesture.pointerType === "touch" && !gesture.moved; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } }; return ( @@ -478,6 +530,7 @@ function MiniPlayerShell({ onPointerMove={handlePointerMove} onPointerUp={endGesture} onPointerCancel={endGesture} + onLostPointerCapture={endGesture} onClick={() => { if (!handleTappedRef.current) return; handleTappedRef.current = false; @@ -554,6 +607,7 @@ function MiniPlayerShell({ onPointerMove={handlePointerMove} onPointerUp={endGesture} onPointerCancel={endGesture} + onLostPointerCapture={endGesture} /> ))} From 915f729b7fbca9c60cfe1a16c5a52c69696b9c9b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 17:00:22 +0000 Subject: [PATCH 2/8] fix(web): preserve floating preview gesture geometry --- .../preview/ThreadPreviewMiniPlayer.test.tsx | 237 ----------------- .../preview/ThreadPreviewMiniPlayer.tsx | 135 ++-------- .../preview/previewMiniPlayerGesture.test.ts | 244 ++++++++++++++++++ .../preview/previewMiniPlayerGesture.ts | 146 +++++++++++ 4 files changed, 412 insertions(+), 350 deletions(-) delete mode 100644 apps/web/src/components/preview/ThreadPreviewMiniPlayer.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.test.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.test.tsx deleted file mode 100644 index da6fe3700e458..0000000000000 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.test.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { isValidElement, type ReactElement, type ReactNode } from "react"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import type { PreviewMiniPlayerState } from "~/previewMiniPlayerStore"; -import type { PreviewMiniPlayerFrame } from "./previewMiniPlayerLayout"; - -const hooks = vi.hoisted(() => ({ - effects: [] as Array<() => void | (() => void)>, - canvas: { - container: { width: 1000, height: 800 }, - previewKey: "device:host:device", - layout: { frame: { x: 100, y: 100, width: 300, height: 650 } }, - reportPreview: () => {}, - clearPreview: () => {}, - }, -})); -vi.mock("react", async (original) => ({ - ...(await original()), - useRef: (current: T) => ({ current }), - useState: (initial: T) => [initial, vi.fn()], - useEffect: (effect: () => void | (() => void)) => hooks.effects.push(effect), - useLayoutEffect: (effect: () => void | (() => void)) => hooks.effects.push(effect), -})); -vi.mock("../chat/ChatCanvasContext", () => ({ useChatCanvas: () => hooks.canvas })); -vi.mock("~/state/device", () => ({ - useDeviceState: () => ({ state: { devices: [], hosts: [] } }), -})); -vi.mock("~/state/environments", () => ({ useEnvironmentHttpBaseUrl: () => "" })); -vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: "BrowserSurfaceSlot" })); -vi.mock("~/browser/browserRecording", () => ({})); -vi.mock("~/browser/browserSurfaceStore", () => ({})); -vi.mock("~/browser/previewRuntime", () => ({})); -vi.mock("~/browser/ServerBrowserSurface", () => ({ ServerBrowserSurface: "ServerBrowserSurface" })); -vi.mock("~/browser/serverPictureInPicture", () => ({})); -vi.mock("~/browser/useBrowserLayoutZoomFactor", () => ({})); -vi.mock("~/components/ui/button", () => ({ Button: "Button" })); -vi.mock("~/components/ui/toast", () => ({})); -vi.mock("~/components/ui/tooltip", () => ({ - Tooltip: "Tooltip", - TooltipTrigger: "TooltipTrigger", - TooltipPopup: "TooltipPopup", -})); -vi.mock("~/previewStateStore", () => ({})); -vi.mock("~/rightPanelStore", () => ({})); -vi.mock("../device/DeviceStreamView", () => ({ DeviceStreamView: "DeviceStreamView" })); -vi.mock("./previewBridge", () => ({ previewBridge: null })); -vi.mock("./showPreviewPopup", () => ({})); - -import { ThreadPreviewMiniPlayer } from "./ThreadPreviewMiniPlayer"; -import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; -import { scopedThreadKey } from "@t3tools/client-runtime/environment"; - -const threadRef = { - environmentId: EnvironmentId.make("gesture-test"), - threadId: ThreadId.make("thread"), -}; -const source = { - kind: "device", - hostId: "host", - deviceId: "device", - platform: "ios", - name: "Phone", -} as const; -type HandlerProps = { - children?: ReactNode; - onPointerDown?: (event: unknown) => void; - onPointerMove?: (event: unknown) => void; - onPointerUp?: (event: unknown) => void; - onPointerCancel?: (event: unknown) => void; - onLostPointerCapture?: (event: unknown) => void; - "data-preview-mini-player-resize"?: string; -}; - -function invoke(element: ReactElement): ReactElement { - if (typeof element.type !== "function") throw new Error("Expected component"); - return (element.type as (props: unknown) => ReactElement)(element.props); -} -function find( - node: ReactNode, - predicate: (props: HandlerProps) => boolean, -): HandlerProps | undefined { - if (Array.isArray(node)) return node.map((child) => find(child, predicate)).find(Boolean); - if (!isValidElement(node)) return undefined; - return predicate(node.props) ? node.props : find(node.props.children, predicate); -} - -function setup(direction: string | null, ownFrame?: PreviewMiniPlayerFrame) { - hooks.effects = []; - hooks.canvas.container = { width: 1000, height: 800 }; - hooks.canvas.layout.frame = { x: 100, y: 100, width: 300, height: 650 }; - const frame = ownFrame ?? hooks.canvas.layout.frame; - const miniPlayer: PreviewMiniPlayerState = { - source, - width: frame.width, - position: { x: frame.x, y: frame.y }, - lastInteraction: "drag", - }; - usePreviewMiniPlayerStore.setState({ byThreadKey: { [scopedThreadKey(threadRef)]: miniPlayer } }); - const windowEvents = new EventTarget(); - vi.stubGlobal("window", windowEvents); - const shell = invoke(invoke(ThreadPreviewMiniPlayer({ threadRef, miniPlayer }))); - const handlers = find(shell, (props) => - direction === null - ? Boolean(props.onPointerDown) && props["data-preview-mini-player-resize"] === undefined - : props["data-preview-mini-player-resize"] === direction, - ); - if (!handlers) throw new Error("Gesture handle missing"); - const cleanups = hooks.effects.map((effect) => effect()); - const captured = new Set(); - const rect = { - ...frame, - left: frame.x, - top: frame.y, - right: frame.x + frame.width, - bottom: frame.y + frame.height, - }; - const preview = { - getBoundingClientRect: () => rect, - offsetWidth: frame.width, - offsetHeight: frame.height, - }; - const target = { - parentElement: preview, - closest: () => preview, - getBoundingClientRect: () => ({ - left: rect.right - 8, - top: rect.bottom - 8, - width: 16, - height: 16, - }), - setPointerCapture: (id: number) => captured.add(id), - hasPointerCapture: (id: number) => captured.has(id), - releasePointerCapture: (id: number) => captured.delete(id), - }; - const dispatch = (type: string, x = 400, y = 750, buttons = 1) => { - const event = new Event(type, { cancelable: true }); - Object.assign(event, { - pointerId: 1, - pointerType: "mouse", - button: 0, - buttons, - clientX: x, - clientY: y, - }); - Object.defineProperty(event, "currentTarget", { value: target }); - const eventHandlers: Record void) | undefined> = { - pointerdown: handlers.onPointerDown, - pointermove: handlers.onPointerMove, - pointerup: handlers.onPointerUp, - pointercancel: handlers.onPointerCancel, - lostpointercapture: handlers.onLostPointerCapture, - }; - eventHandlers[type]?.(event); - }; - return { - dispatch, - blur: () => windowEvents.dispatchEvent(new Event("blur")), - state: () => usePreviewMiniPlayerStore.getState().byThreadKey[scopedThreadKey(threadRef)]!, - [Symbol.dispose]() { - for (const cleanup of cleanups.toReversed()) cleanup?.(); - }, - }; -} - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe("floating preview pointer gestures", () => { - it.each([ - { direction: "northwest", dx: -12, dy: -26 }, - { direction: "northeast", dx: 12, dy: -26 }, - { direction: "southwest", dx: -12, dy: 26 }, - { direction: "southeast", dx: 12, dy: 26 }, - ])( - "$direction: resizes from the preview element's own size at pointerdown without a scale jump", - ({ direction, dx, dy }) => { - // The canvas can still hold the previous layout when the rendered preview has changed size. - using gesture = setup(direction, { x: 100, y: 100, width: 240, height: 520 }); - gesture.dispatch("pointerdown"); - gesture.dispatch("pointermove", 400 + dx, 750 + dy); - expect(gesture.state().width).toBe(252); - expect(gesture.state().position).toEqual({ x: dx < 0 ? 88 : 100, y: dy < 0 ? 74 : 100 }); - }, - ); - - it.each([null, "southeast"])( - "%s: idle hover and pointermove leave state unchanged", - (direction) => { - using gesture = setup(direction); - const initial = gesture.state(); - gesture.dispatch("pointermove", 450, 700, 0); - gesture.dispatch("pointermove", 500, 600, 1); - expect(gesture.state()).toBe(initial); - }, - ); - const endings = [null, "southeast"].flatMap((direction) => - ["pointerup", "pointercancel", "lostpointercapture", "blur", "buttons=0"].map((ending) => ({ - direction, - ending, - })), - ); - it.each(endings)( - "$direction: ends on $ending and ignores later pointermove", - ({ direction, ending }) => { - using gesture = setup(direction); - gesture.dispatch("pointerdown"); - gesture.dispatch("pointermove", 420, 710); - if (ending === "blur") gesture.blur(); - else if (ending === "buttons=0") gesture.dispatch("pointermove", 420, 710, 0); - else gesture.dispatch(ending, 420, 710, 0); - const ended = gesture.state(); - gesture.dispatch("pointermove", 600, 600); - expect(gesture.state()).toEqual(ended); - expect(ended.lastInteraction).toBe(direction === null ? "drag" : "resize"); - }, - ); - it.each(endings)( - "$direction: $ending clamps the final frame into a viewport that shrank during the gesture", - ({ direction, ending }) => { - using gesture = setup(direction); - gesture.dispatch("pointerdown"); - gesture.dispatch("pointermove", 600, 750); - hooks.canvas.container.width = 500; - hooks.canvas.container.height = 700; - if (ending === "blur") gesture.blur(); - else if (ending === "buttons=0") gesture.dispatch("pointermove", 600, 750, 0); - else gesture.dispatch(ending, 600, 750, 0); - const final = gesture.state(); - expect(final.position!.x).toBeGreaterThanOrEqual(12); - expect(final.position!.y).toBeGreaterThanOrEqual(12); - expect(final.position!.x + final.width!).toBeLessThanOrEqual(488); - expect(final.position!.y + final.width! * (650 / 300)).toBeLessThanOrEqual(688); - }, - ); -}); diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index e0773691be06f..7298a21ea2e37 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, useLayoutEffect, useRef, useState, @@ -51,31 +52,15 @@ import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/serv import { previewBridge } from "./previewBridge"; import { showPreviewPopup } from "./showPreviewPopup"; import { - clampPreviewMiniPlayerPosition, - NO_PREVIEW_MINI_PLAYER_OBSTACLES, PREVIEW_MINI_PLAYER_CORNER_RADIUS, PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, type PreviewMiniPlayerFrame, - resizePreviewMiniPlayer, - resolvePreviewMiniPlayerFrame, resolveDeviceMiniPlayerCornerRadius, resolveDeviceMiniPlayerSourceSize, resolvePreviewMiniPlayerSourceSize, } from "./previewMiniPlayerLayout"; -interface PointerGesture { - readonly pointerId: number; - readonly pointerType: string; - readonly target: HTMLElement; - readonly pointerX: number; - readonly pointerY: number; - readonly frame: PreviewMiniPlayerFrame; - readonly direction: BrowserViewportResizeDirection | null; - currentFrame: PreviewMiniPlayerFrame; - moved: boolean; -} - -const HANDLE_TAP_SLOP_PX = 6; +import { createPreviewMiniPlayerGesture } from "./previewMiniPlayerGesture"; interface Props { readonly threadRef: ScopedThreadRef; @@ -345,7 +330,7 @@ function MiniPlayerShell({ readonly children: (frame: PreviewMiniPlayerFrame) => ReactNode; }) { const canvas = useChatCanvas(); - const gestureRef = useRef(null); + const [gesture] = useState(createPreviewMiniPlayerGesture); // Touch has no hover, so tapping the handle toggles the pill instead. The // toggle waits for the tap's click, which would otherwise land on the pill. const [pillOpen, setPillOpen] = useState(false); @@ -361,7 +346,6 @@ function MiniPlayerShell({ return () => document.removeEventListener("pointerdown", dismiss, true); }, [pillOpen]); const container = canvas?.container ?? null; - const obstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES; const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source); const frame = canvas?.previewKey === sourceKey ? canvas.layout.frame : null; const { width: sourceWidth, height: sourceHeight } = sourceSize; @@ -393,34 +377,21 @@ function MiniPlayerShell({ usePreviewMiniPlayerStore.getState().close(threadRef); }; + const commitGesture = ( + next: PreviewMiniPlayerFrame, + direction: BrowserViewportResizeDirection | null, + ) => { + const store = usePreviewMiniPlayerStore.getState(); + const position = { x: next.x, y: next.y }; + if (direction === null) store.move(threadRef, sourceKey, position); + else store.resize(threadRef, sourceKey, next.width, position); + }; const finishGesture = () => { - const gesture = gestureRef.current; - if (!gesture) return; - gestureRef.current = null; handleTappedRef.current = false; - if (container && gesture.moved) { - const next = resolvePreviewMiniPlayerFrame({ - width: gesture.currentFrame.width, - position: gesture.currentFrame, - source: gesture.frame, - container, - }); - const store = usePreviewMiniPlayerStore.getState(); - const position = { x: next.x, y: next.y }; - if (gesture.direction === null) store.move(threadRef, sourceKey, position); - else store.resize(threadRef, sourceKey, next.width, position); - } - if (gesture.target.hasPointerCapture(gesture.pointerId)) { - gesture.target.releasePointerCapture(gesture.pointerId); - } + gesture.finish(container, commitGesture); }; - - const finishGestureRef = useRef(finishGesture); - useLayoutEffect(() => { - finishGestureRef.current = finishGesture; - }); + const handleBlur = useEffectEvent(finishGesture); useEffect(() => { - const handleBlur = () => finishGestureRef.current(); window.addEventListener("blur", handleBlur); return () => window.removeEventListener("blur", handleBlur); }, []); @@ -429,81 +400,19 @@ function MiniPlayerShell({ event: ReactPointerEvent, direction: BrowserViewportResizeDirection | null, ) => { - if (event.button !== 0 || !frame || gestureRef.current) return; - const preview = event.currentTarget.closest("[data-preview-mini-player]"); - if (!preview) return; - const rect = preview.getBoundingClientRect(); - // The canvas can lag the rendered element after a resize. - const start = { - x: preview.offsetLeft ?? rect.left, - y: preview.offsetTop ?? rect.top, - width: preview.offsetWidth, - height: preview.offsetHeight, - }; - try { - event.currentTarget.setPointerCapture(event.pointerId); - } catch { - return; - } + if (!frame) return; handleTappedRef.current = false; - gestureRef.current = { - pointerId: event.pointerId, - pointerType: event.pointerType, - target: event.currentTarget, - pointerX: event.clientX, - pointerY: event.clientY, - frame: start, - currentFrame: start, - direction, - moved: false, - }; - event.preventDefault(); - event.stopPropagation(); + if (gesture.begin(event, direction)) { + event.preventDefault(); + event.stopPropagation(); + } }; - const handlePointerMove = (event: ReactPointerEvent) => { - const gesture = gestureRef.current; - if (!gesture || gesture.pointerId !== event.pointerId || !container) return; - if ((event.buttons & 1) === 0) { - finishGesture(); - 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; - const store = usePreviewMiniPlayerStore.getState(); - if (gesture.direction === null) { - const position = clampPreviewMiniPlayerPosition( - { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y }, - container, - gesture.frame, - obstacles, - ); - gesture.currentFrame = { ...gesture.frame, ...position }; - store.move(threadRef, sourceKey, position); - return; - } - const next = resizePreviewMiniPlayer({ - start: gesture.frame, - direction: gesture.direction, - delta, - source: gesture.frame, - container, - obstacles, - }); - gesture.currentFrame = next; - store.resize(threadRef, sourceKey, next.width, { x: next.x, y: next.y }); + gesture.move(event, container, commitGesture); }; - const endGesture = (event: ReactPointerEvent) => { - const gesture = gestureRef.current; - if (gesture?.pointerId !== event.pointerId) return; - finishGesture(); - handleTappedRef.current = - event.type === "pointerup" && - gesture.direction === null && - gesture.pointerType === "touch" && - !gesture.moved; + const tapped = gesture.end(event, container, commitGesture); + if (tapped !== null) handleTappedRef.current = tapped; }; return ( 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 0000000000000..2efa2c91100ca --- /dev/null +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { createPreviewMiniPlayerGesture } from "./previewMiniPlayerGesture"; +import { resizePreviewMiniPlayer, resolvePreviewMiniPlayerFrame } from "./previewMiniPlayerLayout"; + +const container = { width: 1000, height: 800 }; +const baseline = { x: 100.25, y: 80.75, width: 355.5, height: 222.25 }; + +function setup(frame = baseline) { + const preview = {}; + const style = { + left: `${frame.x}px`, + top: `${frame.y}px`, + width: `${frame.width}px`, + height: `${frame.height}px`, + }; + const readStyle = vi.fn(() => style); + vi.stubGlobal("getComputedStyle", readStyle); + let captured = false; + const target = { + closest: vi.fn(() => preview as Element), + setPointerCapture: vi.fn(() => { + captured = true; + }), + hasPointerCapture: vi.fn(() => captured), + releasePointerCapture: vi.fn(() => { + captured = false; + }), + }; + const down = { + button: 0, + buttons: 1, + pointerId: 7, + pointerType: "mouse", + clientX: 300.5, + clientY: 200.25, + currentTarget: target, + }; + return { + gesture: createPreviewMiniPlayerGesture(), + target, + down, + style, + readStyle, + preview, + commit: vi.fn(), + }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("floating preview gestures", () => { + it("leaves idle hover and unmatched end events inert", () => { + const { gesture, down, commit, target, readStyle } = setup(); + gesture.move(down, container, commit); + expect(gesture.end({ ...down, type: "pointerup" }, container, commit)).toBeNull(); + gesture.finish(container, commit); + expect(commit).not.toHaveBeenCalled(); + expect(readStyle).not.toHaveBeenCalled(); + expect(target.releasePointerCapture).not.toHaveBeenCalled(); + }); + + it("starts from its own fractional local CSS geometry and captures the handle", () => { + const { gesture, down, commit, target, readStyle, preview } = setup(); + expect(gesture.begin(down, null)).toBe(true); + expect(target.closest).toHaveBeenCalledWith("[data-preview-mini-player]"); + expect(readStyle).toHaveBeenCalledWith(preview); + expect(target.setPointerCapture).toHaveBeenCalledWith(7); + gesture.move( + { ...down, clientX: down.clientX + 10.5, clientY: down.clientY + 8.25 }, + container, + commit, + ); + expect(commit).toHaveBeenLastCalledWith({ ...baseline, x: 110.75, y: 89 }, null); + }); + + it.each(["northwest", "northeast", "southwest", "southeast"] as const)( + "%s keeps the rendered baseline ratio when the rounded frame disagrees with the source", + (direction) => { + const frame = { x: 100.25, y: 80.75, width: 355, height: 222 }; + const { gesture, down, commit, style, readStyle } = setup(frame); + const source = { width: 1600, height: 1000 }; + expect( + resolvePreviewMiniPlayerFrame({ width: 355, position: frame, source, container }), + ).toEqual(frame); + gesture.begin(down, direction); + // A stream/source update can change layout mid-gesture; it must not change the baseline. + style.width = "460px"; + style.height = "288px"; + const west = direction.includes("west"); + const north = direction.includes("north"); + gesture.move( + { + ...down, + clientX: down.clientX + (west ? -6 : 6), + clientY: down.clientY + (north ? 53 : -53), + }, + container, + commit, + ); + const next = commit.mock.lastCall?.[0]; + expect(next).toEqual({ + x: west ? 120.25 : frame.x, + y: north ? 92.75 : frame.y, + width: 335, + height: 210, + }); + const usingLiveSource = resizePreviewMiniPlayer({ + start: frame, + direction, + delta: { x: west ? -6 : 6, y: north ? 53 : -53 }, + source, + container, + }); + expect(next?.width).not.toBe(usingLiveSource.width); + expect(readStyle).toHaveBeenCalledTimes(1); + }, + ); + + describe.each([null, "southeast"] as const)("ending a %s gesture", (direction) => { + it.each(["pointerup", "pointercancel", "lostpointercapture", "blur", "buttons0"])( + "ends on %s, clamps position, and ignores later movement", + (termination) => { + const { gesture, down, commit, target } = setup(); + gesture.begin(down, direction); + gesture.move({ ...down, clientX: 700, clientY: 500 }, container, commit); + const chosen = commit.mock.lastCall![0]; + const smaller = { width: 500, height: 400 }; + if (termination === "blur") gesture.finish(smaller, commit); + else if (termination === "buttons0") gesture.move({ ...down, buttons: 0 }, smaller, commit); + else gesture.end({ ...down, type: termination }, smaller, commit); + const fitted = resolvePreviewMiniPlayerFrame({ + width: chosen.width, + position: chosen, + source: baseline, + container: smaller, + }); + expect(commit).toHaveBeenLastCalledWith( + { + ...chosen, + x: fitted.x, + y: fitted.y, + }, + direction, + ); + expect(target.releasePointerCapture).toHaveBeenCalledWith(7); + const calls = commit.mock.calls.length; + gesture.move({ ...down, clientX: 900 }, container, commit); + gesture.finish(container, commit); + expect(commit).toHaveBeenCalledTimes(calls); + }, + ); + }); + + it("preserves the chosen resize width when the viewport shrinks before release", () => { + const { gesture, down, commit } = setup(); + gesture.begin(down, "east"); + gesture.move({ ...down, clientX: down.clientX + 100 }, container, commit); + const chosen = commit.mock.lastCall?.[0]; + const smaller = { width: 300, height: 300 }; + gesture.end({ ...down, type: "pointerup" }, smaller, commit); + const persisted = commit.mock.lastCall?.[0]; + expect(persisted?.width).toBe(chosen?.width); + expect(commit.mock.lastCall?.[1]).toBe("east"); + expect(persisted?.x).toBe(12); + const displayed = resolvePreviewMiniPlayerFrame({ + width: persisted!.width, + position: persisted!, + source: baseline, + container: smaller, + }); + expect(displayed.x).toBeGreaterThanOrEqual(12); + expect(displayed.y).toBeGreaterThanOrEqual(12); + expect(displayed.x + displayed.width).toBeLessThanOrEqual(smaller.width - 12); + expect(displayed.y + displayed.height).toBeLessThanOrEqual(smaller.height - 12); + expect(displayed.width).toBeLessThan(persisted!.width); + + expect( + resolvePreviewMiniPlayerFrame({ + width: persisted!.width, + position: persisted!, + source: baseline, + container, + }).width, + ).toBe(chosen?.width); + }); + + it("ignores other pointer IDs and a second pointerdown", () => { + const { gesture, down, commit, target } = setup(); + gesture.begin(down, "east"); + expect(gesture.begin({ ...down, pointerId: 8 }, null)).toBe(false); + gesture.move({ ...down, pointerId: 8, buttons: 0 }, container, commit); + expect( + gesture.end({ ...down, pointerId: 8, type: "pointercancel" }, container, commit), + ).toBeNull(); + expect(target.releasePointerCapture).not.toHaveBeenCalled(); + gesture.move({ ...down, clientX: 400 }, container, commit); + expect(commit).toHaveBeenCalledTimes(1); + }); + + it.each(["mouse", "touch", "pen"])("supports %s drag and no-motion release", (pointerType) => { + const { gesture, down, commit } = setup(); + const event = { ...down, pointerType }; + gesture.begin(event, null); + gesture.move({ ...event, clientX: event.clientX + 2 }, container, commit); + expect(gesture.end({ ...event, type: "pointerup" }, container, commit)).toBe( + pointerType === "touch", + ); + expect(commit).not.toHaveBeenCalled(); + gesture.begin(event, null); + gesture.move({ ...event, clientY: event.clientY + 20 }, container, commit); + expect(gesture.end({ ...event, type: "pointerup" }, container, commit)).toBe(false); + expect(commit).toHaveBeenCalled(); + }); + + it("does not treat cancelled touch gestures or resize handles as taps", () => { + const { gesture, down, commit } = setup(); + const touch = { ...down, pointerType: "touch" }; + gesture.begin(touch, null); + expect(gesture.end({ ...touch, type: "pointercancel" }, container, commit)).toBe(false); + gesture.begin(touch, "east"); + expect(gesture.end({ ...touch, type: "pointerup" }, container, commit)).toBe(false); + }); + + it("abandons capture failure without creating an active gesture", () => { + const { gesture, down, commit, target } = setup(); + target.setPointerCapture.mockImplementationOnce(() => { + throw new Error("detached"); + }); + expect(gesture.begin(down, "east")).toBe(false); + gesture.move({ ...down, clientX: 500 }, container, commit); + gesture.finish(container, commit); + expect(commit).not.toHaveBeenCalled(); + expect(target.releasePointerCapture).not.toHaveBeenCalled(); + expect(gesture.begin(down, "east")).toBe(true); + }); + + it("rejects secondary buttons and invalid element geometry", () => { + const { gesture, down, style, readStyle } = setup(); + expect(gesture.begin({ ...down, button: 2 }, null)).toBe(false); + expect(readStyle).not.toHaveBeenCalled(); + style.left = "auto"; + expect(gesture.begin(down, null)).toBe(false); + }); +}); diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.ts new file mode 100644 index 0000000000000..aff1ebbd116a6 --- /dev/null +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.ts @@ -0,0 +1,146 @@ +import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; +import type { PreviewMiniPlayerSize } from "~/previewMiniPlayerStore"; +import { + clampPreviewMiniPlayerPosition, + type PreviewMiniPlayerFrame, + resizePreviewMiniPlayer, + resolvePreviewMiniPlayerFrame, +} from "./previewMiniPlayerLayout"; + +type Direction = BrowserViewportResizeDirection | null; +interface PointerInput { + readonly pointerId: number; + readonly clientX: number; + readonly clientY: number; +} +interface CaptureTarget { + setPointerCapture(pointerId: number): void; + hasPointerCapture(pointerId: number): boolean; + releasePointerCapture(pointerId: number): void; + closest(selector: string): Element | null; +} +interface Gesture extends PointerInput { + readonly pointerType: string; + readonly target: CaptureTarget; + readonly frame: PreviewMiniPlayerFrame; + readonly direction: Direction; + currentFrame: PreviewMiniPlayerFrame; + moved: boolean; +} +type Commit = (frame: PreviewMiniPlayerFrame, direction: Direction) => void; + +export function createPreviewMiniPlayerGesture() { + let gesture: Gesture | null = null; + + const finish = (container: PreviewMiniPlayerSize | null, commit: Commit) => { + const active = gesture; + if (!active) return; + gesture = null; + if (container && active.moved) { + const { x, y } = resolvePreviewMiniPlayerFrame({ + width: active.currentFrame.width, + position: active.currentFrame, + source: active.frame, + container, + }); + // Only persist the end position; fitting a smaller viewport must not replace the chosen width. + commit({ ...active.currentFrame, x, y }, active.direction); + } + if (active.target.hasPointerCapture(active.pointerId)) { + active.target.releasePointerCapture(active.pointerId); + } + }; + + return { + begin( + event: PointerInput & { + readonly button: number; + readonly pointerType: string; + readonly currentTarget: CaptureTarget; + }, + direction: Direction, + ) { + if (event.button !== 0 || gesture) return false; + const preview = event.currentTarget.closest("[data-preview-mini-player]"); + if (!preview) return false; + // Computed CSS geometry preserves fractional coordinates in the player's local space. + const style = getComputedStyle(preview); + const frame = { + x: Number.parseFloat(style.left), + y: Number.parseFloat(style.top), + width: Number.parseFloat(style.width), + height: Number.parseFloat(style.height), + }; + if (!Object.values(frame).every(Number.isFinite) || frame.width <= 0 || frame.height <= 0) { + return false; + } + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + return false; + } + gesture = { + pointerId: event.pointerId, + pointerType: event.pointerType, + clientX: event.clientX, + clientY: event.clientY, + target: event.currentTarget, + frame, + currentFrame: frame, + direction, + moved: false, + }; + return true; + }, + move( + event: PointerInput & { readonly buttons: number }, + container: PreviewMiniPlayerSize | null, + commit: Commit, + ) { + const active = gesture; + if (!active || active.pointerId !== event.pointerId) return; + if ((event.buttons & 1) === 0) { + finish(container, commit); + return; + } + if (!container) return; + const delta = { x: event.clientX - active.clientX, y: event.clientY - active.clientY }; + if (!active.moved && Math.hypot(delta.x, delta.y) < 6) return; + active.moved = true; + active.currentFrame = + active.direction === null + ? { + ...active.frame, + ...clampPreviewMiniPlayerPosition( + { x: active.frame.x + delta.x, y: active.frame.y + delta.y }, + container, + active.frame, + ), + } + : resizePreviewMiniPlayer({ + start: active.frame, + direction: active.direction, + delta, + // Freeze the rendered aspect ratio for the entire gesture, even if the source changes. + source: active.frame, + container, + }); + commit(active.currentFrame, active.direction); + }, + end( + event: PointerInput & { readonly type: string }, + container: PreviewMiniPlayerSize | null, + commit: Commit, + ) { + if (!gesture || gesture.pointerId !== event.pointerId) return null; + const tapped = + event.type === "pointerup" && + gesture.direction === null && + gesture.pointerType === "touch" && + !gesture.moved; + finish(container, commit); + return tapped; + }, + finish, + }; +} From f32aea0156833db221d4f18035a799e686b5022b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 17:05:43 +0000 Subject: [PATCH 3/8] fix(web): discard floating preview gestures after capture loss --- .../preview/previewMiniPlayerGesture.test.ts | 17 +++++++++++++++++ .../preview/previewMiniPlayerGesture.ts | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts index 2efa2c91100ca..16bca915c28f2 100644 --- a/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts @@ -197,6 +197,23 @@ describe("floating preview gestures", () => { expect(commit).toHaveBeenCalledTimes(1); }); + it("discards a gesture whose handle lost capture before a new pointerdown", () => { + const { gesture, down, commit, target } = setup(); + expect(gesture.begin(down, "east")).toBe(true); + gesture.move({ ...down, clientX: 400 }, container, commit); + commit.mockClear(); + target.hasPointerCapture.mockReturnValueOnce(false); + const next = { ...down, pointerId: 8 }; + expect(gesture.begin(next, null)).toBe(true); + expect(commit).not.toHaveBeenCalled(); + expect(target.releasePointerCapture).not.toHaveBeenCalled(); + expect(target.setPointerCapture).toHaveBeenLastCalledWith(8); + gesture.move({ ...down, clientX: 500 }, container, commit); + expect(commit).not.toHaveBeenCalled(); + gesture.move({ ...next, clientX: next.clientX + 10 }, container, commit); + expect(commit).toHaveBeenCalledExactlyOnceWith({ ...baseline, x: baseline.x + 10 }, null); + }); + it.each(["mouse", "touch", "pen"])("supports %s drag and no-motion release", (pointerType) => { const { gesture, down, commit } = setup(); const event = { ...down, pointerType }; diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.ts index aff1ebbd116a6..f1fd859a9be7c 100644 --- a/apps/web/src/components/preview/previewMiniPlayerGesture.ts +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.ts @@ -7,6 +7,8 @@ import { resolvePreviewMiniPlayerFrame, } from "./previewMiniPlayerLayout"; +const HANDLE_TAP_SLOP_PX = 6; + type Direction = BrowserViewportResizeDirection | null; interface PointerInput { readonly pointerId: number; @@ -60,6 +62,7 @@ export function createPreviewMiniPlayerGesture() { }, direction: Direction, ) { + if (gesture && !gesture.target.hasPointerCapture(gesture.pointerId)) gesture = null; if (event.button !== 0 || gesture) return false; const preview = event.currentTarget.closest("[data-preview-mini-player]"); if (!preview) return false; @@ -105,7 +108,7 @@ export function createPreviewMiniPlayerGesture() { } if (!container) return; const delta = { x: event.clientX - active.clientX, y: event.clientY - active.clientY }; - if (!active.moved && Math.hypot(delta.x, delta.y) < 6) return; + if (!active.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) return; active.moved = true; active.currentFrame = active.direction === null From db94329f8965c9893796d82bb78a92c93764ef5f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 17:21:34 +0000 Subject: [PATCH 4/8] chore(web): drop unused preview layout export --- apps/web/src/components/preview/previewMiniPlayerLayout.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.ts index ebba89612d2f4..f6d6b8768887c 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.ts @@ -75,7 +75,7 @@ export interface PreviewMiniPlayerObstacles { readonly detailsCard: (HorizontalSpan & { readonly bottom: number }) | null; } -export const NO_PREVIEW_MINI_PLAYER_OBSTACLES: PreviewMiniPlayerObstacles = { +const NO_PREVIEW_MINI_PLAYER_OBSTACLES: PreviewMiniPlayerObstacles = { composer: null, detailsCard: null, }; From e353f469c7b5f918b72e2df9b53ef16fb01ff219 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 05:51:02 +0000 Subject: [PATCH 5/8] refactor(web): trim floating preview gesture tests and dead source size prop Resize now reads the rendered frame, so MiniPlayerShell no longer needs the source size. Tests keep the ratio freeze, every gesture termination path, width preservation, stale-capture recovery and touch tap detection. AI-assisted; verified with the tests and tools listed above. --- .../preview/ThreadPreviewMiniPlayer.tsx | 13 +- .../preview/previewMiniPlayerGesture.test.ts | 206 +++--------------- 2 files changed, 39 insertions(+), 180 deletions(-) diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index c222758c2f3cb..94f41feca35a5 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -201,8 +201,10 @@ export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer }: Props) { function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly tabId: string }) { const httpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); - const { serverEpoch, snapshot, desktopOverlay, serverTab, sourceSize } = - useBrowserMiniPlayerSource(threadRef, tabId); + const { serverEpoch, snapshot, desktopOverlay, serverTab } = useBrowserMiniPlayerSource( + threadRef, + tabId, + ); const runtimeTabId = previewRuntimeTabId(threadRef, serverEpoch, tabId); const recordingTabIds = useActiveBrowserRecordingTabIds(); const recording = @@ -260,7 +262,6 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly }) { const { state: deviceState } = useDeviceState(threadRef.environmentId); const viewId = miniPlayerDeviceViewId(threadRef, source); - const screen = useAtomValue(deviceStreamScreenAtom(viewId)); - const sourceSize = resolveDeviceMiniPlayerSourceSize(source.platform, screen); const device = deviceState.devices.find( (entry) => entry.hostId === source.hostId && entry.id === source.deviceId, ); @@ -374,7 +373,6 @@ function DeviceMiniPlayer({ void; readonly pillActions?: ReactNode; @@ -464,6 +460,7 @@ function MiniPlayerShell({ gesture.finish(container, commitGesture); }; const handleBlur = useEffectEvent(finishGesture); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window blur listener ends a gesture whose pointerup lands outside the window useEffect(() => { window.addEventListener("blur", handleBlur); return () => window.removeEventListener("blur", handleBlur); diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts index 16bca915c28f2..8f46112489cb7 100644 --- a/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts @@ -1,23 +1,21 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { createPreviewMiniPlayerGesture } from "./previewMiniPlayerGesture"; -import { resizePreviewMiniPlayer, resolvePreviewMiniPlayerFrame } from "./previewMiniPlayerLayout"; +import { resolvePreviewMiniPlayerFrame } from "./previewMiniPlayerLayout"; const container = { width: 1000, height: 800 }; const baseline = { x: 100.25, y: 80.75, width: 355.5, height: 222.25 }; function setup(frame = baseline) { - const preview = {}; const style = { left: `${frame.x}px`, top: `${frame.y}px`, width: `${frame.width}px`, height: `${frame.height}px`, }; - const readStyle = vi.fn(() => style); - vi.stubGlobal("getComputedStyle", readStyle); + vi.stubGlobal("getComputedStyle", () => style); let captured = false; const target = { - closest: vi.fn(() => preview as Element), + closest: () => ({}) as Element, setPointerCapture: vi.fn(() => { captured = true; }), @@ -35,90 +33,32 @@ function setup(frame = baseline) { clientY: 200.25, currentTarget: target, }; - return { - gesture: createPreviewMiniPlayerGesture(), - target, - down, - style, - readStyle, - preview, - commit: vi.fn(), - }; + return { gesture: createPreviewMiniPlayerGesture(), target, down, style, commit: vi.fn() }; } afterEach(() => vi.unstubAllGlobals()); describe("floating preview gestures", () => { - it("leaves idle hover and unmatched end events inert", () => { - const { gesture, down, commit, target, readStyle } = setup(); - gesture.move(down, container, commit); - expect(gesture.end({ ...down, type: "pointerup" }, container, commit)).toBeNull(); - gesture.finish(container, commit); - expect(commit).not.toHaveBeenCalled(); - expect(readStyle).not.toHaveBeenCalled(); - expect(target.releasePointerCapture).not.toHaveBeenCalled(); - }); - - it("starts from its own fractional local CSS geometry and captures the handle", () => { - const { gesture, down, commit, target, readStyle, preview } = setup(); - expect(gesture.begin(down, null)).toBe(true); - expect(target.closest).toHaveBeenCalledWith("[data-preview-mini-player]"); - expect(readStyle).toHaveBeenCalledWith(preview); - expect(target.setPointerCapture).toHaveBeenCalledWith(7); + it("resizes from the rendered frame's ratio, not a source that disagrees or changes", () => { + const frame = { x: 100.25, y: 80.75, width: 355, height: 222 }; + const { gesture, down, commit, style } = setup(frame); + gesture.begin(down, "northwest"); + style.width = "460px"; + style.height = "288px"; gesture.move( - { ...down, clientX: down.clientX + 10.5, clientY: down.clientY + 8.25 }, + { ...down, clientX: down.clientX - 6, clientY: down.clientY + 53 }, container, commit, ); - expect(commit).toHaveBeenLastCalledWith({ ...baseline, x: 110.75, y: 89 }, null); + expect(commit).toHaveBeenLastCalledWith( + { x: 120.25, y: 92.75, width: 335, height: 210 }, + "northwest", + ); }); - it.each(["northwest", "northeast", "southwest", "southeast"] as const)( - "%s keeps the rendered baseline ratio when the rounded frame disagrees with the source", - (direction) => { - const frame = { x: 100.25, y: 80.75, width: 355, height: 222 }; - const { gesture, down, commit, style, readStyle } = setup(frame); - const source = { width: 1600, height: 1000 }; - expect( - resolvePreviewMiniPlayerFrame({ width: 355, position: frame, source, container }), - ).toEqual(frame); - gesture.begin(down, direction); - // A stream/source update can change layout mid-gesture; it must not change the baseline. - style.width = "460px"; - style.height = "288px"; - const west = direction.includes("west"); - const north = direction.includes("north"); - gesture.move( - { - ...down, - clientX: down.clientX + (west ? -6 : 6), - clientY: down.clientY + (north ? 53 : -53), - }, - container, - commit, - ); - const next = commit.mock.lastCall?.[0]; - expect(next).toEqual({ - x: west ? 120.25 : frame.x, - y: north ? 92.75 : frame.y, - width: 335, - height: 210, - }); - const usingLiveSource = resizePreviewMiniPlayer({ - start: frame, - direction, - delta: { x: west ? -6 : 6, y: north ? 53 : -53 }, - source, - container, - }); - expect(next?.width).not.toBe(usingLiveSource.width); - expect(readStyle).toHaveBeenCalledTimes(1); - }, - ); - - describe.each([null, "southeast"] as const)("ending a %s gesture", (direction) => { + describe.each([null, "southeast"] as const)("a %s gesture", (direction) => { it.each(["pointerup", "pointercancel", "lostpointercapture", "blur", "buttons0"])( - "ends on %s, clamps position, and ignores later movement", + "ends on %s with a clamped position and ignores later movement", (termination) => { const { gesture, down, commit, target } = setup(); gesture.begin(down, direction); @@ -134,128 +74,50 @@ describe("floating preview gestures", () => { source: baseline, container: smaller, }); - expect(commit).toHaveBeenLastCalledWith( - { - ...chosen, - x: fitted.x, - y: fitted.y, - }, - direction, - ); + expect(commit).toHaveBeenLastCalledWith({ ...chosen, x: fitted.x, y: fitted.y }, direction); expect(target.releasePointerCapture).toHaveBeenCalledWith(7); const calls = commit.mock.calls.length; gesture.move({ ...down, clientX: 900 }, container, commit); - gesture.finish(container, commit); expect(commit).toHaveBeenCalledTimes(calls); }, ); }); - it("preserves the chosen resize width when the viewport shrinks before release", () => { + it("keeps the chosen resize width when the viewport shrinks before release", () => { const { gesture, down, commit } = setup(); gesture.begin(down, "east"); gesture.move({ ...down, clientX: down.clientX + 100 }, container, commit); - const chosen = commit.mock.lastCall?.[0]; - const smaller = { width: 300, height: 300 }; - gesture.end({ ...down, type: "pointerup" }, smaller, commit); - const persisted = commit.mock.lastCall?.[0]; - expect(persisted?.width).toBe(chosen?.width); - expect(commit.mock.lastCall?.[1]).toBe("east"); - expect(persisted?.x).toBe(12); - const displayed = resolvePreviewMiniPlayerFrame({ - width: persisted!.width, - position: persisted!, - source: baseline, - container: smaller, - }); - expect(displayed.x).toBeGreaterThanOrEqual(12); - expect(displayed.y).toBeGreaterThanOrEqual(12); - expect(displayed.x + displayed.width).toBeLessThanOrEqual(smaller.width - 12); - expect(displayed.y + displayed.height).toBeLessThanOrEqual(smaller.height - 12); - expect(displayed.width).toBeLessThan(persisted!.width); - - expect( - resolvePreviewMiniPlayerFrame({ - width: persisted!.width, - position: persisted!, - source: baseline, - container, - }).width, - ).toBe(chosen?.width); - }); - - it("ignores other pointer IDs and a second pointerdown", () => { - const { gesture, down, commit, target } = setup(); - gesture.begin(down, "east"); - expect(gesture.begin({ ...down, pointerId: 8 }, null)).toBe(false); - gesture.move({ ...down, pointerId: 8, buttons: 0 }, container, commit); - expect( - gesture.end({ ...down, pointerId: 8, type: "pointercancel" }, container, commit), - ).toBeNull(); - expect(target.releasePointerCapture).not.toHaveBeenCalled(); - gesture.move({ ...down, clientX: 400 }, container, commit); - expect(commit).toHaveBeenCalledTimes(1); + const chosen = commit.mock.lastCall![0]; + gesture.end({ ...down, type: "pointerup" }, { width: 300, height: 300 }, commit); + expect(commit.mock.lastCall![0].width).toBe(chosen.width); }); it("discards a gesture whose handle lost capture before a new pointerdown", () => { const { gesture, down, commit, target } = setup(); - expect(gesture.begin(down, "east")).toBe(true); + gesture.begin(down, "east"); gesture.move({ ...down, clientX: 400 }, container, commit); commit.mockClear(); target.hasPointerCapture.mockReturnValueOnce(false); const next = { ...down, pointerId: 8 }; expect(gesture.begin(next, null)).toBe(true); - expect(commit).not.toHaveBeenCalled(); - expect(target.releasePointerCapture).not.toHaveBeenCalled(); - expect(target.setPointerCapture).toHaveBeenLastCalledWith(8); gesture.move({ ...down, clientX: 500 }, container, commit); expect(commit).not.toHaveBeenCalled(); gesture.move({ ...next, clientX: next.clientX + 10 }, container, commit); expect(commit).toHaveBeenCalledExactlyOnceWith({ ...baseline, x: baseline.x + 10 }, null); }); - it.each(["mouse", "touch", "pen"])("supports %s drag and no-motion release", (pointerType) => { - const { gesture, down, commit } = setup(); - const event = { ...down, pointerType }; - gesture.begin(event, null); - gesture.move({ ...event, clientX: event.clientX + 2 }, container, commit); - expect(gesture.end({ ...event, type: "pointerup" }, container, commit)).toBe( - pointerType === "touch", - ); - expect(commit).not.toHaveBeenCalled(); - gesture.begin(event, null); - gesture.move({ ...event, clientY: event.clientY + 20 }, container, commit); - expect(gesture.end({ ...event, type: "pointerup" }, container, commit)).toBe(false); - expect(commit).toHaveBeenCalled(); - }); - - it("does not treat cancelled touch gestures or resize handles as taps", () => { + it("reports a tap only for an unmoved, released touch on the move handle", () => { const { gesture, down, commit } = setup(); const touch = { ...down, pointerType: "touch" }; - gesture.begin(touch, null); - expect(gesture.end({ ...touch, type: "pointercancel" }, container, commit)).toBe(false); - gesture.begin(touch, "east"); - expect(gesture.end({ ...touch, type: "pointerup" }, container, commit)).toBe(false); - }); - - it("abandons capture failure without creating an active gesture", () => { - const { gesture, down, commit, target } = setup(); - target.setPointerCapture.mockImplementationOnce(() => { - throw new Error("detached"); - }); - expect(gesture.begin(down, "east")).toBe(false); - gesture.move({ ...down, clientX: 500 }, container, commit); - gesture.finish(container, commit); - expect(commit).not.toHaveBeenCalled(); - expect(target.releasePointerCapture).not.toHaveBeenCalled(); - expect(gesture.begin(down, "east")).toBe(true); - }); - - it("rejects secondary buttons and invalid element geometry", () => { - const { gesture, down, style, readStyle } = setup(); - expect(gesture.begin({ ...down, button: 2 }, null)).toBe(false); - expect(readStyle).not.toHaveBeenCalled(); - style.left = "auto"; - expect(gesture.begin(down, null)).toBe(false); + const tap = (event: typeof down, direction: "east" | null, type: string, dy = 2) => { + gesture.begin(event, direction); + gesture.move({ ...event, clientY: event.clientY + dy }, container, commit); + return gesture.end({ ...event, type }, container, commit); + }; + expect(tap(touch, null, "pointerup")).toBe(true); + expect(tap(down, null, "pointerup")).toBe(false); + expect(tap(touch, null, "pointerup", 20)).toBe(false); + expect(tap(touch, null, "pointercancel")).toBe(false); + expect(tap(touch, "east", "pointerup")).toBe(false); }); }); From 586c856ea1340a92fe4e0f1c10f0ca575ae737ae Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 06:00:10 +0000 Subject: [PATCH 6/8] fix(web): floating preview resize keeps its anchor when the source changes Resizing froze the start frame's ratio while rendering took height from the live source, so a source change mid-gesture moved the opposite edge. Resize and the release clamp now use the live source, matching rendering. AI-assisted; verified with the tests and tools listed above. --- .../preview/ThreadPreviewMiniPlayer.tsx | 19 +++--- .../preview/previewMiniPlayerGesture.test.ts | 62 +++++++++++-------- .../preview/previewMiniPlayerGesture.ts | 35 +++++------ 3 files changed, 61 insertions(+), 55 deletions(-) diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 94f41feca35a5..d79087129f0ff 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -201,10 +201,8 @@ export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer }: Props) { function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly tabId: string }) { const httpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); - const { serverEpoch, snapshot, desktopOverlay, serverTab } = useBrowserMiniPlayerSource( - threadRef, - tabId, - ); + const { serverEpoch, snapshot, desktopOverlay, serverTab, sourceSize } = + useBrowserMiniPlayerSource(threadRef, tabId); const runtimeTabId = previewRuntimeTabId(threadRef, serverEpoch, tabId); const recordingTabIds = useActiveBrowserRecordingTabIds(); const recording = @@ -262,6 +260,7 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly }) { const { state: deviceState } = useDeviceState(threadRef.environmentId); const viewId = miniPlayerDeviceViewId(threadRef, source); + const screen = useAtomValue(deviceStreamScreenAtom(viewId)); + const sourceSize = resolveDeviceMiniPlayerSourceSize(source.platform, screen); const device = deviceState.devices.find( (entry) => entry.hostId === source.hostId && entry.id === source.deviceId, ); @@ -373,6 +374,7 @@ function DeviceMiniPlayer({ void; readonly pillActions?: ReactNode; @@ -436,6 +440,7 @@ function MiniPlayerShell({ return () => document.removeEventListener("pointerdown", dismiss, true); }, [pillOpen]); const container = canvas?.container ?? null; + const space = container ? { container, source: sourceSize } : null; const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source); const frame = canvas?.previewKey === sourceKey ? canvas.layout.frame : null; @@ -457,7 +462,7 @@ function MiniPlayerShell({ }; const finishGesture = () => { handleTappedRef.current = false; - gesture.finish(container, commitGesture); + gesture.finish(space, commitGesture); }; const handleBlur = useEffectEvent(finishGesture); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window blur listener ends a gesture whose pointerup lands outside the window @@ -478,10 +483,10 @@ function MiniPlayerShell({ } }; const handlePointerMove = (event: ReactPointerEvent) => { - gesture.move(event, container, commitGesture); + gesture.move(event, space, commitGesture); }; const endGesture = (event: ReactPointerEvent) => { - const tapped = gesture.end(event, container, commitGesture); + const tapped = gesture.end(event, space, commitGesture); if (tapped !== null) handleTappedRef.current = tapped; }; diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts index 8f46112489cb7..37334dcf77cf6 100644 --- a/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts @@ -2,7 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { createPreviewMiniPlayerGesture } from "./previewMiniPlayerGesture"; import { resolvePreviewMiniPlayerFrame } from "./previewMiniPlayerLayout"; -const container = { width: 1000, height: 800 }; +const source = { width: 1600, height: 1000 }; +const space = { container: { width: 1000, height: 800 }, source }; const baseline = { x: 100.25, y: 80.75, width: 355.5, height: 222.25 }; function setup(frame = baseline) { @@ -39,21 +40,25 @@ function setup(frame = baseline) { afterEach(() => vi.unstubAllGlobals()); describe("floating preview gestures", () => { - it("resizes from the rendered frame's ratio, not a source that disagrees or changes", () => { - const frame = { x: 100.25, y: 80.75, width: 355, height: 222 }; - const { gesture, down, commit, style } = setup(frame); + it("keeps the opposite edge anchored when the source ratio changes mid-resize", () => { + const frame = { x: 1000, y: 300, width: 320, height: 200 }; + const { gesture, down, commit } = setup(frame); + const space = { + container: { width: 1440, height: 900 }, + source: { width: 1600, height: 1000 }, + }; gesture.begin(down, "northwest"); - style.width = "460px"; - style.height = "288px"; - gesture.move( - { ...down, clientX: down.clientX - 6, clientY: down.clientY + 53 }, - container, - commit, - ); - expect(commit).toHaveBeenLastCalledWith( - { x: 120.25, y: 92.75, width: 335, height: 210 }, - "northwest", - ); + const live = { ...space, source: { width: 1600, height: 1200 } }; + gesture.move({ ...down, clientX: down.clientX - 40, clientY: down.clientY - 25 }, live, commit); + gesture.end({ ...down, type: "pointerup" }, live, commit); + const [persisted] = commit.mock.lastCall!; + const rendered = resolvePreviewMiniPlayerFrame({ + width: persisted.width, + position: persisted, + ...live, + }); + expect(rendered.x + rendered.width).toBeCloseTo(frame.x + frame.width); + expect(rendered.y + rendered.height).toBeCloseTo(frame.y + frame.height); }); describe.each([null, "southeast"] as const)("a %s gesture", (direction) => { @@ -62,22 +67,21 @@ describe("floating preview gestures", () => { (termination) => { const { gesture, down, commit, target } = setup(); gesture.begin(down, direction); - gesture.move({ ...down, clientX: 700, clientY: 500 }, container, commit); + gesture.move({ ...down, clientX: 700, clientY: 500 }, space, commit); const chosen = commit.mock.lastCall![0]; - const smaller = { width: 500, height: 400 }; + const smaller = { container: { width: 500, height: 400 }, source }; if (termination === "blur") gesture.finish(smaller, commit); else if (termination === "buttons0") gesture.move({ ...down, buttons: 0 }, smaller, commit); else gesture.end({ ...down, type: termination }, smaller, commit); const fitted = resolvePreviewMiniPlayerFrame({ width: chosen.width, position: chosen, - source: baseline, - container: smaller, + ...smaller, }); expect(commit).toHaveBeenLastCalledWith({ ...chosen, x: fitted.x, y: fitted.y }, direction); expect(target.releasePointerCapture).toHaveBeenCalledWith(7); const calls = commit.mock.calls.length; - gesture.move({ ...down, clientX: 900 }, container, commit); + gesture.move({ ...down, clientX: 900 }, space, commit); expect(commit).toHaveBeenCalledTimes(calls); }, ); @@ -86,23 +90,27 @@ describe("floating preview gestures", () => { it("keeps the chosen resize width when the viewport shrinks before release", () => { const { gesture, down, commit } = setup(); gesture.begin(down, "east"); - gesture.move({ ...down, clientX: down.clientX + 100 }, container, commit); + gesture.move({ ...down, clientX: down.clientX + 100 }, space, commit); const chosen = commit.mock.lastCall![0]; - gesture.end({ ...down, type: "pointerup" }, { width: 300, height: 300 }, commit); + gesture.end( + { ...down, type: "pointerup" }, + { container: { width: 300, height: 300 }, source }, + commit, + ); expect(commit.mock.lastCall![0].width).toBe(chosen.width); }); it("discards a gesture whose handle lost capture before a new pointerdown", () => { const { gesture, down, commit, target } = setup(); gesture.begin(down, "east"); - gesture.move({ ...down, clientX: 400 }, container, commit); + gesture.move({ ...down, clientX: 400 }, space, commit); commit.mockClear(); target.hasPointerCapture.mockReturnValueOnce(false); const next = { ...down, pointerId: 8 }; expect(gesture.begin(next, null)).toBe(true); - gesture.move({ ...down, clientX: 500 }, container, commit); + gesture.move({ ...down, clientX: 500 }, space, commit); expect(commit).not.toHaveBeenCalled(); - gesture.move({ ...next, clientX: next.clientX + 10 }, container, commit); + gesture.move({ ...next, clientX: next.clientX + 10 }, space, commit); expect(commit).toHaveBeenCalledExactlyOnceWith({ ...baseline, x: baseline.x + 10 }, null); }); @@ -111,8 +119,8 @@ describe("floating preview gestures", () => { const touch = { ...down, pointerType: "touch" }; const tap = (event: typeof down, direction: "east" | null, type: string, dy = 2) => { gesture.begin(event, direction); - gesture.move({ ...event, clientY: event.clientY + dy }, container, commit); - return gesture.end({ ...event, type }, container, commit); + gesture.move({ ...event, clientY: event.clientY + dy }, space, commit); + return gesture.end({ ...event, type }, space, commit); }; expect(tap(touch, null, "pointerup")).toBe(true); expect(tap(down, null, "pointerup")).toBe(false); diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.ts index f1fd859a9be7c..a47201e68046d 100644 --- a/apps/web/src/components/preview/previewMiniPlayerGesture.ts +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.ts @@ -30,20 +30,23 @@ interface Gesture extends PointerInput { moved: boolean; } type Commit = (frame: PreviewMiniPlayerFrame, direction: Direction) => void; +interface Space { + readonly container: PreviewMiniPlayerSize; + readonly source: PreviewMiniPlayerSize; +} export function createPreviewMiniPlayerGesture() { let gesture: Gesture | null = null; - const finish = (container: PreviewMiniPlayerSize | null, commit: Commit) => { + const finish = (space: Space | null, commit: Commit) => { const active = gesture; if (!active) return; gesture = null; - if (container && active.moved) { + if (space && active.moved) { const { x, y } = resolvePreviewMiniPlayerFrame({ width: active.currentFrame.width, position: active.currentFrame, - source: active.frame, - container, + ...space, }); // Only persist the end position; fitting a smaller viewport must not replace the chosen width. commit({ ...active.currentFrame, x, y }, active.direction); @@ -95,18 +98,14 @@ export function createPreviewMiniPlayerGesture() { }; return true; }, - move( - event: PointerInput & { readonly buttons: number }, - container: PreviewMiniPlayerSize | null, - commit: Commit, - ) { + move(event: PointerInput & { readonly buttons: number }, space: Space | null, commit: Commit) { const active = gesture; if (!active || active.pointerId !== event.pointerId) return; if ((event.buttons & 1) === 0) { - finish(container, commit); + finish(space, commit); return; } - if (!container) return; + if (!space) return; const delta = { x: event.clientX - active.clientX, y: event.clientY - active.clientY }; if (!active.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) return; active.moved = true; @@ -116,7 +115,7 @@ export function createPreviewMiniPlayerGesture() { ...active.frame, ...clampPreviewMiniPlayerPosition( { x: active.frame.x + delta.x, y: active.frame.y + delta.y }, - container, + space.container, active.frame, ), } @@ -124,24 +123,18 @@ export function createPreviewMiniPlayerGesture() { start: active.frame, direction: active.direction, delta, - // Freeze the rendered aspect ratio for the entire gesture, even if the source changes. - source: active.frame, - container, + ...space, }); commit(active.currentFrame, active.direction); }, - end( - event: PointerInput & { readonly type: string }, - container: PreviewMiniPlayerSize | null, - commit: Commit, - ) { + end(event: PointerInput & { readonly type: string }, space: Space | null, commit: Commit) { if (!gesture || gesture.pointerId !== event.pointerId) return null; const tapped = event.type === "pointerup" && gesture.direction === null && gesture.pointerType === "touch" && !gesture.moved; - finish(container, commit); + finish(space, commit); return tapped; }, finish, From 42570af48e0752d8222f7778c84140c7e69354f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 08:08:54 +0000 Subject: [PATCH 7/8] fix(web): floating preview resize continues from the re-rendered frame after a source change When the preview source size changed mid-gesture, the player re-rendered at the new aspect ratio but the gesture kept resizing from the frame and anchor captured at pointerdown, so the next move snapped the player by the height difference. The gesture now re-reads the rendered frame and resumes from the last committed pointer position when the source size changes. AI-assisted; verified with the tests and tools listed above. --- .../preview/ThreadPreviewMiniPlayer.tsx | 2 +- .../preview/previewMiniPlayerGesture.test.ts | 55 ++++++++++------ .../preview/previewMiniPlayerGesture.ts | 62 +++++++++++++------ 3 files changed, 82 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index d79087129f0ff..618ba5a598180 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -477,7 +477,7 @@ function MiniPlayerShell({ ) => { if (!frame) return; handleTappedRef.current = false; - if (gesture.begin(event, direction)) { + if (gesture.begin(event, direction, sourceSize)) { event.preventDefault(); event.stopPropagation(); } diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts index 37334dcf77cf6..58ba09b351c46 100644 --- a/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts @@ -40,25 +40,44 @@ function setup(frame = baseline) { afterEach(() => vi.unstubAllGlobals()); describe("floating preview gestures", () => { - it("keeps the opposite edge anchored when the source ratio changes mid-resize", () => { + it("continues a resize from the re-rendered frame when the source ratio changes mid-gesture", () => { const frame = { x: 1000, y: 300, width: 320, height: 200 }; - const { gesture, down, commit } = setup(frame); - const space = { - container: { width: 1440, height: 900 }, - source: { width: 1600, height: 1000 }, - }; - gesture.begin(down, "northwest"); - const live = { ...space, source: { width: 1600, height: 1200 } }; - gesture.move({ ...down, clientX: down.clientX - 40, clientY: down.clientY - 25 }, live, commit); - gesture.end({ ...down, type: "pointerup" }, live, commit); - const [persisted] = commit.mock.lastCall!; + const { gesture, down, commit, style } = setup(frame); + const container = { width: 1440, height: 900 }; + gesture.begin(down, "northwest", { width: 1600, height: 1000 }); + const pointer = { ...down, clientX: down.clientX - 40, clientY: down.clientY - 25 }; + gesture.move(pointer, { container, source: { width: 1600, height: 1000 } }, commit); + const chosen = commit.mock.lastCall![0]; + const live = { container, source: { width: 1600, height: 1200 } }; const rendered = resolvePreviewMiniPlayerFrame({ + width: chosen.width, + position: chosen, + ...live, + }); + Object.assign(style, { + left: `${rendered.x}px`, + top: `${rendered.y}px`, + width: `${rendered.width}px`, + height: `${rendered.height}px`, + }); + gesture.move(pointer, live, commit); + expect(commit.mock.lastCall![0]).toEqual(rendered); + gesture.move( + { ...pointer, clientX: pointer.clientX - 30, clientY: pointer.clientY - 20 }, + live, + commit, + ); + gesture.end({ ...down, type: "pointerup" }, live, commit); + const persisted = commit.mock.lastCall![0]; + const final = resolvePreviewMiniPlayerFrame({ width: persisted.width, position: persisted, ...live, }); - expect(rendered.x + rendered.width).toBeCloseTo(frame.x + frame.width); - expect(rendered.y + rendered.height).toBeCloseTo(frame.y + frame.height); + expect(final.width).toBeGreaterThan(rendered.width); + expect(final.height / final.width).toBeCloseTo(1200 / 1600); + expect(final.x + final.width).toBeCloseTo(rendered.x + rendered.width); + expect(final.y + final.height).toBeCloseTo(rendered.y + rendered.height); }); describe.each([null, "southeast"] as const)("a %s gesture", (direction) => { @@ -66,7 +85,7 @@ describe("floating preview gestures", () => { "ends on %s with a clamped position and ignores later movement", (termination) => { const { gesture, down, commit, target } = setup(); - gesture.begin(down, direction); + gesture.begin(down, direction, source); gesture.move({ ...down, clientX: 700, clientY: 500 }, space, commit); const chosen = commit.mock.lastCall![0]; const smaller = { container: { width: 500, height: 400 }, source }; @@ -89,7 +108,7 @@ describe("floating preview gestures", () => { it("keeps the chosen resize width when the viewport shrinks before release", () => { const { gesture, down, commit } = setup(); - gesture.begin(down, "east"); + gesture.begin(down, "east", source); gesture.move({ ...down, clientX: down.clientX + 100 }, space, commit); const chosen = commit.mock.lastCall![0]; gesture.end( @@ -102,12 +121,12 @@ describe("floating preview gestures", () => { it("discards a gesture whose handle lost capture before a new pointerdown", () => { const { gesture, down, commit, target } = setup(); - gesture.begin(down, "east"); + gesture.begin(down, "east", source); gesture.move({ ...down, clientX: 400 }, space, commit); commit.mockClear(); target.hasPointerCapture.mockReturnValueOnce(false); const next = { ...down, pointerId: 8 }; - expect(gesture.begin(next, null)).toBe(true); + expect(gesture.begin(next, null, source)).toBe(true); gesture.move({ ...down, clientX: 500 }, space, commit); expect(commit).not.toHaveBeenCalled(); gesture.move({ ...next, clientX: next.clientX + 10 }, space, commit); @@ -118,7 +137,7 @@ describe("floating preview gestures", () => { const { gesture, down, commit } = setup(); const touch = { ...down, pointerType: "touch" }; const tap = (event: typeof down, direction: "east" | null, type: string, dy = 2) => { - gesture.begin(event, direction); + gesture.begin(event, direction, source); gesture.move({ ...event, clientY: event.clientY + dy }, space, commit); return gesture.end({ ...event, type }, space, commit); }; diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.ts index a47201e68046d..f4af414da707a 100644 --- a/apps/web/src/components/preview/previewMiniPlayerGesture.ts +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.ts @@ -21,11 +21,15 @@ interface CaptureTarget { releasePointerCapture(pointerId: number): void; closest(selector: string): Element | null; } -interface Gesture extends PointerInput { +interface Gesture { + readonly pointerId: number; readonly pointerType: string; readonly target: CaptureTarget; - readonly frame: PreviewMiniPlayerFrame; readonly direction: Direction; + origin: { x: number; y: number }; + frame: PreviewMiniPlayerFrame; + source: PreviewMiniPlayerSize; + last: { x: number; y: number }; currentFrame: PreviewMiniPlayerFrame; moved: boolean; } @@ -35,6 +39,23 @@ interface Space { readonly source: PreviewMiniPlayerSize; } +// Computed CSS geometry preserves fractional coordinates in the player's local space. +function readRenderedFrame(target: CaptureTarget): PreviewMiniPlayerFrame | null { + const preview = target.closest("[data-preview-mini-player]"); + if (!preview) return null; + const style = getComputedStyle(preview); + const frame = { + x: Number.parseFloat(style.left), + y: Number.parseFloat(style.top), + width: Number.parseFloat(style.width), + height: Number.parseFloat(style.height), + }; + if (!Object.values(frame).every(Number.isFinite) || frame.width <= 0 || frame.height <= 0) { + return null; + } + return frame; +} + export function createPreviewMiniPlayerGesture() { let gesture: Gesture | null = null; @@ -64,22 +85,12 @@ export function createPreviewMiniPlayerGesture() { readonly currentTarget: CaptureTarget; }, direction: Direction, + source: PreviewMiniPlayerSize, ) { if (gesture && !gesture.target.hasPointerCapture(gesture.pointerId)) gesture = null; if (event.button !== 0 || gesture) return false; - const preview = event.currentTarget.closest("[data-preview-mini-player]"); - if (!preview) return false; - // Computed CSS geometry preserves fractional coordinates in the player's local space. - const style = getComputedStyle(preview); - const frame = { - x: Number.parseFloat(style.left), - y: Number.parseFloat(style.top), - width: Number.parseFloat(style.width), - height: Number.parseFloat(style.height), - }; - if (!Object.values(frame).every(Number.isFinite) || frame.width <= 0 || frame.height <= 0) { - return false; - } + const frame = readRenderedFrame(event.currentTarget); + if (!frame) return false; try { event.currentTarget.setPointerCapture(event.pointerId); } catch { @@ -88,10 +99,11 @@ export function createPreviewMiniPlayerGesture() { gesture = { pointerId: event.pointerId, pointerType: event.pointerType, - clientX: event.clientX, - clientY: event.clientY, target: event.currentTarget, + origin: { x: event.clientX, y: event.clientY }, frame, + source, + last: { x: event.clientX, y: event.clientY }, currentFrame: frame, direction, moved: false, @@ -106,9 +118,23 @@ export function createPreviewMiniPlayerGesture() { return; } if (!space) return; - const delta = { x: event.clientX - active.clientX, y: event.clientY - active.clientY }; + if ( + active.source.width !== space.source.width || + active.source.height !== space.source.height + ) { + // The player re-rendered at the new source ratio; continue from what is on screen. + const rendered = readRenderedFrame(active.target); + if (rendered) { + active.frame = rendered; + active.currentFrame = rendered; + active.origin = active.last; + } + } + active.source = space.source; + const delta = { x: event.clientX - active.origin.x, y: event.clientY - active.origin.y }; if (!active.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) return; active.moved = true; + active.last = { x: event.clientX, y: event.clientY }; active.currentFrame = active.direction === null ? { From 330c3b085bc1775d03b5000334ac3c125e421e4c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 08:20:12 +0000 Subject: [PATCH 8/8] fix(web): floating preview resize keeps its anchored edges through a source change Rendering derives the player's height from the live source ratio, so a source change during a resize grew the player away from the edge the gesture holds fixed, and the previous change adopted that displaced edge as the new anchor. The gesture now refits its frames to the new ratio around the original anchored edges and commits that frame on the next move or release. AI-assisted; verified with the tests and tools listed above. --- .../preview/previewMiniPlayerGesture.test.ts | 62 ++++++---------- .../preview/previewMiniPlayerGesture.ts | 74 +++++++++---------- 2 files changed, 58 insertions(+), 78 deletions(-) diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts index 58ba09b351c46..2ef79b35d4b24 100644 --- a/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts @@ -40,45 +40,29 @@ function setup(frame = baseline) { afterEach(() => vi.unstubAllGlobals()); describe("floating preview gestures", () => { - it("continues a resize from the re-rendered frame when the source ratio changes mid-gesture", () => { - const frame = { x: 1000, y: 300, width: 320, height: 200 }; - const { gesture, down, commit, style } = setup(frame); - const container = { width: 1440, height: 900 }; - gesture.begin(down, "northwest", { width: 1600, height: 1000 }); - const pointer = { ...down, clientX: down.clientX - 40, clientY: down.clientY - 25 }; - gesture.move(pointer, { container, source: { width: 1600, height: 1000 } }, commit); - const chosen = commit.mock.lastCall![0]; - const live = { container, source: { width: 1600, height: 1200 } }; - const rendered = resolvePreviewMiniPlayerFrame({ - width: chosen.width, - position: chosen, - ...live, - }); - Object.assign(style, { - left: `${rendered.x}px`, - top: `${rendered.y}px`, - width: `${rendered.width}px`, - height: `${rendered.height}px`, - }); - gesture.move(pointer, live, commit); - expect(commit.mock.lastCall![0]).toEqual(rendered); - gesture.move( - { ...pointer, clientX: pointer.clientX - 30, clientY: pointer.clientY - 20 }, - live, - commit, - ); - gesture.end({ ...down, type: "pointerup" }, live, commit); - const persisted = commit.mock.lastCall![0]; - const final = resolvePreviewMiniPlayerFrame({ - width: persisted.width, - position: persisted, - ...live, - }); - expect(final.width).toBeGreaterThan(rendered.width); - expect(final.height / final.width).toBeCloseTo(1200 / 1600); - expect(final.x + final.width).toBeCloseTo(rendered.x + rendered.width); - expect(final.y + final.height).toBeCloseTo(rendered.y + rendered.height); - }); + it.each([0, 40])( + "keeps the anchored edges where they were when the source ratio changes mid-resize (pre-move %d px)", + (premove) => { + const frame = { x: 996, y: 433, width: 268, height: 179 }; + const { gesture, down, commit } = setup(frame); + const container = { width: 1440, height: 900 }; + gesture.begin(down, "northwest", { width: 1200, height: 800 }); + const pointer = { ...down, clientX: down.clientX - premove, clientY: down.clientY - premove }; + gesture.move(pointer, { container, source: { width: 1200, height: 800 } }, commit); + const live = { container, source: { width: 1200, height: 1000 } }; + gesture.move(pointer, live, commit); + gesture.end({ ...down, type: "pointerup" }, live, commit); + const [persisted] = commit.mock.lastCall!; + const rendered = resolvePreviewMiniPlayerFrame({ + width: persisted.width, + position: persisted, + ...live, + }); + expect(rendered.height / rendered.width).toBeCloseTo(1000 / 1200, 2); + expect(rendered.x + rendered.width).toBeCloseTo(frame.x + frame.width, 0); + expect(rendered.y + rendered.height).toBeCloseTo(frame.y + frame.height, 0); + }, + ); describe.each([null, "southeast"] as const)("a %s gesture", (direction) => { it.each(["pointerup", "pointercancel", "lostpointercapture", "blur", "buttons0"])( diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.ts index f4af414da707a..30fa2b9b5a664 100644 --- a/apps/web/src/components/preview/previewMiniPlayerGesture.ts +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.ts @@ -21,16 +21,13 @@ interface CaptureTarget { releasePointerCapture(pointerId: number): void; closest(selector: string): Element | null; } -interface Gesture { - readonly pointerId: number; +interface Gesture extends PointerInput { readonly pointerType: string; readonly target: CaptureTarget; readonly direction: Direction; - origin: { x: number; y: number }; frame: PreviewMiniPlayerFrame; - source: PreviewMiniPlayerSize; - last: { x: number; y: number }; currentFrame: PreviewMiniPlayerFrame; + source: PreviewMiniPlayerSize; moved: boolean; } type Commit = (frame: PreviewMiniPlayerFrame, direction: Direction) => void; @@ -39,21 +36,21 @@ interface Space { readonly source: PreviewMiniPlayerSize; } -// Computed CSS geometry preserves fractional coordinates in the player's local space. -function readRenderedFrame(target: CaptureTarget): PreviewMiniPlayerFrame | null { - const preview = target.closest("[data-preview-mini-player]"); - if (!preview) return null; - const style = getComputedStyle(preview); - const frame = { - x: Number.parseFloat(style.left), - y: Number.parseFloat(style.top), - width: Number.parseFloat(style.width), - height: Number.parseFloat(style.height), +// Rendering derives height from the live source ratio, so a source change mid-resize would move the +// anchored edge. Refit the gesture's frames to the new ratio around the edges it holds fixed. +function followSource(active: Gesture, space: Space | null) { + if (!space) return; + const { source } = space; + if (source.width === active.source.width && source.height === active.source.height) return; + active.source = source; + const refit = (frame: PreviewMiniPlayerFrame) => { + const height = (frame.width * source.height) / source.width; + const north = active.direction?.includes("north") ?? false; + return { ...frame, height, y: north ? frame.y + frame.height - height : frame.y }; }; - if (!Object.values(frame).every(Number.isFinite) || frame.width <= 0 || frame.height <= 0) { - return null; - } - return frame; + active.frame = refit(active.frame); + active.currentFrame = refit(active.currentFrame); + if (active.direction !== null) active.moved = true; } export function createPreviewMiniPlayerGesture() { @@ -63,6 +60,7 @@ export function createPreviewMiniPlayerGesture() { const active = gesture; if (!active) return; gesture = null; + followSource(active, space); if (space && active.moved) { const { x, y } = resolvePreviewMiniPlayerFrame({ width: active.currentFrame.width, @@ -89,8 +87,19 @@ export function createPreviewMiniPlayerGesture() { ) { if (gesture && !gesture.target.hasPointerCapture(gesture.pointerId)) gesture = null; if (event.button !== 0 || gesture) return false; - const frame = readRenderedFrame(event.currentTarget); - if (!frame) return false; + const preview = event.currentTarget.closest("[data-preview-mini-player]"); + if (!preview) return false; + // Computed CSS geometry preserves fractional coordinates in the player's local space. + const style = getComputedStyle(preview); + const frame = { + x: Number.parseFloat(style.left), + y: Number.parseFloat(style.top), + width: Number.parseFloat(style.width), + height: Number.parseFloat(style.height), + }; + if (!Object.values(frame).every(Number.isFinite) || frame.width <= 0 || frame.height <= 0) { + return false; + } try { event.currentTarget.setPointerCapture(event.pointerId); } catch { @@ -99,12 +108,12 @@ export function createPreviewMiniPlayerGesture() { gesture = { pointerId: event.pointerId, pointerType: event.pointerType, + clientX: event.clientX, + clientY: event.clientY, target: event.currentTarget, - origin: { x: event.clientX, y: event.clientY }, frame, - source, - last: { x: event.clientX, y: event.clientY }, currentFrame: frame, + source, direction, moved: false, }; @@ -118,23 +127,10 @@ export function createPreviewMiniPlayerGesture() { return; } if (!space) return; - if ( - active.source.width !== space.source.width || - active.source.height !== space.source.height - ) { - // The player re-rendered at the new source ratio; continue from what is on screen. - const rendered = readRenderedFrame(active.target); - if (rendered) { - active.frame = rendered; - active.currentFrame = rendered; - active.origin = active.last; - } - } - active.source = space.source; - const delta = { x: event.clientX - active.origin.x, y: event.clientY - active.origin.y }; + followSource(active, space); + const delta = { x: event.clientX - active.clientX, y: event.clientY - active.clientY }; if (!active.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) return; active.moved = true; - active.last = { x: event.clientX, y: event.clientY }; active.currentFrame = active.direction === null ? {