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:
2026-10-28 11:46:14 +08:00
parent 4f0f34f460
commit e57e0f099f
12 changed files with 325 additions and 31 deletions
+21 -12
View File
@@ -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>