Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 35 additions & 66 deletions apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
useEffect,
useEffectEvent,
useRef,
useState,
} from "react";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -435,7 +424,7 @@ function MiniPlayerShell({
readonly children: (frame: PreviewMiniPlayerFrame) => ReactNode;
}) {
const canvas = useChatCanvas();
const gestureRef = useRef<PointerGesture | null>(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);
Expand All @@ -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;

Expand All @@ -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<HTMLElement>,
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<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,
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<HTMLElement>) => {
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 (
Expand All @@ -547,6 +514,7 @@ function MiniPlayerShell({
onPointerMove={handlePointerMove}
onPointerUp={endGesture}
onPointerCancel={endGesture}
onLostPointerCapture={endGesture}
onClick={() => {
if (!handleTappedRef.current) return;
handleTappedRef.current = false;
Expand Down Expand Up @@ -623,6 +591,7 @@ function MiniPlayerShell({
onPointerMove={handlePointerMove}
onPointerUp={endGesture}
onPointerCancel={endGesture}
onLostPointerCapture={endGesture}
/>
))}
</section>
Expand Down
134 changes: 134 additions & 0 deletions apps/web/src/components/preview/previewMiniPlayerGesture.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading