Files
claude-code/packages/@ant/ink/src/components/ScrollBox.tsx
2026-04-07 16:17:48 +08:00

258 lines
9.5 KiB
TypeScript

import React, {
type PropsWithChildren,
type Ref,
useImperativeHandle,
useRef,
useState,
} from 'react'
import type { Except } from 'type-fest'
import type { DOMElement } from '../core/dom.js'
import { markDirty, scheduleRenderFrom } from '../core/dom.js'
import { markCommitStart } from '../core/reconciler.js'
import type { Styles } from '../core/styles.js'
import Box from './Box.js'
export type ScrollBoxHandle = {
scrollTo: (y: number) => void
scrollBy: (dy: number) => void
/**
* Scroll so `el`'s top is at the viewport top (plus `offset`). Unlike
* scrollTo which bakes a number that's stale by the time the throttled
* render fires, this defers the position read to render time —
* render-node-to-output reads `el.yogaNode.getComputedTop()` in the
* SAME Yoga pass that computes scrollHeight. Deterministic. One-shot.
*/
scrollToElement: (el: DOMElement, offset?: number) => void
scrollToBottom: () => void
getScrollTop: () => number
getPendingDelta: () => number
getScrollHeight: () => number
/**
* Like getScrollHeight, but reads Yoga directly instead of the cached
* value written by render-node-to-output (throttled, up to 16ms stale).
* Use when you need a fresh value in useLayoutEffect after a React commit
* that grew content. Slightly more expensive (native Yoga call).
*/
getFreshScrollHeight: () => number
getViewportHeight: () => number
/**
* Absolute screen-buffer row of the first visible content line (inside
* padding). Used for drag-to-scroll edge detection.
*/
getViewportTop: () => number
/**
* True when scroll is pinned to the bottom. Set by scrollToBottom, the
* initial stickyScroll attribute, and by the renderer when positional
* follow fires (scrollTop at prevMax, content grows). Cleared by
* scrollTo/scrollBy. Stable signal for "at bottom" that doesn't depend on
* layout values (unlike scrollTop+viewportH >= scrollHeight).
*/
isSticky: () => boolean
/**
* Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom).
* Does NOT fire for stickyScroll updates done by the Ink renderer — those
* happen during Ink's render phase after React has committed. Callers that
* care about the sticky case should treat "at bottom" as a fallback.
*/
subscribe: (listener: () => void) => () => void
/**
* Set the render-time scrollTop clamp to the currently-mounted children's
* coverage span. Called by useVirtualScroll after computing its range;
* render-node-to-output clamps scrollTop to [min, max] so burst scrollTo
* calls that race past React's async re-render show the edge of mounted
* content instead of blank spacer. Pass undefined to disable (sticky,
* cold start).
*/
setClampBounds: (min: number | undefined, max: number | undefined) => void
}
export type ScrollBoxProps = Except<
Styles,
'textWrap' | 'overflow' | 'overflowX' | 'overflowY'
> & {
ref?: Ref<ScrollBoxHandle>
/**
* When true, automatically pins scroll position to the bottom when content
* grows. Unset manually via scrollTo/scrollBy to break the stickiness.
*/
stickyScroll?: boolean
}
/**
* A Box with `overflow: scroll` and an imperative scroll API.
*
* Children are laid out at their full Yoga-computed height inside a
* constrained container. At render time, only children intersecting the
* visible window (scrollTop..scrollTop+height) are rendered (viewport
* culling). Content is translated by -scrollTop and clipped to the box bounds.
*
* Works best inside a fullscreen (constrained-height root) Ink tree.
*/
function ScrollBox({
children,
ref,
stickyScroll,
...style
}: PropsWithChildren<ScrollBoxProps>): React.ReactNode {
const domRef = useRef<DOMElement>(null)
// scrollTo/scrollBy bypass React: they mutate scrollTop on the DOM node,
// mark it dirty, and call the root's throttled scheduleRender directly.
// The Ink renderer reads scrollTop from the node — no React state needed,
// no reconciler overhead per wheel event. The microtask defer coalesces
// multiple scrollBy calls in one input batch (discreteUpdates) into one
// render — otherwise scheduleRender's leading edge fires on the FIRST
// event before subsequent events mutate scrollTop. scrollToBottom still
// forces a React render: sticky is attribute-observed, no DOM-only path.
const [, forceRender] = useState(0)
const listenersRef = useRef(new Set<() => void>())
const renderQueuedRef = useRef(false)
const notify = () => {
for (const l of listenersRef.current) l()
}
function scrollMutated(el: DOMElement): void {
// Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan
// check) to skip their next tick — they compete for the event loop and
// contributed to 1402ms max frame gaps during scroll drain.
// noop — injected by business layer via onScrollActivity callback
markDirty(el)
markCommitStart()
notify()
if (renderQueuedRef.current) return
renderQueuedRef.current = true
queueMicrotask(() => {
renderQueuedRef.current = false
scheduleRenderFrom(el)
})
}
useImperativeHandle(
ref,
(): ScrollBoxHandle => ({
scrollTo(y: number) {
const el = domRef.current
if (!el) return
// Explicit false overrides the DOM attribute so manual scroll
// breaks stickiness. Render code checks ?? precedence.
el.stickyScroll = false
el.pendingScrollDelta = undefined
el.scrollAnchor = undefined
el.scrollTop = Math.max(0, Math.floor(y))
scrollMutated(el)
},
scrollToElement(el: DOMElement, offset = 0) {
const box = domRef.current
if (!box) return
box.stickyScroll = false
box.pendingScrollDelta = undefined
box.scrollAnchor = { el, offset }
scrollMutated(box)
},
scrollBy(dy: number) {
const el = domRef.current
if (!el) return
el.stickyScroll = false
// Wheel input cancels any in-flight anchor seek — user override.
el.scrollAnchor = undefined
// Accumulate in pendingScrollDelta; renderer drains it at a capped
// rate so fast flicks show intermediate frames. Pure accumulator:
// scroll-up followed by scroll-down naturally cancels.
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy)
scrollMutated(el)
},
scrollToBottom() {
const el = domRef.current
if (!el) return
el.pendingScrollDelta = undefined
el.stickyScroll = true
markDirty(el)
notify()
forceRender(n => n + 1)
},
getScrollTop() {
return domRef.current?.scrollTop ?? 0
},
getPendingDelta() {
// Accumulated-but-not-yet-drained delta. useVirtualScroll needs
// this to mount the union [committed, committed+pending] range —
// otherwise intermediate drain frames find no children (blank).
return domRef.current?.pendingScrollDelta ?? 0
},
getScrollHeight() {
return domRef.current?.scrollHeight ?? 0
},
getFreshScrollHeight() {
const content = domRef.current?.childNodes[0] as DOMElement | undefined
return (
content?.yogaNode?.getComputedHeight() ??
domRef.current?.scrollHeight ??
0
)
},
getViewportHeight() {
return domRef.current?.scrollViewportHeight ?? 0
},
getViewportTop() {
return domRef.current?.scrollViewportTop ?? 0
},
isSticky() {
const el = domRef.current
if (!el) return false
return el.stickyScroll ?? Boolean(el.attributes['stickyScroll'])
},
subscribe(listener: () => void) {
listenersRef.current.add(listener)
return () => listenersRef.current.delete(listener)
},
setClampBounds(min, max) {
const el = domRef.current
if (!el) return
el.scrollClampMin = min
el.scrollClampMax = max
},
}),
// notify/scrollMutated are inline (no useCallback) but only close over
// refs + imports — stable. Empty deps avoids rebuilding the handle on
// every render (which re-registers the ref = churn).
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
)
// Structure: outer viewport (overflow:scroll, constrained height) >
// inner content (flexGrow:1, flexShrink:0 — fills at least the viewport
// but grows beyond it for tall content). flexGrow:1 lets children use
// spacers to pin elements to the bottom of the scroll area. Yoga's
// Overflow.Scroll prevents the viewport from growing to fit the content.
// The renderer computes scrollHeight from the content box and culls
// content's children based on scrollTop.
//
// stickyScroll is passed as a DOM attribute (via ink-box directly) so it's
// available on the first render — ref callbacks fire after the initial
// commit, which is too late for the first frame.
return (
<ink-box
ref={el => {
domRef.current = el
if (el) el.scrollTop ??= 0
}}
style={{
flexWrap: 'nowrap',
flexDirection: style.flexDirection ?? 'row',
flexGrow: style.flexGrow ?? 0,
flexShrink: style.flexShrink ?? 1,
...style,
overflowX: 'scroll',
overflowY: 'scroll',
}}
{...(stickyScroll ? { stickyScroll: true } : {})}
>
<Box flexDirection="column" flexGrow={1} flexShrink={0} width="100%">
{children}
</Box>
</ink-box>
)
}
export default ScrollBox