데이터 페치, 라우팅, 사이드바 설정
- 앱 최초 기동시에 각 사용자 매뉴얼 파일 정보를 트리 형태로 불러왔다.
export default function ManualSite() {
const [manuals, setManuals] = useState([])
useEffect(() => {
getManuals().then(res => {
setManauals(res)
})
}, [])
// ...생략
}
트리의 depth는 3이고 각 노드들은 파일 주소, 이름, path이름을 가지고 있다.
manuals = [{mdUrl: "botCategory.md", name: "봇 카테고리", "botCategory"}, ...]
- 파일 주소를 바탕으로 md 파일을 읽어와 저장했다.
getMdFiles(manauals).then(manualsWithMdFile => {
setManaulsWithMdFile(manualsWithFile)
})
getMdFile
은 재귀 형태로 트리의 노드들을 탐색하여 mdFile이 존재하는 경우 파일을 읽어오도록 했다.
const getMdFiles = async (manuals) => {
for(manual of manuals) {
const mdFile = manual.mdUrl ? await downloadFile(manual.mdUrl) : null
manual.mdFile = mdFile
if(mdFile.children.length > 0) {
getMdfiles(manual.children)
}
/// ..생략
}
}
- mdFile을 가지고 있는 경우
pathName
을 활용해 라우트를 생성해 넣어줬다. 그리고path
가 바뀔 시 사이드바의 선택 값을 변경을 용이하게 하기 위해 id와 path 를 매핑해 저장했다.
function initRoutes(manuals, parentNames=[]) {
for(let manual of manuals) {
if(manual.mdFile) {
const path = makePath(manual.pathName+"", parentNames)
routes.push({
id: manual.id,
path,
element: <Content key={manual.id} manual={manual}/>
})
//id - path 매핑
idPathMap[manual.id] = path
//
}
if(manual.children.length > 0) initRoutes(manual.children, [...parentNames, manual.id+""])
}
}
// ... 생략
<Routes>
<Route path="/" element={routes[0]?.element}/>
{routes.map(route=> <Route key={route.id} {...route} />)}
<Route path="/faq" element= {<AccordionList type="faq"/>}/>
<Route path="/release-notes" element={<AccordionList type="release-notes"/>}/>
<Route path="*" element = {<NotFound/>}/>
</Routes>
- 사이드바는
mui
의 treeview를 활용해 구현했다. 받아온 트리형태의 데이터를 넣어줬다.
<TreeView
// ... 생략
>
{manuals.map(high => (
<TreeItem
key={high.id}
nodeId={`${high.id}`}
label={high.name}
>
{high.children.map(middle => (
<TreeItem
key={middle.id}
nodeId={`${middle.id}`}
label={middle.name}
>
{middle.children.map(low => (
<TreeItem
key={low.id}
nodeId={`${low.id}`}
label={low.name}
/>
))}
</TreeItem>
))}
</TreeItem>
))}
</TreeView>
MD파일 보여주기
react-markdown
을 활용해 간편하게 구현했다. <ReactMarkdown/>
의 children으로 매뉴얼의 mdFile을 전달했다.
해당 라이브러리의 여러 플러그인을 활용해 코드 강조, 표 등의 스타일링을 간편하게 처리했다. 그 외에 추가적으로 스타일링이 필요한 부분은 css
를 활용해 입혔다.
인쇄 기능 구현
document.print
의 경우 html문서 전체를 다운로드 한다. 이는 새 window를 생성하고 내용으로 인쇄하고자 하는 컴포넌트를 복사 붙여넣기하여 해결할 수 있었다.
const print = function(doc){
const printArea = findDOMNode(doc).innerHTML
const [popupWidth, popupHeight] = [1000, 600]
const [popupLeft, popupTop] = [(document.body.offsetWidth - popupWidth)/2, (document.body.offsetHeight - popupHeight)/2]
const printWindow = window.open('', '', `height=${popupHeight},width=${popupWidth},left=${popupLeft},top=${popupTop}, toolbars=no,status=no,resizable=no`)
printWindow?.document.write(printArea)
printWindow?.document.close()
printWindow.focus()
// 추후 변경, 이미지 로드 위해
setTimeout(() => {
printWindow?.print()
printWindow?.close()
},250)
}
이 방식은 컴포넌트의 이미지가 로드되기 이전에 print 메소드가 실행되어 이미지가 인쇄되지 않는 결함이 있었다. 이를 setTimeout을 활용해 로드 시간을 기다리는 식으로 구현했다.
목차 기능 구현
- 목차 기능을 위하여 앱 최초 기동시 mdFile을 다운로드 하고, 해당 mdFile의 내용에서 #의 개수를 활용해서 mdFile의 제목들을 파싱해서
titles
프로퍼티에 저장했다. - 해당 titles들을 목록 형태로 화면 우측에 나타냈다.
intersectionobserver
를 활용해 사용자가 현재 보고 있는 화면의 최상단에 위치한 제목을 목차 컴포넌트에서 강조 표시했다.
export default function Toc({titles}) {
const [activeId, setActiveId] = useState('')
const location = useLocation()
const navigate = useNavigate()
const hrefRefs = useRef({})
const headingEls = useIntersectionObserver(setActiveId, titles, location)
// ...생략
}
export const useIntersectionObserver = (
// 넘겨받은 setActiveId 를 통해 화면 상단의 제목 element를 set해준다.
setActiveId,
// 게시글 내용이 바뀔때를 알기 위해 titles를 넘겨받는다.
titles,
location
) => {
// heading element를 담아서 사용하기 위한 ref
const headingElementsRef = useRef({});
useEffect(() => {
// 새로고침 없이 다른 게시물로 이동할 경우를 대비한 초기화
headingElementsRef.current = {};
// callback은 intersectionObserver로 관찰할 대상 비교 로직
const callback = (headings) => {
// 모든 제목을 reduce로 순회해서 headingElementsRef.current에 키 밸류 형태로 할당.
headingElementsRef.current = headings.reduce((map, headingElement) => {
map[headingElement.target.id] = headingElement;
return map;
}, headingElementsRef.current);
// 화면 상단에 보이고 있는 제목을 찾아내기 위한 로직
const visibleHeadings = [];
Object.keys(headingElementsRef.current).forEach((key) => {
const headingElement = headingElementsRef.current[key];
// isIntersecting이 true라면 visibleHeadings에 push한다.
if (headingElement.isIntersecting) visibleHeadings.push(headingElement);
});
// observer가 관찰하는 영역에 여러개의 제목이 있을때 가장 상단의 제목을 알아내기 위한 함수
const getIndexFromId = (id) =>
headingElements.findIndex((heading) => heading.id === id);
// console.log(visibleHeadings)
if (visibleHeadings.length === 1) {
// 화면에 보이고 있는 제목이 1개라면 해당 element의 target.id를 setActiveId로 set해준다.
setActiveId(visibleHeadings[0].target.id);
} else if (visibleHeadings.length > 1) {
// 2개 이상이라면 sort로 더 상단에 있는 제목을 set해준다.
const sortedVisibleHeadings = visibleHeadings.sort(
(a, b) => getIndexFromId(a.target.id) - getIndexFromId(b.target.id)
);
setActiveId(sortedVisibleHeadings[0].target.id);
}
};
// IntersectionObserver에 callback과 옵션을 생성자로 넘겨 주고 새로 생성한다.
const observer = new IntersectionObserver(callback, {
// rootMargin 옵션을 통해 화면 상단에서 네비바 영역(-64px)을 빼고, 위에서부터 40%정도 영역만 관찰한다.
rootMargin: '-64px 0px 40% 0px',
});
// 제목 태그들을 다 찾아낸다.
const headingElements = Array.from(document.querySelectorAll('h1, h2, h3'))
.filter(element => titles.find(t => t.title === element.id) !== undefined)
// console.log('관찰 시작~', headingElements)
// 이 요소들을 observer로 관찰한다.
headingElements.forEach((element) => {
observer.observe(element)
});
// 컴포넌트 언마운트시 observer의 관찰을 멈춘다.
return () => {
// console.log('관찰안해~')
observer.disconnect();
}
// titles, manual 내용이 바뀔때를 대비하여 deps로 titles,location을 넣어준다.
}, [titles, location]);
return headingElementsRef
};
- 목차의 각 제목 클릭시에는 해당 제목이 화면 최상단에 위치하도록 스크롤이 움직이게 했다. 이를 위해 사용자 매뉴얼 컴포넌트
Article
에서 각 제목에 태그를 달았다.
useEffect(() => {
try {
if(location.hash!=="")
{
const hashRaw = location.hash.replaceAll('-', ' ').slice(1)
const curTag = decodeURI(decodeURIComponent(hashRaw))
const $hashTargetDom = Array.from(document.querySelectorAll('h1, h2, h3'))
.filter(e => !e.classList.contains("topBar-title"))
.find((el,elIdx) => {
return(el.id === curTag)
})
// console.log($hashTargetDom.getClientRects()[0].top)
articleWrapRef?.current?.scrollBy(0,$hashTargetDom.getClientRects()[0].top-64)
} else {
articleWrapRef?.current?.scrollBy(0,-500000)
}
} catch(e) {
console.error(e)
}
}, [manual.titles, location])
검색 기능 구현
- 검색 기능을 위해 앱 최초 기동시 받아온 mdFile을 파싱했다. 불필요한 부분들을 정규식을 활용해 다 없애고 트리 형태로 저장해뒀다.
export const parseManualsForSearch = (manuals, parentNames = []) => {
let result = []
for(let manual of manuals) {
result.push(parseManualForSearch(manual, parentNames))
if(manual.children.length > 0) result.push(...parseManualsForSearch(manual.children, [...parentNames, manual.name]))
}
return result
}
const regexCode = /\`{3}[^```]*\`{3}/g
const regexHtml = /<.*>\r\n | <\/.*>\r\n | <.*>.*<\/.*>\r\n/g
// const regexHtml =/(<\/.*>\r\n)/g
const regexEndTagHtml = /<\/.*>\r\n/g
const regexLinkTitleWrapper = /[\[\]]/g
const regexLinkUrl = /\(https?:\/\/.*\)/g
const regexImage = /\!.*\(.*\)/g
const regexImageTag = /\<img.*\/\>/g
const regexBold = /\*\*/g
const convertToAoo = (parsedMd, titles) => {
let result = titles.map(title => ({
title,
sentences: []
}))
try {
let idx = -1
for(let sentence of parsedMd) {
if(isHeading(sentence)) {
idx++
} else {
result[idx].sentences.push(sentence)
}
}
} catch(e) {
console.error(e)
return []
}
return result
}
export const parseManualForSearch = (manual,parentNames) => {
// name과 parentNames, mdData split한 것들을 넣어준다.
const {id, name, mdFile, titles} = manual
let parsedMd = null
// const parsedMd = mdFile?.data ? mdFile?.data?.split('\r\n') : null
if(mdFile?.data === undefined || mdFile?.data === null) return {name, parentNames, parsedMd}
// 검색에는 코드블럭 제외
parsedMd = mdFile?.data.replace(regexCode, '')
// HTML도 제외
parsedMd = parsedMd.replace(regexHtml,'').replace(regexEndTagHtml,'')
// 링크도 일반 문장으로 변경
// console.log(parsedMd.match(regexLinkTitleWrapper), parsedMd.match(regexLinkUrl))
parsedMd = parsedMd.replace(regexLinkTitleWrapper, '').replace(regexLinkUrl, '')
// 이미지 제외
parsedMd = parsedMd.replace(regexImage, '').replace(regexImageTag, '')
// 강조 제외
parsedMd = parsedMd.replace(regexBold, '')
// 인용문 --- 없애줌, 공백 없애줌
// 배열로 변경
parsedMd = parsedMd.split('\r\n').filter(e => e.trim() !== "").filter(e => e !== "---")
// heading - sentence[]
// arr to aoo
parsedMd = convertToAoo(parsedMd, titles)
// console.log(convertToAoo(parsedMd, titles))
return {id, name, parentNames, parsedMd}
}
- 검색 시 트리를 재귀 형태로 순회하면서 keyword를 포함할 경우 keyword의 상위 Heading과 주변 2단어씩을 결과에 담았다. 검색 결과 역시 트리형태로 담았다.
export const getSearchResultArr = (keyword, manualsParsed) => {
// 검색결과를 일단 배열로 만듬
let searchResults = []
for(let manual of manualsParsed) {
const {id, parentNames, name} = manual
// 이름이 검색어를 포함하고 매뉴얼을 가지고 있는애 (path)
//
const searchResultForManual = {id, parentNames, name, results: []}
if(isNameIncludesKeyword(manual,keyword)) {
// searchResultForManual.results.push({})
// continue
}
// 본문이 검색어를 포함하는 경우
// 포함되는 문장도 가져온다.
// 가장 가까운 Heading을 찾아야한다.
if(!manual.parsedMd) continue
for(let paragraph of manual.parsedMd) {
const {title} = paragraph
let searchResultForParagraph = {heading:title.title, sentences: []}
for(let sentence of paragraph.sentences) {
const words = sentence.toLowerCase().split(" ")
const firstWordInKeyword = keyword.split(" ")[0]
const keywordIdx = words.findIndex(e=> e.toLowerCase().includes(firstWordInKeyword.toLowerCase()))
if(sentence.toLowerCase().includes(keyword.toLowerCase())) {
// 양옆 2개씩만 포함 시키기
let start = keywordIdx-2
while(start < 0) start++
let resultSentence = words.slice(start, start+5).join(" ")
searchResultForParagraph.sentences.push(resultSentence)
continue
}
}
if(searchResultForParagraph.sentences.length > 0) {
searchResultForManual.results.push(searchResultForParagraph)
}
}
if(searchResultForManual.results.length > 0) {
searchResults.push(searchResultForManual)
}
}
console.log(searchResults)
return searchResults
}
- 해당 검색 결과를 트리형태로 잘 정리해서 보여줬다. 그리고 클릭시에
idPathMap
과heading
정보를 활용해서 해당 매뉴얼의 해당 영역으로 이동하게 했다. 검색 keyword는mark
태그를 활용해 강조표시도 했다.
{searchResults.map((sR, idx) => (
<React.Fragment key={sR.id}>
<Box sx={{backgroundColor: "rgba(87,92,102,0.9)", color: "white",pl: 1}}>
<Typography variant="subtitle1" sx={{whiteSpace: "pre-line"}}>
{/* <HighlightedText keyword={e.heading ? "noKeyword123135571213" : searchInput}> */}
{sR.parentNames.concat(sR.name).join(" > ")}
{/* </HighlightedText> */}
</Typography>
</Box>
<MenuList>
{
sR.results.map((e,idx) =><MenuItem
key={idx}
sx={{display: "flex", alignItems:"start"}}
onClick={() => {
const path = idPathMap[sR.id]
navigate(path + (e.heading? `#${e.heading}`.replaceAll(' ', '-') : ""))
onPopOverClose()
}}
>
<Box sx={{pr:1, height: "100%", width: "calc(50% - 8px)", textAlign:"end"}}>
<Typography variant="subtitle2" sx={{whiteSpace: "pre-line"}}>
<HighlightedText keyword={e.heading ? "noKeyword123135571213" : searchInput}>
{/* {sR.parentNames.concat(sR.name).join(" > ")} */}
{e.heading}
</HighlightedText>
</Typography>
</Box>
<Box sx={{borderLeft: "1px solid #ccc", height: "100%", pl:1, width: "calc(50% - 8px)"}}>
{/* */}
{e.sentences && e?.sentences?.length > 0 && e.sentences.map((sentence,idx) => <Typography variant="body2" key={idx} sx={{whiteSpace: "pre-line"}}>
<HighlightedText keyword={searchInput}>
{sentence.replace(/#+ /g, "")+"..."}
</HighlightedText>
</Typography>)}
</Box>
</MenuItem>)
}
</MenuList>
<Divider/>
</React.Fragment>
))
}
이미지 캐싱
앱 기동 후 각 매뉴얼을 최초로 조회하는 경우 이미지를 api를 쏴서 불러와 로드하는 시간 때문에 초기 잠깐의 시간동안 레이아웃이 망가지는 문제가 있었다.
앱 최초 기동시 모든 매뉴얼에 포함된 이미지를 파싱해 온 후 api를 쏴서 저장해 놓는(캐싱) 방식으로 해당 문제를 해결했다.
const getImgSrcsInManual = (manual) => {
const {mdFile} = manual
if(mdFile?.data === undefined || mdFile?.data === null) return []
let imgSrcs = mdFile.data.match(regexImageSrc)
// let imgSrcs = imgs.map(img => img.exec(regexImageSrc))
return imgSrcs
}
const getImgSrcsInManuals = (manuals) => {
let result = []
for(let manual of manuals) {
result.push(...getImgSrcsInManual(manual))
if(manual.children.length > 0) result.push(...getImgSrcsInManuals(manual.children))
}
return result
}
export const initImageCache = async(manuals) => {
try {
// 재귀로 manuals md파일들 탐색
const allImgSrcs = getImgSrcsInManuals(manuals)
for(let src of allImgSrcs) {
const img = await loadImage(`${process.env.REACT_APP_HOST}/solution-menu/download/${src}`)
imageCache[src] = img
}
// console.dir(imageCache)
} catch(e) {
console.error(e)
}
}
'프로젝트 회고 > 사용자 매뉴얼 사이트 개발' 카테고리의 다른 글
3. 정리 - 사용자 매뉴얼 사이트 (0) | 2022.09.12 |
---|---|
1. 요구사항 정리, 기초 구상 (0) | 2022.09.07 |