refactor(docs): 문서 뷰어 iframe 제거, Shadow DOM 네이티브 렌더
iframe의 이중 스크롤 문제를 없애고, 문서 HTML/스타일을 Shadow DOM에 격리해 렌더한다. 문서 HTML(public/docs/*.html)은 한 글자도 안 고치고 재사용.
This commit is contained in:
parent
0a7f91ba8c
commit
cd61f06ba5
@ -1,32 +1,99 @@
|
||||
// 이 파일이 하는 일: 문서 하나를 iframe으로 크게 띄우는 뷰어.
|
||||
// 주소의 :slug로 문서를 찾아 filePath의 HTML을 그대로 보여준다.
|
||||
import { useEffect, useState } from 'react';
|
||||
// 이 파일이 하는 일: 학습 문서 하나를 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;
|
||||
}, [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 [notFound, setNotFound] = useState(false);
|
||||
const [shadowHtml, setShadowHtml] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
// ① slug로 문서 메타(제목·파일경로)를 찾는다. 단건 조회 API가 없어 목록을 재사용.
|
||||
useEffect(() => {
|
||||
// 문서 목록에서 slug가 일치하는 것을 찾는다.
|
||||
// (단건 조회 API가 없으므로 목록 API를 재사용 — 문서 수가 적어 충분하다.)
|
||||
client
|
||||
.get('/documents')
|
||||
.then((res) => {
|
||||
const found = res.data.find((d) => d.slug === slug);
|
||||
if (found) {
|
||||
setDoc(found);
|
||||
} else {
|
||||
setNotFound(true);
|
||||
}
|
||||
if (found) setDoc(found);
|
||||
else setError(true);
|
||||
})
|
||||
.catch(() => setNotFound(true));
|
||||
.catch(() => setError(true));
|
||||
}, [slug]);
|
||||
|
||||
if (notFound) {
|
||||
// ② 문서 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>
|
||||
@ -37,38 +104,25 @@ export default function DocViewerPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!doc) {
|
||||
if (!doc || !shadowHtml) {
|
||||
return <p className="empty">불러오는 중...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
|
||||
{/* whiteSpace: nowrap — 모바일에서 버튼 글자가 "뒤로가/기"로 꺾이는 것 방지 */}
|
||||
<button className="btn" style={{ whiteSpace: 'nowrap', flexShrink: 0 }} onClick={() => navigate(-1)}>
|
||||
← 뒤로가기
|
||||
// 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>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>{doc.title}</h1>
|
||||
<span className="doc-immersive-title">{doc.title}</span>
|
||||
</div>
|
||||
{/* 문서 본문 — 남은 세로 공간을 채우고 여기'만' 스크롤한다(이중 스크롤 없음). */}
|
||||
<div className="doc-immersive-scroll">
|
||||
<DocShadow html={shadowHtml} />
|
||||
</div>
|
||||
{/* 학습 포인트: 기존 HTML 자산 재사용 —
|
||||
학습 문서들은 이미 완성된 정적 HTML(public/docs/*.html)이다.
|
||||
React로 다시 만들 필요 없이 iframe으로 "액자에 넣듯" 보여주면
|
||||
문서의 자체 스타일도 그대로 살고 앱 CSS와 충돌하지도 않는다.
|
||||
⚠️ ?v= 캐시 버스터: 예전에 잘못된 보안헤더(X-Frame-Options:DENY)가 붙은 문서가
|
||||
브라우저 캐시에 박혀 새로고침으로도 안 지워지는 문제가 있었다. 주소 뒤에 버전을
|
||||
붙이면 브라우저가 "다른 파일"로 보고 새로 받아온다(캐시 키가 달라짐). 헤더가 또
|
||||
바뀌면 이 숫자만 올리면 된다. */}
|
||||
<iframe
|
||||
src={`/docs/${doc.filePath}?v=2`}
|
||||
title={doc.title}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'calc(100vh - 180px)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius)',
|
||||
background: '#fff',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1416,3 +1416,54 @@ inline-code, .icode {
|
||||
.cert-border { box-shadow: none; border-color: #333; }
|
||||
body { background: #fff; }
|
||||
}
|
||||
|
||||
/* ── 학습 문서 몰입 뷰어 (DocViewerPage) ──────────────────────────────
|
||||
학습 포인트: 문서를 볼 때만 뷰포트 전체를 덮어 문서가 화면을 가득 쓰게 한다.
|
||||
position: fixed + inset:0 이 앱 상단바까지 덮는다(그래서 z-index를 상단바보다 높게).
|
||||
세로 flex로 [얇은 바][문서]를 쌓고, 문서(iframe)에 flex:1을 줘서 남는 공간을 전부
|
||||
차지하게 한다 — 높이를 calc(100vh - 얼마)처럼 손으로 계산하지 않아도 된다.
|
||||
이게 예전 방식보다 나은 점: 이중 스크롤이 사라지고(스크롤은 iframe 하나뿐),
|
||||
모바일에서 문서가 작은 상자에 갇히지 않는다. */
|
||||
.doc-immersive {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50; /* 앱 상단바(z-index:10)보다 위 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.doc-immersive-bar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.doc-immersive-title {
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--ink);
|
||||
font-size: var(--text-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis; /* 제목이 길면 … 로 줄여 한 줄 유지 */
|
||||
}
|
||||
|
||||
/* 문서 본문 스크롤 영역 — 예전엔 iframe이 이 자리에 있었다. 지금은 Shadow DOM 호스트를
|
||||
담고 이 영역'만' 세로 스크롤한다(바깥 페이지는 fixed라 스크롤이 없다 → 이중 스크롤 제거).
|
||||
학습 문서들은 밝은 배경으로 디자인돼 있어 배경을 흰색으로 둔다(다크 모드에서도 문서는 밝게). */
|
||||
.doc-immersive-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch; /* iOS에서 관성 스크롤 */
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Shadow DOM 호스트: 문서 스타일이 여기 격리된다. 폭은 문서 자체 CSS(.wrap 등)가 정하므로
|
||||
호스트는 블록으로만 두면 된다. */
|
||||
.doc-shadow-host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user