From cab015431dff1d8b483fc0101eef7e8a63df47f1 Mon Sep 17 00:00:00 2001 From: Ben <3447705+bcabanes@users.noreply.github.com> Date: Mon, 27 Mar 2023 16:17:49 -0400 Subject: [PATCH] feat(nx-dev): add table of content for documents --- .../feature-doc-viewer/src/lib/doc-viewer.tsx | 33 +++++-- .../src/lib/table-of-contents.tsx | 90 +++++++++++++++++++ .../src/lib/use-headings-observer.ts | 44 +++++++++ nx-dev/ui-markdoc/src/index.ts | 20 +++-- 4 files changed, 174 insertions(+), 13 deletions(-) create mode 100644 nx-dev/feature-doc-viewer/src/lib/table-of-contents.tsx create mode 100644 nx-dev/feature-doc-viewer/src/lib/use-headings-observer.ts diff --git a/nx-dev/feature-doc-viewer/src/lib/doc-viewer.tsx b/nx-dev/feature-doc-viewer/src/lib/doc-viewer.tsx index 06a2da8407e49c..dee9013980d424 100644 --- a/nx-dev/feature-doc-viewer/src/lib/doc-viewer.tsx +++ b/nx-dev/feature-doc-viewer/src/lib/doc-viewer.tsx @@ -8,6 +8,8 @@ import { Breadcrumbs, Footer } from '@nrwl/nx-dev/ui-common'; import { renderMarkdown } from '@nrwl/nx-dev/ui-markdoc'; import { NextSeo } from 'next-seo'; import { useRouter } from 'next/router'; +import { useRef } from 'react'; +import { collectHeadings, TableOfContents } from './table-of-contents'; export function DocViewer({ document, @@ -17,10 +19,14 @@ export function DocViewer({ relatedDocuments: RelatedDocument[]; }): JSX.Element { const router = useRouter(); + const ref = useRef(null); - const { metadata, node } = renderMarkdown(document.content.toString(), { - filePath: document.filePath, - }); + const { metadata, node, treeNode } = renderMarkdown( + document.content.toString(), + { + filePath: document.filePath, + } + ); const vm = { title: metadata['title'] ?? document.name, @@ -34,6 +40,7 @@ export function DocViewer({ filePath: '', } ).node, + tableOfContent: collectHeadings(treeNode), }; return ( @@ -75,11 +82,21 @@ export function DocViewer({
{/*MAIN CONTENT*/} -
- {vm.content} +
+
+ {vm.content} +
+
+ +
{/*RELATED CONTENT*/}
item.id && headingLevelTargets.includes(item.level) + ); + + const activeId = useHeadingsObserver( + elementRef, + { + threshold: [0, 0.25, 0.5, 0.75, 1], + root: null, + rootMargin: '-10% 0% -45% 0%', + }, + headings.find((i) => i.level === 1)?.title || null + ); + + return ( + + ); +} diff --git a/nx-dev/feature-doc-viewer/src/lib/use-headings-observer.ts b/nx-dev/feature-doc-viewer/src/lib/use-headings-observer.ts new file mode 100644 index 00000000000000..ac69b8daa59cc3 --- /dev/null +++ b/nx-dev/feature-doc-viewer/src/lib/use-headings-observer.ts @@ -0,0 +1,44 @@ +import { RefObject, useEffect, useState } from 'react'; + +export function useHeadingsObserver( + elementRef: RefObject, + { threshold = 0, root = null, rootMargin = '0%' }: IntersectionObserverInit, + cachekey: string | null = null +): string { + const [activeId, setActiveId] = useState(''); + + useEffect(() => { + const handleObserver = (entries: IntersectionObserverEntry[]): void => { + for (const entry of entries) { + if (entry?.isIntersecting) setActiveId(entry.target.id); + } + }; + + const node = elementRef?.current; // DOM Ref + const hasIOSupport = !!window.IntersectionObserver; + + if (!hasIOSupport || !node) return; + const observer = new IntersectionObserver(handleObserver, { + threshold, + root, + rootMargin, + }); + + const elements: NodeListOf = node.querySelectorAll('h1, h2, h3'); + elements.forEach((e: Element) => { + observer.observe(e); + }); + + return () => observer.disconnect(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + elementRef?.current, + JSON.stringify(threshold), + root, + rootMargin, + cachekey, + ]); + + return activeId; +} diff --git a/nx-dev/ui-markdoc/src/index.ts b/nx-dev/ui-markdoc/src/index.ts index 6a7c2b30c33c82..cbe0e851f60b34 100644 --- a/nx-dev/ui-markdoc/src/index.ts +++ b/nx-dev/ui-markdoc/src/index.ts @@ -1,4 +1,11 @@ -import { Node, parse, renderers, Tokenizer, transform } from '@markdoc/markdoc'; +import { + Node, + parse, + RenderableTreeNode, + renderers, + Tokenizer, + transform, +} from '@markdoc/markdoc'; import { load as yamlLoad } from 'js-yaml'; import React, { ReactNode } from 'react'; import { Fence } from './lib/nodes/fence.component'; @@ -92,12 +99,14 @@ export const parseMarkdown: (markdown: string) => Node = (markdown) => { export const renderMarkdown: ( documentContent: string, options: { filePath: string } -) => { metadata: Record; node: ReactNode } = ( - documentContent: string, - options: { filePath: string } = { filePath: '' } -): { metadata: Record; node: ReactNode } => { +) => { + metadata: Record; + node: ReactNode; + treeNode: RenderableTreeNode; +} = (documentContent, options = { filePath: '' }) => { const ast = parseMarkdown(documentContent); const configuration = getMarkdocCustomConfig(options.filePath); + const treeNode = transform(ast, configuration.config); return { metadata: ast.attributes['frontmatter'] @@ -106,5 +115,6 @@ export const renderMarkdown: ( node: renderers.react(transform(ast, configuration.config), React, { components: configuration.components, }), + treeNode, }; };