claude-code/ink/components/ScrollBox.tsx

237 lines
31 KiB
TypeScript
Raw Normal View History

import React, { type PropsWithChildren, type Ref, useImperativeHandle, useRef, useState } from 'react';
import type { Except } from 'type-fest';
import { markScrollActivity } from '../../bootstrap/state.js';
import type { DOMElement } from '../dom.js';
import { markDirty, scheduleRenderFrom } from '../dom.js';
import { markCommitStart } from '../reconciler.js';
import type { Styles } from '../styles.js';
import '../global.d.ts';
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.
markScrollActivity();
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;
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzV2l0aENoaWxkcmVuIiwiUmVmIiwidXNlSW1wZXJhdGl2ZUhhbmRsZSIsInVzZVJlZiIsInVzZVN0YXRlIiwiRXhjZXB0IiwibWFya1Njcm9sbEFjdGl2aXR5IiwiRE9NRWxlbWVudCIsIm1hcmtEaXJ0eSIsInNjaGVkdWxlUmVuZGVyRnJvbSIsIm1hcmtDb21taXRTdGFydCIsIlN0eWxlcyIsIkJveCIsIlNjcm9sbEJveEhhbmRsZSIsInNjcm9sbFRvIiwieSIsInNjcm9sbEJ5IiwiZHkiLCJzY3JvbGxUb0VsZW1lbnQiLCJlbCIsIm9mZnNldCIsInNjcm9sbFRvQm90dG9tIiwiZ2V0U2Nyb2xsVG9wIiwiZ2V0UGVuZGluZ0RlbHRhIiwiZ2V0U2Nyb2xsSGVpZ2h0IiwiZ2V0RnJlc2hTY3JvbGxIZWlnaHQiLCJnZXRWaWV3cG9ydEhlaWdodCIsImdldFZpZXdwb3J0VG9wIiwiaXNTdGlja3kiLCJzdWJzY3JpYmUiLCJsaXN0ZW5lciIsInNldENsYW1wQm91bmRzIiwibWluIiwibWF4IiwiU2Nyb2xsQm94UHJvcHMiLCJyZWYiLCJzdGlja3lTY3JvbGwiLCJTY3JvbGxCb3giLCJjaGlsZHJlbiIsInN0eWxlIiwiUmVhY3ROb2RlIiwiZG9tUmVmIiwiZm9yY2VSZW5kZXIiLCJsaXN0ZW5lcnNSZWYiLCJTZXQiLCJyZW5kZXJRdWV1ZWRSZWYiLCJub3RpZnkiLCJsIiwiY3VycmVudCIsInNjcm9sbE11dGF0ZWQiLCJxdWV1ZU1pY3JvdGFzayIsInBlbmRpbmdTY3JvbGxEZWx0YSIsInVuZGVmaW5lZCIsInNjcm9sbEFuY2hvciIsInNjcm9sbFRvcCIsIk1hdGgiLCJmbG9vciIsImJveCIsIm4iLCJzY3JvbGxIZWlnaHQiLCJjb250ZW50IiwiY2hpbGROb2RlcyIsInlvZ2FOb2RlIiwiZ2V0Q29tcHV0ZWRIZWlnaHQiLCJzY3JvbGxWaWV3cG9ydEhlaWdodCIsInNjcm9sbFZpZXdwb3J0VG9wIiwiQm9vbGVhbiIsImF0dHJpYnV0ZXMiLCJhZGQiLCJkZWxldGUiLCJzY3JvbGxDbGFtcE1pbiIsInNjcm9sbENsYW1wTWF4IiwiZmxleFdyYXAiLCJmbGV4RGlyZWN0aW9uIiwiZmxleEdyb3ciLCJmbGV4U2hyaW5rIiwib3ZlcmZsb3dYIiwib3ZlcmZsb3dZIl0sInNvdXJjZXMiOlsiU2Nyb2xsQm94LnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QsIHtcbiAgdHlwZSBQcm9wc1dpdGhDaGlsZHJlbixcbiAgdHlwZSBSZWYsXG4gIHVzZUltcGVyYXRpdmVIYW5kbGUsXG4gIHVzZVJlZixcbiAgdXNlU3RhdGUsXG59IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHR5cGUgeyBFeGNlcHQgfSBmcm9tICd0eXBlLWZlc3QnXG5pbXBvcnQgeyBtYXJrU2Nyb2xsQWN0aXZpdHkgfSBmcm9tICcuLi8uLi9ib290c3RyYXAvc3RhdGUuanMnXG5pbXBvcnQgdHlwZSB7IERPTUVsZW1lbnQgfSBmcm9tICcuLi9kb20uanMnXG5pbXBvcnQgeyBtYXJrRGlydHksIHNjaGVkdWxlUmVuZGVyRnJvbSB9IGZyb20gJy4uL2RvbS5qcydcbmltcG9ydCB7IG1hcmtDb21taXRTdGFydCB9IGZyb20gJy4uL3JlY29uY2lsZXIuanMnXG5pbXBvcnQgdHlwZSB7IFN0eWxlcyB9IGZyb20gJy4uL3N0eWxlcy5qcydcbmltcG9ydCAnLi4vZ2xvYmFsLmQudHMnXG5pbXBvcnQgQm94IGZyb20gJy4vQm94LmpzJ1xuXG5leHBvcnQgdHlwZSBTY3JvbGxCb3hIYW5kbGUgPSB7XG4gIHNjcm9sbFRvOiAoeTogbnVtYmVyKSA9PiB2b2lkXG4gIHNjcm9sbEJ5OiAoZHk6IG51bWJlcikgPT4gdm9pZFxuICAvKipcbiAgICogU2Nyb2xsIHNvIGBlbGAncyB0b3AgaXMgYXQgdGhlIHZpZXdwb3J0IHRvcCAocGx1cyBgb2Zmc2V0YCkuIFVubGlrZVxuICAgKiBzY3JvbGxUbyB3aGljaCBiYWtlcyBhIG51bWJlciB0aGF0J3Mgc3RhbGUgYnkgdGhlIHRpbWUgdGhlIHRocm90dGxlZFxuICAgKiByZW5kZXIgZmlyZXMsIHRoaXMgZGVmZXJzIHRoZSBwb3NpdGlvbiByZWFkIHRvIHJlbmRlciB0aW1lIOKAlFxuICAgKiByZW5kZXItbm9kZS10by1vdXRwdXQgcmVhZHMgYGVsLnlvZ2FOb2RlLmdldENvbXB1dGVkVG9wKClgIGluIHRoZVxuICAgKiBTQU1FIFlvZ2EgcGFzcyB0aGF0IGNvbXB1dGVzIHNjcm9sbEhlaWdodC4gRGV0ZXJtaW5pc3RpYy4gT25lLXNob3QuXG4gICAqL1xuICBzY3JvbGxUb0VsZW1lbnQ6IChlbDogRE9NRWxlbWVudCwgb2Zmc2V0PzogbnVtYmVyKSA9PiB2b2lkXG4gIHNjcm9sbFRvQm90dG9tOiAoKSA9PiB2b2lkXG4gIGdldFNjcm9sbFRvcDogKCkgPT4gbnVtYmVyXG4gIGdldFBlbmRpbmdEZWx0YTogKCkgPT4gbnVtYmVyXG4gIGdldFNjcm9sbEhlaWdodDogKCkgPT4gbnVtYmVyXG4gIC8qKlxuICAgKiBMaWtlIGdldFNjcm9sbEhlaWdodCwgYnV0IHJlYWRzIFlvZ2EgZGlyZWN0bHkgaW5zdGVhZCBvZiB0aGUgY2FjaGVkXG4gICAqIHZhbHVlIHdyaXR0ZW4gYnkgcmVuZGVyLW5vZGUtdG8tb3V0cHV0ICh0aHJvdHRsZWQsIHVwIHRvIDE2bXMgc3RhbGUpLlxuICAgKiBVc2Ugd2hlbiB5b3UgbmVlZCBhIGZyZXNoIHZhbHVlIGluIHVzZUxheW91dEVmZmVjdCBhZnRlciBhIFJlYWN0IGNvbW1pdFxuICAgKiB0aGF0IGdyZXcgY29udGVudC4gU2xpZ2h0bHkgbW9yZSBleHBlbnNpdmUgKG5hdGl2ZSBZb2dhIGNhbGwpLlxuICAgKi9cbiAgZ2V0RnJlc2hTY3JvbGxIZWlnaHQ6ICgpID0+IG51bWJlclxuICBnZXRWaWV3cG9ydEhlaWdodDogKCkgPT4gbnVtYmVyXG4gIC8qKlxuICAgKiBBYnNvbHV0ZSBzY3JlZW4tYnVmZmVyIHJvdyBvZiB0aGUgZmlyc3QgdmlzaWJsZSBjb250ZW50IGxpbmUgKGluc2lkZVxuICAgKiBwYWRkaW5nKS4gVXNlZCBmb3IgZHJhZy10by1zY3JvbGwgZWRnZSBkZXRlY3Rpb24uXG4gICAqL1xuICBnZXRWaWV3cG9ydFRvcDogKCkgPT4gbnVtYmVyXG4gIC8qKlxuICAgKiBUcnVlIHdoZW4gc2Nyb2xsIGlzIHBpbm5lZCB0byB0aGUgYm90dG9tLiBTZXQgYnkgc2Nyb2xsVG9Cb3R0b20sIHRoZVxuICAgKiBpbml0aWFsIHN0aWNreVNjcm9sbCBhdHRyaWJ1dGUsIGFuZCBieSB0aGUgcmVuZGVyZXIgd2hlbiBwb3NpdGlvbmFsXG4gICAqIGZvbGxvdyBmaXJlcyAoc2Nyb2xsVG9wIGF0IHByZXZNYXgsIGNvbnRlbnQgZ3Jvd3MpLiBDbGVhcmVkIGJ5XG4gICAqIHNjcm9sbFRvL3Njcm9sbEJ