# Files changed · PR \#23002 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23002/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(web\): floating preview resize ends cleanly without scale jumps](https://git.cafe/pingdotgg/t3code/pull/23002.md)

Version: 13

Head commit: db94329f8965c9893796d82bb78a92c93764ef5f

Base commit: 680152e27b2a961686389ca69a1557cbb2c55f08

Comparison base: 680152e27b2a961686389ca69a1557cbb2c55f08

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx

Status: modified; +33; −70

```diff
@@ -6,6 +6,7 @@
   type PointerEvent as ReactPointerEvent,
   type ReactNode,
   useEffect,
+  useEffectEvent,
   useLayoutEffect,
   useRef,
   useState,
@@ -51,28 +52,15 @@
 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,
   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;
@@ -342,7 +330,7 @@
   readonly children: (frame: PreviewMiniPlayerFrame) => ReactNode;
 }) {
   const canvas = useChatCanvas();
+  const [gesture] = useState(createPreviewMiniPlayerGesture);
-  const gestureRef = useRef<PointerGesture | null>(null);
   // 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);
@@ -358,7 +346,6 @@
     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;
@@ -390,68 +377,42 @@
     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(container, commitGesture);
+  };
+  const handleBlur = useEffectEvent(finishGesture);
+  useEffect(() => {
+    window.addEventListener("blur", handleBlur);
+    return () => window.removeEventListener("blur", handleBlur);
+  }, []);
+
   const beginGesture = (
     event: ReactPointerEvent<HTMLElement>,
     direction: BrowserViewportResizeDirection | null,
   ) => {
-    if (event.button !== 0 || !frame) return;
+    if (!frame) return;
+    handleTappedRef.current = false;
+    if (gesture.begin(event, direction)) {
+      event.preventDefault();
+      event.stopPropagation();
+    }
-    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();
   };
-
   const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
-    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,
+    gesture.move(event, container, commitGesture);
-        clampPreviewMiniPlayerPosition(
-          { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y },
-          container,
-          gesture.frame,
-          obstacles,
-        ),
-      );
-      return;
-    }
-    const next = resizePreviewMiniPlayer({
-      start: gesture.frame,
-      direction: gesture.direction,
-      delta,
-      source: sourceSize,
-      container,
-      obstacles,
-    });
-    store.resize(threadRef, sourceKey, next.width, { x: next.x, y: next.y });
   };
-
   const endGesture = (event: ReactPointerEvent<HTMLElement>) => {
+    const tapped = gesture.end(event, container, commitGesture);
+    if (tapped !== null) handleTappedRef.current = tapped;
-    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);
-    }
   };
 
   return (
@@ -478,6 +439,7 @@
             onPointerMove={handlePointerMove}
             onPointerUp={endGesture}
             onPointerCancel={endGesture}
+            onLostPointerCapture={endGesture}
             onClick={() => {
               if (!handleTappedRef.current) return;
               handleTappedRef.current = false;
@@ -554,6 +516,7 @@
               onPointerMove={handlePointerMove}
               onPointerUp={endGesture}
               onPointerCancel={endGesture}
+              onLostPointerCapture={endGesture}
             />
           ))}
         </section>
```

## apps/web/src/components/preview/previewMiniPlayerGesture.test.ts

Status: added; +261; −0

```diff
@@ -1,0 +1,261 @@
+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("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 };
+    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);
+  });
+});
```

## apps/web/src/components/preview/previewMiniPlayerGesture.ts

Status: added; +149; −0

```diff
@@ -1,0 +1,149 @@
+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 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 (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,
+        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) < 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 },
+                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,
+  };
+}
```

## apps/web/src/components/preview/previewMiniPlayerLayout.ts

Status: modified; +1; −1

```diff
@@ -75,7 +75,7 @@
   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,
 };
```
