[251011] fix: ProudctAllSection scroller 리렌더링 최적화

🕐 커밋 시간: 2025. 10. 11. 19:42:08

📊 변경 통계:
  • 총 파일: 4개
  • 추가: +48줄
  • 삭제: -32줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/views/DetailPanel/components/AutoScrollAreaDetail/AutoScrollAreaDetail.jsx
  + com.twin.app.shoptime/src/views/DetailPanel/components/AutoScrollAreaDetail/AutoScrollAreaDetail.module.less

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
  ~ com.twin.app.shoptime/src/views/DetailPanel/components/TScroller/TScrollerDetail.jsx

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx (javascript):
    🔄 Modified: LayoutSample()
  📄 com.twin.app.shoptime/src/views/DetailPanel/components/TScroller/TScrollerDetail.jsx (javascript):
     Added: scrollToElement()
  📄 com.twin.app.shoptime/src/views/DetailPanel/components/AutoScrollAreaDetail/AutoScrollAreaDetail.jsx (javascript):
     Added: animationScroll()

🔧 주요 변경 내용:
  • UI 컴포넌트 아키텍처 개선
This commit is contained in:
2025-10-11 19:42:11 +09:00
parent 6125ca034c
commit 110cd2760a
4 changed files with 500 additions and 395 deletions

View File

@@ -1,31 +1,19 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import { throttle } from 'lodash';
import { PropTypes } from 'prop-types';
import {
useDispatch,
useSelector,
} from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import Spotlight from '@enact/spotlight';
/* eslint-disable react/jsx-no-bind */
// src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
import SpotlightContainerDecorator
from '@enact/spotlight/SpotlightContainerDecorator';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
//image
import arrowDown
from '../../../../assets/images/icons/ic_arrow_down_3x_new.png';
import indicatorDefaultImage
from '../../../../assets/images/img-thumb-empty-144@3x.png';
import arrowDown from '../../../../assets/images/icons/ic_arrow_down_3x_new.png';
import indicatorDefaultImage from '../../../../assets/images/img-thumb-empty-144@3x.png';
import { pushPanel } from '../../../actions/panelActions';
import { resetShowAllReviews } from '../../../actions/productActions';
import { showToast } from '../../../actions/toastActions';
@@ -59,16 +47,12 @@ import ProductTag from '../components/ProductTag';
import StarRating from '../components/StarRating';
// ProductContentSection imports
import TScrollerDetail from '../components/TScroller/TScrollerDetail';
import ProductDescription
from '../ProductContentSection/ProductDescription/ProductDescription';
import ProductDetail
from '../ProductContentSection/ProductDetail/ProductDetail.new';
import ProductDescription from '../ProductContentSection/ProductDescription/ProductDescription';
import ProductDetail from '../ProductContentSection/ProductDetail/ProductDetail.new';
import ProductVideo from '../ProductContentSection/ProductVideo/ProductVideo';
import UserReviews from '../ProductContentSection/UserReviews/UserReviews';
import ViewAllReviewsButton
from '../ProductContentSection/UserReviews/ViewAllReviewsButton';
import YouMayAlsoLike
from '../ProductContentSection/YouMayAlsoLike/YouMayAlsoLike';
import ViewAllReviewsButton from '../ProductContentSection/UserReviews/ViewAllReviewsButton';
import YouMayAlsoLike from '../ProductContentSection/YouMayAlsoLike/YouMayAlsoLike';
import QRCode from '../ProductInfoSection/QRCode/QRCode';
import ProductOverview from '../ProductOverview/ProductOverview';
// CSS imports
@@ -78,45 +62,42 @@ import css from './ProductAllSection.module.less';
const Container = SpotlightContainerDecorator(
{
enterTo: "last-focused",
enterTo: 'last-focused',
preserveld: true,
leaveFor: { right: "content-scroller-container" },
spotlightDirection: "vertical",
leaveFor: { right: 'content-scroller-container' },
spotlightDirection: 'vertical',
},
"div"
'div'
);
const ContentContainer = SpotlightContainerDecorator(
{
enterTo: "default-element",
enterTo: 'default-element',
preserveld: true,
leaveFor: {
left: "spotlight-product-info-section-container",
left: 'spotlight-product-info-section-container',
},
restrict: "none",
spotlightDirection: "vertical",
restrict: 'none',
spotlightDirection: 'vertical',
},
"div"
'div'
);
const HorizontalContainer = SpotlightContainerDecorator(
{
enterTo: "last-focused",
enterTo: 'last-focused',
preserveld: true,
defaultElement: "spotlight-product-info-section-container",
spotlightDirection: "horizontal",
defaultElement: 'spotlight-product-info-section-container',
spotlightDirection: 'horizontal',
},
"div"
'div'
);
// FP: Pure function to determine product data based on typeP
const getProductData = curry((productType, themeProductInfo, productInfo) =>
pipe(
when(
() =>
isVal(productType) &&
productType === "theme" &&
isVal(themeProductInfo),
() => isVal(productType) && productType === 'theme' && isVal(themeProductInfo),
() => themeProductInfo
),
defaultTo(productInfo),
@@ -132,17 +113,17 @@ const deriveFavoriteFlag = curry((favoriteOverride, productData) => {
return favoriteOverride;
}
// 그렇지 않으면 productData의 favorYn 값을 사용 (기본값 'N')
return pipe(get("favorYn"), defaultTo("N"))(productData);
return pipe(get('favorYn'), defaultTo('N'))(productData);
});
// FP: Pure function to extract review grade and order phone
const extractProductMeta = (productInfo) => ({
revwGrd: get("revwGrd", productInfo),
orderPhnNo: get("orderPhnNo", productInfo),
revwGrd: get('revwGrd', productInfo),
orderPhnNo: get('orderPhnNo', productInfo),
});
// 레이아웃 확인용 샘플 컴포넌트 - Spottable로 변경
const SpottableComponent = Spottable("div");
const SpottableComponent = Spottable('div');
const LayoutSample = ({ onClick }) => (
<SpottableComponent
@@ -171,9 +152,7 @@ export default function ProductAllSection({
const dispatch = useDispatch();
// Redux 상태
const webOSVersion = useSelector(
(state) => state.common.appStatus.webOSVersion
);
const webOSVersion = useSelector((state) => state.common.appStatus.webOSVersion);
const groupInfos = useSelector((state) => state.product.groupInfo);
// YouMayLike 데이터는 API 응답 시간이 걸리므로 직접 구독
@@ -224,20 +203,20 @@ export default function ProductAllSection({
// 단품(결제 가능 상품) - DetailPanel.backup.jsx와 동일한 로직
const isBillingProductVisible = useMemo(() => {
return (
productData?.pmtSuptYn === "Y" &&
productData?.grPrdtProcYn === "N" &&
productData?.pmtSuptYn === 'Y' &&
productData?.grPrdtProcYn === 'N' &&
panelInfo?.prdtId &&
webOSVersion >= "6.0"
webOSVersion >= '6.0'
);
}, [productData, webOSVersion, panelInfo?.prdtId]);
// 구매 불가 상품 - DetailPanel.backup.jsx와 동일한 로직
const isUnavailableProductVisible = useMemo(() => {
return (
productData?.pmtSuptYn === "N" ||
(productData?.pmtSuptYn === "Y" &&
productData?.grPrdtProcYn === "N" &&
webOSVersion < "6.0" &&
productData?.pmtSuptYn === 'N' ||
(productData?.pmtSuptYn === 'Y' &&
productData?.grPrdtProcYn === 'N' &&
webOSVersion < '6.0' &&
panelInfo?.prdtId)
);
}, [productData, webOSVersion, panelInfo?.prdtId]);
@@ -245,8 +224,8 @@ export default function ProductAllSection({
// 그룹 상품 - DetailPanel.backup.jsx와 동일한 로직
const isGroupProductVisible = useMemo(() => {
return (
productData?.pmtSuptYn === "Y" &&
productData?.grPrdtProcYn === "Y" &&
productData?.pmtSuptYn === 'Y' &&
productData?.grPrdtProcYn === 'Y' &&
groupInfos &&
groupInfos.length > 0
);
@@ -254,10 +233,7 @@ export default function ProductAllSection({
// 여행/테마 상품 - DetailPanel.backup.jsx와 동일한 로직
const isTravelProductVisible = useMemo(() => {
return (
panelInfo?.curationId &&
(panelInfo?.type === "theme" || panelInfo?.type === "hotel")
);
return panelInfo?.curationId && (panelInfo?.type === 'theme' || panelInfo?.type === 'hotel');
}, [panelInfo]);
// useReviews Hook 사용 - 모든 리뷰 관련 로직을 담당
@@ -283,69 +259,66 @@ export default function ProductAllSection({
// ProductAllSection 마운트 시 showAllReviews 초기화
useEffect(() => {
console.log(
"[ProductAllSection] Component mounted - resetting showAllReviews to false"
);
console.log('[ProductAllSection] Component mounted - resetting showAllReviews to false');
dispatch(resetShowAllReviews());
}, []); // 빈 dependency array = 마운트 시에만 실행
// [임시 테스트] LayoutSample 클릭 핸들러 - ShowUserReviews와 동일하게 UserReviewPanel 열기
const handleLayoutSampleClick = useCallback(() => {
console.log(`[ProductId] LayoutSample clicked - opening UserReviewPanel`, {
productDataPrdtId: productData && productData.prdtId,
hasProductData: !!productData,
reviewTotalCount: stats.totalReviews,
averageRating: stats.averageRating,
productData: productData,
});
dispatch(
pushPanel({
name: panel_names.USER_REVIEW_PANEL,
panelInfo: {
prdtId: productData.prdtId,
productImage:
(productData.imgUrls600 && productData.imgUrls600[0]) ||
(productData.imgUrls && productData.imgUrls[0]) ||
productData.thumbnailUrl ||
"https://placehold.co/150x150",
brandLogo: productData.patncLogoPath || "https://placehold.co/50x50",
productName: productData.prdtNm || "상품명 정보가 없습니다",
avgRating: stats.averageRating || 5,
reviewCount: stats.totalReviews || 0,
},
})
);
}, [dispatch, productData, stats]);
// const handleLayoutSampleClick = useCallback(() => {
// console.log(`[ProductId] LayoutSample clicked - opening UserReviewPanel`, {
// productDataPrdtId: productData && productData.prdtId,
// hasProductData: !!productData,
// reviewTotalCount: stats.totalReviews,
// averageRating: stats.averageRating,
// productData: productData,
// });
// dispatch(
// pushPanel({
// name: panel_names.USER_REVIEW_PANEL,
// panelInfo: {
// prdtId: productData.prdtId,
// productImage:
// (productData.imgUrls600 && productData.imgUrls600[0]) ||
// (productData.imgUrls && productData.imgUrls[0]) ||
// productData.thumbnailUrl ||
// "https://placehold.co/150x150",
// brandLogo: productData.patncLogoPath || "https://placehold.co/50x50",
// productName: productData.prdtNm || "상품명 정보가 없습니다",
// avgRating: stats.averageRating || 5,
// reviewCount: stats.totalReviews || 0,
// },
// })
// );
// }, [dispatch, productData, stats]);
// BUY NOW 버튼 클릭 핸들러 - Toast로 BuyOption 표시
const handleBuyNowClick = useCallback(() => {
console.log("[BuyNow] Buy Now button clicked");
console.log('[BuyNow] Buy Now button clicked');
dispatch(
showToast({
message: "",
type: "buyOption",
message: '',
type: 'buyOption',
duration: 0,
position: "bottom-center",
position: 'bottom-center',
})
);
}, [dispatch]);
// ADD TO CART 버튼 클릭 핸들러
const handleAddToCartClick = useCallback(() => {
console.log("[AddToCart] Add To Cart button clicked");
console.log('[AddToCart] Add To Cart button clicked');
// TODO: 장바구니 추가 로직 구현
}, []);
// 디버깅: 실제 이미지 및 동영상 데이터 확인
useEffect(() => {
console.log("[ProductId] ProductAllSection productData check:", {
console.log('[ProductId] ProductAllSection productData check:', {
hasProductData: !!productData,
productDataPrdtId: productData && productData.prdtId,
imgUrls600: productData && productData.imgUrls600,
imgUrls600Length:
productData && productData.imgUrls600 && productData.imgUrls600.length,
imgUrls600Length: productData && productData.imgUrls600 && productData.imgUrls600.length,
imgUrls600Type: Array.isArray(productData && productData.imgUrls600)
? "array"
? 'array'
: typeof (productData && productData.imgUrls600),
// 동영상 관련 정보 추가
prdtMediaUrl: productData && productData.prdtMediaUrl,
@@ -357,10 +330,7 @@ export default function ProductAllSection({
});
}, [productData, renderItems]);
const { revwGrd, orderPhnNo } = useMemo(
() => extractProductMeta(productInfo),
[productInfo]
);
const { revwGrd, orderPhnNo } = useMemo(() => extractProductMeta(productInfo), [productInfo]);
// FP: derive favorite flag from props with local override, avoid non-I/O useEffect
const [favoriteOverride, setFavoriteOverride] = useState(null);
@@ -383,7 +353,7 @@ export default function ProductAllSection({
// User Reviews 스크롤 핸들러 추가
const handleUserReviewsClick = useCallback(
() => scrollToSection("scroll-marker-user-reviews"),
() => scrollToSection('scroll-marker-user-reviews'),
[]
);
@@ -400,7 +370,7 @@ export default function ProductAllSection({
// 동영상이 있으면 첫 번째에 추가 (Indicator.jsx와 동일한 로직)
if (productData && productData.prdtMediaUrl) {
items.push({
type: "video",
type: 'video',
url: productData.prdtMediaUrl,
thumbnail: productData.thumbnailUrl960 || indicatorDefaultImage,
index: 0,
@@ -408,17 +378,12 @@ export default function ProductAllSection({
}
// 이미지들 추가
if (
productData &&
productData.imgUrls600 &&
productData.imgUrls600.length > 0
) {
if (productData && productData.imgUrls600 && productData.imgUrls600.length > 0) {
productData.imgUrls600.forEach((image, imgIndex) => {
items.push({
type: "image",
type: 'image',
url: image,
index:
productData && productData.prdtMediaUrl ? imgIndex + 1 : imgIndex,
index: productData && productData.prdtMediaUrl ? imgIndex + 1 : imgIndex,
});
});
}
@@ -437,7 +402,7 @@ export default function ProductAllSection({
// FP: Pure function for focus navigation to back button
const handleSpotlightUpToBackButton = useCallback((e) => {
e.stopPropagation();
Spotlight.focus("spotlightId_backBtn");
Spotlight.focus('spotlightId_backBtn');
}, []);
// FP: Pure function for favorite flag change
@@ -450,7 +415,7 @@ export default function ProductAllSection({
const handleThemeItemButtonClick = useCallback(
pipe(
() => setOpenThemeItemOverlay(true),
tap(() => setTimeout(() => Spotlight.focus("theme-close-button"), 0))
tap(() => setTimeout(() => Spotlight.focus('theme-close-button'), 0))
),
[setOpenThemeItemOverlay]
);
@@ -471,12 +436,12 @@ export default function ProductAllSection({
// FP: Curried scroll handlers
const handleProductDetailsClick = useCallback(
() => scrollToSection("scroll-marker-product-details"),
() => scrollToSection('scroll-marker-product-details'),
[scrollToSection]
);
const handleYouMayAlsoLikeClick = useCallback(
() => scrollToSection("scroll-marker-you-may-also-like"),
() => scrollToSection('scroll-marker-you-may-also-like'),
[scrollToSection]
);
const scrollPositionRef = useRef(0);
@@ -546,12 +511,7 @@ export default function ProductAllSection({
(descriptionRef.current?.scrollHeight || 0) +
(reviewRef.current?.scrollHeight || 0)
);
}, [
productDetailRef.current,
descriptionRef.current,
hasReviews,
hasYouMayAlsoLike,
]);
}, [productDetailRef.current, descriptionRef.current, hasReviews, hasYouMayAlsoLike]);
//spot관련
useEffect(() => {
@@ -575,10 +535,10 @@ export default function ProductAllSection({
<div className={css.leftInfoWrapper}>
<div className={css.headerContent}>
<ProductTag productInfo={productData} />
{revwGrd && revwGrd !== "0.0" && (
{revwGrd && revwGrd !== '0.0' && (
<StarRating
rating={revwGrd}
aria-label={"star rating " + revwGrd + " out of 5"}
aria-label={'star rating ' + revwGrd + ' out of 5'}
/>
)}
</div>
@@ -593,17 +553,13 @@ export default function ProductAllSection({
>
<div className={css.qrWrapper}>
{isShowQRCode ? (
<QRCode
productInfo={productData}
productType={productType}
kind={"detail"}
/>
<QRCode productInfo={productData} productType={productType} kind={'detail'} />
) : (
<div className={css.qrRollingWrap}>
<div className={css.innerText}>
<h3>{$L("Scan QR")}</h3>
<p>{$L("with your phone, Check Product")}</p>
<p>{$L("info & Purchase easily")}</p>
<h3>{$L('Scan QR')}</h3>
<p>{$L('with your phone, Check Product')}</p>
<p>{$L('info & Purchase easily')}</p>
</div>
</div>
)}
@@ -619,7 +575,7 @@ export default function ProductAllSection({
onSpotlightUp={handleSpotlightUpToBackButton}
type="detail_small"
>
<div className={css.buyNowText}>{$L("BUY NOW")}</div>
<div className={css.buyNowText}>{$L('BUY NOW')}</div>
</TButton>
<TButton
spotlightId="detail-add-to-cart-button"
@@ -628,7 +584,7 @@ export default function ProductAllSection({
onSpotlightUp={handleSpotlightUpToBackButton}
type="detail_small"
>
<div className={css.addToCartText}>{$L("ADD TO CART")}</div>
<div className={css.addToCartText}>{$L('ADD TO CART')}</div>
</TButton>
</HorizontalContainer>
)}
@@ -645,9 +601,7 @@ export default function ProductAllSection({
onClick={handleShopByMobileOpen}
onSpotlightUp={handleSpotlightUpToBackButton}
>
<div className={css.shopByMobileText}>
{$L("SHOP BY MOBILE")}
</div>
<div className={css.shopByMobileText}>{$L('SHOP BY MOBILE')}</div>
</TButton>
{panelInfo && (
<div className={css.favoriteBtnWrapper}>
@@ -657,7 +611,7 @@ export default function ProductAllSection({
selectedPrdtId={panelInfo && panelInfo.prdtId}
favoriteFlag={favoriteFlag}
onFavoriteFlagChanged={onFavoriteFlagChanged}
kind={"item_detail"}
kind={'item_detail'}
/>
</div>
)}
@@ -666,9 +620,7 @@ export default function ProductAllSection({
<div className={css.callToOrderSection}>
{orderPhnNo && (
<>
<div className={css.callToOrderText}>
{$L("Call to Order")}
</div>
<div className={css.callToOrderText}>{$L('Call to Order')}</div>
<div className={css.phoneSection}>
<div className={css.phoneIconContainer}>
<div className={css.phoneIcon} />
@@ -686,34 +638,31 @@ export default function ProductAllSection({
<TButton
className={classNames(
css.productDetailsButton,
activeProductBtn ? css.active : ""
activeProductBtn ? css.active : ''
)}
onClick={handleProductDetailsClick}
spotlightId="product-details-button"
>
{$L("PRODUCT DETAILS")}
{$L('PRODUCT DETAILS')}
</TButton>
{hasReviews && (
<TButton
className={classNames(
css.userReviewsButton,
activeReviewBtn ? css.active : ""
)}
className={classNames(css.userReviewsButton, activeReviewBtn ? css.active : '')}
onClick={handleUserReviewsClick}
spotlightId="user-reviews-button"
>
{$L("USER REVIEWS")} ({reviewTotalCount})
{$L('USER REVIEWS')} ({reviewTotalCount})
</TButton>
)}
{hasYouMayAlsoLike && (
<TButton
className={classNames(
css.youMayLikeButton,
activeYouMayLikeBtn ? css.active : ""
activeYouMayLikeBtn ? css.active : ''
)}
onClick={handleYouMayAlsoLikeClick}
>
{$L("YOU MAY ALSO LIKE")}
{$L('YOU MAY ALSO LIKE')}
</TButton>
)}
{/* YouMayLike 버튼 렌더링 상태 로그 */}
@@ -727,18 +676,15 @@ export default function ProductAllSection({
})()} */}
</Container>
{panelInfo &&
panelInfo &&
panelInfo.type === "theme" &&
!openThemeItemOverlay && (
<TButton
className={css.themeButton}
onClick={handleThemeItemButtonClick}
spotlightId="theme-open-button"
>
{$L("THEME ITEM")}
</TButton>
)}
{panelInfo && panelInfo && panelInfo.type === 'theme' && !openThemeItemOverlay && (
<TButton
className={css.themeButton}
onClick={handleThemeItemButtonClick}
spotlightId="theme-open-button"
>
{$L('THEME ITEM')}
</TButton>
)}
<DetailMobileSendPopUp
ismobileSendPopupOpen={mobileSendPopupOpen}
@@ -770,10 +716,7 @@ export default function ProductAllSection({
onScroll={handleScroll}
>
<div className={css.productDetail}>
<div
id="scroll-marker-product-details"
className={css.scrollMarker}
></div>
<div id="scroll-marker-product-details" className={css.scrollMarker}></div>
{/* <LayoutSample onClick={handleLayoutSampleClick} /> */}
<div
id="product-details-section"
@@ -783,7 +726,7 @@ export default function ProductAllSection({
>
{renderItems.length > 0 ? (
renderItems.map((item, index) =>
item.type === "video" ? (
item.type === 'video' ? (
<ProductVideo
key="product-video-0"
productInfo={productData}
@@ -817,10 +760,7 @@ export default function ProductAllSection({
{/* 리뷰가 있을 때만 UserReviews 섹션 표시 */}
{hasReviews && (
<>
<div
id="scroll-marker-user-reviews"
className={css.scrollMarker}
></div>
<div id="scroll-marker-user-reviews" className={css.scrollMarker}></div>
<div
id="user-reviews-section"
ref={reviewRef}
@@ -844,10 +784,7 @@ export default function ProductAllSection({
</div>
{hasYouMayAlsoLike && (
<div ref={youMayAlsoLikelRef}>
<div
id="scroll-marker-you-may-also-like"
className={css.scrollMarker}
></div>
<div id="scroll-marker-you-may-also-like" className={css.scrollMarker}></div>
<div id="you-may-also-like-section">
{/* {(() => {
console.log('[YouMayLike] YouMayAlsoLike 컴포넌트 렌더링:', {
@@ -885,5 +822,5 @@ export default function ProductAllSection({
}
ProductAllSection.propTypes = {
productType: PropTypes.oneOf(["buyNow", "shopByMobile", "theme"]).isRequired,
productType: PropTypes.oneOf(['buyNow', 'shopByMobile', 'theme']).isRequired,
};

View File

@@ -0,0 +1,126 @@
import React, { useCallback, useRef, useEffect } from 'react';
import classNames from 'classnames';
import Spottable from '@enact/spotlight/Spottable';
import { AUTO_SCROLL_GAP } from '../../../../utils/Config';
import { scaleW } from '../../../../utils/helperMethods';
import css from './AutoScrollAreaDetail.module.less';
const AutoScrollComponent = Spottable('div');
const POSITION = {
left: 'left',
right: 'right',
top: 'top',
bottom: 'bottom',
};
/**
* DetailPanel 전용 AutoScrollArea
* cursorVisible Redux 구독을 제거하여 TScrollerDetail 재렌더링 방지
*/
export default function AutoScrollAreaDetail({
position,
autoScroll,
scrollHorizontalPos,
scrollVerticalPos,
scrollToRef,
scrollPosition,
direction,
}) {
const requestIdRef = useRef();
// cursorVisible Redux 구독 제거 - TScrollerDetail 재렌더링 방지
const handleFocusAutoScroll = useCallback(() => {
if (!autoScroll) return;
const scrollStep =
position === 'right' || position === 'bottom'
? scaleW(AUTO_SCROLL_GAP)
: scaleW(-AUTO_SCROLL_GAP);
let start = null;
const animationScroll = (timestamp) => {
if (!start) start = timestamp;
const progress = timestamp - start;
const step = Math.min(progress / 1000, 1);
if (direction === 'horizontal') {
scrollHorizontalPos.current += scrollStep * step;
scrollToRef.current({
position: { x: scrollHorizontalPos.current },
animate: false,
});
} else if (direction === 'vertical') {
scrollVerticalPos.current += scrollStep * step;
scrollToRef.current({
position: { y: scrollVerticalPos.current },
animate: false,
});
}
if (
direction === 'horizontal' &&
((position === 'right' && scrollPosition.current === 'right') ||
(position === 'left' && scrollPosition.current === 'left'))
) {
if (typeof window === 'object') {
window.cancelAnimationFrame(requestIdRef.current);
}
} else if (
direction === 'vertical' &&
((position === 'bottom' && scrollPosition.current === 'bottom') ||
(position === 'top' && scrollPosition.current === 'top'))
) {
if (typeof window === 'object') {
window.cancelAnimationFrame(requestIdRef.current);
}
} else {
if (typeof window === 'object') {
requestIdRef.current = window.requestAnimationFrame(animationScroll);
}
}
};
if (typeof window === 'object') {
requestIdRef.current = window.requestAnimationFrame(animationScroll);
}
}, [
autoScroll,
position,
scrollHorizontalPos,
scrollVerticalPos,
scrollToRef,
scrollPosition,
direction,
]);
const handleBlur = useCallback(() => {
if (requestIdRef.current && typeof window === 'object') {
window.cancelAnimationFrame(requestIdRef.current);
}
}, [autoScroll, direction]);
useEffect(() => {
return () => {
if (requestIdRef.current && typeof window === 'object') {
window.cancelAnimationFrame(requestIdRef.current);
}
};
}, []);
return (
<AutoScrollComponent
className={classNames(css.autoScrollArea, position && css[position])}
spotlightDisabled={false}
disabled={false}
onFocus={handleFocusAutoScroll}
onBlur={handleBlur}
/>
);
}
export { POSITION };

View File

@@ -0,0 +1,36 @@
.autoScrollArea {
position: absolute;
z-index: 20;
background-color: transparent; /* production */
// background-color: #ff00001f; /* develop */
&.left {
width: 50px;
height: 100%;
top: 50%;
left: 0;
transform: translateY(-50%);
}
&.right {
width: 50px;
height: 100%;
top: 50%;
right: 0;
transform: translateY(-50%);
}
&.top {
width: 100%;
height: 50px;
top: 0;
left: 0;
}
&.bottom {
width: 100%;
height: 50px;
bottom: 0;
left: 0;
}
}

View File

@@ -1,236 +1,225 @@
import React, {
useCallback,
useEffect,
useRef,
useState,
useMemo,
forwardRef,
} from "react";
import React, { useCallback, useEffect, useRef, useState, useMemo, forwardRef } from 'react';
import classNames from "classnames";
import { useSelector } from "react-redux";
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { off, on } from "@enact/core/dispatcher";
import { Job } from "@enact/core/util";
import Scroller from "@enact/sandstone/Scroller";
import { off, on } from '@enact/core/dispatcher';
import { Job } from '@enact/core/util';
import Scroller from '@enact/sandstone/Scroller';
import AutoScrollArea, { POSITION } from "../../../../components/AutoScrollArea/AutoScrollArea";
import css from "./TScrollerDetail.module.less";
import AutoScrollAreaDetail, { POSITION } from '../AutoScrollAreaDetail/AutoScrollAreaDetail';
import css from './TScrollerDetail.module.less';
/**
* DetailPanel 전용 TScroller - 커스텀 스크롤바 구현
* onScroll* event can't use Callback dependency
*/
const TScrollerDetail = forwardRef(({
className,
children,
verticalScrollbar = "hidden",
focusableScrollbar = false,
direction = "vertical",
horizontalScrollbar = "hidden",
scrollMode,
onScrollStart,
onScrollStop,
onScroll,
noScrollByWheel = false,
cbScrollTo,
autoScroll = direction === "horizontal",
setScrollVerticalPos,
setCheckScrollPosition,
...rest
}, ref) => {
const { cursorVisible } = useSelector((state) => state.common.appStatus);
const isScrolling = useRef(false);
const scrollPosition = useRef("top");
const scrollToRef = useRef(null);
const scrollHorizontalPos = useRef(0);
const scrollVerticalPos = useRef(0);
const actualScrollerElement = useRef(null); // 실제 스크롤 DOM 요소
const [isMounted, setIsMounted] = useState(false);
// ref를 내부 Scroller 요소에 연결
useEffect(() => {
if (ref && isMounted) {
// DOM에서 Scroller 요소 찾기
let scrollerElement = document.querySelector(`.${css.tScroller}`);
if (!scrollerElement) {
// 다른 방법으로 찾기
scrollerElement = document.querySelector('[data-spotlight-container="true"]');
}
if (!scrollerElement) {
// 스크롤 가능한 요소 찾기
scrollerElement = document.querySelector('[style*="overflow"]');
}
if (scrollerElement) {
// ref가 함수인 경우와 객체인 경우를 모두 처리
if (typeof ref === 'function') {
ref(scrollerElement);
} else if (ref && ref.current !== undefined) {
ref.current = scrollerElement;
}
actualScrollerElement.current = scrollerElement; // 실제 스크롤 요소 저장
}
}
}, [ref, isMounted]);
// 스크롤 제어 메서드 추가
const scrollToElement = useCallback((element) => {
if (actualScrollerElement.current && element) {
const scrollerRect = actualScrollerElement.current.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const relativeTop = elementRect.top - scrollerRect.top;
const scrollTop = actualScrollerElement.current.scrollTop + relativeTop - 20;
actualScrollerElement.current.scrollTo({
top: scrollTop,
behavior: 'smooth'
});
}
}, []);
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
const _onScrollStart = useCallback(
(e) => {
if (onScrollStart) {
onScrollStart(e);
}
isScrolling.current = true;
const TScrollerDetail = forwardRef(
(
{
className,
children,
verticalScrollbar = 'hidden',
focusableScrollbar = false,
direction = 'vertical',
horizontalScrollbar = 'hidden',
scrollMode,
onScrollStart,
onScrollStop,
onScroll,
noScrollByWheel = false,
cbScrollTo,
autoScroll = direction === 'horizontal',
setScrollVerticalPos,
setCheckScrollPosition,
...rest
},
[onScrollStart]
);
ref
) => {
// cursorVisible을 Redux에서 구독하지 않음 - children 변경 방지
const _onScrollStop = useCallback(
(e) => {
if (onScrollStop) {
onScrollStop(e);
const isScrolling = useRef(false);
const scrollPosition = useRef('top');
const scrollToRef = useRef(null);
const scrollHorizontalPos = useRef(0);
const scrollVerticalPos = useRef(0);
const actualScrollerElement = useRef(null); // 실제 스크롤 DOM 요소
const [isMounted, setIsMounted] = useState(false);
// ref를 내부 Scroller 요소에 연결
useEffect(() => {
if (ref && isMounted) {
// DOM에서 Scroller 요소 찾기
let scrollerElement = document.querySelector(`.${css.tScroller}`);
if (!scrollerElement) {
// 다른 방법으로 찾기
scrollerElement = document.querySelector('[data-spotlight-container="true"]');
}
if (!scrollerElement) {
// 스크롤 가능한 요소 찾기
scrollerElement = document.querySelector('[style*="overflow"]');
}
if (scrollerElement) {
// ref가 함수인 경우와 객체인 경우를 모두 처리
if (typeof ref === 'function') {
ref(scrollerElement);
} else if (ref && ref.current !== undefined) {
ref.current = scrollerElement;
}
actualScrollerElement.current = scrollerElement; // 실제 스크롤 요소 저장
}
}
}, [ref, isMounted]);
isScrolling.current = false;
// 스크롤 제어 메서드 추가
const scrollToElement = useCallback((element) => {
if (actualScrollerElement.current && element) {
const scrollerRect = actualScrollerElement.current.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const relativeTop = elementRect.top - scrollerRect.top;
const scrollTop = actualScrollerElement.current.scrollTop + relativeTop - 20;
if (e.reachedEdgeInfo) {
if (e.reachedEdgeInfo.top) {
scrollPosition.current = "top";
} else if (e.reachedEdgeInfo.bottom) {
scrollPosition.current = "bottom";
} else if (e.reachedEdgeInfo.left) {
scrollPosition.current = "left";
} else if (e.reachedEdgeInfo.right) {
scrollPosition.current = "right";
actualScrollerElement.current.scrollTo({
top: scrollTop,
behavior: 'smooth',
});
}
}, []);
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
const _onScrollStart = useCallback(
(e) => {
if (onScrollStart) {
onScrollStart(e);
}
isScrolling.current = true;
},
[onScrollStart]
);
const _onScrollStop = useCallback(
(e) => {
if (onScrollStop) {
onScrollStop(e);
}
isScrolling.current = false;
if (e.reachedEdgeInfo) {
if (e.reachedEdgeInfo.top) {
scrollPosition.current = 'top';
} else if (e.reachedEdgeInfo.bottom) {
scrollPosition.current = 'bottom';
} else if (e.reachedEdgeInfo.left) {
scrollPosition.current = 'left';
} else if (e.reachedEdgeInfo.right) {
scrollPosition.current = 'right';
} else {
scrollPosition.current = 'middle';
}
} else {
scrollPosition.current = "middle";
scrollPosition.current = 'middle';
}
} else {
scrollPosition.current = "middle";
scrollHorizontalPos.current = e.scrollLeft;
scrollVerticalPos.current = e.scrollTop;
if (setScrollVerticalPos) {
setScrollVerticalPos(scrollVerticalPos.current);
}
if (setCheckScrollPosition) {
setCheckScrollPosition(scrollPosition.current);
}
},
[onScrollStop]
);
const _onScroll = useCallback(
(ev) => {
if (onScroll) {
onScroll(ev);
}
},
[onScroll]
);
const _cbScrollTo = useCallback(
(ref) => {
if (cbScrollTo) {
cbScrollTo(ref);
}
scrollToRef.current = ref;
},
[cbScrollTo]
);
const relevantPositions = useMemo(() => {
switch (direction) {
case 'horizontal':
return ['left', 'right'];
case 'vertical':
return ['top', 'bottom'];
default:
return [];
}
}, [direction]);
scrollHorizontalPos.current = e.scrollLeft;
scrollVerticalPos.current = e.scrollTop;
if (setScrollVerticalPos) {
setScrollVerticalPos(scrollVerticalPos.current);
}
if (setCheckScrollPosition) {
setCheckScrollPosition(scrollPosition.current);
}
},
[onScrollStop]
);
const _onScroll = useCallback(
(ev) => {
if (onScroll) {
onScroll(ev);
}
},
[onScroll]
);
const _cbScrollTo = useCallback(
(ref) => {
if (cbScrollTo) {
cbScrollTo(ref);
}
scrollToRef.current = ref;
},
[cbScrollTo]
);
const relevantPositions = useMemo(() => {
switch (direction) {
case "horizontal":
return ["left", "right"];
case "vertical":
return ["top", "bottom"];
default:
return [];
}
}, [direction]);
return (
<div
className={classNames(
className ? className : null,
css.scrollerContainer
)}
>
<Scroller
cbScrollTo={_cbScrollTo}
onScrollStart={_onScrollStart}
onScrollStop={_onScrollStop}
onScroll={_onScroll}
scrollMode={scrollMode || "translate"}
focusableScrollbar={focusableScrollbar}
className={classNames(
isMounted && css.tScroller,
noScrollByWheel && css.preventScroll
)}
direction={direction}
horizontalScrollbar={horizontalScrollbar}
verticalScrollbar={verticalScrollbar}
overscrollEffectOn={{
arrowKey: false,
drag: false,
pageKey: false,
track: false,
wheel: false,
}}
noScrollByWheel={noScrollByWheel}
noScrollByDrag
// rest props에서 ref만 제외하고 전달
{...(rest.ref ? { ...rest, ref: undefined } : rest)}
>
{children}
</Scroller>
{cursorVisible &&
autoScroll &&
relevantPositions.map((pos) => (
<AutoScrollArea
key={pos}
position={POSITION[pos]}
autoScroll={autoScroll}
scrollHorizontalPos={scrollHorizontalPos}
scrollVerticalPos={scrollVerticalPos}
scrollToRef={scrollToRef}
scrollPosition={scrollPosition}
direction={direction}
/>
))}
</div>
);
});
return (
<div className={classNames(className ? className : null, css.scrollerContainer)}>
<Scroller
cbScrollTo={_cbScrollTo}
onScrollStart={_onScrollStart}
onScrollStop={_onScrollStop}
onScroll={_onScroll}
scrollMode={scrollMode || 'translate'}
focusableScrollbar={focusableScrollbar}
className={classNames(isMounted && css.tScroller, noScrollByWheel && css.preventScroll)}
direction={direction}
horizontalScrollbar={horizontalScrollbar}
verticalScrollbar={verticalScrollbar}
overscrollEffectOn={{
arrowKey: false,
drag: false,
pageKey: false,
track: false,
wheel: false,
}}
noScrollByWheel={noScrollByWheel}
noScrollByDrag
// rest props에서 ref만 제외하고 전달
{...(rest.ref ? { ...rest, ref: undefined } : rest)}
>
{children}
</Scroller>
{autoScroll &&
relevantPositions.map((pos) => (
<AutoScrollAreaDetail
key={pos}
position={POSITION[pos]}
autoScroll={autoScroll}
scrollHorizontalPos={scrollHorizontalPos}
scrollVerticalPos={scrollVerticalPos}
scrollToRef={scrollToRef}
scrollPosition={scrollPosition}
direction={direction}
/>
))}
</div>
);
}
);
// TScrollerDetail에 메서드 노출
TScrollerDetail.scrollToElement = (element) => {
@@ -240,5 +229,22 @@ TScrollerDetail.scrollToElement = (element) => {
// displayName을 명확하게 설정
TScrollerDetail.displayName = 'TScrollerDetail';
// React.memo로 최적화 - props가 동일하면 재렌더링 방지
const MemoizedTScrollerDetail = React.memo(TScrollerDetail, (prevProps, nextProps) => {
// children 비교는 얕은 비교로 충분 (React.Children.count 사용)
const childrenEqual =
React.Children.count(prevProps.children) === React.Children.count(nextProps.children);
// 주요 props 비교
return (
childrenEqual &&
prevProps.className === nextProps.className &&
prevProps.verticalScrollbar === nextProps.verticalScrollbar &&
prevProps.direction === nextProps.direction &&
prevProps.cbScrollTo === nextProps.cbScrollTo &&
prevProps.onScroll === nextProps.onScroll
);
});
// forwardRef를 사용하는 컴포넌트임을 명시
export default TScrollerDetail;
export default MemoizedTScrollerDetail;