From 2994735f3b70e58dc95a3db28b75db3b7aec6440 Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 6 Apr 2026 20:16:46 +0200 Subject: [PATCH] feat: performance improvements --- .../src/components/editor/EditorPointer.tsx | 45 +++++++++++++------ .../src/components/shared/FloatingWindow.tsx | 14 +++++- frontend/src/components/shared/LazyEyes.tsx | 18 ++++---- frontend/src/hooks/useLazyEyes.ts | 36 ++++++++++----- 4 files changed, 78 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/editor/EditorPointer.tsx b/frontend/src/components/editor/EditorPointer.tsx index 14c0cd0..60a797f 100644 --- a/frontend/src/components/editor/EditorPointer.tsx +++ b/frontend/src/components/editor/EditorPointer.tsx @@ -8,7 +8,7 @@ import { useLazyEyes } from "@/hooks/useLazyEyes"; * containerRef (for floating windows). */ export default function EditorPointer({ containerRef, focused = true }: { containerRef?: React.RefObject; focused?: boolean }) { - const [y, setY] = useState(null); + const [visible, setVisible] = useState(false); const [editorLeft, setEditorLeft] = useState(null); const targetRef = useRef(0); const currentRef = useRef(0); @@ -17,10 +17,25 @@ export default function EditorPointer({ containerRef, focused = true }: { contai const [clickKey, setClickKey] = useState(0); const clickTimerRef = useRef>(undefined); const cmRef = useRef(null); + const pointerElRef = useRef(null); - // Eye anchor must be in viewport coords (useLazyEyes compares against e.clientX/Y) const eyeAnchorRef = useRef<{ x: number; y: number } | null>(null); - const eyeOffset = useLazyEyes({ anchorRef: eyeAnchorRef }); + const { registerIris, unregisterIris } = useLazyEyes({ anchorRef: eyeAnchorRef }); + + const iris1Ref = useRef(null); + const iris2Ref = useRef(null); + + // Register/unregister iris elements + useEffect(() => { + const i1 = iris1Ref.current; + const i2 = iris2Ref.current; + if (i1) registerIris(i1); + if (i2) registerIris(i2); + return () => { + if (i1) unregisterIris(i1); + if (i2) unregisterIris(i2); + }; + }, [visible, registerIris, unregisterIris]); useEffect(() => { const ease = 0.09; @@ -44,7 +59,6 @@ export default function EditorPointer({ containerRef, focused = true }: { contai const onCursorMove = (e: Event) => { const { top } = (e as CustomEvent).detail; - // Find editor container lazily, scoped to our window if (!cmRef.current) { const scope = containerRef?.current ?? document; const cm = scope.querySelector(".cm-editor"); @@ -55,14 +69,16 @@ export default function EditorPointer({ containerRef, focused = true }: { contai } } - // Convert viewport top to container-relative top const containerTop = getContainerRect().top; targetRef.current = top - containerTop; updateLeft(); if (!activeRef.current) { currentRef.current = targetRef.current; - setY(targetRef.current); + if (pointerElRef.current) { + pointerElRef.current.style.top = `${targetRef.current}px`; + } + setVisible(true); activeRef.current = true; } }; @@ -75,9 +91,12 @@ export default function EditorPointer({ containerRef, focused = true }: { contai } else { currentRef.current += diff * ease; } - setY(currentRef.current); - // Eye anchor in viewport coords for useLazyEyes + // Direct DOM update — no setState + if (pointerElRef.current) { + pointerElRef.current.style.top = `${currentRef.current}px`; + } + const cmLeft = cmRef.current?.getBoundingClientRect().left ?? 0; const containerTop = getContainerRect().top; eyeAnchorRef.current = { x: cmLeft - 100, y: containerTop + currentRef.current }; @@ -88,7 +107,6 @@ export default function EditorPointer({ containerRef, focused = true }: { contai window.addEventListener("cm-cursor-move", onCursorMove); rafRef.current = requestAnimationFrame(tick); - // Keep left position updated on resize const ro = new ResizeObserver(() => updateLeft()); const scope = containerRef?.current ?? document; const existingCm = scope.querySelector(".cm-editor"); @@ -107,13 +125,14 @@ export default function EditorPointer({ containerRef, focused = true }: { contai }; }, [containerRef]); - if (!focused || y === null || editorLeft === null) return null; + if (!focused || !visible || editorLeft === null) return null; return (
diff --git a/frontend/src/components/shared/FloatingWindow.tsx b/frontend/src/components/shared/FloatingWindow.tsx index c12c87a..ef4788e 100644 --- a/frontend/src/components/shared/FloatingWindow.tsx +++ b/frontend/src/components/shared/FloatingWindow.tsx @@ -91,12 +91,22 @@ export default function FloatingWindow({ let curVx = vx; let curVy = vy; const friction = 0.92; + const el = ref.current; const tick = () => { curVx *= friction; curVy *= friction; - if (Math.abs(curVx) < 0.3 && Math.abs(curVy) < 0.3) { inertiaRef.current = 0; return; } + if (Math.abs(curVx) < 0.3 && Math.abs(curVy) < 0.3) { + inertiaRef.current = 0; + // Sync final position to React state once + onUpdate(id, posRef.current); + return; + } posRef.current = { x: posRef.current.x + curVx, y: Math.max(0, posRef.current.y + curVy) }; - onUpdate(id, posRef.current); + // Direct DOM update during animation — skip React reconciliation + if (el) { + el.style.left = `${posRef.current.x}px`; + el.style.top = `${posRef.current.y}px`; + } inertiaRef.current = requestAnimationFrame(tick); }; inertiaRef.current = requestAnimationFrame(tick); diff --git a/frontend/src/components/shared/LazyEyes.tsx b/frontend/src/components/shared/LazyEyes.tsx index 32161d6..62173a9 100644 --- a/frontend/src/components/shared/LazyEyes.tsx +++ b/frontend/src/components/shared/LazyEyes.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect } from "react"; +import { useRef, useEffect, useCallback } from "react"; import { useLazyEyes } from "@/hooks/useLazyEyes"; interface EyeSpec { @@ -21,7 +21,6 @@ export default function LazyEyes({ eyes, anchor, maxShift, ease, className }: La const containerRef = useRef(null); const anchorRef = useRef<{ x: number; y: number } | null>(anchor ?? null); - // Keep anchorRef in sync with prop or auto-compute from DOM useEffect(() => { if (anchor) { anchorRef.current = anchor; @@ -42,19 +41,18 @@ export default function LazyEyes({ eyes, anchor, maxShift, ease, className }: La }; }, [anchor]); - const offset = useLazyEyes({ anchorRef, maxShift, ease }); + const { registerIris, unregisterIris } = useLazyEyes({ anchorRef, maxShift, ease }); + + const irisRef = useCallback((el: HTMLElement | null) => { + if (el) registerIris(el); + return () => { if (el) unregisterIris(el); }; + }, [registerIris, unregisterIris]); return (
{eyes.map((eye, i) => { const size = eye.size ?? 5; const irisSize = eye.irisSize ?? 2; - // Clamp iris within eye circle - const maxR = (size - irisSize) / 2; - const dist = Math.sqrt(offset.x * offset.x + offset.y * offset.y) || 0; - const scale = dist > maxR && dist > 0 ? maxR / dist : 1; - const cx = offset.x * scale; - const cy = offset.y * scale; return (
diff --git a/frontend/src/hooks/useLazyEyes.ts b/frontend/src/hooks/useLazyEyes.ts index 3cc303c..6c99f86 100644 --- a/frontend/src/hooks/useLazyEyes.ts +++ b/frontend/src/hooks/useLazyEyes.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useCallback } from "react"; interface LazyEyesOptions { /** Reference point the eyes "live" at (viewport coords). Eyes look away from this toward the mouse. */ @@ -14,8 +14,8 @@ interface LazyEyesOptions { } /** - * Returns a smoothly-interpolated { x, y } offset for positioning irises - * that lazily track the mouse cursor relative to an anchor point. + * Returns a register function to attach iris elements for direct DOM updates. + * No React state is set per frame — transforms are applied directly. * * Movement model: * - Small mouse moves → slow, lazy drift (ease) @@ -29,10 +29,23 @@ export function useLazyEyes({ saccadeThreshold = 0.8, saccadeEase = 0.35, }: LazyEyesOptions) { - const [offset, setOffset] = useState({ x: 0, y: 0 }); const targetRef = useRef({ x: 0, y: 0 }); const currentRef = useRef({ x: 0, y: 0 }); const velocityRef = useRef({ x: 0, y: 0 }); + const irisesRef = useRef>(new Set()); + const offsetRef = useRef({ x: 0, y: 0 }); + + const registerIris = useCallback((el: HTMLElement | null) => { + if (el) { + irisesRef.current.add(el); + } + }, []); + + const unregisterIris = useCallback((el: HTMLElement | null) => { + if (el) { + irisesRef.current.delete(el); + } + }, []); useEffect(() => { const onMouseMove = (e: MouseEvent) => { @@ -55,27 +68,30 @@ export function useLazyEyes({ const et = targetRef.current; const vel = velocityRef.current; - // Distance to target const dx = et.x - ec.x; const dy = et.y - ec.y; const dist = Math.sqrt(dx * dx + dy * dy); - // Saccade: snap fast when target jumps significantly const e_ = dist > saccadeThreshold ? saccadeEase : ease; - // Apply eased movement vel.x = vel.x * 0.6 + dx * e_ * 0.4; vel.y = vel.y * 0.6 + dy * e_ * 0.4; ec.x += vel.x; ec.y += vel.y; - // Micro-drift: tiny organic tremor when nearly still if (dist < 0.1) { ec.x += (Math.random() - 0.5) * 0.02; ec.y += (Math.random() - 0.5) * 0.02; } - setOffset({ x: ec.x, y: ec.y }); + offsetRef.current.x = ec.x; + offsetRef.current.y = ec.y; + + // Direct DOM updates — no React re-render + for (const iris of irisesRef.current) { + iris.style.transform = `translate(${ec.x}px, ${ec.y}px)`; + } + raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); @@ -86,5 +102,5 @@ export function useLazyEyes({ }; }, [anchorRef, maxShift, ease, saccadeThreshold, saccadeEase]); - return offset; + return { offsetRef, registerIris, unregisterIris }; }