feat: performance improvements
This commit is contained in:
@@ -8,7 +8,7 @@ import { useLazyEyes } from "@/hooks/useLazyEyes";
|
|||||||
* containerRef (for floating windows).
|
* containerRef (for floating windows).
|
||||||
*/
|
*/
|
||||||
export default function EditorPointer({ containerRef, focused = true }: { containerRef?: React.RefObject<HTMLElement | null>; focused?: boolean }) {
|
export default function EditorPointer({ containerRef, focused = true }: { containerRef?: React.RefObject<HTMLElement | null>; focused?: boolean }) {
|
||||||
const [y, setY] = useState<number | null>(null);
|
const [visible, setVisible] = useState(false);
|
||||||
const [editorLeft, setEditorLeft] = useState<number | null>(null);
|
const [editorLeft, setEditorLeft] = useState<number | null>(null);
|
||||||
const targetRef = useRef(0);
|
const targetRef = useRef(0);
|
||||||
const currentRef = useRef(0);
|
const currentRef = useRef(0);
|
||||||
@@ -17,10 +17,25 @@ export default function EditorPointer({ containerRef, focused = true }: { contai
|
|||||||
const [clickKey, setClickKey] = useState(0);
|
const [clickKey, setClickKey] = useState(0);
|
||||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
const cmRef = useRef<Element | null>(null);
|
const cmRef = useRef<Element | null>(null);
|
||||||
|
const pointerElRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Eye anchor must be in viewport coords (useLazyEyes compares against e.clientX/Y)
|
|
||||||
const eyeAnchorRef = useRef<{ x: number; y: number } | null>(null);
|
const eyeAnchorRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
const eyeOffset = useLazyEyes({ anchorRef: eyeAnchorRef });
|
const { registerIris, unregisterIris } = useLazyEyes({ anchorRef: eyeAnchorRef });
|
||||||
|
|
||||||
|
const iris1Ref = useRef<HTMLDivElement>(null);
|
||||||
|
const iris2Ref = useRef<HTMLDivElement>(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(() => {
|
useEffect(() => {
|
||||||
const ease = 0.09;
|
const ease = 0.09;
|
||||||
@@ -44,7 +59,6 @@ export default function EditorPointer({ containerRef, focused = true }: { contai
|
|||||||
const onCursorMove = (e: Event) => {
|
const onCursorMove = (e: Event) => {
|
||||||
const { top } = (e as CustomEvent).detail;
|
const { top } = (e as CustomEvent).detail;
|
||||||
|
|
||||||
// Find editor container lazily, scoped to our window
|
|
||||||
if (!cmRef.current) {
|
if (!cmRef.current) {
|
||||||
const scope = containerRef?.current ?? document;
|
const scope = containerRef?.current ?? document;
|
||||||
const cm = scope.querySelector(".cm-editor");
|
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;
|
const containerTop = getContainerRect().top;
|
||||||
targetRef.current = top - containerTop;
|
targetRef.current = top - containerTop;
|
||||||
updateLeft();
|
updateLeft();
|
||||||
|
|
||||||
if (!activeRef.current) {
|
if (!activeRef.current) {
|
||||||
currentRef.current = targetRef.current;
|
currentRef.current = targetRef.current;
|
||||||
setY(targetRef.current);
|
if (pointerElRef.current) {
|
||||||
|
pointerElRef.current.style.top = `${targetRef.current}px`;
|
||||||
|
}
|
||||||
|
setVisible(true);
|
||||||
activeRef.current = true;
|
activeRef.current = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -75,9 +91,12 @@ export default function EditorPointer({ containerRef, focused = true }: { contai
|
|||||||
} else {
|
} else {
|
||||||
currentRef.current += diff * ease;
|
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 cmLeft = cmRef.current?.getBoundingClientRect().left ?? 0;
|
||||||
const containerTop = getContainerRect().top;
|
const containerTop = getContainerRect().top;
|
||||||
eyeAnchorRef.current = { x: cmLeft - 100, y: containerTop + currentRef.current };
|
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);
|
window.addEventListener("cm-cursor-move", onCursorMove);
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
// Keep left position updated on resize
|
|
||||||
const ro = new ResizeObserver(() => updateLeft());
|
const ro = new ResizeObserver(() => updateLeft());
|
||||||
const scope = containerRef?.current ?? document;
|
const scope = containerRef?.current ?? document;
|
||||||
const existingCm = scope.querySelector(".cm-editor");
|
const existingCm = scope.querySelector(".cm-editor");
|
||||||
@@ -107,13 +125,14 @@ export default function EditorPointer({ containerRef, focused = true }: { contai
|
|||||||
};
|
};
|
||||||
}, [containerRef]);
|
}, [containerRef]);
|
||||||
|
|
||||||
if (!focused || y === null || editorLeft === null) return null;
|
if (!focused || !visible || editorLeft === null) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={pointerElRef}
|
||||||
className={`editor-pointer${clickKey ? " editor-pointer-click" : ""}`}
|
className={`editor-pointer${clickKey ? " editor-pointer-click" : ""}`}
|
||||||
key={clickKey}
|
key={clickKey}
|
||||||
style={{ top: y, left: editorLeft }}
|
style={{ left: editorLeft }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="editor-pointer-img"
|
className="editor-pointer-img"
|
||||||
@@ -124,14 +143,14 @@ export default function EditorPointer({ containerRef, focused = true }: { contai
|
|||||||
/>
|
/>
|
||||||
<div className="editor-pointer-eye" style={{ top: 33, left: 134 }}>
|
<div className="editor-pointer-eye" style={{ top: 33, left: 134 }}>
|
||||||
<div
|
<div
|
||||||
|
ref={iris1Ref}
|
||||||
className="editor-pointer-iris"
|
className="editor-pointer-iris"
|
||||||
style={{ transform: `translate(${eyeOffset.x}px, ${eyeOffset.y}px)` }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-pointer-eye" style={{ top: 29, left: 144 }}>
|
<div className="editor-pointer-eye" style={{ top: 29, left: 144 }}>
|
||||||
<div
|
<div
|
||||||
|
ref={iris2Ref}
|
||||||
className="editor-pointer-iris"
|
className="editor-pointer-iris"
|
||||||
style={{ transform: `translate(${eyeOffset.x}px, ${eyeOffset.y}px)` }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,12 +91,22 @@ export default function FloatingWindow({
|
|||||||
let curVx = vx;
|
let curVx = vx;
|
||||||
let curVy = vy;
|
let curVy = vy;
|
||||||
const friction = 0.92;
|
const friction = 0.92;
|
||||||
|
const el = ref.current;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
curVx *= friction;
|
curVx *= friction;
|
||||||
curVy *= 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) {
|
||||||
posRef.current = { x: posRef.current.x + curVx, y: Math.max(0, posRef.current.y + curVy) };
|
inertiaRef.current = 0;
|
||||||
|
// Sync final position to React state once
|
||||||
onUpdate(id, posRef.current);
|
onUpdate(id, posRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
posRef.current = { x: posRef.current.x + curVx, y: Math.max(0, posRef.current.y + curVy) };
|
||||||
|
// 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);
|
||||||
};
|
};
|
||||||
inertiaRef.current = requestAnimationFrame(tick);
|
inertiaRef.current = requestAnimationFrame(tick);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect, useCallback } from "react";
|
||||||
import { useLazyEyes } from "@/hooks/useLazyEyes";
|
import { useLazyEyes } from "@/hooks/useLazyEyes";
|
||||||
|
|
||||||
interface EyeSpec {
|
interface EyeSpec {
|
||||||
@@ -21,7 +21,6 @@ export default function LazyEyes({ eyes, anchor, maxShift, ease, className }: La
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const anchorRef = useRef<{ x: number; y: number } | null>(anchor ?? null);
|
const anchorRef = useRef<{ x: number; y: number } | null>(anchor ?? null);
|
||||||
|
|
||||||
// Keep anchorRef in sync with prop or auto-compute from DOM
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (anchor) {
|
if (anchor) {
|
||||||
anchorRef.current = anchor;
|
anchorRef.current = anchor;
|
||||||
@@ -42,19 +41,18 @@ export default function LazyEyes({ eyes, anchor, maxShift, ease, className }: La
|
|||||||
};
|
};
|
||||||
}, [anchor]);
|
}, [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 (
|
return (
|
||||||
<div ref={containerRef} className={className} style={{ position: "absolute", pointerEvents: "none" }}>
|
<div ref={containerRef} className={className} style={{ position: "absolute", pointerEvents: "none" }}>
|
||||||
{eyes.map((eye, i) => {
|
{eyes.map((eye, i) => {
|
||||||
const size = eye.size ?? 5;
|
const size = eye.size ?? 5;
|
||||||
const irisSize = eye.irisSize ?? 2;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
@@ -62,13 +60,13 @@ export default function LazyEyes({ eyes, anchor, maxShift, ease, className }: La
|
|||||||
style={{ top: eye.top, left: eye.left, width: size, height: size }}
|
style={{ top: eye.top, left: eye.left, width: size, height: size }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={irisRef}
|
||||||
className="editor-pointer-iris"
|
className="editor-pointer-iris"
|
||||||
style={{
|
style={{
|
||||||
width: irisSize,
|
width: irisSize,
|
||||||
height: irisSize,
|
height: irisSize,
|
||||||
marginTop: -(irisSize / 2) - 0.5,
|
marginTop: -(irisSize / 2) - 0.5,
|
||||||
marginLeft: -(irisSize / 2) - 0.5,
|
marginLeft: -(irisSize / 2) - 0.5,
|
||||||
transform: `translate(${cx}px, ${cy}px)`,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
interface LazyEyesOptions {
|
interface LazyEyesOptions {
|
||||||
/** Reference point the eyes "live" at (viewport coords). Eyes look away from this toward the mouse. */
|
/** 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
|
* Returns a register function to attach iris elements for direct DOM updates.
|
||||||
* that lazily track the mouse cursor relative to an anchor point.
|
* No React state is set per frame — transforms are applied directly.
|
||||||
*
|
*
|
||||||
* Movement model:
|
* Movement model:
|
||||||
* - Small mouse moves → slow, lazy drift (ease)
|
* - Small mouse moves → slow, lazy drift (ease)
|
||||||
@@ -29,10 +29,23 @@ export function useLazyEyes({
|
|||||||
saccadeThreshold = 0.8,
|
saccadeThreshold = 0.8,
|
||||||
saccadeEase = 0.35,
|
saccadeEase = 0.35,
|
||||||
}: LazyEyesOptions) {
|
}: LazyEyesOptions) {
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
|
||||||
const targetRef = useRef({ x: 0, y: 0 });
|
const targetRef = useRef({ x: 0, y: 0 });
|
||||||
const currentRef = useRef({ x: 0, y: 0 });
|
const currentRef = useRef({ x: 0, y: 0 });
|
||||||
const velocityRef = useRef({ x: 0, y: 0 });
|
const velocityRef = useRef({ x: 0, y: 0 });
|
||||||
|
const irisesRef = useRef<Set<HTMLElement>>(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(() => {
|
useEffect(() => {
|
||||||
const onMouseMove = (e: MouseEvent) => {
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
@@ -55,27 +68,30 @@ export function useLazyEyes({
|
|||||||
const et = targetRef.current;
|
const et = targetRef.current;
|
||||||
const vel = velocityRef.current;
|
const vel = velocityRef.current;
|
||||||
|
|
||||||
// Distance to target
|
|
||||||
const dx = et.x - ec.x;
|
const dx = et.x - ec.x;
|
||||||
const dy = et.y - ec.y;
|
const dy = et.y - ec.y;
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
// Saccade: snap fast when target jumps significantly
|
|
||||||
const e_ = dist > saccadeThreshold ? saccadeEase : ease;
|
const e_ = dist > saccadeThreshold ? saccadeEase : ease;
|
||||||
|
|
||||||
// Apply eased movement
|
|
||||||
vel.x = vel.x * 0.6 + dx * e_ * 0.4;
|
vel.x = vel.x * 0.6 + dx * e_ * 0.4;
|
||||||
vel.y = vel.y * 0.6 + dy * e_ * 0.4;
|
vel.y = vel.y * 0.6 + dy * e_ * 0.4;
|
||||||
ec.x += vel.x;
|
ec.x += vel.x;
|
||||||
ec.y += vel.y;
|
ec.y += vel.y;
|
||||||
|
|
||||||
// Micro-drift: tiny organic tremor when nearly still
|
|
||||||
if (dist < 0.1) {
|
if (dist < 0.1) {
|
||||||
ec.x += (Math.random() - 0.5) * 0.02;
|
ec.x += (Math.random() - 0.5) * 0.02;
|
||||||
ec.y += (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);
|
||||||
};
|
};
|
||||||
raf = requestAnimationFrame(tick);
|
raf = requestAnimationFrame(tick);
|
||||||
@@ -86,5 +102,5 @@ export function useLazyEyes({
|
|||||||
};
|
};
|
||||||
}, [anchorRef, maxShift, ease, saccadeThreshold, saccadeEase]);
|
}, [anchorRef, maxShift, ease, saccadeThreshold, saccadeEase]);
|
||||||
|
|
||||||
return offset;
|
return { offsetRef, registerIris, unregisterIris };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user