Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/components/layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface LayoutProps {
export default function Layout({ showBottomNav = false, contentClassName }: LayoutProps) {
const { pathname } = useLocation();
const { contentPaddingClassName, hasHeader } = getHeaderPresentation(pathname);
const isChatRoomPage = pathname.startsWith('/chats/') && pathname !== '/chats';
const mainBackgroundClassName = pathname === '/chats' ? 'bg-white' : 'bg-background';
const { bottomNavRef, bottomOverlayInset, handleLayoutElement, layoutElement, mainRef } =
useLayoutElements(showBottomNav);
Expand Down Expand Up @@ -48,7 +49,8 @@ export default function Layout({ showBottomNav = false, contentClassName }: Layo
ref={mainRef}
style={mainStyle}
className={cn(
'box-border flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
'box-border flex min-h-0 flex-1 flex-col [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
isChatRoomPage ? 'overflow-hidden' : 'overflow-y-auto overscroll-contain',
mainBackgroundClassName,
hasHeader && contentPaddingClassName,
contentClassName
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Chat/ChatRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ function ChatRoom() {
}, [value]);

return (
<div className="flex min-h-0 flex-1 flex-col bg-white">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white">
<div
ref={scrollContainerRef}
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain py-3"
Expand Down
89 changes: 89 additions & 0 deletions src/utils/hooks/useViewportHeightLock.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,109 @@
import { useLayoutEffect } from 'react';
import { isTextInputElement } from '@/utils/ts/dom';

const SCROLL_RESET_TIMEOUT_MS = 180;

function useViewportHeightLock() {
useLayoutEffect(() => {
const root = document.documentElement;
const body = document.body;
const scrollingElement = document.scrollingElement as HTMLElement | null;
const prevBodyOverflow = body.style.overflow;
const prevBodyHeight = body.style.height;
const prevRootOverflow = root.style.overflow;
const prevRootHeight = root.style.height;
let isEditableFocused = false;
let resetFrameId = 0;
let trailingResetFrameId = 0;
let resetTimeoutId = 0;

const resetDocumentScroll = () => {
window.scrollTo(0, 0);
root.scrollTop = 0;
body.scrollTop = 0;

if (scrollingElement) {
scrollingElement.scrollTop = 0;
}
};

// iOS Safari는 focus와 visualViewport 갱신 뒤에 문서 스크롤을 비동기로 다시 적용할 수 있습니다.
// 즉시 1번, 두 번의 animation frame, 그리고 기기 테스트 기반의 짧은 휴리스틱 timeout으로
// 한 번 더 복구해 뒤늦게 들어오는 브라우저 스크롤도 최대한 잡습니다.
const scheduleDocumentScrollReset = () => {
cancelAnimationFrame(resetFrameId);
cancelAnimationFrame(trailingResetFrameId);
clearTimeout(resetTimeoutId);

resetDocumentScroll();
resetFrameId = requestAnimationFrame(() => {
resetDocumentScroll();
trailingResetFrameId = requestAnimationFrame(resetDocumentScroll);
});
resetTimeoutId = window.setTimeout(resetDocumentScroll, SCROLL_RESET_TIMEOUT_MS);
};

const handleFocusIn = (event: FocusEvent) => {
isEditableFocused = isTextInputElement(event.target);

if (isEditableFocused) {
scheduleDocumentScrollReset();
}
};

const handleFocusOut = (event: FocusEvent) => {
if (!isTextInputElement(event.target)) return;

isEditableFocused = false;
scheduleDocumentScrollReset();
};

const handleViewportChange = () => {
if (isEditableFocused) {
scheduleDocumentScrollReset();
}
};

const handleWindowScroll = () => {
const currentScrollTop = Math.max(
scrollingElement?.scrollTop ?? 0,
root.scrollTop,
body.scrollTop,
window.scrollY
);

if (currentScrollTop > 0) {
scheduleDocumentScrollReset();
}
Comment thread
ff1451 marked this conversation as resolved.
};

root.style.overflow = 'hidden';
body.style.overflow = 'hidden';
body.style.height = 'var(--viewport-height)';
root.style.height = 'var(--viewport-height)';
scheduleDocumentScrollReset();

// installViewportVars는 같은 신호로 CSS 변수를 갱신하고,
// 이 훅은 ChatRoom에서 문서 스크롤 복구를 위해 의도적으로 한 번 더 사용합니다.
window.addEventListener('focusin', handleFocusIn);
window.addEventListener('focusout', handleFocusOut);
window.addEventListener('scroll', handleWindowScroll, { passive: true });

window.visualViewport?.addEventListener('resize', handleViewportChange);
window.visualViewport?.addEventListener('scroll', handleViewportChange);

return () => {
cancelAnimationFrame(resetFrameId);
cancelAnimationFrame(trailingResetFrameId);
clearTimeout(resetTimeoutId);

window.removeEventListener('focusin', handleFocusIn);
window.removeEventListener('focusout', handleFocusOut);
window.removeEventListener('scroll', handleWindowScroll);

window.visualViewport?.removeEventListener('resize', handleViewportChange);
window.visualViewport?.removeEventListener('scroll', handleViewportChange);

root.style.overflow = prevRootOverflow;
body.style.overflow = prevBodyOverflow;
body.style.height = prevBodyHeight;
Expand Down
5 changes: 5 additions & 0 deletions src/utils/ts/dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const isTextInputElement = (element: EventTarget | null): element is HTMLElement => {
if (!(element instanceof HTMLElement)) return false;

return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element.isContentEditable;
};
8 changes: 2 additions & 6 deletions src/utils/ts/viewport.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isTextInputElement } from '@/utils/ts/dom';

const KEYBOARD_OPEN_THRESHOLD_PX = 120;

export function installViewportVars() {
Expand All @@ -6,12 +8,6 @@ export function installViewportVars() {
let restingViewportHeight = 0;
let restingViewportWidth = 0;

const isTextInputElement = (element: EventTarget | null): element is HTMLElement => {
if (!(element instanceof HTMLElement)) return false;

return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element.isContentEditable;
};

const setViewportHeight = () => {
const vv = window.visualViewport;
const h = vv?.height ?? window.innerHeight;
Expand Down
Loading