브라우저의 #프래그먼트 이동은 Shadow DOM 안의 id를 못 찾아 목차 순번 클릭이 안 먹혔다. 클릭을 가로채 그림자 안에서 대상을 찾아 scrollIntoView.
151 lines
7.2 KiB
JavaScript
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>
|
|
);
|
|
}
|