fix(avatar): declarative canvas element in JSX; remove manual DOM append

React was potentially clearing the canvas on re-render because we
appended it manually to a div. Now using a <canvas ref={canvasRef}>
element directly in JSX that React manages. Pixi app uses .
Scale set to 82% of container.
This commit is contained in:
2026-06-05 10:10:03 -04:00
parent e00dc37e68
commit 95f97fa897
+21 -20
View File
@@ -23,7 +23,7 @@ const OUTFIT_TEXTURES: Record<string, string> = {
};
export default function KiraAvatar(props: Props) {
const wrapRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const modelRef = useRef<any>(null);
const textureRef = useRef<any>(null);
const lipSyncRef = useRef<number>(0);
@@ -32,11 +32,10 @@ export default function KiraAvatar(props: Props) {
const [loadError, setLoadError] = useState(false);
const [currentExpression, setCurrentExpression] = useState<ExpressionName>('Normal');
// Initialize Live2D
useEffect(() => {
let mounted = true;
const container = wrapRef.current;
if (!container) return;
const canvas = canvasRef.current;
if (!canvas) return;
let app: any = null;
let model: any = null;
@@ -51,8 +50,13 @@ export default function KiraAvatar(props: Props) {
const { Live2DModel } = await import('pixi-live2d-display/cubism4');
(Live2DModel as any).registerTicker(Ticker as any);
const w = canvas.clientWidth || 260;
const h = canvas.clientHeight || 320;
app = new Application({
resizeTo: container,
view: canvas,
width: w,
height: h,
antialias: true,
resolution: Math.min(window.devicePixelRatio || 1, 2),
backgroundAlpha: 0,
@@ -60,8 +64,6 @@ export default function KiraAvatar(props: Props) {
});
if (!mounted) { app.destroy(true); return; }
container.appendChild(app.view as HTMLCanvasElement);
model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', {
autoInteract: false,
});
@@ -70,9 +72,7 @@ export default function KiraAvatar(props: Props) {
const fit = () => {
const sw = app.screen.width;
const sh = app.screen.height;
// Scale to fit within the container, leaving margin so nothing clips
const margin = 0.9; // 90% of container
const s = Math.min((sw * margin) / model.width, (sh * margin) / model.height);
const s = Math.min((sw * 0.82) / model.width, (sh * 0.82) / model.height);
model.scale.set(s);
model.anchor.set(0.5, 0.5);
model.position.set(sw / 2, sh / 2);
@@ -82,9 +82,6 @@ export default function KiraAvatar(props: Props) {
app.stage.addChild(model as any);
(model as any).isInteractive = () => false;
// Re-fit on resize
app.renderer.on('resize', fit);
try {
textureRef.current = {
index: 2,
@@ -191,10 +188,14 @@ export default function KiraAvatar(props: Props) {
return (
<div className="flex flex-col items-center w-full h-full overflow-hidden">
<div className="relative w-full flex-1" style={{ minHeight: 250 }}>
{/* Pixi canvas mounts here */}
<div ref={wrapRef} className="w-full h-full" />
{/* Live2D canvas */}
<canvas
ref={canvasRef}
className="w-full h-full block"
style={{ opacity: live2dReady ? 1 : 0 }}
/>
{/* SVG fallback when Live2D fails */}
{/* SVG fallback */}
{(!live2dReady || loadError) && (
<div className="absolute inset-0 flex items-center justify-center">
<AnimatedAvatar
@@ -207,7 +208,7 @@ export default function KiraAvatar(props: Props) {
</div>
)}
{/* Loading spinner */}
{/* Loading */}
{!live2dReady && !loadError && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<div className="w-8 h-8 border-3 border-kira-pink border-t-transparent rounded-full animate-spin" />
@@ -215,7 +216,7 @@ export default function KiraAvatar(props: Props) {
</div>
)}
{/* Expression buttons overlay top */}
{/* Expression buttons */}
{live2dReady && (
<>
<div className="absolute top-1 left-0 right-0 flex gap-1 justify-center flex-wrap pointer-events-none">
@@ -238,7 +239,7 @@ export default function KiraAvatar(props: Props) {
</div>
</div>
{/* Talk button overlay bottom-center */}
{/* Talk button */}
<div className="absolute bottom-2 left-0 right-0 flex justify-center pointer-events-none">
<button
onClick={props.onTalkToggle}
@@ -253,7 +254,7 @@ export default function KiraAvatar(props: Props) {
</button>
</div>
{/* Status overlay bottom-right */}
{/* Status */}
<div className="absolute bottom-2 right-3 flex items-center gap-2 text-xs text-kira-plum/50 pointer-events-none">
<span className={`w-2 h-2 rounded-full ${
props.isSpeaking ? 'bg-kira-pink animate-pulse'