* Auto-scroll the ScrollBox when the user drags a selection past its top or * bottom edge. The anchor is shifted in the opposite direction so it stays * on the same content (content that was at viewport row N is now at row N±d * after scrolling by d). Focus stays at the mouse position (edge row).
(scrollRef: RefObject<ScrollBoxHandle | null>, selection: ReturnType<typeof useSelection>, isActive: boolean, onScroll: Props['onScroll'])
| 635 | * getSelectedText. |
| 636 | */ |
| 637 | function useDragToScroll(scrollRef: RefObject<ScrollBoxHandle | null>, selection: ReturnType<typeof useSelection>, isActive: boolean, onScroll: Props['onScroll']): void { |
| 638 | const timerRef = useRef<NodeJS.Timeout | null>(null); |
| 639 | const dirRef = useRef<-1 | 0 | 1>(0); // -1 scrolling up, +1 down, 0 idle |
| 640 | // Survives stop() — reset only on drag-finish. See check() for semantics. |
| 641 | const lastScrolledDirRef = useRef<-1 | 0 | 1>(0); |
| 642 | const ticksRef = useRef(0); |
| 643 | // onScroll may change identity every render (if not memoized by caller). |
| 644 | // Read through a ref so the effect doesn't re-subscribe and kill the timer |
| 645 | // on each scroll-induced re-render. |
| 646 | const onScrollRef = useRef(onScroll); |
| 647 | onScrollRef.current = onScroll; |
| 648 | useEffect(() => { |
| 649 | if (!isActive) return; |
| 650 | function stop(): void { |
| 651 | dirRef.current = 0; |
| 652 | if (timerRef.current) { |
| 653 | clearInterval(timerRef.current); |
| 654 | timerRef.current = null; |
| 655 | } |
| 656 | } |
| 657 | function tick(): void { |
| 658 | const sel = selection.getState(); |
| 659 | const s = scrollRef.current; |
| 660 | const dir = dirRef.current; |
| 661 | // dir === 0 defends against a stale interval (start() may have set one |
| 662 | // after the immediate tick already called stop() at a scroll boundary). |
| 663 | // ticks cap defends against a lost release event (mouse released |
| 664 | // outside terminal window) leaving isDragging stuck true. |
| 665 | if (!sel?.isDragging || !sel.focus || !s || dir === 0 || ++ticksRef.current > AUTOSCROLL_MAX_TICKS) { |
| 666 | stop(); |
| 667 | return; |
| 668 | } |
| 669 | // scrollBy accumulates into pendingScrollDelta; the screen buffer |
| 670 | // doesn't update until the next render drains it. If a previous |
| 671 | // tick's scroll hasn't drained yet, captureScrolledRows would read |
| 672 | // stale content (same rows as last tick → duplicated in the |
| 673 | // accumulator AND missing the rows that actually scrolled out). |
| 674 | // Skip this tick; the 50ms interval will retry after Ink's 16ms |
| 675 | // render catches up. Also prevents shiftAnchor from desyncing. |
| 676 | if (s.getPendingDelta() !== 0) return; |
| 677 | const top = s.getViewportTop(); |
| 678 | const bottom = top + s.getViewportHeight() - 1; |
| 679 | // Clamp anchor within [top, bottom]. Not [0, bottom]: the ScrollBox |
| 680 | // padding row at 0 would produce a blank line between scrolledOffAbove |
| 681 | // and the on-screen content in getSelectedText. The padding-row |
| 682 | // highlight was a minor visual nicety; text correctness wins. |
| 683 | if (dir < 0) { |
| 684 | if (s.getScrollTop() <= 0) { |
| 685 | stop(); |
| 686 | return; |
| 687 | } |
| 688 | // Scrolling up: content moves down in viewport, so anchor row +N. |
| 689 | // Clamp to actual scroll distance so anchor stays in sync when near |
| 690 | // the top boundary (renderer clamps scrollTop to 0 on drain). |
| 691 | const actual = Math.min(AUTOSCROLL_LINES, s.getScrollTop()); |
| 692 | // Capture rows about to scroll out the BOTTOM before scrollBy |
| 693 | // overwrites them. Only rows inside the selection are captured |
| 694 | // (captureScrolledRows intersects with selection bounds). |
no test coverage detected