Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
335 lines
8.7 KiB
TypeScript
335 lines
8.7 KiB
TypeScript
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type HTMLAttributes,
|
|
type ForwardedRef,
|
|
type MouseEvent as ReactMouseEvent,
|
|
type MutableRefObject,
|
|
type ReactNode
|
|
} from "react";
|
|
import { cn } from "../../lib/utils";
|
|
|
|
const MIN_THUMB_HEIGHT = 24;
|
|
|
|
interface ScrollbarState {
|
|
scrollable: boolean;
|
|
thumbHeight: number;
|
|
thumbTop: number;
|
|
}
|
|
|
|
interface ScrollbarDragState {
|
|
maxScrollTop: number;
|
|
maxThumbTop: number;
|
|
startClientY: number;
|
|
startScrollTop: number;
|
|
}
|
|
|
|
export interface CustomScrollbarProps {
|
|
getViewport: () => HTMLElement | null;
|
|
className?: string;
|
|
thumbClassName?: string;
|
|
testId?: string;
|
|
thumbTestId?: string;
|
|
syncKey?: unknown;
|
|
}
|
|
|
|
export function CustomScrollbar({
|
|
getViewport,
|
|
className,
|
|
thumbClassName,
|
|
testId,
|
|
thumbTestId,
|
|
syncKey
|
|
}: CustomScrollbarProps): React.JSX.Element {
|
|
"use memo";
|
|
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
const dragStateRef = useRef<ScrollbarDragState | null>(null);
|
|
const [scrollbarState, setScrollbarState] = useState<ScrollbarState>({
|
|
scrollable: false,
|
|
thumbHeight: 0,
|
|
thumbTop: 0
|
|
});
|
|
const [dragging, setDragging] = useState(false);
|
|
|
|
const syncScrollbarState = useCallback(() => {
|
|
const viewport = getViewport();
|
|
if (!viewport) {
|
|
setScrollbarState({ scrollable: false, thumbHeight: 0, thumbTop: 0 });
|
|
return;
|
|
}
|
|
|
|
const { clientHeight, scrollHeight, scrollTop } = viewport;
|
|
const trackHeight = trackRef.current?.clientHeight ?? clientHeight;
|
|
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
|
|
if (clientHeight <= 0 || trackHeight <= 0 || maxScrollTop <= 0) {
|
|
setScrollbarState({ scrollable: false, thumbHeight: 0, thumbTop: 0 });
|
|
return;
|
|
}
|
|
|
|
const thumbHeight = Math.max(
|
|
MIN_THUMB_HEIGHT,
|
|
Math.round((clientHeight / scrollHeight) * trackHeight)
|
|
);
|
|
const maxThumbTop = Math.max(0, trackHeight - thumbHeight);
|
|
const thumbTop = Math.round((scrollTop / maxScrollTop) * maxThumbTop);
|
|
setScrollbarState((previous) =>
|
|
previous.scrollable &&
|
|
previous.thumbHeight === thumbHeight &&
|
|
previous.thumbTop === thumbTop
|
|
? previous
|
|
: { scrollable: true, thumbHeight, thumbTop }
|
|
);
|
|
}, [getViewport]);
|
|
|
|
const scrollViewportToThumbTop = useCallback(
|
|
(thumbTop: number) => {
|
|
const viewport = getViewport();
|
|
const track = trackRef.current;
|
|
if (!viewport || !track) {
|
|
return;
|
|
}
|
|
|
|
const maxScrollTop = Math.max(
|
|
0,
|
|
viewport.scrollHeight - viewport.clientHeight
|
|
);
|
|
const maxThumbTop = Math.max(
|
|
0,
|
|
track.clientHeight - scrollbarState.thumbHeight
|
|
);
|
|
if (maxScrollTop <= 0 || maxThumbTop <= 0) {
|
|
return;
|
|
}
|
|
|
|
viewport.scrollTop =
|
|
(clamp(thumbTop, 0, maxThumbTop) / maxThumbTop) * maxScrollTop;
|
|
syncScrollbarState();
|
|
},
|
|
[getViewport, scrollbarState.thumbHeight, syncScrollbarState]
|
|
);
|
|
|
|
const handleTrackMouseDown = useCallback(
|
|
(event: ReactMouseEvent<HTMLDivElement>) => {
|
|
if (event.button !== 0 || !scrollbarState.scrollable) {
|
|
return;
|
|
}
|
|
|
|
const track = trackRef.current;
|
|
if (!track) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const trackRect = track.getBoundingClientRect();
|
|
scrollViewportToThumbTop(
|
|
event.clientY - trackRect.top - scrollbarState.thumbHeight / 2
|
|
);
|
|
},
|
|
[
|
|
scrollViewportToThumbTop,
|
|
scrollbarState.scrollable,
|
|
scrollbarState.thumbHeight
|
|
]
|
|
);
|
|
|
|
const handleThumbMouseDown = useCallback(
|
|
(event: ReactMouseEvent<HTMLDivElement>) => {
|
|
if (event.button !== 0 || !scrollbarState.scrollable) {
|
|
return;
|
|
}
|
|
|
|
const viewport = getViewport();
|
|
const track = trackRef.current;
|
|
if (!viewport || !track) {
|
|
return;
|
|
}
|
|
|
|
const maxScrollTop = Math.max(
|
|
0,
|
|
viewport.scrollHeight - viewport.clientHeight
|
|
);
|
|
const maxThumbTop = Math.max(
|
|
0,
|
|
track.clientHeight - scrollbarState.thumbHeight
|
|
);
|
|
if (maxScrollTop <= 0 || maxThumbTop <= 0) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
dragStateRef.current = {
|
|
maxScrollTop,
|
|
maxThumbTop,
|
|
startClientY: event.clientY,
|
|
startScrollTop: viewport.scrollTop
|
|
};
|
|
setDragging(true);
|
|
},
|
|
[getViewport, scrollbarState.scrollable, scrollbarState.thumbHeight]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!dragging) {
|
|
return;
|
|
}
|
|
|
|
const handleMouseMove = (event: MouseEvent): void => {
|
|
const dragState = dragStateRef.current;
|
|
const viewport = getViewport();
|
|
if (!dragState || !viewport) {
|
|
return;
|
|
}
|
|
|
|
const nextThumbTop =
|
|
(dragState.startScrollTop / dragState.maxScrollTop) *
|
|
dragState.maxThumbTop +
|
|
(event.clientY - dragState.startClientY);
|
|
viewport.scrollTop =
|
|
(clamp(nextThumbTop, 0, dragState.maxThumbTop) /
|
|
dragState.maxThumbTop) *
|
|
dragState.maxScrollTop;
|
|
syncScrollbarState();
|
|
};
|
|
|
|
const handleMouseUp = (): void => {
|
|
dragStateRef.current = null;
|
|
setDragging(false);
|
|
};
|
|
|
|
window.addEventListener("mousemove", handleMouseMove);
|
|
window.addEventListener("mouseup", handleMouseUp);
|
|
return () => {
|
|
window.removeEventListener("mousemove", handleMouseMove);
|
|
window.removeEventListener("mouseup", handleMouseUp);
|
|
};
|
|
}, [dragging, getViewport, syncScrollbarState]);
|
|
|
|
useEffect(() => {
|
|
const viewport = getViewport();
|
|
if (!viewport) {
|
|
setScrollbarState({ scrollable: false, thumbHeight: 0, thumbTop: 0 });
|
|
return;
|
|
}
|
|
|
|
syncScrollbarState();
|
|
viewport.addEventListener("scroll", syncScrollbarState, { passive: true });
|
|
const resizeObserver =
|
|
typeof ResizeObserver !== "undefined"
|
|
? new ResizeObserver(syncScrollbarState)
|
|
: null;
|
|
resizeObserver?.observe(viewport);
|
|
const animationFrameId = window.requestAnimationFrame(syncScrollbarState);
|
|
|
|
return () => {
|
|
window.cancelAnimationFrame(animationFrameId);
|
|
viewport.removeEventListener("scroll", syncScrollbarState);
|
|
resizeObserver?.disconnect();
|
|
};
|
|
}, [getViewport, syncKey, syncScrollbarState]);
|
|
|
|
return (
|
|
<div
|
|
ref={trackRef}
|
|
className={cn("tsh-custom-scrollbar", className)}
|
|
data-scrollable={scrollbarState.scrollable ? "true" : "false"}
|
|
data-dragging={dragging ? "true" : "false"}
|
|
data-testid={testId}
|
|
aria-hidden="true"
|
|
onMouseDown={handleTrackMouseDown}
|
|
>
|
|
<div
|
|
className={cn("tsh-custom-scrollbar__thumb", thumbClassName)}
|
|
data-testid={thumbTestId}
|
|
onMouseDown={handleThumbMouseDown}
|
|
style={{
|
|
height: `${scrollbarState.thumbHeight}px`,
|
|
transform: `translateY(${scrollbarState.thumbTop}px)`
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface CustomScrollAreaProps extends HTMLAttributes<HTMLDivElement> {
|
|
children: ReactNode;
|
|
viewportClassName?: string;
|
|
scrollbarClassName?: string;
|
|
scrollbarThumbClassName?: string;
|
|
scrollbarTestId?: string;
|
|
scrollbarThumbTestId?: string;
|
|
syncKey?: unknown;
|
|
}
|
|
|
|
export const CustomScrollArea = forwardRef<
|
|
HTMLDivElement,
|
|
CustomScrollAreaProps
|
|
>(function CustomScrollArea(
|
|
{
|
|
children,
|
|
className,
|
|
viewportClassName,
|
|
scrollbarClassName,
|
|
scrollbarThumbClassName,
|
|
scrollbarTestId,
|
|
scrollbarThumbTestId,
|
|
syncKey,
|
|
...viewportProps
|
|
},
|
|
forwardedRef
|
|
): React.JSX.Element {
|
|
"use memo";
|
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
const getViewport = useCallback(() => viewportRef.current, []);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"tsh-custom-scroll-area relative min-h-0 min-w-0",
|
|
className
|
|
)}
|
|
>
|
|
<div
|
|
ref={setRefs(viewportRef, forwardedRef)}
|
|
className={cn(
|
|
"overflow-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
|
viewportClassName
|
|
)}
|
|
{...viewportProps}
|
|
>
|
|
{children}
|
|
</div>
|
|
<CustomScrollbar
|
|
getViewport={getViewport}
|
|
className={scrollbarClassName}
|
|
thumbClassName={scrollbarThumbClassName}
|
|
testId={scrollbarTestId}
|
|
thumbTestId={scrollbarThumbTestId}
|
|
syncKey={syncKey ?? children}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
function setRefs<T>(
|
|
localRef: MutableRefObject<T | null>,
|
|
forwardedRef: ForwardedRef<T>
|
|
): (node: T | null) => void {
|
|
return (node) => {
|
|
localRef.current = node;
|
|
if (typeof forwardedRef === "function") {
|
|
forwardedRef(node);
|
|
} else if (forwardedRef) {
|
|
forwardedRef.current = node;
|
|
}
|
|
};
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|