From 222a625b6391ebfc47cad174b30d8d97139bc878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=B8=EB=AF=BC?= <89172499+semnil5202@users.noreply.github.com> Date: Fri, 5 Apr 2024 06:23:21 +0900 Subject: [PATCH] =?UTF-8?q?Refactor/#87:=20=EB=8C=93=EA=B8=80=20=EB=B0=8F?= =?UTF-8?q?=20=EB=8B=B5=EA=B8=80=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=ED=8F=AC?= =?UTF-8?q?=EC=BB=A4=EC=8B=B1=20=EB=AC=B8=EC=A0=9C,=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 브라우저 resize 이벤트로 디바이스 키보드 높이값 계산하여 Context로 할당 * refactor: MobileViewContext 변경사항 적용 * refactor: 각 OS 및 디바이스에 따라 스크롤 로직 분기 적용 및 보완 * feat: 댓글이 하나도 없을 경우 Empty Section 표시 * fix: 무한 스크롤 intersectionRef div 위치 조정 * feat: 댓글 및 답글 프로필 클릭 시 해당 프로필 페이지로 네비게이션 * feat: 게시글 상세 profile 영역 클릭 시 해당 프로필 페이지로 네비게이션 * refactor: 불필요한 Promise 대신 setTimeout 적용 * refactor: Empty Section 에서 댓글 반영이 되지 않던 오류 수정 * refactor: 세부 스킬 영역이 길 경우 오작동 우려가 있어 프로필 이미지 클릭시 프로필 라우팅하도록 변경 --- src/components/ProfileInfo.tsx | 9 +- src/hooks/useInitScrollPosition.ts | 2 +- src/layouts/MobileView.tsx | 33 +++++-- src/layouts/contexts/MobileViewContext.tsx | 5 +- src/pages/FeedDetail/FeedDetail.page.tsx | 3 +- src/pages/FeedDetail/components/Comment.tsx | 2 + .../components/CommentProfileInfo.tsx | 14 ++- src/pages/FeedDetail/components/Comments.tsx | 37 +++++--- src/pages/FeedDetail/components/Recomment.tsx | 2 + .../contexts/CommentFocusContext.tsx | 88 ++++++++++++++++--- src/pages/FeedDetail/types/index.ts | 3 + 11 files changed, 159 insertions(+), 39 deletions(-) diff --git a/src/components/ProfileInfo.tsx b/src/components/ProfileInfo.tsx index 8da8e138..bc371e90 100644 --- a/src/components/ProfileInfo.tsx +++ b/src/components/ProfileInfo.tsx @@ -1,7 +1,10 @@ import { Text, TextDivider, Box, Flex, ImageView, PNGDefaultProfileInfo36 } from 'concept-be-design-system'; import { Fragment } from 'react'; +import useNavigatePage from '../pages/hooks/useNavigatePage'; + interface Props { + memberId: number; imageUrl: string; nickname: string; skillList: string[]; @@ -9,9 +12,11 @@ interface Props { // TODO: 프로필 이미지 사진 오류 시 보여줄 기본 프로필 이미지 사진 URL -const ProfileInfo = ({ imageUrl, nickname, skillList }: Props) => { +const ProfileInfo = ({ memberId, imageUrl, nickname, skillList }: Props) => { + const { goProfilePage } = useNavigatePage(); + return ( - + goProfilePage(memberId)} cursor="pointer"> diff --git a/src/hooks/useInitScrollPosition.ts b/src/hooks/useInitScrollPosition.ts index bfc1dea1..71a10c6d 100644 --- a/src/hooks/useInitScrollPosition.ts +++ b/src/hooks/useInitScrollPosition.ts @@ -12,7 +12,7 @@ const setPageScrollPosition = (pageName: string, position: number) => { }; const useInitScrollPosition = (pageName: string) => { - const mobileViewRef = useMobileViewRefContext(); + const { mobileViewRef } = useMobileViewRefContext(); const { hasMatched } = useRouteMatched(); useLayoutEffect(() => { diff --git a/src/layouts/MobileView.tsx b/src/layouts/MobileView.tsx index 239780b0..bccb3168 100644 --- a/src/layouts/MobileView.tsx +++ b/src/layouts/MobileView.tsx @@ -1,18 +1,41 @@ import styled from '@emotion/styled'; -import { useRef } from 'react'; +import { MutableRefObject, useEffect, useRef } from 'react'; import { Outlet } from 'react-router-dom'; import { MobileViewRefContext } from './contexts/MobileViewContext'; import Navbar from './Navbar'; -const MobileView = () => { - // TODO: Header 도메인 얽힘 문제 해결 확인 시 사용 예정 - // const isMatchedHeader = hasMatched('/feed', '/feed/:id', '/profile', '/profile/:id', '/profile/:id/more'); +const innerHeight = window.innerHeight; + +const calculateKeyboardHeight = (keyboardHeightRef: MutableRefObject) => { + const visualViewHeight = window.visualViewport?.height; + + if (visualViewHeight && keyboardHeightRef.current === 0) { + keyboardHeightRef.current = innerHeight - visualViewHeight; + } +}; +const MobileView = () => { const mobileViewRef = useRef(null); + const keyboardHeightRef = useRef(0); + + useEffect(() => { + if (!window.visualViewport) return; + const windowVisualViewPort = window.visualViewport; + + const onResizeViewPortHeight = () => { + calculateKeyboardHeight(keyboardHeightRef); + }; + + windowVisualViewPort.addEventListener('resize', onResizeViewPortHeight); + + return () => { + windowVisualViewPort.removeEventListener('resize', onResizeViewPortHeight); + }; + }, []); return ( - + diff --git a/src/layouts/contexts/MobileViewContext.tsx b/src/layouts/contexts/MobileViewContext.tsx index 1acf98d0..715b829e 100644 --- a/src/layouts/contexts/MobileViewContext.tsx +++ b/src/layouts/contexts/MobileViewContext.tsx @@ -2,6 +2,7 @@ import { MutableRefObject, createContext, useContext } from 'react'; interface MobileViewMainContextProps { mobileViewRef: MutableRefObject; + keyboardHeightRef: MutableRefObject; } export const MobileViewRefContext = createContext(null); @@ -17,7 +18,7 @@ const useMobileViewContext = () => { export const useMobileViewRefContext = () => { const context = useMobileViewContext(); - const { mobileViewRef } = context; + const { mobileViewRef, keyboardHeightRef } = context; - return mobileViewRef; + return { mobileViewRef, keyboardHeightRef }; }; diff --git a/src/pages/FeedDetail/FeedDetail.page.tsx b/src/pages/FeedDetail/FeedDetail.page.tsx index eee015e0..69d5c0b2 100644 --- a/src/pages/FeedDetail/FeedDetail.page.tsx +++ b/src/pages/FeedDetail/FeedDetail.page.tsx @@ -17,6 +17,7 @@ const FeedDetailPage = () => { const navigate = useNavigate(); const { id: feedId } = useParams() as { id: string }; const { + memberId, imageUrl, nickname, skillList, @@ -59,7 +60,7 @@ const FeedDetailPage = () => { - +
diff --git a/src/pages/FeedDetail/components/Comment.tsx b/src/pages/FeedDetail/components/Comment.tsx index 4d7a4efd..59e0eafe 100644 --- a/src/pages/FeedDetail/components/Comment.tsx +++ b/src/pages/FeedDetail/components/Comment.tsx @@ -27,6 +27,7 @@ const Comment = ({ myImageUrl, myNickname, comment: { + memberId, parentCommentId, nickname, profileImageUrl, @@ -86,6 +87,7 @@ const Comment = ({ { +const CommentProfileInfo = ({ memberId, imageUrl, nickname, skillList, createdAt }: Props) => { + const { goProfilePage } = useNavigatePage(); return ( - + goProfilePage(memberId)} + cursor="pointer" + > diff --git a/src/pages/FeedDetail/components/Comments.tsx b/src/pages/FeedDetail/components/Comments.tsx index e01218a6..8502e37b 100644 --- a/src/pages/FeedDetail/components/Comments.tsx +++ b/src/pages/FeedDetail/components/Comments.tsx @@ -1,8 +1,9 @@ -import { Box, Divider } from 'concept-be-design-system'; +import { Box, Divider, SVGProfileMessageDots } from 'concept-be-design-system'; import { Fragment, useRef } from 'react'; import Comment from './Comment'; import WriteComment from './WriteComment'; +import EmptyTabContentSection from '../../Profile/components/EmptyTabContentSection'; import { useMemberInfoQuery } from '../../Profile/hooks/queries/useMemberInfoQuery'; import { getUserId } from '../../Profile/utils/getUserId'; import useCommentsQuery from '../hooks/queries/useCommentsQuery'; @@ -22,19 +23,27 @@ const Comments = ({ feedId }: Props) => { return ( - {comments.map((comment, idx) => ( - - - {idx !== comments.length - 1 ? : <>} - - ))} -
+ {comments.length > 0 ? ( + comments.map((comment, idx) => ( + + + {idx !== comments.length - 1 ? : <>} + + )) + ) : ( + <> +
+ + + )} + {/* 무한 스크롤이 간헐적으로 되지 않는 문제 때문에 생긴 중복 분기 처리 로직입니다. 문제 해결시 리팩토링 예정 */} + {comments.length > 0 &&
}
); }; diff --git a/src/pages/FeedDetail/components/Recomment.tsx b/src/pages/FeedDetail/components/Recomment.tsx index 4a7f9762..112f366e 100644 --- a/src/pages/FeedDetail/components/Recomment.tsx +++ b/src/pages/FeedDetail/components/Recomment.tsx @@ -22,6 +22,7 @@ const Recomment = ({ myImageUrl, myNickname, recomment: { + memberId, childCommentId, profileImageUrl, nickname, @@ -73,6 +74,7 @@ const Recomment = ({
void; @@ -17,49 +19,111 @@ interface Props { children: ReactNode; } -const CommentFocusContext = createContext(null); +interface FocusUsingKeyboardHeightProps { + textareaRef: MutableRefObject; + mobileViewRef: MutableRefObject; + keyboardCurrent: number; + isComment?: boolean; +} -const focusTextareaRef = (textareaRef: MutableRefObject) => { - if (!textareaRef.current) return; +const CommentFocusContext = createContext(null); - textareaRef.current.focus(); - textareaRef.current.scrollIntoView({ block: 'center', behavior: 'smooth' }); +const isIphone = /ip/i.test(navigator.userAgent.toLowerCase()); +const INIT_KEYBOARD_HEIGHT = 280; +const FOCUSING_DIFFERENCE = 1.4; + +const focusUsingKeyboardHeight = ({ + textareaRef, + mobileViewRef, + keyboardCurrent, + isComment, +}: FocusUsingKeyboardHeightProps) => { + if (!textareaRef.current || !mobileViewRef.current) return; + + const textareaRefCurrent = textareaRef.current; + const mobileViewRefCurrent = mobileViewRef.current; + const textareaRect = textareaRefCurrent.getBoundingClientRect(); + const innerHeight = window.innerHeight; + const keyboardHeight = keyboardCurrent || INIT_KEYBOARD_HEIGHT; + const elementAbsolutePosition = mobileViewRefCurrent.scrollTop + textareaRect.top; + + textareaRefCurrent.focus(); + + // focus() 동작 완료 이후 포커싱 로직 동작토록 의도적으로 비동기 상황으로 수행 + const timerId = setTimeout(() => { + clearTimeout(timerId); + + // 가상 키보드 내에 댓글 및 답글 입력창이 가려질 가능성이 있는 경우에만 포커싱 로직 동작 + if (innerHeight - textareaRect.top >= keyboardHeight) return; + + // 댓글 입력창 및 IOS 디바이스는 아래 포커싱 로직으로 동작 + if (isIphone && isComment) { + mobileViewRefCurrent.scroll({ + top: elementAbsolutePosition - innerHeight + keyboardHeight * FOCUSING_DIFFERENCE, + behavior: 'smooth', + }); + return; + } + + // 답글 입력창 및 IOS 외 모든 디바이스는 아래 포커싱 로직으로 동작 + textareaRefCurrent.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }, 0); }; -const initTextareaRef = (textareaRef: MutableRefObject) => { +const resetTextareaFocus = (textareaRef: MutableRefObject) => { textareaRef.current = null; }; export const CommentFocusProvider = ({ children }: Props) => { + const { mobileViewRef, keyboardHeightRef } = useMobileViewRefContext(); const [isFocusComment, setIsFocusComment] = useState(false); const commentTextareaRef = useRef(null); const recommentTextareaRef = useRef(null); const editCommentTextareaRef = useRef(null); + /* --- 댓글 입력창 포커싱 및 초기화 --- */ const openCommentTextarea = () => { - focusTextareaRef(commentTextareaRef); - setIsFocusComment(true); + + focusUsingKeyboardHeight({ + textareaRef: commentTextareaRef, + mobileViewRef, + keyboardCurrent: keyboardHeightRef.current, + isComment: true, + }); + return; }; const closeCommentTextarea = () => { setIsFocusComment(false); }; + /* --- 대댓글 입력창 포커싱 및 초기화 --- */ const focusRecommentTextarea = () => { - focusTextareaRef(recommentTextareaRef); + focusUsingKeyboardHeight({ + textareaRef: recommentTextareaRef, + mobileViewRef, + keyboardCurrent: keyboardHeightRef.current, + isComment: false, + }); }; const initRecommentTextarea = () => { - initTextareaRef(recommentTextareaRef); + resetTextareaFocus(recommentTextareaRef); }; + /* --- 댓글 및 대댓글 수정 입력창 포커싱 및 초기화 --- */ const focusEditCommentTextarea = () => { - focusTextareaRef(editCommentTextareaRef); + focusUsingKeyboardHeight({ + textareaRef: editCommentTextareaRef, + mobileViewRef, + keyboardCurrent: keyboardHeightRef.current, + isComment: false, + }); }; const initEditCommentTextarea = () => { - initTextareaRef(editCommentTextareaRef); + resetTextareaFocus(editCommentTextareaRef); }; return ( diff --git a/src/pages/FeedDetail/types/index.ts b/src/pages/FeedDetail/types/index.ts index 98debc51..1631f865 100644 --- a/src/pages/FeedDetail/types/index.ts +++ b/src/pages/FeedDetail/types/index.ts @@ -1,4 +1,5 @@ export interface FeedDetailResponse { + memberId: number; imageUrl: string; nickname: string; skillList: string[]; @@ -20,6 +21,7 @@ export interface FeedDetailResponse { } export interface CommentParentResponse { + memberId: number; parentCommentId: string; nickname: string; profileImageUrl: string; @@ -35,6 +37,7 @@ export interface CommentParentResponse { } export interface CommentChildResponse { + memberId: number; childCommentId: string; nickname: string; profileImageUrl: string;