/** @jsxImportSource react */ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type ReactNode, type WheelEvent as ReactWheelEvent, } from "react"; import type { ScrollBoxRenderable } from "../../../../ui/host"; import { WEB_CELL_HEIGHT, WEB_CELL_WIDTH } from "../input-host"; import { callMouseHandler } from "./mouse"; import { cleanDomProps, commonStyle } from "./style"; import { useScrollbarActivity } from "../scrollbar-activity"; type InsetSide = "Left" | "Right" | "Top" | "Bottom"; function insetCells(source: Record, kind: "padding" | "margin", side: InsetSide): number { const axis = side === "Left" || side === "Right" ? "X" : "Y"; const value = source[`${kind}${side}`] ?? source[`${kind}${axis}`] ?? source[kind]; return typeof value === "number" ? value : 0; } /** * OpenTUI lays a ScrollBox's children out in an inner content box styled by * `contentOptions`, so panes pad scrolled content there. The DOM scroller is * its own content box: the content padding and margins go on it as padding, * on top of the ScrollBox's own, so the content does not sit flush against * the pane border. */ function contentInsetStyle(props: Record, contentOptions: unknown): CSSProperties { if (!contentOptions || typeof contentOptions !== "object") return {}; const options = contentOptions as Record; const style: CSSProperties = {}; for (const side of ["Left", "Right", "Top", "Bottom"] as const) { const extra = insetCells(options, "padding", side) + insetCells(options, "margin", side); if (extra === 0) continue; const unit = side === "Left" || side === "Right" ? WEB_CELL_WIDTH : WEB_CELL_HEIGHT; style[`padding${side}`] = `${(insetCells(props, "padding", side) + extra) * unit}px`; } return style; } export const WebScrollBox = forwardRef & { children?: ReactNode }>( function WebScrollBox({ children, contentOptions, ...props }, ref) { const elementRef = useRef(null); const isHeaderLikeScroller = props.scrollX === true && props.scrollY !== true && props.height === 1; /** * OpenTUI's ScrollBox scrolls vertically unless told otherwise, so this one * has to as well: a pane that renders a ScrollBox and nothing more expects * the wheel to work, and used to get an unscrollable box that only its own * arrow-key handler could move. The one-row horizontal strips (tab bars, * table headers, chart legends) are the exception and scroll on their axis. */ const scrollYEnabled = props.scrollY !== false && !isHeaderLikeScroller; const [horizontalScrollBarVisible, setHorizontalScrollBarVisible] = useState( () => props.scrollX === true && !isHeaderLikeScroller, ); const [verticalScrollBarVisible, setVerticalScrollBarVisible] = useState( () => scrollYEnabled, ); const horizontalScrollBarVisibleRef = useRef(horizontalScrollBarVisible); const verticalScrollBarVisibleRef = useRef(verticalScrollBarVisible); const horizontalScrollChangeHandlersRef = useRef(new Set<() => void>()); const verticalScrollChangeHandlersRef = useRef(new Set<() => void>()); const scrollFrameRef = useRef(null); const lastWheelAtRef = useRef(0); const [scrollbarActive, markScrollbarActive] = useScrollbarActivity(); const getElement = useCallback(() => elementRef.current, []); const toCellY = (pixels: number) => Math.max(0, Math.round(pixels / WEB_CELL_HEIGHT)); const toCellX = (pixels: number) => Math.max(0, Math.round(pixels / WEB_CELL_WIDTH)); const emitHorizontalScrollChange = useCallback(() => { for (const handler of horizontalScrollChangeHandlersRef.current) { handler(); } }, []); const emitVerticalScrollChange = useCallback(() => { for (const handler of verticalScrollChangeHandlersRef.current) { handler(); } }, []); const emitScrollPositionChanges = useCallback(() => { emitVerticalScrollChange(); emitHorizontalScrollChange(); }, [emitHorizontalScrollChange, emitVerticalScrollChange]); const horizontalScrollBar = useMemo(() => ({ get visible() { return horizontalScrollBarVisibleRef.current; }, set visible(nextVisible: boolean) { const normalized = nextVisible === true; horizontalScrollBarVisibleRef.current = normalized; setHorizontalScrollBarVisible(normalized); }, on(event: "change", handler: () => void) { if (event === "change") horizontalScrollChangeHandlersRef.current.add(handler); }, off(event: "change", handler: () => void) { if (event === "change") horizontalScrollChangeHandlersRef.current.delete(handler); }, }), []); const verticalScrollBar = useMemo(() => ({ get visible() { return verticalScrollBarVisibleRef.current; }, set visible(nextVisible: boolean) { const normalized = nextVisible === true; verticalScrollBarVisibleRef.current = normalized; setVerticalScrollBarVisible(normalized); }, on(event: "change", handler: () => void) { if (event === "change") verticalScrollChangeHandlersRef.current.add(handler); }, off(event: "change", handler: () => void) { if (event === "change") verticalScrollChangeHandlersRef.current.delete(handler); }, }), []); useEffect(() => { if (props.scrollX !== true) { horizontalScrollBar.visible = false; } }, [horizontalScrollBar, props.scrollX]); useEffect(() => { if (!scrollYEnabled) { verticalScrollBar.visible = false; } }, [scrollYEnabled, verticalScrollBar]); useEffect(() => () => { if (scrollFrameRef.current != null) { cancelAnimationFrame(scrollFrameRef.current); } }, []); useImperativeHandle(ref, () => ({ get scrollTop() { return toCellY(getElement()?.scrollTop ?? 0); }, set scrollTop(value: number) { const element = getElement(); if (element) { element.scrollTop = Math.max(0, value) * WEB_CELL_HEIGHT; emitVerticalScrollChange(); } }, get scrollTopPx() { return Math.max(0, getElement()?.scrollTop ?? 0); }, set scrollTopPx(value: number) { const element = getElement(); if (element) { element.scrollTop = Math.max(0, value); emitVerticalScrollChange(); } }, get scrollLeft() { return toCellX(getElement()?.scrollLeft ?? 0); }, set scrollLeft(value: number) { const element = getElement(); if (element) { element.scrollLeft = Math.max(0, value) * WEB_CELL_WIDTH; emitHorizontalScrollChange(); } }, get scrollLeftPx() { return Math.max(0, getElement()?.scrollLeft ?? 0); }, set scrollLeftPx(value: number) { const element = getElement(); if (element) { element.scrollLeft = Math.max(0, value); emitHorizontalScrollChange(); } }, get scrollHeight() { return toCellY(getElement()?.scrollHeight ?? 0); }, get scrollWidth() { return toCellX(getElement()?.scrollWidth ?? 0); }, get scrollHeightPx() { return Math.max(0, getElement()?.scrollHeight ?? 0); }, get scrollWidthPx() { return Math.max(0, getElement()?.scrollWidth ?? 0); }, get viewport() { const element = getElement(); return { width: toCellX(element?.clientWidth ?? 0), height: toCellY(element?.clientHeight ?? 0), }; }, get viewportPx() { const element = getElement(); return { width: Math.max(0, element?.clientWidth ?? 0), height: Math.max(0, element?.clientHeight ?? 0), }; }, getBoundingClientRect() { const rect = getElement()?.getBoundingClientRect(); return { x: rect?.x ?? 0, y: rect?.y ?? 0, width: rect?.width ?? 0, height: rect?.height ?? 0, }; }, horizontalScrollBar, verticalScrollBar, scrollTo(target: number | { x?: number; y?: number }, y?: number) { const element = getElement(); if (!element) return; if (typeof target === "number") { element.scrollTop = Math.max(0, target) * WEB_CELL_HEIGHT; emitVerticalScrollChange(); if (typeof y === "number") { element.scrollLeft = Math.max(0, y) * WEB_CELL_WIDTH; emitHorizontalScrollChange(); } return; } element.scrollTo({ left: Math.max(0, target.x ?? toCellX(element.scrollLeft)) * WEB_CELL_WIDTH, top: Math.max(0, target.y ?? toCellY(element.scrollTop)) * WEB_CELL_HEIGHT, }); emitScrollPositionChanges(); }, scrollToPixels(target: number | { x?: number; y?: number }, y?: number) { const element = getElement(); if (!element) return; if (typeof target === "number") { element.scrollTop = Math.max(0, target); emitVerticalScrollChange(); if (typeof y === "number") { element.scrollLeft = Math.max(0, y); emitHorizontalScrollChange(); } return; } element.scrollTo({ left: Math.max(0, target.x ?? element.scrollLeft), top: Math.max(0, target.y ?? element.scrollTop), }); emitScrollPositionChanges(); }, }), [ emitHorizontalScrollChange, emitScrollPositionChanges, emitVerticalScrollChange, getElement, horizontalScrollBar, verticalScrollBar, ]); const scrollable = props.scrollX === true || scrollYEnabled; const overflowX = props.scrollX === true ? "auto" : "hidden"; const overflowY = scrollYEnabled ? "auto" : "hidden"; const handleScroll = useCallback(() => { emitScrollPositionChanges(); markScrollbarActive(); if (typeof props.onMouseScroll !== "function") return; if (Date.now() - lastWheelAtRef.current < 32) return; if (scrollFrameRef.current != null) return; scrollFrameRef.current = requestAnimationFrame(() => { scrollFrameRef.current = null; (props.onMouseScroll as () => void)(); }); }, [emitScrollPositionChanges, markScrollbarActive, props.onMouseScroll]); const handleWheel = useCallback((event: ReactWheelEvent) => { markScrollbarActive(); lastWheelAtRef.current = Date.now(); callMouseHandler(props.onMouseScroll, event, "scroll"); }, [markScrollbarActive, props.onMouseScroll]); return (
callMouseHandler(props.onMouseDown, event, "down")} onMouseMove={(event) => callMouseHandler(props.onMouseMove, event, "move")} onMouseUp={(event) => callMouseHandler(props.onMouseUp, event, "up")} onMouseOut={(event) => callMouseHandler(props.onMouseOut, event, "out")} onScroll={scrollable ? handleScroll : undefined} onWheel={scrollable ? handleWheel : undefined} style={{ ...commonStyle(props), ...contentInsetStyle(props, contentOptions), overflowX, overflowY, ...(props.style as CSSProperties | undefined), ...(props.visible === false ? { display: "none" } : undefined), }} > {children as ReactNode}
); }, );