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:
parent
1e64e96028
commit
c223b7c2d7
3 changed files with 121 additions and 48 deletions
|
@ -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<scrollerFactory | null>;
|
||||
|
@ -22,31 +18,16 @@ const ScrollButtons: FC<ScrollButtonsProps> = ({ scrollerFactoryRef, scrollState
|
|||
const [localeScrollPos, setLocaleScrollPos] = useState<number>(0);
|
||||
const scrollButtonsRef = useRef<HTMLDivElement>(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;
|
||||
|
|
|
@ -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 () {
|
||||
|
|
101
src/utils/scroller.ts
Normal file
101
src/utils/scroller.ts
Normal 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);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue