diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 50b5ae47c449b..618ba5a598180 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -6,6 +6,7 @@ import { type PointerEvent as ReactPointerEvent, type ReactNode, useEffect, + useEffectEvent, useRef, useState, } from "react"; @@ -58,27 +59,15 @@ import type { PreviewStreamViewport } from "@t3tools/client-runtime/preview/serv import { previewBridge } from "./previewBridge"; import { showPreviewPopup } from "./showPreviewPopup"; import { - clampPreviewMiniPlayerPosition, PREVIEW_MINI_PLAYER_CORNER_RADIUS, PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, type PreviewMiniPlayerFrame, - resizePreviewMiniPlayer, resolveDeviceMiniPlayerCornerRadius, resolveDeviceMiniPlayerSourceSize, resolvePreviewMiniPlayerSourceSize, } from "./previewMiniPlayerLayout"; -interface PointerGesture { - readonly pointerId: number; - readonly pointerType: string; - readonly pointerX: number; - readonly pointerY: number; - readonly frame: PreviewMiniPlayerFrame; - readonly direction: BrowserViewportResizeDirection | null; - moved: boolean; -} - -const HANDLE_TAP_SLOP_PX = 6; +import { createPreviewMiniPlayerGesture } from "./previewMiniPlayerGesture"; interface Props { readonly threadRef: ScopedThreadRef; @@ -435,7 +424,7 @@ function MiniPlayerShell({ readonly children: (frame: PreviewMiniPlayerFrame) => ReactNode; }) { const canvas = useChatCanvas(); - const gestureRef = useRef(null); + const [gesture] = useState(createPreviewMiniPlayerGesture); // Touch toggles the pill after the tap's click so that click cannot land on the newly opened pill. const [pillOpen, setPillOpen] = useState(false); const handleTappedRef = useRef(false); @@ -451,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; @@ -461,66 +451,43 @@ 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 = () => { + handleTappedRef.current = false; + 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 + useEffect(() => { + window.addEventListener("blur", handleBlur); + return () => window.removeEventListener("blur", handleBlur); + }, []); + const beginGesture = ( event: ReactPointerEvent, direction: BrowserViewportResizeDirection | null, ) => { - if (event.button !== 0 || !frame) return; - gestureRef.current = { - pointerId: event.pointerId, - pointerType: event.pointerType, - pointerX: event.clientX, - pointerY: event.clientY, - frame, - direction, - moved: false, - }; - event.currentTarget.setPointerCapture(event.pointerId); - event.preventDefault(); - event.stopPropagation(); + if (!frame) return; + handleTappedRef.current = false; + if (gesture.begin(event, direction, sourceSize)) { + event.preventDefault(); + event.stopPropagation(); + } }; - const handlePointerMove = (event: ReactPointerEvent) => { - const gesture = gestureRef.current; - if (!gesture || gesture.pointerId !== event.pointerId || !container) return; - const delta = { x: event.clientX - gesture.pointerX, y: event.clientY - gesture.pointerY }; - if (!gesture.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) return; - gesture.moved = true; - 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, - ), - ); - return; - } - const next = resizePreviewMiniPlayer({ - start: gesture.frame, - direction: gesture.direction, - delta, - source: sourceSize, - container, - }); - store.resize(threadRef, sourceKey, next.width, { x: next.x, y: next.y }); + gesture.move(event, space, commitGesture); }; - const endGesture = (event: ReactPointerEvent) => { - const gesture = gestureRef.current; - if (gesture?.pointerId !== event.pointerId) return; - gestureRef.current = null; - handleTappedRef.current = - event.type === "pointerup" && - gesture.direction === null && - gesture.pointerType === "touch" && - !gesture.moved; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } + const tapped = gesture.end(event, space, commitGesture); + if (tapped !== null) handleTappedRef.current = tapped; }; return ( @@ -547,6 +514,7 @@ function MiniPlayerShell({ onPointerMove={handlePointerMove} onPointerUp={endGesture} onPointerCancel={endGesture} + onLostPointerCapture={endGesture} onClick={() => { if (!handleTappedRef.current) return; handleTappedRef.current = false; @@ -623,6 +591,7 @@ function MiniPlayerShell({ onPointerMove={handlePointerMove} onPointerUp={endGesture} onPointerCancel={endGesture} + onLostPointerCapture={endGesture} /> ))} 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..2ef79b35d4b24 --- /dev/null +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { createPreviewMiniPlayerGesture } from "./previewMiniPlayerGesture"; +import { resolvePreviewMiniPlayerFrame } from "./previewMiniPlayerLayout"; + +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) { + const style = { + left: `${frame.x}px`, + top: `${frame.y}px`, + width: `${frame.width}px`, + height: `${frame.height}px`, + }; + vi.stubGlobal("getComputedStyle", () => style); + let captured = false; + const target = { + closest: () => ({}) 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, commit: vi.fn() }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("floating preview gestures", () => { + 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"])( + "ends on %s with a clamped position and ignores later movement", + (termination) => { + const { gesture, down, commit, target } = setup(); + 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 }; + 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, + ...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 }, space, commit); + expect(commit).toHaveBeenCalledTimes(calls); + }, + ); + }); + + it("keeps the chosen resize width when the viewport shrinks before release", () => { + const { gesture, down, commit } = setup(); + gesture.begin(down, "east", source); + gesture.move({ ...down, clientX: down.clientX + 100 }, space, commit); + const chosen = commit.mock.lastCall![0]; + 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", source); + gesture.move({ ...down, clientX: 400 }, space, commit); + commit.mockClear(); + target.hasPointerCapture.mockReturnValueOnce(false); + const next = { ...down, pointerId: 8 }; + 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); + expect(commit).toHaveBeenCalledExactlyOnceWith({ ...baseline, x: baseline.x + 10 }, null); + }); + + it("reports a tap only for an unmoved, released touch on the move handle", () => { + 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, source); + 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); + expect(tap(touch, null, "pointerup", 20)).toBe(false); + expect(tap(touch, null, "pointercancel")).toBe(false); + expect(tap(touch, "east", "pointerup")).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..30fa2b9b5a664 --- /dev/null +++ b/apps/web/src/components/preview/previewMiniPlayerGesture.ts @@ -0,0 +1,164 @@ +import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout"; +import type { PreviewMiniPlayerSize } from "~/previewMiniPlayerStore"; +import { + clampPreviewMiniPlayerPosition, + type PreviewMiniPlayerFrame, + resizePreviewMiniPlayer, + resolvePreviewMiniPlayerFrame, +} from "./previewMiniPlayerLayout"; + +const HANDLE_TAP_SLOP_PX = 6; + +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 direction: Direction; + frame: PreviewMiniPlayerFrame; + currentFrame: PreviewMiniPlayerFrame; + source: PreviewMiniPlayerSize; + moved: boolean; +} +type Commit = (frame: PreviewMiniPlayerFrame, direction: Direction) => void; +interface Space { + readonly container: PreviewMiniPlayerSize; + readonly source: PreviewMiniPlayerSize; +} + +// 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 }; + }; + active.frame = refit(active.frame); + active.currentFrame = refit(active.currentFrame); + if (active.direction !== null) active.moved = true; +} + +export function createPreviewMiniPlayerGesture() { + let gesture: Gesture | null = null; + + const finish = (space: Space | null, commit: Commit) => { + const active = gesture; + if (!active) return; + gesture = null; + followSource(active, space); + if (space && active.moved) { + const { x, y } = resolvePreviewMiniPlayerFrame({ + width: active.currentFrame.width, + position: active.currentFrame, + ...space, + }); + // 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, + 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; + } + 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, + source, + direction, + moved: false, + }; + return true; + }, + 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(space, commit); + return; + } + if (!space) return; + 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.currentFrame = + active.direction === null + ? { + ...active.frame, + ...clampPreviewMiniPlayerPosition( + { x: active.frame.x + delta.x, y: active.frame.y + delta.y }, + space.container, + active.frame, + ), + } + : resizePreviewMiniPlayer({ + start: active.frame, + direction: active.direction, + delta, + ...space, + }); + commit(active.currentFrame, active.direction); + }, + 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(space, commit); + return tapped; + }, + finish, + }; +}