介紹
接著上文說完,實現了在markdown編輯器中插入視頻的能力,接下來還需要繼續優化 markdown文檔的閱讀體驗,比如 再加個目錄
熟悉markdown語法的朋友可能會說,直接在編輯時添加 @toc 標簽,可以在文章頂部自動生成目錄,但是這并不是我們想要的效果。我們想要什么效果呢,就和掘金這種效果一樣(🤓?)。找了一圈沒有看到 bytemd有自帶的ToC組件,于是決定自行實現目錄效果。
目錄主要是展示的時候用,所以只需要處理查看頁的相關邏輯。寫之前也有參考bytemd自帶預覽視圖的目錄效果,不過不太好直接復用,因為實際上我們的目錄還需要 - 1. 響應點擊定位到具體的片段、2. 自定義樣式效果 (其實主要原因是 項目開了es嚴格檢查,直接copy過來的目錄代碼要改的東西太多。。。)
UI層
我們先實現目錄的UI組件
export interface Heading {id: string,text: string,level: number
}interface TocProps {hast: Heading[];currentBlockIndex: number;onTocClick: (clickIndex: number) => void;
}
const Toc: React.FC<TocProps> = ({ hast, currentBlockIndex, onTocClick}) => {const [items, setItems] = useState<Heading[]>([]);const [minLevel, setMinLevel] = useState(6);const [currentHeadingIndex, setCurrentHeadingIndex] = useState(0);useEffect(() => {let newMinLevel = 6;setCurrentHeadingIndex(currentBlockIndex);setItems(hast);hast.forEach((item, index) => {newMinLevel = Math.min(newMinLevel, item.level);})setMinLevel(newMinLevel);}, [hast, currentBlockIndex]);const handleClick = (index: number) => {onTocClick(index);};return (<div className={`bytemd-toc`}><h2 style={{marginBottom: '0.5em', fontSize: '16px'}}>目錄</h2><div className={styles.tocDivider}/><ul>{items.map((item, index) => (<likey={index}className={`bytemd-toc-${item.level} ${currentHeadingIndex === index ? 'bytemd-toc-active' : ''} ${item.level === minLevel ? 'bytemd-toc-first' : ''}`}style={{paddingLeft: `${(item.level - minLevel) * 16 + 8}px`}}onClick={() => handleClick(index)}onKeyDown={(e) => {if (['Enter', 'Space'].includes(e.code)) {handleClick(index); // 監聽目錄項的點擊}}}tabIndex={0} // Make it focusable>{item.text}</li>))}</ul></div>);
};export default Toc;
目錄其實就是循環添加<li>
標簽,當遇到level小一級的,就添加一個縮進;并處理目錄項的選中與未選中的樣式。
數據層
實現完目錄的UI效果后,接下來就是獲取目錄數據了。因為文章內容是基于markdown語法編寫的,所以渲染到頁面上時,標題和正文會由不同的標簽來區分,我們只需要將其中的<h>
標簽過濾出來,就能獲取到整個文章的目錄結構了。
const extractHeadings = () => {if (viewerRef && viewerRef.current) {const headingElements = Array.from(viewerRef.current!.querySelectorAll('h1, h2, h3, h4, h5, h6'));addIdsToHeadings(headingElements)const headingData = headingElements.map((heading) => ({id: heading.id,text: heading.textContent || "",level: parseInt(heading.tagName.replace('H', ''), 10),}));setHeadings(headingData);}
};
function addIdsToHeadings(headingElements: Element[]) {const ids = new Set(); // 用于存儲已經生成的ID,確保唯一性let count = 1;headingElements.forEach(heading => {let slug = generateSlug(heading.textContent);let uniqueSlug = slug;// 如果生成的ID已經存在,添加一個計數器來使其唯一while (ids.has(uniqueSlug)) {uniqueSlug = `${slug}-${count++}`;}ids.add(uniqueSlug);heading.id = uniqueSlug;});
}
交互層
然后再處理目錄項的點擊和滾動事件,點擊某一項時頁面要滾動到具體的位置(需要根據當前的內容高度動態計算);滾動到某一區域時對應的目錄項也要展示被選中的狀態
// 處理目錄項點擊事件
const handleTocClick = (index: number) => {if (viewerRef.current && headings.length > index) {const node = document.getElementById(headings[index].id)if (node == null) {return}// 獲取元素當前的位置const elementPosition = node.getBoundingClientRect().top;// 獲取當前視窗的滾動位置const currentScrollPosition = scrollableDivRef.current?.scrollTop || 0;// 計算目標位置const targetScrollPosition = currentScrollPosition + elementPosition - OFFSET_TOP;console.log("elementPosition ", elementPosition, "currentScrollPosition ", currentScrollPosition, "targetScrollPosition ", targetScrollPosition)// 滾動到目標位置scrollableDivRef.current?.scrollTo({top: targetScrollPosition,behavior: 'smooth' // 可選,平滑滾動});setTimeout(() => {setCurrentBlockIndex(index)}, 100)}
};const handleScroll = throttle(() => {if (isFromClickRef.current) {return;}if (viewerRef.current) {const headings = viewerRef.current.querySelectorAll('h1, h2, h3, h4, h5, h6');let lastPassedHeadingIndex = 0;for (let i = 0; i < headings.length; i++) {const heading = headings[i];const {top} = heading.getBoundingClientRect();if (top < window.innerHeight * 0.3) {lastPassedHeadingIndex = i;} else {break;}}setCurrentBlockIndex(lastPassedHeadingIndex);}
}, 100);
最后,在需要的位置添加ToC組件即可完成目錄的展示啦
<Tochast={headings}currentBlockIndex={currentBlockIndex}onTocClick={handleTocClick}
/>
題外話
也許是由于初始選中組件的原因,整個markdown的開發過程并不算順利,拓展能力幾乎沒有,需要自行添加。
同時也還遇到了 其中縮放組件 mediumZoom()
會跟隨頁面的渲染而重復初始化創建overlay層,導致預覽失敗。這里也提供一個常用的解決方案:使用useMemo
對組件進行處理,使其復用,避免了 mediumZoom()
的多次初始化
const viewerComponent = useMemo(() => {return <div ref={viewerRef}><Viewerplugins={plugins}value={articleData.content}/></div>
}, [articleData]);