fix: smooth persona hover transitions
Preserve the original reflection material while adding pointer inertia and a short exit hold. Retain the Rive pointer constraint through the fade and blend rapid re-entry to avoid abrupt changes. Cover timing, re-entry, cancellation, renderer disposal and native Rive frames. The fade regression fails with the previous resource and passes with the updated resource.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"enterMs": 150,
|
||||
"followMs": 80,
|
||||
"exitHoldMs": 100,
|
||||
"exitMs": 600,
|
||||
"blendMs": 100
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPersonaHover } from "./persona-hover";
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function setup() {
|
||||
const canvas = document.createElement("canvas");
|
||||
const moves: number[] = [];
|
||||
const enter = vi.fn();
|
||||
const exit = vi.fn();
|
||||
canvas.addEventListener("mousemove", (event) => moves.push(event.clientX));
|
||||
canvas.addEventListener("mouseover", enter);
|
||||
canvas.addEventListener("mouseout", exit);
|
||||
return { hover: createPersonaHover(canvas), moves, enter, exit };
|
||||
}
|
||||
|
||||
describe("Persona hover timing", () => {
|
||||
it("positions the original reflection before entry and follows movement with inertia", () => {
|
||||
const { hover, moves } = setup();
|
||||
hover.enter({ clientX: 16, clientY: 32 });
|
||||
expect(moves).toEqual([16]);
|
||||
hover.move({ clientX: 48, clientY: 32 });
|
||||
expect(moves).toEqual([16]);
|
||||
vi.advanceTimersByTime(80);
|
||||
expect(moves.at(-1)).toBeGreaterThan(32);
|
||||
expect(moves.at(-1)).toBeLessThan(48);
|
||||
vi.advanceTimersByTime(800);
|
||||
expect(moves.at(-1)).toBe(48);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
hover.dispose();
|
||||
});
|
||||
|
||||
it("holds the last in-bounds position for 100ms before asking Rive to fade", () => {
|
||||
const { hover, moves, exit } = setup();
|
||||
hover.enter({ clientX: 16, clientY: 32 });
|
||||
hover.move({ clientX: 48, clientY: 32 });
|
||||
hover.leave();
|
||||
vi.advanceTimersByTime(99);
|
||||
expect(exit).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(exit).toHaveBeenCalledOnce();
|
||||
const count = moves.length;
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(moves).toHaveLength(count);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels a pending exit on quick re-entry without replaying the entrance", () => {
|
||||
const { hover, enter, exit } = setup();
|
||||
hover.enter({ clientX: 16, clientY: 32 });
|
||||
hover.leave();
|
||||
vi.advanceTimersByTime(60);
|
||||
hover.enter({ clientX: 48, clientY: 32 });
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(enter).toHaveBeenCalledOnce();
|
||||
expect(exit).not.toHaveBeenCalled();
|
||||
hover.dispose();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("releases cancellation immediately and removes pending work on disposal", () => {
|
||||
const { hover, moves, exit } = setup();
|
||||
hover.enter({ clientX: 16, clientY: 32 });
|
||||
hover.leave();
|
||||
hover.cancel();
|
||||
expect(exit).toHaveBeenCalledOnce();
|
||||
hover.enter({ clientX: 48, clientY: 32 });
|
||||
hover.move({ clientX: 32, clientY: 32 });
|
||||
hover.leave();
|
||||
hover.dispose();
|
||||
const count = moves.length;
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(moves).toHaveLength(count);
|
||||
expect(exit).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("continues from the visible reflection when re-entering during its fade", () => {
|
||||
const { hover, moves } = setup();
|
||||
hover.enter({ clientX: 16, clientY: 32 });
|
||||
hover.leave();
|
||||
vi.advanceTimersByTime(200);
|
||||
hover.enter({ clientX: 48, clientY: 32 });
|
||||
expect(moves.at(-1)).toBe(16);
|
||||
vi.advanceTimersByTime(80);
|
||||
expect(moves.at(-1)).toBeGreaterThan(16);
|
||||
expect(moves.at(-1)).toBeLessThan(48);
|
||||
hover.leave();
|
||||
vi.advanceTimersByTime(800);
|
||||
hover.enter({ clientX: 32, clientY: 32 });
|
||||
expect(moves.at(-1)).toBe(32);
|
||||
hover.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import timing from "./persona-hover-timing.json";
|
||||
|
||||
type Point = { clientX: number; clientY: number };
|
||||
|
||||
export function createPersonaHover(canvas: HTMLCanvasElement) {
|
||||
let position: Point | undefined;
|
||||
let target: Point | undefined;
|
||||
let frame: number | undefined;
|
||||
let exitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let previousTime = 0;
|
||||
let active = false;
|
||||
let exitedAt = Number.NEGATIVE_INFINITY;
|
||||
|
||||
const dispatch = (type: "mouseover" | "mousemove" | "mouseout") => {
|
||||
if (position) canvas.dispatchEvent(new MouseEvent(type, position));
|
||||
};
|
||||
const stopFollowing = () => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame);
|
||||
frame = undefined;
|
||||
};
|
||||
const clearExit = () => {
|
||||
clearTimeout(exitTimer);
|
||||
exitTimer = undefined;
|
||||
};
|
||||
const follow = (time: number) => {
|
||||
frame = undefined;
|
||||
if (!position || !target || !active) return;
|
||||
const weight = 1 - Math.exp(-(time - previousTime) / timing.followMs);
|
||||
previousTime = time;
|
||||
position.clientX += (target.clientX - position.clientX) * weight;
|
||||
position.clientY += (target.clientY - position.clientY) * weight;
|
||||
const settled = Math.hypot(target.clientX - position.clientX, target.clientY - position.clientY) < 0.05;
|
||||
if (settled) position = { ...target };
|
||||
dispatch("mousemove");
|
||||
if (!settled) frame = requestAnimationFrame(follow);
|
||||
};
|
||||
const enter = (point: Point) => {
|
||||
clearExit();
|
||||
target = { clientX: point.clientX, clientY: point.clientY };
|
||||
if (!active) {
|
||||
active = true;
|
||||
if (!position || performance.now() - exitedAt >= timing.exitMs) position = { ...target };
|
||||
// Set the target before revealing the reflection so it never flashes at
|
||||
// the artboard's default center on entry or re-entry.
|
||||
dispatch("mousemove");
|
||||
dispatch("mouseover");
|
||||
}
|
||||
if (frame === undefined) {
|
||||
previousTime = performance.now();
|
||||
frame = requestAnimationFrame(follow);
|
||||
}
|
||||
};
|
||||
const move = (point: Point) => {
|
||||
if (!active) return enter(point);
|
||||
target = { clientX: point.clientX, clientY: point.clientY };
|
||||
if (frame === undefined) {
|
||||
previousTime = performance.now();
|
||||
frame = requestAnimationFrame(follow);
|
||||
}
|
||||
};
|
||||
const exit = () => {
|
||||
clearExit();
|
||||
stopFollowing();
|
||||
if (active) dispatch("mouseout");
|
||||
active = false;
|
||||
exitedAt = performance.now();
|
||||
};
|
||||
|
||||
return {
|
||||
enter,
|
||||
move,
|
||||
leave: () => {
|
||||
clearExit();
|
||||
// Do not follow the pointer's outside coordinates. Finish following the
|
||||
// last in-bounds target, then let Rive fade at that position.
|
||||
if (active) exitTimer = setTimeout(exit, timing.exitHoldMs);
|
||||
},
|
||||
cancel: exit,
|
||||
dispose: () => {
|
||||
clearExit();
|
||||
stopFollowing();
|
||||
active = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"path": "/agent/obsidian-water-v1.riv",
|
||||
"sha256": "ea556c57d3fd966710fc1c31af09dc706c72f6f2570a82a7e4201cde3c332a00",
|
||||
"byteLength": 9879
|
||||
"sha256": "367c4856b54a47bdae868b772edb4281885d0c33653079c8c2a998ecea27fb4d",
|
||||
"byteLength": 9929
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const riveMocks = vi.hoisted(() => {
|
||||
const bindViewModelInstance = vi.fn();
|
||||
@@ -41,6 +41,7 @@ beforeEach(() => {
|
||||
input.value = false;
|
||||
});
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("Persona", () => {
|
||||
it("loads the original bytes after deferred initialization without a View Model or color filter", async () => {
|
||||
@@ -72,6 +73,7 @@ describe("Persona", () => {
|
||||
it("forwards mouse coordinates and exit to Rive without turning touch into hover", async () => {
|
||||
const { getByLabelText } = render(<Persona />);
|
||||
await waitFor(() => expect(riveMocks.load).toHaveBeenCalled());
|
||||
vi.useFakeTimers();
|
||||
const wrapper = getByLabelText("Rive Persona").parentElement!;
|
||||
const move = vi.fn();
|
||||
const exit = vi.fn();
|
||||
@@ -85,10 +87,17 @@ describe("Persona", () => {
|
||||
pointer("pointermove", "mouse", 16);
|
||||
pointer("pointermove", "mouse", 48);
|
||||
expect(move).toHaveBeenNthCalledWith(1, expect.objectContaining({ clientX: 16, clientY: 48 }));
|
||||
expect(move).toHaveBeenNthCalledWith(2, expect.objectContaining({ clientX: 48, clientY: 48 }));
|
||||
expect(move).toHaveBeenCalledOnce();
|
||||
vi.advanceTimersByTime(80);
|
||||
const intermediate = (move.mock.lastCall![0] as MouseEvent).clientX;
|
||||
expect(intermediate).toBeGreaterThan(16);
|
||||
expect(intermediate).toBeLessThan(48);
|
||||
vi.advanceTimersByTime(800);
|
||||
expect(move).toHaveBeenLastCalledWith(expect.objectContaining({ clientX: 48, clientY: 48 }));
|
||||
const count = move.mock.calls.length;
|
||||
pointer("pointermove", "touch", 32);
|
||||
pointer("pointerdown", "touch", 32);
|
||||
expect(move).toHaveBeenCalledTimes(2);
|
||||
expect(move).toHaveBeenCalledTimes(count);
|
||||
pointer("pointercancel", "mouse", 48);
|
||||
expect(exit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { RiveParameters } from "@rive-app/react-webgl2";
|
||||
import { EventType, useRive, useStateMachineInput } from "@rive-app/react-webgl2";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { loadPersonaResource } from "./persona-resource";
|
||||
import { createPersonaHover } from "./persona-hover";
|
||||
|
||||
const STATE_MACHINE = "default";
|
||||
|
||||
@@ -69,6 +70,17 @@ export const Persona = memo(function Persona({
|
||||
const thinkingInput = useStateMachineInput(rive, STATE_MACHINE, "thinking");
|
||||
const speakingInput = useStateMachineInput(rive, STATE_MACHINE, "speaking");
|
||||
const asleepInput = useStateMachineInput(rive, STATE_MACHINE, "asleep");
|
||||
const hoverRef = useRef<ReturnType<typeof createPersonaHover> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvas) return;
|
||||
const hover = createPersonaHover(canvas);
|
||||
hoverRef.current = hover;
|
||||
return () => {
|
||||
hover.dispose();
|
||||
hoverRef.current = null;
|
||||
};
|
||||
}, [canvas]);
|
||||
|
||||
useEffect(() => {
|
||||
if (listeningInput) {
|
||||
@@ -85,24 +97,21 @@ export const Persona = memo(function Persona({
|
||||
}
|
||||
}, [state, listeningInput, thinkingInput, speakingInput, asleepInput]);
|
||||
|
||||
const forwardPointer = (event: ReactPointerEvent<HTMLSpanElement>, type: "mouseover" | "mousemove" | "mouseout") => {
|
||||
const forwardPointer = (event: ReactPointerEvent<HTMLSpanElement>, type: "enter" | "move" | "leave" | "cancel") => {
|
||||
if (!event.isPrimary || event.pointerType === "touch") return;
|
||||
// Let Rive transform CSS coordinates into artboard coordinates and update
|
||||
// its original pointer constraint. Only forward hover events, never presses
|
||||
// or touch-generated mouse events, so mobile taps retain their button action.
|
||||
canvas?.dispatchEvent(new MouseEvent(type, {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY
|
||||
}));
|
||||
// its original pointer constraint after smoothing CSS coordinates. Only
|
||||
// hover events enter this path, so mobile taps retain their button action.
|
||||
hoverRef.current?.[type](event);
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("relative block size-16 shrink-0", className)}
|
||||
onPointerEnter={(event) => forwardPointer(event, "mouseover")}
|
||||
onPointerMove={(event) => forwardPointer(event, "mousemove")}
|
||||
onPointerLeave={(event) => forwardPointer(event, "mouseout")}
|
||||
onPointerCancel={(event) => forwardPointer(event, "mouseout")}
|
||||
onPointerEnter={(event) => forwardPointer(event, "enter")}
|
||||
onPointerMove={(event) => forwardPointer(event, "move")}
|
||||
onPointerLeave={(event) => forwardPointer(event, "leave")}
|
||||
onPointerCancel={(event) => forwardPointer(event, "cancel")}
|
||||
>
|
||||
<RiveComponent className="pointer-events-none size-full" />
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user