refactor(docs): 문서 뷰어 iframe 제거, Shadow DOM 네이티브 렌더
All checks were successful
CI / backend-test (push) Successful in 1m12s
CI / frontend-build (push) Successful in 50s
CI / backend-dep-scan (push) Successful in 6s

iframe의 이중 스크롤 문제를 없애고, 문서 HTML/스타일을 Shadow DOM에
격리해 렌더한다. 문서 HTML(public/docs/*.html)은 한 글자도 안 고치고 재사용.
This commit is contained in:
AWESOMEDEV 2026-07-23 12:35:31 +09:00
parent 0a7f91ba8c
commit cd61f06ba5
2 changed files with 144 additions and 39 deletions

View File

@ -1,32 +1,99 @@
// : iframe . // : iframe React .
// :slug filePath HTML . //
import { useEffect, useState } from '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 { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import client from '../api/client'; 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() { export default function DocViewerPage() {
const { slug } = useParams(); const { slug } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [doc, setDoc] = useState(null); const [doc, setDoc] = useState(null);
const [notFound, setNotFound] = useState(false); const [shadowHtml, setShadowHtml] = useState('');
const [error, setError] = useState(false);
// slug (·) . API .
useEffect(() => { useEffect(() => {
// slug .
// ( API API .)
client client
.get('/documents') .get('/documents')
.then((res) => { .then((res) => {
const found = res.data.find((d) => d.slug === slug); const found = res.data.find((d) => d.slug === slug);
if (found) { if (found) setDoc(found);
setDoc(found); else setError(true);
} else {
setNotFound(true);
}
}) })
.catch(() => setNotFound(true)); .catch(() => setError(true));
}, [slug]); }, [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 ( return (
<div> <div>
<p className="empty">문서를 찾을 없어요.</p> <p className="empty">문서를 찾을 없어요.</p>
@ -37,38 +104,25 @@ export default function DocViewerPage() {
); );
} }
if (!doc) { if (!doc || !shadowHtml) {
return <p className="empty">불러오는 ...</p>; return <p className="empty">불러오는 ...</p>;
} }
return ( return (
<div> // position: fixed + inset:0 ().
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}> // " " " " .
{/* whiteSpace: nowrap — 모바일에서 버튼 글자가 "뒤로가/기"로 꺾이는 것 방지 */} <div className="doc-immersive">
<button className="btn" style={{ whiteSpace: 'nowrap', flexShrink: 0 }} onClick={() => navigate(-1)}> {/* 얇은 상단 바: 나가는 길 하나만. 문서 제목은 문서 자체에 있으니 반복하지 않는다. */}
뒤로가기 <div className="doc-immersive-bar">
<button className="btn btn-ghost" onClick={() => navigate('/docs')}>
<ArrowLeft size={16} strokeWidth={1.5} aria-hidden="true" /> 문서 목록
</button> </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> </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> </div>
); );
} }

View File

@ -1416,3 +1416,54 @@ inline-code, .icode {
.cert-border { box-shadow: none; border-color: #333; } .cert-border { box-shadow: none; border-color: #333; }
body { background: #fff; } 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;
}