diff --git a/src/elements/emby-scrollbuttons/ScrollButtons.tsx b/src/elements/emby-scrollbuttons/ScrollButtons.tsx index 87aa27d78..80e8a705c 100644 --- a/src/elements/emby-scrollbuttons/ScrollButtons.tsx +++ b/src/elements/emby-scrollbuttons/ScrollButtons.tsx @@ -3,11 +3,7 @@ import scrollerFactory from '../../libraries/scroller'; import globalize from '../../scripts/globalize'; import IconButton from '../emby-button/IconButton'; import './emby-scrollbuttons.scss'; - -enum Direction { - RIGHT, - LEFT, - } +import { ScrollDirection, scrollerItemSlideIntoView } from './utils'; interface ScrollButtonsProps { scrollerFactoryRef: React.MutableRefObject; @@ -22,31 +18,16 @@ const ScrollButtons: FC = ({ scrollerFactoryRef, scrollState const [localeScrollPos, setLocaleScrollPos] = useState(0); const scrollButtonsRef = useRef(null); - const scrollToPosition = useCallback((pos: number, immediate: boolean) => { - if (scrollerFactoryRef.current) { - scrollerFactoryRef.current.slideTo(pos, immediate, undefined ); - } - }, [scrollerFactoryRef]); + const onScrollButtonClick = useCallback((direction: ScrollDirection) => { + scrollerItemSlideIntoView({ + direction, + scroller: scrollerFactoryRef.current, + scrollState + }); + }, [scrollState, scrollerFactoryRef]); - const onScrollButtonClick = useCallback((direction: Direction) => { - let newPos; - if (direction === Direction.LEFT) { - newPos = Math.max(0, scrollState.scrollPos - scrollState.scrollSize); - } else { - newPos = scrollState.scrollPos + scrollState.scrollSize; - } - - if (globalize.getIsRTL() && direction === Direction.LEFT) { - newPos = scrollState.scrollPos + scrollState.scrollSize; - } else if (globalize.getIsRTL()) { - newPos = Math.min(0, scrollState.scrollPos - scrollState.scrollSize); - } - - scrollToPosition(newPos, false); - }, [ scrollState.scrollPos, scrollState.scrollSize, scrollToPosition ]); - - const triggerScrollLeft = useCallback(() => onScrollButtonClick(Direction.LEFT), [ onScrollButtonClick ]); - const triggerScrollRight = useCallback(() => onScrollButtonClick(Direction.RIGHT), [ onScrollButtonClick ]); + const triggerScrollLeft = useCallback(() => onScrollButtonClick(ScrollDirection.LEFT), [ onScrollButtonClick ]); + const triggerScrollRight = useCallback(() => onScrollButtonClick(ScrollDirection.RIGHT), [ onScrollButtonClick ]); useEffect(() => { const parent = scrollButtonsRef.current?.parentNode as HTMLDivElement; diff --git a/src/elements/emby-scrollbuttons/emby-scrollbuttons.js b/src/elements/emby-scrollbuttons/emby-scrollbuttons.js index 770a435ef..7adfaa8e7 100644 --- a/src/elements/emby-scrollbuttons/emby-scrollbuttons.js +++ b/src/elements/emby-scrollbuttons/emby-scrollbuttons.js @@ -2,6 +2,7 @@ import './emby-scrollbuttons.scss'; import 'webcomponents.js/webcomponents-lite'; import '../emby-button/paper-icon-button-light'; import globalize from '../../scripts/globalize'; +import { scrollerItemSlideIntoView } from './utils'; const EmbyScrollButtonsPrototype = Object.create(HTMLDivElement.prototype); @@ -128,26 +129,16 @@ function getScrollSize(elem) { } function onScrollButtonClick() { - const scroller = this.parentNode.nextSibling; - const direction = this.getAttribute('data-direction'); - const scrollSize = getScrollSize(scroller); - const scrollPos = getScrollPosition(scroller); - - let newPos; - if (direction === 'left') { - newPos = Math.max(0, scrollPos - scrollSize); - } else { - newPos = scrollPos + scrollSize; - } - - if (globalize.getIsRTL() && direction === 'left') { - newPos = scrollPos + scrollSize; - } else if (globalize.getIsRTL()) { - newPos = Math.min(0, scrollPos - scrollSize); - } - - scroller.scrollToPosition(newPos, false); + const scroller = this.parentNode.nextSibling; + const scrollPosition = getScrollPosition(scroller); + scrollerItemSlideIntoView({ + direction, + scroller, + scrollState: { + scrollPos: scrollPosition + } + }); } EmbyScrollButtonsPrototype.attachedCallback = function () { diff --git a/src/elements/emby-scrollbuttons/utils.ts b/src/elements/emby-scrollbuttons/utils.ts new file mode 100644 index 000000000..dc8a7c1d4 --- /dev/null +++ b/src/elements/emby-scrollbuttons/utils.ts @@ -0,0 +1,103 @@ +import ScrollerFactory from 'libraries/scroller'; +import globalize from 'scripts/globalize'; + +export enum ScrollDirection { + RIGHT = 'right', + LEFT = 'left', +} + +interface ScrollState { + scrollPos: number; +} + +interface ScrollerItemSlideIntoViewProps { + direction: ScrollDirection; + scroller: ScrollerFactory | null; + scrollState: ScrollState; +} + +interface ScrollToWindowProps { + scroller: ScrollerFactory; + items: HTMLElement[]; + scrollState: ScrollState; + direction: ScrollDirection +} + +export function scrollerItemSlideIntoView({ direction, scroller, scrollState }: ScrollerItemSlideIntoViewProps) { + if (!scroller) { + return; + } + + const slider: HTMLElement = scroller.getScrollSlider(); + const items = [...slider.children] as HTMLElement[]; + + scrollToWindow({ + scroller, + items, + scrollState, + direction + }); +} + +function getFirstAndLastVisible(scrollFrame: HTMLElement, items: HTMLElement[], { scrollPos: scrollPosition }: ScrollState) { + const isRTL = globalize.getIsRTL(); + const localeModifier = isRTL ? -1 : 1; + + const currentScrollPos = scrollPosition * localeModifier; + const scrollerWidth = scrollFrame.offsetWidth; + const itemWidth = items[0].offsetWidth; + + // Rounding down here will give us the first item index which is fully visible. We want the first partially visible + // index so we'll subtract one. + const firstVisibleIndex = Math.max(Math.floor(currentScrollPos / itemWidth) - 1, 0); + // Rounding up will give us the last index which is at least partially visible (overflows at container end). + const lastVisibleIndex = Math.floor((currentScrollPos + scrollerWidth) / itemWidth); + + return [firstVisibleIndex, lastVisibleIndex]; +} + +function scrollToWindow({ + scroller, + items, + scrollState, + direction = ScrollDirection.RIGHT +}: ScrollToWindowProps) { + // When we're rendering RTL, scrolling toward the end of the container is toward the left so all of our scroll + // positions need to be negative. + const isRTL = globalize.getIsRTL(); + const localeModifier = isRTL ? -1 : 1; + + // NOTE: The legacy scroller is passing in an Element which is the frame element and has some of the scroller + // factory functions on it, but is not a true scroller factory. For legacy, we need to pass `scroller` directly + // instead of getting the frame from the factory instance. + const frame = scroller.getScrollFrame?.() ?? scroller; + const [firstVisibleIndex, lastVisibleIndex] = getFirstAndLastVisible(frame, items, scrollState); + + let scrollToPosition: number; + + if (direction === ScrollDirection.RIGHT) { + const nextItem = items[lastVisibleIndex]; + + // This will be the position to anchor the item at `lastVisibleIndex` to the start of the view window. + const nextItemScrollOffset = lastVisibleIndex * nextItem.offsetWidth; + scrollToPosition = nextItemScrollOffset * localeModifier; + } else { + const previousItem = items[firstVisibleIndex]; + const previousItemScrollOffset = firstVisibleIndex * previousItem.offsetWidth; + + // Find the total number of items that can fit in a view window and subtract one to account for item at + // `firstVisibleIndex`. The total width of these items is the amount that we need to adjust the scroll position by + // to anchor item at `firstVisibleIndex` to the end of the view window. + const offsetAdjustment = (Math.floor(frame.offsetWidth / previousItem.offsetWidth) - 1) * previousItem.offsetWidth; + + // This will be the position to anchor the item at `firstVisibleIndex` to the end of the view window. + scrollToPosition = (previousItemScrollOffset - offsetAdjustment) * localeModifier; + } + + if (scroller.slideTo) { + scroller.slideTo(scrollToPosition, false, undefined); + } else { + // @ts-expect-error Legacy support passes in a `scroller` that isn't a ScrollFactory + scroller.scrollToPosition(scrollToPosition); + } +}