Files
next-tjwater-frontend/src/shared/ai-elements/persona.tsx
T
jiang e57e0f099f 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.
2026-10-28 11:46:14 +08:00

120 lines
3.7 KiB
TypeScript

import type { RiveParameters } from "@rive-app/react-webgl2";
import { EventType, useRive, useStateMachineInput } from "@rive-app/react-webgl2";
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";
function useDeferredRiveInit() {
const [ready, setReady] = useState(false);
useEffect(() => {
const id = requestAnimationFrame(() => setReady(true));
return () => cancelAnimationFrame(id);
}, []);
return ready;
}
export type PersonaState = "idle" | "listening" | "thinking" | "speaking" | "asleep";
export type PersonaProps = {
className?: string;
onLoad?: RiveParameters["onLoad"];
onLoadError?: RiveParameters["onLoadError"];
state?: PersonaState;
};
export const Persona = memo(function Persona({
className,
onLoad,
onLoadError,
state = "idle"
}: PersonaProps) {
const ready = useDeferredRiveInit();
const [buffer, setBuffer] = useState<ArrayBuffer>();
useEffect(() => {
if (!ready) return;
let cancelled = false;
void loadPersonaResource().then(
(bytes) => {
if (!cancelled) setBuffer(bytes);
},
(error: unknown) => {
if (!cancelled) onLoadError?.({ type: EventType.LoadError, data: String(error) });
}
);
return () => {
cancelled = true;
};
}, [ready, onLoadError]);
const { rive, canvas, RiveComponent } = useRive(
ready && buffer
? {
autoplay: true,
onLoad,
onLoadError,
buffer,
stateMachines: STATE_MACHINE,
isTouchScrollEnabled: true
}
: null
);
const listeningInput = useStateMachineInput(rive, STATE_MACHINE, "listening");
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) {
listeningInput.value = state === "listening";
}
if (thinkingInput) {
thinkingInput.value = state === "thinking";
}
if (speakingInput) {
speakingInput.value = state === "speaking";
}
if (asleepInput) {
asleepInput.value = state === "asleep";
}
}, [state, listeningInput, thinkingInput, speakingInput, asleepInput]);
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 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, "enter")}
onPointerMove={(event) => forwardPointer(event, "move")}
onPointerLeave={(event) => forwardPointer(event, "leave")}
onPointerCancel={(event) => forwardPointer(event, "cancel")}
>
<RiveComponent className="pointer-events-none size-full" />
</span>
);
});