1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

fix: (scroller) scroll to card boundaries

This commit is contained in:
Grady Hallenbeck 2023-10-07 19:25:58 -07:00
parent 1e64e96028
commit c223b7c2d7
3 changed files with 121 additions and 48 deletions

View file

@ -3,11 +3,7 @@ import scrollerFactory from '../../libraries/scroller';
import globalize from '../../scripts/globalize'; import globalize from '../../scripts/globalize';
import IconButton from '../emby-button/IconButton'; import IconButton from '../emby-button/IconButton';
import './emby-scrollbuttons.scss'; import './emby-scrollbuttons.scss';
import { ScrollDirection, scrollerItemSlideIntoView } from '../../utils/scroller';
enum Direction {
RIGHT,
LEFT,
}
interface ScrollButtonsProps { interface ScrollButtonsProps {
scrollerFactoryRef: React.MutableRefObject<scrollerFactory | null>; scrollerFactoryRef: React.MutableRefObject<scrollerFactory | null>;
@ -22,31 +18,16 @@ const ScrollButtons: FC<ScrollButtonsProps> = ({ scrollerFactoryRef, scrollState
const [localeScrollPos, setLocaleScrollPos] = useState<number>(0); const [localeScrollPos, setLocaleScrollPos] = useState<number>(0);
const scrollButtonsRef = useRef<HTMLDivElement>(null); const scrollButtonsRef = useRef<HTMLDivElement>(null);
const scrollToPosition = useCallback((pos: number, immediate: boolean) => { const onScrollButtonClick = useCallback((direction: ScrollDirection) => {
if (scrollerFactoryRef.current) { scrollerItemSlideIntoView({
scrollerFactoryRef.current.slideTo(pos, immediate, undefined ); direction,
} scroller: scrollerFactoryRef.current,
}, [scrollerFactoryRef]); scrollState
});
}, [scrollState, scrollerFactoryRef]);
const onScrollButtonClick = useCallback((direction: Direction) => { const triggerScrollLeft = useCallback(() => onScrollButtonClick(ScrollDirection.LEFT), [ onScrollButtonClick ]);
let newPos; const triggerScrollRight = useCallback(() => onScrollButtonClick(ScrollDirection.RIGHT), [ onScrollButtonClick ]);
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 ]);
useEffect(() => { useEffect(() => {
const parent = scrollButtonsRef.current?.parentNode as HTMLDivElement; const parent = scrollButtonsRef.current?.parentNode as HTMLDivElement;

View file

@ -2,6 +2,7 @@ import './emby-scrollbuttons.scss';
import 'webcomponents.js/webcomponents-lite'; import 'webcomponents.js/webcomponents-lite';
import '../emby-button/paper-icon-button-light'; import '../emby-button/paper-icon-button-light';
import globalize from '../../scripts/globalize'; import globalize from '../../scripts/globalize';
import { scrollerItemSlideIntoView } from '../../utils/scroller';
const EmbyScrollButtonsPrototype = Object.create(HTMLDivElement.prototype); const EmbyScrollButtonsPrototype = Object.create(HTMLDivElement.prototype);
@ -128,26 +129,16 @@ function getScrollSize(elem) {
} }
function onScrollButtonClick() { function onScrollButtonClick() {
const scroller = this.parentNode.nextSibling;
const direction = this.getAttribute('data-direction'); const direction = this.getAttribute('data-direction');
const scrollSize = getScrollSize(scroller); const scroller = this.parentNode.nextSibling;
const scrollPos = getScrollPosition(scroller); const scrollPosition = getScrollPosition(scroller);
scrollerItemSlideIntoView({
let newPos; direction,
if (direction === 'left') { scroller,
newPos = Math.max(0, scrollPos - scrollSize); scrollState: {
} else { scrollPos: scrollPosition
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);
} }
EmbyScrollButtonsPrototype.attachedCallback = function () { EmbyScrollButtonsPrototype.attachedCallback = function () {

101
src/utils/scroller.ts Normal file
View file

@ -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);
}
}