이전 글에서 코드 블록을 모바일 가로 모드로 보여주는 전체화면 뷰어를 만들었다.
CSS transform: rotate(90deg)로 화면을 돌리고, 롱프레스로 종료하는
패턴이었다. 이번에는 같은 기법을 글 전체에 적용해봤다. 블로그
글을 PPT처럼 슬라이드로 넘기면서 읽는 프레젠테이션 모드다. 이 모드를 통해서
스터디를 위한 별도의 자료 없이, 블로그만으로 진행할 수 있도록 하고 싶었다.
가장 먼저 떠오른 방법은 MDX에 {/* @slide */} 같은 마커를 넣는 것이었다.
하지만 이러면 모든 기존 글을 수정해야 하고, 마커가 일반 렌더링에 영향을 줄 수
있다. 렌더된 DOM을 읽어서 자동으로 분할하는 방식이 더 낫다고
판단했다.
블로그 글의 구조는 대부분 h2(SubTitle 컴포넌트)로 섹션을 구분한다. 이 h2를
기준으로 DOM 자식들을 그룹핑하면 자연스럽게 챕터가 된다. hr(---)도 명시적
구분자로 사용한다.
const chapters: Element[][] = [];
let currentChapter: Element[] = [];
for (const child of Array.from(article.children)) {
const tag = child.tagName.toLowerCase();
// 위젯(toolbar, nav 등)은 건너뛴다
if (child.classList.contains("sticky") || tag === "nav") continue;
이 시점에서 각 챕터는 "h2 제목 + 그 아래 콘텐츠"의 배열이다. 짧은 챕터는 하나의 슬라이드가 되고, 긴 챕터는 2차 분할로 넘어간다.
PPT처럼 스크롤 없이 보여주려면, 각 챕터를 화면 높이에 맞춰 잘라야 한다. 핵심은 각 블록 요소의 렌더링 높이를 측정해서, 누적 높이가 슬라이드 가용 높이를 초과하면 새 슬라이드를 시작하는 것이다.
const measurer = document.createElement("div");
measurer.style.cssText = `
position: fixed; top: -9999px; left: -9999px;
width: ${slideWidth}px;
visibility: hidden; pointer-events: none;
`;
document.body.appendChild(measurer);
function measure(node: Node): number {
measurer.innerHTML = "";
measurer.appendChildnode
요소를 클론해서 측정 컨테이너에 넣고, getBoundingClientRect().height로 실제
렌더링 높이를 가져온다. 누적 높이가 가용 높이를 넘으면 현재 슬라이드를
확정하고 새 슬라이드를 시작한다.
for (const element of chapter) {
const cloned = element.cloneNode(true) as HTMLElement;
const elementHeight = measure(cloned);
if (
currentHeight + elementHeight > availableHeight &&
currentSlide.children.length > 0
) {
slides.push(currentSlide);
currentSlide = document.createElement("div");
currentHeight = 0;
}
currentSlide.cloned
여기까지는 단순하다. 문제는 모바일에서 레이아웃이 깨져보였다.
버그: 모바일에서 슬라이드에 콘텐츠가 너무 많이 들어간다
PC에서는 잘 동작했는데, 모바일에서는 한 슬라이드에 콘텐츠가 넘쳐흘렀다. 분명히 가용 높이를 계산해서 자르고 있는데, 왜 넘치는 걸까?
원인 1: 측정 컨테이너의 너비가 실제 슬라이드 너비와 다르다
처음에 측정 컨테이너의 너비를 width: 64rem; max-width: 100vw로 고정했다.
64rem은 약 1024px이다. 하지만 모바일에서 CSS 회전을 하면 슬라이드의 실제
너비는 100vh — 대략 700~850px 정도다.
너비가 넓으면 텍스트가 덜 줄바꿈되고, 높이가 더 낮게 측정된다.
실제로는 좁은 슬라이드 안에서 텍스트가 더 많이 줄바꿈되어 높이가 커지는데, 측정할 때는 넓은 컨테이너에서 측정하니 높이가 작게 나온다. 그래서 "아직 공간이 남았다"고 판단해서 콘텐츠를 계속 넣는 것이다.
// 수정 전: 고정 너비
measurer.style.width = "64rem";
// 수정 후: 실제 슬라이드 너비와 동일
const slideW = isMobile
? window.innerHeight - 64 // 회전 시 vh가 너비, 좌우 패딩 제외
: Math.min(window.innerWidth - 64, 896);
measurer.style.width = `${slideW}px`;
너비를 맞춰도 여전히 넘치는 슬라이드가 있었다. 로그를 보니 이런 상황이었다.
[Slide Element] { tag: "UL", elementHeight: 384, availableHeight: 280 }
[Slide Element] { tag: "OL", elementHeight: 732, availableHeight: 280 }
ul의 높이가 384px인데 가용 높이는 280px이다. 분명히 넘치지만, 분할 로직에는
이런 조건이 있다.
if (currentHeight + elementHeight > availableHeight
&& currentSlide.children.length > 0) {
currentSlide.children.length > 0 — 현재 슬라이드에 이미 내용이 있을 때만 새 슬라이드를 시작한다. 빈 슬라이드에 첫 번째 요소로 들어가면 아무리 커도 분할되지 않는다. 그리고 ul 안에 li가 10개 있어도, 분할 단위는 ul 전체이기 때문에 리스트 내부를 자를 수 없다.
ul이나 ol이 가용 높이를 초과하면, 자식 li들을 하나씩 측정해서
슬라이드별로 나눈다. 분할된 li들은 원본과 동일한 태그(ul/ol)와 클래스로
감싸서 스타일이 유지되도록 한다.
const SPLITTABLE_TAGS = new Set(["UL", "OL"]);
// 리스트가 가용 높이를 초과하면 li 단위로 분할
if (elementHeight > availableHeight && SPLITTABLE_TAGS.has(element.tagName)) {
// 현재 슬라이드에 내용이 있으면 먼저 확정
if (currentSlide.children.length > 0) {
slides.push(currentSlide);
[currentSlide, currentHeight] = [document.createElement("div"), 0];
li 단위 측정: 리스트 전체가 아닌 각 항목의 높이를 개별
측정한다.li들을 ul/ol로 감싸서 원본의
클래스(list-disc, space-y-2 등)를 그대로 적용한다. 그래야 불릿/번호
스타일이 유지된다.프레젠테이션 모드에서는 탭으로 슬라이드를 넘기고, 롱프레스로 종료해야 한다. 코드 뷰어에서는 롱프레스만 있었지만, 이번에는 두 제스처를 구분해야 한다.
const LONG_PRESS_MS = 1500;
const pressStartTime = useRef(0);
const handlePointerDown = () => {
pressStartTime.current = Date.now();
timerRef.current = setTimeout(onClose, LONG_PRESS_MS);
};
const handlePointerUp = (side: "left" | "right") => {
clearTimeout(timerRef.current);
const elapsed = pressStartTime
onPointerDown에서 타이머를 시작하고, onPointerUp에서 경과 시간을 확인한다.
1.5초 미만이면 탭으로 판단해서 슬라이드를 넘기고, 1.5초 이상이면 타이머 콜백이
이미 onClose()를 호출한 상태다.
PC에서는 롱프레스 대신 오른쪽 하단의 X 버튼과 ESC 키로 종료한다. 마우스로
1.5초 꾹 누르는 건 자연스럽지 않기 때문이다. 모바일/PC 분기는
window.matchMedia("(max-width: 768px)")로 판단한다.
이전 글에서 다뤘던 CSS transform 패턴을 그대로 재사용한다. 모바일에서는
width: 100vh, height: 100vw, rotate(90deg)로 가로 모드를 시뮬레이션하고,
PC에서는 단순히 fixed inset-0으로 전체화면을 만든다.
const containerStyle = isMobile
? {
width: "100vh",
height: "100vw",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%) rotate(90deg)",
}
: { width: "100%", height: "100%" };
슬라이드 콘텐츠는 createPortal로 document.body에 직접 렌더링한다. 기존
페이지 레이아웃의 overflow나 z-index에 영향받지 않기 위해서다.
toolbarRef.current.closest("section")으로 글의 DOM 루트를 찾는다.h2/hr 기준으로 챕터를 나눈다. (1차 분할)ul/ol이 가용 높이를 넘으면 li 단위로 더 잘게 분할한다.createPortal로 전체화면 오버레이를 렌더링한다.내 개발 환경은 QHD(2560×1440) 모니터다. 이 해상도에서는 슬라이드가 잘 보였는데, 개발자 도구에서 FHD(1920×1080) 크기로 줄여보니 슬라이드에 콘텐츠가 넘쳐흘렀다. 가용 높이가 줄었는데 채움 비율은 그대로이니, 더 좁은 공간에 같은 양의 콘텐츠를 밀어 넣고 있었다. 계산식을 보면 바로 이해된다.
available = (viewH - padding - bottomBar) * ratio
QHD: (1440 - 96 - 48) × 0.85 = 1101px → 넉넉, 문제 없음
FHD: (1080 - 96 - 48) × 0.85 = 796px → 여전히 높아서 콘텐츠가 많이 들어감
하지만 실제 렌더링 영역은 py-12 + overflow-hidden으로 더 좁음
QHD에서 85%는 적절한 여백을 남기지만, FHD에서 85%는 거의 꽉 찬 상태다. 화면이 작아질수록 패딩과 하단 바가 차지하는 비중이 커지는데, 고정 비율은 이를 반영하지 못한다. 결국 작은 화면일수록 슬라이드가 더 빡빡해지는 문제가 생긴다.
처음 시도한 해결책은 고정 최대 높이 캡(720px)이었다. 어떤 화면이든 슬라이드 콘텐츠 높이가 720px을 넘지 않도록 제한하는 것이다. FHD에서도 잘 동작했지만, 이 프레젠테이션 모드를 강의실 메인 TV나 빔프로젝터에서도 쓸 계획이었다. 고정 캡이면 큰 화면에서 슬라이드가 위아래로 비어 보이고, 작은 노트북에서는 여전히 빡빡해진다. 고정값은 특정 환경에 맞추면 다른 환경에서 깨진다.
최종 해결책은 화면 높이 구간별로 채움 비율을 다르게 적용하는 것이다.
function getFillRatio(viewH: number): number {
if (viewH <= 600) return 0.92; // 작은 화면: 최대한 활용
if (viewH <= 900) return 0.82; // 일반 노트북
if (viewH <= 1200) return 0.72; // 데스크탑/빔프로젝터
return 0.65; // 대형 모니터/TV
}
const available = (viewH - padding - bottomBar) * getFillRatio(viewH);
작은 화면에서는 공간이 부족하니 92%까지 채우고, 큰 화면에서는 65%만 채워서 슬라이드가 답답해 보이지 않도록 한다. 어떤 환경에서든 적절한 밀도가 유지된다.
DOM 높이 측정은 측정 환경이 실제 렌더링 환경과 다르면 틀린다. 특히 너비가 다르면 텍스트 줄바꿈이 달라지고, 높이 계산이 완전히 어긋난다. "측정만 하면 되겠지"라고 생각했지만, 측정 컨테이너의 너비를 실제 슬라이드 너비에 맞추는 것이 핵심이었다.
그리고 분할 단위를 잘 선택해야 한다. ul 전체를 하나의
블록으로 취급하면 10개의 li가 하나의 슬라이드에 몰리게 된다. "이 요소가 너무
크면 더 잘게 자를 수 있는가?"를 항상 고려해야 한다. 이번에는 리스트의 li만
처리했지만, 같은 패턴으로 긴 테이블의 tr이나 정의 목록의 dt/dd도 분할할
수 있다.
마지막으로, 고정 상수는 특정 환경에서만 동작한다. 내 모니터에서 잘 보이는 값이 강의실 TV에서는 콘텐츠가 넘치고, 작은 노트북에서는 비어 보인다. 화면 크기에 따라 동적으로 조절되는 반응형 접근이 필요하다.