- Drop characterData from scroll-follow and file-link MutationObservers; ResizeObserver already catches content growth. - extractMermaidBlocks: bail out when markdown has no 'mermaid' at all. - MarkdownCodeBlock: skip Prism highlighting while code is actively changing, render plain pre until settled for 300ms.
330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
import React from 'react';
|
|
import { animate, type AnimationPlaybackControls } from 'motion';
|
|
|
|
type ScrollEngineOptions = {
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
isMobile: boolean;
|
|
};
|
|
|
|
type ScrollOptions = {
|
|
instant?: boolean;
|
|
followBottom?: boolean; // Dynamically track bottom during streaming
|
|
persistFollow?: boolean;
|
|
};
|
|
|
|
type ScrollEngineResult = {
|
|
handleScroll: () => void;
|
|
scrollToPosition: (position: number, options?: ScrollOptions) => void;
|
|
forceManualMode: () => void;
|
|
cancelFollow: () => void;
|
|
isAtTop: boolean;
|
|
isFollowingBottom: boolean;
|
|
isManualOverrideActive: () => boolean;
|
|
getScrollTop: () => number;
|
|
getScrollHeight: () => number;
|
|
getClientHeight: () => number;
|
|
};
|
|
|
|
// Spring config for one-shot scroll-to-bottom (button click, session switch).
|
|
const FAST_SPRING = {
|
|
type: 'spring' as const,
|
|
visualDuration: 0.35,
|
|
bounce: 0,
|
|
};
|
|
|
|
// Exponential smoothing factor for the follow-bottom rAF loop.
|
|
// Each frame: scrollTop += (target - scrollTop) * LERP_FACTOR
|
|
// ~0.12-0.18 gives a smooth camera-follow feel at 60fps.
|
|
const LERP_FACTOR = 0.14;
|
|
|
|
// When the remaining distance is below this, snap exactly to bottom.
|
|
const SNAP_EPSILON = 0.5;
|
|
const FOLLOW_STABLE_FRAME_LIMIT = 8;
|
|
|
|
export const useScrollEngine = ({
|
|
containerRef,
|
|
}: ScrollEngineOptions): ScrollEngineResult => {
|
|
const [isAtTop, setIsAtTop] = React.useState(true);
|
|
const [isFollowingBottom, setIsFollowingBottom] = React.useState(false);
|
|
|
|
const atTopRef = React.useRef(true);
|
|
const manualOverrideRef = React.useRef(false);
|
|
|
|
// One-shot spring animation (for scroll-to-bottom button etc.)
|
|
const scrollAnimRef = React.useRef<AnimationPlaybackControls | undefined>(undefined);
|
|
|
|
// Continuous follow-bottom rAF loop (for streaming)
|
|
const followRafRef = React.useRef<number | null>(null);
|
|
const followActiveRef = React.useRef(false);
|
|
const followPersistRef = React.useRef(false);
|
|
const followObserversRef = React.useRef<{ resize: ResizeObserver; mutation: MutationObserver } | null>(null);
|
|
|
|
const cancelSpring = React.useCallback(() => {
|
|
if (scrollAnimRef.current) {
|
|
scrollAnimRef.current.stop();
|
|
scrollAnimRef.current = undefined;
|
|
}
|
|
}, []);
|
|
|
|
const teardownFollowObservers = React.useCallback(() => {
|
|
const observers = followObserversRef.current;
|
|
if (!observers) return;
|
|
observers.resize.disconnect();
|
|
observers.mutation.disconnect();
|
|
followObserversRef.current = null;
|
|
}, []);
|
|
|
|
const cancelFollow = React.useCallback(() => {
|
|
teardownFollowObservers();
|
|
if (followRafRef.current !== null && typeof window !== 'undefined') {
|
|
window.cancelAnimationFrame(followRafRef.current);
|
|
followRafRef.current = null;
|
|
}
|
|
followActiveRef.current = false;
|
|
followPersistRef.current = false;
|
|
setIsFollowingBottom(false);
|
|
}, [teardownFollowObservers]);
|
|
|
|
const cancelAll = React.useCallback(() => {
|
|
cancelSpring();
|
|
cancelFollow();
|
|
}, [cancelSpring, cancelFollow]);
|
|
|
|
// One burst of the lerp loop — runs until scrollTop catches up to bottom, then stops.
|
|
// Re-invoked by observers when persist mode content grows.
|
|
const runFollowBurst = React.useCallback(() => {
|
|
if (followActiveRef.current) return;
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
followActiveRef.current = true;
|
|
if (!followPersistRef.current) {
|
|
setIsFollowingBottom(true);
|
|
}
|
|
let stableFrames = 0;
|
|
|
|
const tick = () => {
|
|
const c = containerRef.current;
|
|
if (!c || !followActiveRef.current) {
|
|
followActiveRef.current = false;
|
|
followRafRef.current = null;
|
|
if (!followPersistRef.current) {
|
|
setIsFollowingBottom(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const target = c.scrollHeight - c.clientHeight;
|
|
const current = c.scrollTop;
|
|
const delta = target - current;
|
|
|
|
if (Math.abs(delta) <= SNAP_EPSILON) {
|
|
c.scrollTop = target;
|
|
stableFrames += 1;
|
|
if (stableFrames >= FOLLOW_STABLE_FRAME_LIMIT) {
|
|
followActiveRef.current = false;
|
|
followRafRef.current = null;
|
|
if (!followPersistRef.current) {
|
|
setIsFollowingBottom(false);
|
|
}
|
|
return;
|
|
}
|
|
followRafRef.current = window.requestAnimationFrame(tick);
|
|
return;
|
|
}
|
|
|
|
stableFrames = 0;
|
|
c.scrollTop = current + delta * LERP_FACTOR;
|
|
followRafRef.current = window.requestAnimationFrame(tick);
|
|
};
|
|
|
|
followRafRef.current = window.requestAnimationFrame(tick);
|
|
}, [containerRef]);
|
|
|
|
// Observer-driven persist mode — RAF bursts only fire when content actually changes.
|
|
// No idle CPU cost while waiting for the next token.
|
|
const setupFollowObservers = React.useCallback(() => {
|
|
const container = containerRef.current;
|
|
if (!container || followObserversRef.current) return;
|
|
|
|
const onChange = () => {
|
|
if (!followPersistRef.current) return;
|
|
runFollowBurst();
|
|
};
|
|
|
|
const resize = new ResizeObserver(onChange);
|
|
const inner = container.firstElementChild;
|
|
if (inner instanceof Element) {
|
|
resize.observe(inner);
|
|
}
|
|
resize.observe(container);
|
|
|
|
const mutation = new MutationObserver(onChange);
|
|
mutation.observe(container, { childList: true, subtree: true });
|
|
|
|
followObserversRef.current = { resize, mutation };
|
|
}, [containerRef, runFollowBurst]);
|
|
|
|
const startFollowLoop = React.useCallback((persist = false) => {
|
|
const wasPersist = followPersistRef.current;
|
|
followPersistRef.current = persist || wasPersist;
|
|
if (followPersistRef.current) {
|
|
if (!wasPersist) {
|
|
setIsFollowingBottom(true);
|
|
}
|
|
setupFollowObservers();
|
|
}
|
|
runFollowBurst();
|
|
}, [runFollowBurst, setupFollowObservers]);
|
|
|
|
const scrollToPosition = React.useCallback(
|
|
(position: number, options?: ScrollOptions) => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
const target = Math.max(0, position);
|
|
const preferInstant = options?.instant ?? false;
|
|
const followBottom = options?.followBottom ?? false;
|
|
const persistFollow = options?.persistFollow ?? false;
|
|
|
|
manualOverrideRef.current = false;
|
|
|
|
// Instant scroll (session switch, etc.)
|
|
if (typeof window === 'undefined' || preferInstant) {
|
|
cancelAll();
|
|
container.scrollTop = target;
|
|
|
|
if (followBottom && typeof window !== 'undefined') {
|
|
startFollowLoop(persistFollow);
|
|
}
|
|
|
|
const atTop = target <= 1;
|
|
if (atTopRef.current !== atTop) {
|
|
atTopRef.current = atTop;
|
|
setIsAtTop(atTop);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Follow-bottom mode: start the continuous lerp loop
|
|
if (followBottom) {
|
|
cancelSpring();
|
|
startFollowLoop(persistFollow);
|
|
return;
|
|
}
|
|
|
|
// One-shot scroll: stop everything and use spring animation
|
|
cancelAll();
|
|
|
|
const distance = Math.abs(target - container.scrollTop);
|
|
if (distance <= SNAP_EPSILON) {
|
|
container.scrollTop = target;
|
|
const atTop = target <= 1;
|
|
if (atTopRef.current !== atTop) {
|
|
atTopRef.current = atTop;
|
|
setIsAtTop(atTop);
|
|
}
|
|
return;
|
|
}
|
|
|
|
scrollAnimRef.current = animate(container.scrollTop, target, {
|
|
...FAST_SPRING,
|
|
onUpdate: (v) => {
|
|
container.scrollTop = v;
|
|
},
|
|
onComplete: () => {
|
|
scrollAnimRef.current = undefined;
|
|
},
|
|
});
|
|
},
|
|
[cancelAll, cancelSpring, containerRef, setIsAtTop, startFollowLoop]
|
|
);
|
|
|
|
const forceManualMode = React.useCallback(() => {
|
|
manualOverrideRef.current = true;
|
|
}, []);
|
|
|
|
const markManualOverride = React.useCallback(() => {
|
|
manualOverrideRef.current = true;
|
|
cancelFollow();
|
|
}, [cancelFollow]);
|
|
|
|
const isManualOverrideActive = React.useCallback(() => {
|
|
return manualOverrideRef.current;
|
|
}, []);
|
|
|
|
const getScrollTop = React.useCallback(() => {
|
|
return containerRef.current?.scrollTop ?? 0;
|
|
}, [containerRef]);
|
|
|
|
const getScrollHeight = React.useCallback(() => {
|
|
return containerRef.current?.scrollHeight ?? 0;
|
|
}, [containerRef]);
|
|
|
|
const getClientHeight = React.useCallback(() => {
|
|
return containerRef.current?.clientHeight ?? 0;
|
|
}, [containerRef]);
|
|
|
|
const handleScroll = React.useCallback(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
if (manualOverrideRef.current && scrollAnimRef.current) {
|
|
cancelSpring();
|
|
}
|
|
|
|
const atTop = container.scrollTop <= 1;
|
|
if (atTopRef.current !== atTop) {
|
|
atTopRef.current = atTop;
|
|
setIsAtTop(atTop);
|
|
}
|
|
}, [cancelSpring, containerRef]);
|
|
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
container.addEventListener('wheel', markManualOverride, { passive: true });
|
|
container.addEventListener('touchstart', markManualOverride, { passive: true });
|
|
|
|
return () => {
|
|
container.removeEventListener('wheel', markManualOverride);
|
|
container.removeEventListener('touchstart', markManualOverride);
|
|
};
|
|
}, [containerRef, markManualOverride]);
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
cancelAll();
|
|
};
|
|
}, [cancelAll]);
|
|
|
|
return React.useMemo(
|
|
() => ({
|
|
handleScroll,
|
|
scrollToPosition,
|
|
forceManualMode,
|
|
cancelFollow,
|
|
isAtTop,
|
|
isFollowingBottom,
|
|
isManualOverrideActive,
|
|
getScrollTop,
|
|
getScrollHeight,
|
|
getClientHeight,
|
|
}),
|
|
[
|
|
handleScroll,
|
|
scrollToPosition,
|
|
forceManualMode,
|
|
cancelFollow,
|
|
isAtTop,
|
|
isFollowingBottom,
|
|
isManualOverrideActive,
|
|
getScrollTop,
|
|
getScrollHeight,
|
|
getClientHeight,
|
|
]
|
|
);
|
|
};
|
|
|
|
export type { ScrollEngineResult, ScrollEngineOptions, ScrollOptions };
|