/* eslint-disable prefer-template */ /* eslint-disable no-else-return */ import React from 'react'; import _ from 'lodash'; import SingleElement from './SingleElement'; // import debounce from '../lib/debounce'; // import { scrollTo } from '../lib/smoothScrollTo'; import { Styled } from './CarouselQueue.style'; import { Button } from './Button.style'; import { tabletDelimiter } from '../lib/customMediaQuery'; /** * @param { object } themeColor - Carousel Theme color, including prev/next buttons and scroll bar * @param { string } themeColor.reminder - reminder color * @param { string } themeColor.reminderTxt - reminder Text Color * @param { object } [reminder] - both ends reminder * @param { boolean } [showReminder = true] - whether to show reminder * @param { string } [reminder.firstTxt = 'First One'] - text on the reminder for first one, default first one * @param { string } [reminder.lastTxt = 'Last One'] - text on the reminder for last one, default last one * @param { boolean } [componentHeight = 'auto'] - height of the Carousel, * @param { object } [buttonText] - buttons text * @param { boolean } [showButton = true] - whether show buttons * @param { number } [buttonWidth = 20] - button width * @param { number } [buttonHeight = 100] - button height * @param { boolean } [buttonText.isImageBg = false] - whether take image as background, image size should be 40 x 100 * @param { string } [buttonText.prev = '<'] - prev button text / img src * @param { string } [buttonText.next = '>'] - next button text / img src * @param { number } gap - space between children * @param { number } [roundCorner = 0] - round corner of child element * @param { boolean } isDivElement - if the children are div element * @param { array } [urlArray] - if not div elements, urlArray has to be set * @param { number } [divElementMinWidth] - if div element, need to set divElementMinWidth */ interface Props { themeColor: { reminder: string; reminderTxt: string }; reminder: { showReminder: boolean; firstTxt: string; lastTxt: string }; buttonText?: { showButton: boolean; buttonWidth: number; buttonHeight: number; isImageBg: boolean; prev: string; next: string; }; componentHeight?: number; gap: number; // gap is necessary roundCorner?: number; isDivElement: boolean; urlArray?: { url: string; link?: string; isVideo?: boolean }[]; divElementMinWidth?: number; } interface Position { position: 'left-end' | 'right-end' | 'middle'; } // TODO: fixed amount children(not exactly!!!) /** * when imageHolder is shorter than container * e.g.: only have one slide, but the container is ready to show 2 * solution: imageHolder occupy 100%, hold the elements as normal * */ // FIXME: not working in Safari const CarouselQueue: React.FC = (props) => { // const [containerWidth, setContainerWidth] = React.useState(0); // const [currentSliderIndex, setCurrentSliderIndex] = React.useState(0); const [carouselPosition, setCarouselPosition] = React.useState({ position: 'left-end', }); const containerRef = React.useRef(null); const imagesHolderRef = React.useRef(null); const imagesHolderBeforeRef = React.useRef(null); const imagesHolderAfterRef = React.useRef(null); const [ imageHolderBeforeVisibility, setImageHolderBeforeVisibility, ] = React.useState(false); const [ imageHolderAfterVisibility, setImageHolderAfterVisibility, ] = React.useState(false); const prevButtonRef = React.useRef(null); const nextButtonRef = React.useRef(null); // const [slideRefs, setSlideRefs] = React.useState< // Array<(ele: HTMLElement) => void> // >([]); const [slidesPosition, setSlidesPosition] = React.useState< Array<[number | undefined, number | undefined]> >(); const [currentFirstIndex, setCurrentFirstIndex] = React.useState(0); const [holderScrollLeft, setHolderScrollLeft] = React.useState(0); const { themeColor, reminder, buttonText, componentHeight, gap, roundCorner, isDivElement, urlArray, divElementMinWidth, children, } = props; const [thisUrlArray, setThisUrlArray] = React.useState( urlArray || [{ url: '', link: '', isVideo: false }] ); const [thisThemeColor, setThisThemeColor] = React.useState( themeColor || { reminder: '', reminderTxt: '' } ); const [thisReminder, setThisReminder] = React.useState( reminder || { showReminder: true, firstTxt: 'First One', lastTxt: 'Last One', } ); const [thisButtonText, setThisButtonText] = React.useState( buttonText || { showButton: false, buttonWidth: 20, buttonHeight: 40, isImageBg: false, prev: '<', next: '>', } ); const [thisComponentHeight, setThisComponentHeight] = React.useState( componentHeight || 0 ); const [thisGap, setThisGap] = React.useState(gap); const [thisRoundCorner, setThisRoundCorner] = React.useState( roundCorner || 12 ); const [thisIsDivElement, setThisIsDivElement] = React.useState( isDivElement || false ); const [thisDivElementMinWidth, setThisDivElementMinWidth] = React.useState( divElementMinWidth || 200 ); const [thisChildren, setThisChildren] = React.useState(children); // const [slidesBoundingInfo, setSlidesBoundingInfo] = React.useState({}); // set new state when get new prop React.useEffect(() => { if (props.urlArray !== undefined) setThisUrlArray(props.urlArray); if (props.themeColor !== undefined) setThisThemeColor(props.themeColor); if (props.reminder !== undefined) setThisReminder(props.reminder); if (props.buttonText !== undefined) setThisButtonText(props.buttonText); if (props.componentHeight !== undefined) setThisComponentHeight(props.componentHeight); if (props.gap !== undefined) setThisGap(props.gap); if (props.roundCorner !== undefined) setThisRoundCorner(props.roundCorner); if (props.isDivElement !== undefined) setThisIsDivElement(props.isDivElement); if (props.divElementMinWidth !== undefined) setThisDivElementMinWidth(props.divElementMinWidth); if (props.children !== undefined) setThisChildren(props.children); }, [props]); // handler drag move carousel React.useEffect(() => { if (imagesHolderRef.current !== null) { const holder = imagesHolderRef.current; holder.style.cursor = 'grab'; let pos = { top: 0, left: 0, x: 0, y: 0 }; const mouseDownHandler = (e: MouseEvent) => { e.stopPropagation(); // console.log('Mouse down'); holder.style.cursor = 'grabbing'; holder.style.userSelect = 'none'; holder.style.removeProperty('scroll-snap-type'); holder.style.margin = '0'; // scroll snap will conflict with preset margin pos = { left: holder.scrollLeft, top: holder.scrollTop, x: e.clientX, y: e.clientY, }; holder.addEventListener('mousemove', mouseMoveHandler); holder.addEventListener('mouseup', mouseUpHandler); }; const mouseMoveHandler = (e: MouseEvent) => { e.stopPropagation(); // console.log('Mouse move'); const dx = e.clientX - pos.x; const dy = e.clientY - pos.y; holder.scrollTop = pos.top - dy; holder.scrollLeft = pos.left - dx; }; const mouseUpHandler = (e: MouseEvent) => { e.stopPropagation(); // console.log('Mouse up'); holder.style.cursor = 'grab'; holder.style.removeProperty('user-select'); holder.style['scroll-snap-type' as any] = 'x mandatory'; // console.log('ScrollLeft: ' + holder.scrollLeft); // console.log('width: ' + holder.offsetWidth); // console.log('ScrollWidth: ' + holder.scrollWidth); if (holder.scrollWidth - holder.offsetWidth <= holder.scrollLeft) { setCarouselPosition({ position: 'right-end' }); } else if (holder.scrollLeft <= thisGap) { setCarouselPosition({ position: 'left-end' }); } else { setCarouselPosition({ position: 'middle' }); } setHolderScrollLeft(holder.scrollLeft); // console.log(slidesPosition); const _tempIndexArray = slidesPosition ?.map((x, index) => { if (x[0] && x[0] > holder.scrollLeft) { return index; } if (index === 0 && holder.scrollLeft === 0) { return 0; } return null; }) .filter((x) => x !== null); // console.log(_tempIndexArray); const _currentFirstIndex = _tempIndexArray ? _tempIndexArray[0] : 0; // console.log(_currentFirstIndex); if (_currentFirstIndex) setCurrentFirstIndex(_currentFirstIndex); holder.removeEventListener('mousemove', mouseMoveHandler); holder.removeEventListener('mouseup', mouseUpHandler); }; holder.addEventListener('mousedown', mouseDownHandler); const touchstartHandler = (e: TouchEvent) => { e.stopPropagation(); // console.log('touch start'); holder.style.cursor = 'grabbing'; holder.style.userSelect = 'none'; holder.style.removeProperty('scroll-snap-type'); holder.style.margin = '0'; // scroll snap will conflict with preset margin pos = { left: holder.scrollLeft, top: holder.scrollTop, x: e.touches[0].clientX, y: e.touches[0].clientY, }; holder.addEventListener('touchmove', touchmoveHandler); holder.addEventListener('touchend', touchendHandler); holder.addEventListener('touchcancel', touchcancelHandler); }; const touchmoveHandler = (e: TouchEvent) => { e.stopPropagation(); // console.log('touch move'); const dx = e.touches[0].clientX - pos.x; const dy = e.touches[0].clientY - pos.y; holder.scrollTop = pos.top - dy; holder.scrollLeft = pos.left - dx; }; const touchendHandler = (e: TouchEvent) => { e.stopPropagation(); // console.log('touch end'); holder.style.cursor = 'grab'; holder.style.removeProperty('user-select'); holder.style['scroll-snap-type' as any] = 'x mandatory'; // console.log('ScrollLeft: '+holder.scrollLeft); // console.log('width: '+holder.offsetWidth); // console.log('ScrollWidth: '+holder.scrollWidth); if (holder.scrollWidth - holder.offsetWidth <= holder.scrollLeft) { setCarouselPosition({ position: 'right-end' }); } else if (holder.scrollLeft <= thisGap) { setCarouselPosition({ position: 'left-end' }); } else { setCarouselPosition({ position: 'middle' }); } setHolderScrollLeft(holder.scrollLeft); const _tempIndexArray = slidesPosition ?.map((x, index) => { if (x[0] && x[0] > holder.scrollLeft) { return index; } if (index === 0 && holder.scrollLeft === 0) { return 0; } return null; }) .filter((x) => x !== null); const _currentFirstIndex = _tempIndexArray ? _tempIndexArray[0] : 0; if (_currentFirstIndex) setCurrentFirstIndex(_currentFirstIndex); holder.removeEventListener('touchmove', touchmoveHandler); holder.removeEventListener('touchend', touchendHandler); }; const touchcancelHandler = () => { // console.log('touch canceled'); holder.removeEventListener('touchmove', touchmoveHandler); holder.removeEventListener('touchend', touchendHandler); holder.removeEventListener('touchcancel', touchcancelHandler); }; holder.addEventListener('touchstart', touchstartHandler); return () => { holder.removeEventListener('mousedown', mouseDownHandler); holder.removeEventListener('mousemove', mouseMoveHandler); holder.removeEventListener('mouseup', mouseUpHandler); holder.removeEventListener('touchstart', touchstartHandler); holder.removeEventListener('touchmove', touchmoveHandler); holder.removeEventListener('touchend', touchendHandler); holder.removeEventListener('touchcancel', touchcancelHandler); }; } else { return () => {}; } }, [slidesPosition]); // both end reminder React.useEffect(() => { const _before = imagesHolderBeforeRef.current; const _after = imagesHolderAfterRef.current; // console.log('ScrollLeft: '+holder.scrollLeft); // console.log('width: '+holder.offsetWidth); // console.log('ScrollWidth: '+holder.scrollWidth); if (_before && _after) { if (carouselPosition.position === 'left-end') { setImageHolderBeforeVisibility(true); setTimeout(() => { setImageHolderBeforeVisibility(false); }, 2000); } else if (carouselPosition.position === 'right-end') { setImageHolderAfterVisibility(true); setTimeout(() => { setImageHolderAfterVisibility(false); }, 2000); } else { setImageHolderAfterVisibility(false); setImageHolderBeforeVisibility(false); } } }, [carouselPosition]); // when reaching ends disable buttons React.useEffect(() => { if (prevButtonRef.current && nextButtonRef.current) { const prevButton = prevButtonRef.current; const nextButton = nextButtonRef.current; if (carouselPosition.position === 'left-end') { // left end, disable prev prevButton.style.filter = 'grayscale(1)'; prevButton.style.cursor = 'not-allowed'; nextButton.style.filter = 'none'; nextButton.style.cursor = 'pointer'; } else if (carouselPosition.position === 'right-end') { // right end, disable next nextButton.style.filter = 'grayscale(1)'; nextButton.style.cursor = 'not-allowed'; prevButton.style.filter = 'none'; prevButton.style.cursor = 'pointer'; } else { prevButton.style.filter = 'none'; nextButton.style.filter = 'none'; prevButton.style.cursor = 'pointer'; nextButton.style.cursor = 'pointer'; } } }, [carouselPosition]); // initialize callback refs // measure children with callback refs let _tempLength = 0; if (thisUrlArray[0].url) { // thisUrlArray has a default value {url:"",link:"", isVideo:false} _tempLength = thisUrlArray.length; } else if (React.Children) { _tempLength = React.Children.toArray(thisChildren).length; } let slidesBoundingInfo = {}; let offsetLeftWidthInfo = {}; // console.dir(thisUrlArray); const callbackRefsArray = Array(_tempLength) .fill(0) .map((_, i) => { // console.dir('This is a callback ref'); return (ele) => { const thisIndex = i; if (ele) { // console.dir(ele); // console.dir(ele.offsetLeft); offsetLeftWidthInfo = { ...offsetLeftWidthInfo, [thisIndex]: [ele.offsetLeft, ele.offsetWidth], }; slidesBoundingInfo = { ...slidesBoundingInfo, [thisIndex]: ele.getBoundingClientRect(), }; // console.log(`Number ${thisIndex} is added!`); } }; }); // console.dir(callbackRefsArray); // hide buttons when content is shorter than container React.useEffect(() => { if (slidesPosition && imagesHolderRef.current) { let contentWidth = slidesPosition.reduce( (acc, cur) => acc + Number(cur[1]), 0 ); contentWidth += (slidesPosition.length - 1) * thisGap; // console.dir(contentWidth); // console.dir(imagesHolderRef.current?.offsetWidth); if (contentWidth < imagesHolderRef.current.offsetWidth) { setThisButtonText({ ...thisButtonText, showButton: false, }); } if ( contentWidth > imagesHolderRef.current.offsetWidth && buttonText?.showButton ) { // console.dir('1'); setThisButtonText({ ...thisButtonText, showButton: true, }); } } }, [slidesPosition]); // setSlidesPosition React.useEffect(() => { const _tempSlidesPosition: Array<[number, number]> = Array( Object.keys(offsetLeftWidthInfo).length ) .fill([0, 0]) .map((_, index) => { return [offsetLeftWidthInfo[index][0], offsetLeftWidthInfo[index][1]]; }); // console.dir(_tempSlidesPosition); setSlidesPosition(_tempSlidesPosition); }, [thisUrlArray]); const resizeHandler = _.debounce(() => { // setSlidesPosition(getSlidesPosition()); const _tempSlidesPosition: Array<[number, number]> = Array( Object.keys(offsetLeftWidthInfo).length ) .fill([0, 0]) .map((_, index) => { return [offsetLeftWidthInfo[index][0], offsetLeftWidthInfo[index][1]]; }); // console.dir(_tempSlidesPosition); setSlidesPosition(_tempSlidesPosition); }, 500); // set slidesPosition on window resize React.useEffect(() => { // set containerWidth on window resize // Don't apply [] to this useEffect, otherwise offsetWidth will not equal to the real width after first render window.addEventListener('resize', resizeHandler); return () => { window.removeEventListener('resize', resizeHandler); }; }, []); // get current first card's index React.useEffect(() => { const _tempIndexArray = slidesPosition ?.map((x, index) => { if (x[0] && x[0] > holderScrollLeft) { return index; } if (index === 0 && holderScrollLeft === 0) { return 0; } return null; }) .filter((x) => x !== null); const _currentFirstIndex = _tempIndexArray ? _tempIndexArray[0] : 0; // _tempIndexArray[0]: get the first element in the result array if (_currentFirstIndex) setCurrentFirstIndex(_currentFirstIndex); }, []); // set holder scroll left base on index React.useEffect(() => { const _tempSlidesPosition: Array<[number, number]> = Array( Object.keys(offsetLeftWidthInfo).length ) .fill([0, 0]) .map((_, index) => { return [offsetLeftWidthInfo[index][0], offsetLeftWidthInfo[index][1]]; }); const holder = imagesHolderRef.current; // console.dir(_tempSlidesPosition); if ( _tempSlidesPosition && _tempSlidesPosition[currentFirstIndex] && holder ) { const targetScrollLeft = _tempSlidesPosition[currentFirstIndex][0]; if (typeof targetScrollLeft !== 'undefined') { holder.scrollLeft = targetScrollLeft; } // console.log(targetScrollLeft); setHolderScrollLeft(holder.scrollLeft); } // console.log('set holder scroll left base on index'); }, [currentFirstIndex]); // handle prev button React.useEffect(() => { if (prevButtonRef.current !== null && imagesHolderRef.current !== null) { const prevButton = prevButtonRef.current; const holder = imagesHolderRef.current; const mouseDownHandler = (e: MouseEvent) => { e.stopPropagation(); // console.log(holder.scrollLeft); let _currentFirstIndex = currentFirstIndex; // console.log(holder.scrollWidth); // console.log(holder.scrollLeft); // console.log(holder.offsetWidth); if (holder.scrollWidth - holder.scrollLeft < holder.offsetWidth) { setCarouselPosition({ position: 'right-end' }); // keep moving _currentFirstIndex -= 1; } else if (_currentFirstIndex === 0) { setCarouselPosition({ position: 'left-end' }); } else if (holder.scrollLeft === 0) { setCarouselPosition({ position: 'left-end' }); // reaching left end, do nothing } else { setCarouselPosition({ position: 'middle' }); // keep moving // console.log('keep moving'); _currentFirstIndex -= 1; // if (_currentFirstIndex === 0) // setCarouselPosition({ position: 'left-end' }); } // console.log(`afterCLick index: ${_currentFirstIndex}`); setCurrentFirstIndex(_currentFirstIndex); }; prevButton.addEventListener('mousedown', mouseDownHandler); return () => { prevButton.removeEventListener('mousedown', mouseDownHandler); }; } else { return () => {}; } }, [slidesPosition, currentFirstIndex]); // handle next button React.useEffect(() => { if (nextButtonRef.current !== null && imagesHolderRef.current !== null) { const nextButton = nextButtonRef.current; const holder = imagesHolderRef.current; const clickHandler = (e: MouseEvent) => { e.stopPropagation(); // console.log(`currentFirstIndex: ${currentFirstIndex}`); let _currentFirstIndex = currentFirstIndex; if (holder.scrollLeft >= holder.scrollWidth - holder.offsetWidth) { setCarouselPosition({ position: 'right-end' }); // reaching right end, do nothing // console.dir('right-end'); } else if (_currentFirstIndex === 0) { // keep moving _currentFirstIndex += 1; setCarouselPosition({ position: 'middle' }); } else { // console.dir('middle'); setCarouselPosition({ position: 'middle' }); // keep moving _currentFirstIndex += 1; } // console.log(`_currentFirstIndex: ${_currentFirstIndex}`); setCurrentFirstIndex(_currentFirstIndex); }; nextButton.addEventListener('click', clickHandler); return () => { nextButton.removeEventListener('click', clickHandler); }; } else { return () => {}; } }, [slidesPosition, currentFirstIndex]); // DONE: disable buttons when imageHolder is shorter than container // React.useEffect(() => { // const _tempSlidesPosition: Array<[number, number]> = Array( // Object.keys(offsetLeftWidthInfo).length // ) // .fill([0, 0]) // .map((_, index) => { // return [offsetLeftWidthInfo[index][0], offsetLeftWidthInfo[index][1]]; // }); // let contentLength = 0; // _tempSlidesPosition.map((x) => { // contentLength = contentLength + thisGap + x[1]; // return <>; // }); // // console.dir(_tempSlidesPosition); // // console.dir(imagesHolderRef.current?.offsetWidth); // // console.dir(contentLength); // const containerLength = imagesHolderRef.current?.offsetWidth; // if (containerLength && contentLength < containerLength) { // // console.log('triggered function to hide buttons'); // setThisButtonText({ // ...thisButtonText, // showButton: false, // }); // } // }, []); const mouseEnterHandler = () => { // DONE: hide buttons when tablet const mql = window.matchMedia(`(max-width: ${tabletDelimiter}px)`); if (mql.matches) return; if (prevButtonRef.current && nextButtonRef.current) { prevButtonRef.current.style.display = 'grid'; nextButtonRef.current.style.display = 'grid'; } }; const mouseLeaveHandler = () => { const mql = window.matchMedia(`(max-width: ${tabletDelimiter}px)`); if (mql.matches) return; if (prevButtonRef.current && nextButtonRef.current) { prevButtonRef.current.style.display = 'none'; nextButtonRef.current.style.display = 'none'; } }; const buttonContent = (param: 'prev' | 'next') => { const tempBtn = { prev: '<', next: '>', }; if (thisButtonText) { if (thisButtonText.isImageBg && thisButtonText[param].length >= 4) { return {param}; } return tempBtn[param]; } return tempBtn[param]; }; const setColor = (param: 'reminder' | 'reminderTxt') => { const tempColor = { reminder: '#000000', reminderTxt: '#fff', }; if (thisThemeColor && thisThemeColor[param]) { if (/^#([0-9A-F]{3}){1,2}$/i.test(thisThemeColor[param])) { // valid hex color return thisThemeColor[param]; } console.error(`themeColor.${param} need to be valid hex color code`); return tempColor[param]; } return tempColor[param]; }; const setButtonColor = (param: 'buttonText' | 'button' | 'scrollBar') => { const tempColor = { buttonText: '#fff', button: '#961c1c', scrollBar: 'darkgrey', }; if (thisThemeColor && thisThemeColor[param]) { if (/^#([0-9A-F]{3}){1,2}$/i.test(thisThemeColor[param])) { // valid hex color return thisThemeColor[param]; } console.error(`themeColor.${param} need to be valid hex color code`); return tempColor[param]; } return tempColor[param]; }; const reminderContent = (param: 'firstTxt' | 'lastTxt') => { const tempBtn = { firstTxt: 'First One', lastTxt: 'Last One', }; if (thisReminder && thisReminder[param]) { return thisReminder[param]; } return tempBtn[param]; }; const setDivMinWidth = () => { if (thisIsDivElement) { if (thisDivElementMinWidth) { return thisDivElementMinWidth; } else { console.error('Div Elements must have minWidth'); return undefined; } } else { return undefined; } }; if (thisUrlArray[0].url) { return ( mouseEnterHandler()} onMouseLeave={() => mouseLeaveHandler()} ref={containerRef} > {thisButtonText?.showButton ? ( <> {buttonContent('prev')} {buttonContent('next')} ) : ( <> )} {thisReminder?.showReminder ? ( {reminderContent('firstTxt')} ) : ( <> )} {thisUrlArray.map((x, index) => { // console.dir(callbackRefsArray?.[index]?.toString()); return ( console.dir(ele) as any} /> ); })} {thisReminder?.showReminder ? ( {reminderContent('lastTxt')} ) : ( <> )} ); } return ( mouseEnterHandler()} onMouseLeave={() => mouseLeaveHandler()} ref={containerRef} > {thisButtonText?.showButton ? ( <> {buttonContent('prev')} {buttonContent('next')} ) : ( <> )} {thisReminder?.showReminder ? ( {reminderContent('firstTxt')} ) : ( <> )} {thisIsDivElement && thisChildren ? React.Children.map( thisChildren as any, (child: React.ReactElement, index: number) => { return ( {child} ); } ) : 'Please set thisUrlArray or children'} {thisReminder?.showReminder ? ( {reminderContent('lastTxt')} ) : ( <> )} ); }; export default CarouselQueue;