정현우12 2022. 9. 12. 00:04

데이터 페치, 라우팅, 사이드바 설정

  1. 앱 최초 기동시에 각 사용자 매뉴얼 파일 정보를 트리 형태로 불러왔다.
export default function ManualSite() {
    const [manuals, setManuals] = useState([])
    useEffect(() => {
        getManuals().then(res => {
            setManauals(res)
        })
    }, [])
    // ...생략
}

트리의 depth는 3이고 각 노드들은 파일 주소, 이름, path이름을 가지고 있다.

manuals = [{mdUrl: "botCategory.md", name: "봇 카테고리", "botCategory"}, ...]
  1. 파일 주소를 바탕으로 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)
        }
        /// ..생략
    }
}
  1. 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>

  1. 사이드바는 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을 활용해 로드 시간을 기다리는 식으로 구현했다.

목차 기능 구현

  1. 목차 기능을 위하여 앱 최초 기동시 mdFile을 다운로드 하고, 해당 mdFile의 내용에서 #의 개수를 활용해서 mdFile의 제목들을 파싱해서 titles 프로퍼티에 저장했다.
  2. 해당 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
  };
  1. 목차의 각 제목 클릭시에는 해당 제목이 화면 최상단에 위치하도록 스크롤이 움직이게 했다. 이를 위해 사용자 매뉴얼 컴포넌트 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])

검색 기능 구현

  1. 검색 기능을 위해 앱 최초 기동시 받아온 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}
}
  1. 검색 시 트리를 재귀 형태로 순회하면서 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
}
  1. 해당 검색 결과를 트리형태로 잘 정리해서 보여줬다. 그리고 클릭시에 idPathMapheading 정보를 활용해서 해당 매뉴얼의 해당 영역으로 이동하게 했다. 검색 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)
    } 
}