mirim-app/frontend/src/pages/DocViewerPage.jsx
AWESOMEDEV a209bb1d80
All checks were successful
CI / backend-test (push) Successful in 1m11s
CI / frontend-build (push) Successful in 47s
CI / backend-dep-scan (push) Successful in 5s
fix(docs): Shadow DOM 목차 앵커 클릭 시 스크롤 이동
브라우저의 #프래그먼트 이동은 Shadow DOM 안의 id를 못 찾아 목차 순번
클릭이 안 먹혔다. 클릭을 가로채 그림자 안에서 대상을 찾아 scrollIntoView.
2026-07-23 12:44:26 +09:00

151 lines
7.2 KiB
JavaScript

// 이 파일이 하는 일: 학습 문서 하나를 iframe 없이 React 안에서 네이티브로 렌더한다.
//
// ⚠️ 왜 iframe을 걷어냈나 — iframe은 "페이지 속 또 다른 완성된 페이지"라, 스크롤이 이중으로
// 생기고(바깥+안쪽), 모바일에선 문서가 좁은 창에 갇혔다. 앱과 완전히 분리된 세계라 로고도
// 두 번 나왔다. 그렇다고 문서 HTML을 앱 화면에 그냥 부어 넣으면, 문서마다 다른 226줄짜리
// 자체 스타일(.wrap, .callout …)이 앱 CSS와 뒤엉켜 서로를 망가뜨린다.
//
// 해법: Shadow DOM. 문서의 HTML과 자체 <style>을 "격리된 그림자 트리"에 넣는다.
// · iframe이 아니므로 스크롤은 하나뿐이고, 높이는 내용만큼 자연스럽게 늘어난다(모바일 OK).
// · Shadow 경계가 스타일을 완벽히 가둔다 — 문서 스타일이 앱으로 새지 않고, 앱 스타일도
// 문서로 새지 않는다. 문서 19개가 서로 다른 클래스 체계를 써도 충돌하지 않는다.
// 문서 HTML(public/docs/*.html)은 한 글자도 안 고치고 그대로 재사용한다.
import { useEffect, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import client from '../api/client';
// 학습 포인트: 문서의 자체 스타일 + 본문을 Shadow DOM에 주입하는 작은 컴포넌트.
// attachShadow로 그림자 뿌리를 만들고 innerHTML로 내용을 넣는다.
// ⚠️ innerHTML에 외부 문자열을 넣는 건 보통 위험(XSS)하지만, 여기 들어가는 건 우리가 직접
// 작성해 저장소에 넣은 정적 문서다(사용자 입력 아님). 게다가 innerHTML은 <script>를 실행하지
// 않고, 위에서 script 태그도 제거한다. "신뢰할 수 있는 자체 콘텐츠"라는 전제가 성립할 때만
// 이렇게 써도 된다 — 사용자가 입력한 내용이었다면 절대 안 된다.
function DocShadow({ html }) {
const hostRef = useRef(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const shadow = host.shadowRoot || host.attachShadow({ mode: 'open' });
shadow.innerHTML = html;
// ⚠️ Shadow DOM 안의 목차 앵커(<a href="#id">)는 브라우저 기본 스크롤이 안 먹는다.
// 브라우저의 #프래그먼트 이동은 light DOM의 id만 찾고, 그림자 트리 안은 못 본다.
// 그래서 클릭을 직접 가로채, 그림자 안에서 대상을 찾아 스크롤한다.
// (스크롤은 가장 가까운 스크롤 조상 .doc-immersive-scroll이 담당한다.)
const onClick = (e) => {
const a = e.composedPath().find(
(el) => el.tagName === 'A' && el.getAttribute?.('href')?.startsWith('#'),
);
if (!a) return;
const id = decodeURIComponent(a.getAttribute('href').slice(1));
if (!id) return;
const target =
shadow.querySelector(`#${CSS.escape(id)}`) ||
shadow.querySelector(`[name="${CSS.escape(id)}"]`);
if (target) {
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
shadow.addEventListener('click', onClick);
return () => shadow.removeEventListener('click', onClick);
}, [html]);
return <div ref={hostRef} className="doc-shadow-host" />;
}
export default function DocViewerPage() {
const { slug } = useParams();
const navigate = useNavigate();
const [doc, setDoc] = useState(null);
const [shadowHtml, setShadowHtml] = useState('');
const [error, setError] = useState(false);
// ① slug로 문서 메타(제목·파일경로)를 찾는다. 단건 조회 API가 없어 목록을 재사용.
useEffect(() => {
client
.get('/documents')
.then((res) => {
const found = res.data.find((d) => d.slug === slug);
if (found) setDoc(found);
else setError(true);
})
.catch(() => setError(true));
}, [slug]);
// ② 문서 HTML을 받아 Shadow DOM에 넣을 형태로 가공한다.
useEffect(() => {
if (!doc) return;
let cancelled = false;
// ?v= 캐시 버스터: 옛 보안헤더가 캐시에 눌어붙는 문제 방지(헤더 정책 바뀌면 숫자만 올린다).
fetch(`/docs/${doc.filePath}?v=2`)
.then((r) => r.text())
.then((text) => {
if (cancelled) return;
const parsed = new DOMParser().parseFromString(text, 'text/html');
// 방어적으로 script 제거(현재 문서엔 없지만, 원칙상 실행 코드는 넣지 않는다).
parsed.querySelectorAll('script').forEach((s) => s.remove());
// 문서의 모든 <style>을 모은다.
let styles = [...parsed.querySelectorAll('style')].map((s) => s.textContent).join('\n');
// 학습 포인트: HTML 조각을 innerHTML로 넣으면 <body> 태그는 파서가 버린다(문서 문맥이
// 아니라서). 그러면 문서 CSS의 `body { ... }` 기본 규칙(폰트·배경 등)이 적용될
// 대상이 사라진다. 그래서 본문을 .__docbody div로 감싸고, CSS의 `body` 선택자를
// `.__docbody`로 바꿔치기해 규칙이 그 div에 걸리게 한다. (선택자 시작 위치에서만 치환)
styles = styles.replace(/(^|[}{,])(\s*)body\b/g, '$1$2.__docbody');
const bodyInner = parsed.body.innerHTML;
const bodyClass = parsed.body.className;
setShadowHtml(`<style>${styles}</style><div class="__docbody ${bodyClass}">${bodyInner}</div>`);
})
.catch(() => {
if (!cancelled) setError(true);
});
return () => {
cancelled = true;
};
}, [doc]);
// 학습 포인트: 몰입 화면에선 ESC로 빠져나가는 게 관례다. 키보드만 쓰는 사용자를 위한 배려.
useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') navigate('/docs');
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [navigate]);
if (error) {
return (
<div>
<p className="empty">문서를 찾을 없어요.</p>
<button className="btn" onClick={() => navigate('/docs')}>
문서 목록으로
</button>
</div>
);
}
if (!doc || !shadowHtml) {
return <p className="empty">불러오는 ...</p>;
}
return (
// position: fixed + inset:0 → 앱 상단바까지 덮어 문서가 화면을 가득 쓴다(몰입).
// 앱 크롬이 물러나므로 문서의 자체 헤더가 "중복 로고"가 아니라 "이 페이지의 제목"이 된다.
<div className="doc-immersive">
{/* 얇은 상단 바: 나가는 길 하나만. 문서 제목은 문서 자체에 있으니 반복하지 않는다. */}
<div className="doc-immersive-bar">
<button className="btn btn-ghost" onClick={() => navigate('/docs')}>
<ArrowLeft size={16} strokeWidth={1.5} aria-hidden="true" /> 문서 목록
</button>
<span className="doc-immersive-title">{doc.title}</span>
</div>
{/* 문서 본문 — 남은 세로 공간을 채우고 여기'만' 스크롤한다(이중 스크롤 없음). */}
<div className="doc-immersive-scroll">
<DocShadow html={shadowHtml} />
</div>
</div>
);
}