From c223b7c2d776bbda8932e431075374843170afd4 Mon Sep 17 00:00:00 2001 From: Grady Hallenbeck Date: Sat, 7 Oct 2023 19:25:58 -0700 Subject: [PATCH 1/3] fix: (scroller) scroll to card boundaries --- .../emby-scrollbuttons/ScrollButtons.tsx | 39 ++----- .../emby-scrollbuttons/emby-scrollbuttons.js | 29 ++--- src/utils/scroller.ts | 101 ++++++++++++++++++ 3 files changed, 121 insertions(+), 48 deletions(-) create mode 100644 src/utils/scroller.ts diff --git a/src/elements/emby-scrollbuttons/ScrollButtons.tsx b/src/elements/emby-scrollbuttons/ScrollButtons.tsx index aaeb9bc23..5d670bb91 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/scroller'; 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..2fafbf77f 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/scroller'; 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/utils/scroller.ts b/src/utils/scroller.ts new file mode 100644 index 000000000..4f43025eb --- /dev/null +++ b/src/utils/scroller.ts @@ -0,0 +1,101 @@ +import ScrollerFactory from 'libraries/scroller'; +import globalize from 'scripts/globalize'; + +export enum ScrollDirection { + RIGHT = 'right', + LEFT = 'left', +} + +interface ScrollState { + scrollPosition: number; +} +interface ScrollerSlideViewWindowProps { + direction: ScrollDirection; + scroller: ScrollerFactory | null; + scrollState: ScrollState; +} +export function scrollerItemSlideIntoView({ direction, scroller, scrollState }: ScrollerSlideViewWindowProps) { + if (!scroller) { + return; + } + + const slider: HTMLElement = scroller.getScrollSlider(); + const items = [...slider.children]; + + if (direction === ScrollDirection.LEFT) { + scrollToPreviousVisibleWindow(scroller, items as HTMLElement[], scrollState); + } else { + scrollToNextVisibleWindow(scroller, items as HTMLElement[], scrollState); + } +} + +function getFirstAndLastVisible(scrollFrame: HTMLElement, items: HTMLElement[], { 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 scrollToNextVisibleWindow(scroller: ScrollerFactory, items: HTMLElement[], scrollState: ScrollState) { + const isRTL = globalize.getIsRTL(); + // 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 localeModifier = isRTL ? -1 : 1; + // NOTE: Legacy scroller 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 [, lastVisibleIndex] = getFirstAndLastVisible(scroller.getScrollFrame?.() || scroller, items, scrollState); + + const nextItem = items[lastVisibleIndex]; + const nextItemScrollOffset = lastVisibleIndex * nextItem.offsetWidth; + + // This will be the position to anchor the item at `lastVisibleIndex` to the start of the view window. + const nextItemScrollPos = (nextItemScrollOffset) * localeModifier; + + if (scroller.slideTo) { + scroller.slideTo(nextItemScrollPos, false, undefined); + } else { + // @ts-expect-error Legacy support passes in a `scroller` that isn't a ScrollFactory + scroller.scrollToPosition(nextItemScrollPos); + } +} + +function scrollToPreviousVisibleWindow(scroller: ScrollerFactory, items: HTMLElement[], scrollState: ScrollState) { + // NOTE: Legacy scroller 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 isRTL = globalize.getIsRTL(); + // 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 localeModifier = isRTL ? -1 : 1; + + const [firstVisibleIndex] = getFirstAndLastVisible(frame, items, scrollState); + + 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; + + const previousItemScrollPos = (previousItemScrollOffset - offsetAdjustment) * localeModifier; + + if (scroller.slideTo) { + scroller.slideTo(previousItemScrollPos, false, undefined); + } else { + // @ts-expect-error Legacy support passes in a `scroller` that isn't a ScrollFactory + scroller.scrollToPosition(previousItemScrollPos); + } +} From 76e3761ffdfa5fe61d17d989e2588a487e019baf Mon Sep 17 00:00:00 2001 From: Grady Hallenbeck Date: Mon, 16 Oct 2023 20:38:25 -0700 Subject: [PATCH 2/3] chore: (scroller) combine scroll directions to single function --- src/utils/scroller.ts | 100 +++++++++++++++++++++--------------------- 1 file changed, 51 insertions(+), 49 deletions(-) diff --git a/src/utils/scroller.ts b/src/utils/scroller.ts index 4f43025eb..dc8a7c1d4 100644 --- a/src/utils/scroller.ts +++ b/src/utils/scroller.ts @@ -7,29 +7,39 @@ export enum ScrollDirection { } interface ScrollState { - scrollPosition: number; + scrollPos: number; } -interface ScrollerSlideViewWindowProps { + +interface ScrollerItemSlideIntoViewProps { direction: ScrollDirection; scroller: ScrollerFactory | null; scrollState: ScrollState; } -export function scrollerItemSlideIntoView({ direction, scroller, scrollState }: ScrollerSlideViewWindowProps) { + +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]; + const items = [...slider.children] as HTMLElement[]; - if (direction === ScrollDirection.LEFT) { - scrollToPreviousVisibleWindow(scroller, items as HTMLElement[], scrollState); - } else { - scrollToNextVisibleWindow(scroller, items as HTMLElement[], scrollState); - } + scrollToWindow({ + scroller, + items, + scrollState, + direction + }); } -function getFirstAndLastVisible(scrollFrame: HTMLElement, items: HTMLElement[], { scrollPosition }: ScrollState) { +function getFirstAndLastVisible(scrollFrame: HTMLElement, items: HTMLElement[], { scrollPos: scrollPosition }: ScrollState) { const isRTL = globalize.getIsRTL(); const localeModifier = isRTL ? -1 : 1; @@ -46,56 +56,48 @@ function getFirstAndLastVisible(scrollFrame: HTMLElement, items: HTMLElement[], return [firstVisibleIndex, lastVisibleIndex]; } -function scrollToNextVisibleWindow(scroller: ScrollerFactory, items: HTMLElement[], scrollState: ScrollState) { - const isRTL = globalize.getIsRTL(); +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 localeModifier = isRTL ? -1 : 1; - // NOTE: Legacy scroller 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 [, lastVisibleIndex] = getFirstAndLastVisible(scroller.getScrollFrame?.() || scroller, items, scrollState); - - const nextItem = items[lastVisibleIndex]; - const nextItemScrollOffset = lastVisibleIndex * nextItem.offsetWidth; - - // This will be the position to anchor the item at `lastVisibleIndex` to the start of the view window. - const nextItemScrollPos = (nextItemScrollOffset) * localeModifier; - - if (scroller.slideTo) { - scroller.slideTo(nextItemScrollPos, false, undefined); - } else { - // @ts-expect-error Legacy support passes in a `scroller` that isn't a ScrollFactory - scroller.scrollToPosition(nextItemScrollPos); - } -} - -function scrollToPreviousVisibleWindow(scroller: ScrollerFactory, items: HTMLElement[], scrollState: ScrollState) { - // NOTE: Legacy scroller 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 isRTL = globalize.getIsRTL(); - // 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 localeModifier = isRTL ? -1 : 1; - const [firstVisibleIndex] = getFirstAndLastVisible(frame, items, scrollState); + // 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); - const previousItem = items[firstVisibleIndex]; - const previousItemScrollOffset = firstVisibleIndex * previousItem.offsetWidth; + let scrollToPosition: number; - // 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; + if (direction === ScrollDirection.RIGHT) { + const nextItem = items[lastVisibleIndex]; - const previousItemScrollPos = (previousItemScrollOffset - offsetAdjustment) * localeModifier; + // 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(previousItemScrollPos, false, undefined); + scroller.slideTo(scrollToPosition, false, undefined); } else { // @ts-expect-error Legacy support passes in a `scroller` that isn't a ScrollFactory - scroller.scrollToPosition(previousItemScrollPos); + scroller.scrollToPosition(scrollToPosition); } } From b282dc739a26e2c8eea71628ae9a99079650c172 Mon Sep 17 00:00:00 2001 From: Grady Hallenbeck Date: Mon, 16 Oct 2023 20:40:57 -0700 Subject: [PATCH 3/3] chore: (scroller) colocate scroll utils with scroller buttons --- src/elements/emby-scrollbuttons/ScrollButtons.tsx | 2 +- src/elements/emby-scrollbuttons/emby-scrollbuttons.js | 2 +- src/{utils/scroller.ts => elements/emby-scrollbuttons/utils.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/{utils/scroller.ts => elements/emby-scrollbuttons/utils.ts} (100%) diff --git a/src/elements/emby-scrollbuttons/ScrollButtons.tsx b/src/elements/emby-scrollbuttons/ScrollButtons.tsx index 5d670bb91..20ceaf1e4 100644 --- a/src/elements/emby-scrollbuttons/ScrollButtons.tsx +++ b/src/elements/emby-scrollbuttons/ScrollButtons.tsx @@ -3,7 +3,7 @@ import scrollerFactory from '../../libraries/scroller'; import globalize from '../../scripts/globalize'; import IconButton from '../emby-button/IconButton'; import './emby-scrollbuttons.scss'; -import { ScrollDirection, scrollerItemSlideIntoView } from '../../utils/scroller'; +import { ScrollDirection, scrollerItemSlideIntoView } from './utils'; interface ScrollButtonsProps { scrollerFactoryRef: React.MutableRefObject; diff --git a/src/elements/emby-scrollbuttons/emby-scrollbuttons.js b/src/elements/emby-scrollbuttons/emby-scrollbuttons.js index 2fafbf77f..7adfaa8e7 100644 --- a/src/elements/emby-scrollbuttons/emby-scrollbuttons.js +++ b/src/elements/emby-scrollbuttons/emby-scrollbuttons.js @@ -2,7 +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/scroller'; +import { scrollerItemSlideIntoView } from './utils'; const EmbyScrollButtonsPrototype = Object.create(HTMLDivElement.prototype); diff --git a/src/utils/scroller.ts b/src/elements/emby-scrollbuttons/utils.ts similarity index 100% rename from src/utils/scroller.ts rename to src/elements/emby-scrollbuttons/utils.ts