Files
simstudioai--sim/apps/sim/hooks/use-scroll-anchor.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

173 lines
5.6 KiB
TypeScript

import { useCallback, useLayoutEffect, useRef } from 'react'
const NEAR_BOTTOM_THRESHOLD = 30
/**
* Returns the `minHeight` the spacer needs so `scrollTop` can safely reach
* `targetScrollTop` when replace-mode streaming produces temporarily shorter content.
*/
export function computeSpacerShortage(
targetScrollTop: number,
clientHeight: number,
scrollHeight: number,
prevSpacerHeight: number
): number {
const needed = targetScrollTop + clientHeight
const naturalScrollHeight = scrollHeight - prevSpacerHeight
return Math.max(0, needed - naturalScrollHeight)
}
/**
* Returns whether the scroll container should re-engage auto-follow.
*
* Re-engagement is blocked while the spacer is active. The spacer inflates
* `scrollHeight` to exactly `targetScrollTop + clientHeight`, so programmatic
* scroll restoration produces `distanceFromBottom = 0` — which is artificial
* proximity, not the user genuinely reaching the document bottom.
*/
export function shouldReengage(distanceFromBottom: number, spacerHeight: number): boolean {
return distanceFromBottom <= NEAR_BOTTOM_THRESHOLD && spacerHeight === 0
}
/**
* Manages scroll for a streaming file-preview container.
*
* Never-scrolled: auto-follows new content to the bottom (MutationObserver
* keeps it pinned). Scrolled-up: position is locked via a spacer element that
* inflates `scrollHeight` to prevent the browser from clamping `scrollTop` when
* replace-mode streaming temporarily produces a shorter chunk. Scrolled back to
* the bottom: auto-follow re-engages.
*
* @param isStreaming - whether the container is currently receiving streaming content
* @param content - drives spacer recalculation; pass the current text value
*/
export function useScrollAnchor(isStreaming: boolean, content?: string) {
const containerRef = useRef<HTMLDivElement | null>(null)
const spacerRef = useRef<HTMLDivElement | null>(null)
const hasUserScrolledRef = useRef(false)
const stickyRef = useRef(false)
const intendedScrollTopRef = useRef(0)
// Avoids a layout read inside onScroll.
const spacerHeightRef = useRef(0)
const scrollToBottom = useCallback(() => {
const el = containerRef.current
if (!el) return
el.scrollTop = el.scrollHeight
}, [])
const onWheel = useCallback((e: WheelEvent) => {
if (e.deltaY >= 0 || hasUserScrolledRef.current) return
hasUserScrolledRef.current = true
stickyRef.current = false
const el = containerRef.current
if (el) intendedScrollTopRef.current = el.scrollTop
}, [])
const onScroll = useCallback(() => {
const el = containerRef.current
if (!el) return
if (hasUserScrolledRef.current) {
intendedScrollTopRef.current = el.scrollTop
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
if (shouldReengage(distanceFromBottom, spacerHeightRef.current)) {
hasUserScrolledRef.current = false
stickyRef.current = true
}
return
}
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
if (distanceFromBottom > NEAR_BOTTOM_THRESHOLD) {
hasUserScrolledRef.current = true
stickyRef.current = false
intendedScrollTopRef.current = el.scrollTop
} else {
stickyRef.current = true
}
}, [])
const callbackRef = useCallback(
(el: HTMLDivElement | null) => {
const prev = containerRef.current
if (prev) {
prev.removeEventListener('scroll', onScroll)
prev.removeEventListener('wheel', onWheel as EventListener)
}
containerRef.current = el
if (el) {
el.addEventListener('scroll', onScroll, { passive: true })
el.addEventListener('wheel', onWheel as EventListener, { passive: true })
}
},
[onScroll, onWheel]
)
useLayoutEffect(() => {
if (!isStreaming) return
const el = containerRef.current
if (!el) return
if (hasUserScrolledRef.current) return
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
stickyRef.current = distanceFromBottom <= NEAR_BOTTOM_THRESHOLD
if (stickyRef.current) scrollToBottom()
}, [isStreaming, scrollToBottom])
useLayoutEffect(() => {
if (!isStreaming) return
const el = containerRef.current
if (!el) return
let rafId = 0
const guardedScroll = () => {
if (stickyRef.current) scrollToBottom()
}
const onMutation = () => {
if (!stickyRef.current) return
cancelAnimationFrame(rafId)
rafId = requestAnimationFrame(guardedScroll)
}
const observer = new MutationObserver(onMutation)
observer.observe(el, { childList: true, subtree: true, characterData: true })
return () => {
observer.disconnect()
cancelAnimationFrame(rafId)
}
}, [isStreaming, scrollToBottom])
useLayoutEffect(() => {
const el = containerRef.current
const spacer = spacerRef.current
if (!el) return
if (!hasUserScrolledRef.current || !isStreaming) {
spacerHeightRef.current = 0
if (spacer) spacer.style.minHeight = '0'
return
}
// Must read before scrollHeight: that forced reflow can synchronously fire 'scroll' and clamp the value.
const targetScrollTop = intendedScrollTopRef.current
const prevSpacerHeight = spacer ? spacer.offsetHeight : 0
const shortage = computeSpacerShortage(
targetScrollTop,
el.clientHeight,
el.scrollHeight,
prevSpacerHeight
)
spacerHeightRef.current = shortage
if (spacer) spacer.style.minHeight = `${shortage}px`
if (el.scrollTop < targetScrollTop) el.scrollTop = targetScrollTop
}, [content, isStreaming])
return {
ref: callbackRef,
spacerRef,
}
}