diff --git a/client/src/components/Chat/Messages/MessageNav.tsx b/client/src/components/Chat/Messages/MessageNav.tsx index 0c2d3b0c62..128618b9cd 100644 --- a/client/src/components/Chat/Messages/MessageNav.tsx +++ b/client/src/components/Chat/Messages/MessageNav.tsx @@ -73,6 +73,7 @@ function getMessageEntries(root: ParentNode, messagesById: Map const JUMP_EPS = 4; const SCROLL_DURATION = 400; const BOTTOM_SNAP_RETRIES = 2; +const DRAG_THRESHOLD = 4; function easeOutCubic(t: number): number { return 1 - Math.pow(1 - t, 3); @@ -86,6 +87,18 @@ function readScrollMargin(el: HTMLElement | null): number { return Number.isFinite(value) ? value : 0; } +function computeTargetScroll( + container: HTMLElement, + el: HTMLElement, + scrollMargin: number, +): number { + const cRect = container.getBoundingClientRect(); + const elRect = el.getBoundingClientRect(); + const target = container.scrollTop + (elRect.top - cRect.top) - scrollMargin; + const max = container.scrollHeight - container.clientHeight; + return Math.max(0, Math.min(target, max)); +} + const indicatorButtonClasses = cn( 'flex h-[5px] items-center justify-center rounded-sm', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy', @@ -180,6 +193,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject(null); + const dragCleanupRef = useRef<(() => void) | null>(null); + const suppressClickRef = useRef(false); const getCurrentVisibleId = useCallback((): string | null => { let nextId: string | null = null; @@ -246,11 +262,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { + const el = document.getElementById(id); + if (!el) { + return; + } + const container = el.closest('.scrollbar-gutter-stable'); + if (!container) { + return; + } + scrollTokenRef.current++; + const scrollMargin = scrollMarginRef.current || readScrollMargin(el); + container.scrollTop = computeTargetScroll(container, el, scrollMargin); + }, []); + + const focusMessage = useCallback((id: string) => { + const el = document.getElementById(id); + if (!el) { + return; + } + if (!el.hasAttribute('tabindex')) { + el.setAttribute('tabindex', '-1'); + } + el.focus({ preventScroll: true }); + }, []); + + const handleSelect = useCallback( + (id: string) => { + if (suppressClickRef.current) { + suppressClickRef.current = false; + return; + } + scrollToStart(id); + focusMessage(id); + }, + [scrollToStart, focusMessage], + ); + + const focusNav = useCallback((): boolean => { + const nav = navRef.current; + if (!nav) { + return false; + } + const target = + nav.querySelector('[aria-current="true"]') ?? + nav.querySelector('[data-msg-id]'); + if (!target) { + return false; + } + target.focus(); + return document.activeElement === target; + }, []); + + const scrubTo = useCallback( + (clientY: number) => { + const col = columnRef.current; + if (!col) { + return; + } + const ribs = col.querySelectorAll('[data-msg-id]'); + const count = ribs.length; + if (count === 0) { + return; + } + const rect = col.getBoundingClientRect(); + const fraction = rect.height > 0 ? (clientY - rect.top) / rect.height : 0; + const index = Math.max(0, Math.min(count - 1, Math.round(fraction * (count - 1)))); + const id = ribs[index].getAttribute('data-msg-id'); + if (id) { + scrollToImmediate(id); + } + }, + [scrollToImmediate], + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (e.button !== 0) { + return; + } + dragCleanupRef.current?.(); + suppressClickRef.current = false; + const state = { pointerId: e.pointerId, startY: e.clientY, dragging: false }; + + const finish = (wasDragging: boolean) => { + document.removeEventListener('pointermove', onMove); + document.removeEventListener('pointerup', onUp); + document.removeEventListener('pointercancel', onUp); + window.removeEventListener('blur', onBlur); + dragCleanupRef.current = null; + if (wasDragging) { + suppressClickRef.current = true; + window.setTimeout(() => { + suppressClickRef.current = false; + }, 0); + } + }; + + function onMove(ev: PointerEvent) { + if (ev.pointerId !== state.pointerId) { + return; + } + if ((ev.buttons & 1) === 0) { + finish(state.dragging); + return; + } + if (!state.dragging) { + if (Math.abs(ev.clientY - state.startY) < DRAG_THRESHOLD) { + return; + } + state.dragging = true; + } + scrubTo(ev.clientY); + } + + function onUp(ev: PointerEvent) { + if (ev.pointerId !== state.pointerId) { + return; + } + finish(state.dragging); + } + + function onBlur() { + finish(state.dragging); + } + + dragCleanupRef.current = () => finish(state.dragging); + document.addEventListener('pointermove', onMove); + document.addEventListener('pointerup', onUp); + document.addEventListener('pointercancel', onUp); + window.addEventListener('blur', onBlur); + }, + [scrubTo], + ); + + useEffect(() => () => dragCleanupRef.current?.(), []); + useEffect(() => { refreshEntries(); @@ -628,13 +776,27 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { + const onKeyDown = (e: KeyboardEvent) => { + if (e.altKey && e.shiftKey && (e.code === 'KeyM' || e.key.toLowerCase() === 'm')) { + if (focusNav()) { + e.preventDefault(); + } + } + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [focusNav]); + if (entries.length < 3) { return null; } return (