diff --git a/src/app/(pages)/blogposts/[slug]/page.tsx b/src/app/(pages)/blogposts/[slug]/page.tsx index a60be12..7a84aec 100644 --- a/src/app/(pages)/blogposts/[slug]/page.tsx +++ b/src/app/(pages)/blogposts/[slug]/page.tsx @@ -43,8 +43,11 @@ export default async function BlogpostPage({ params: paramsPromise }) {
- {/* Left column: Navigation*/} - + + {/* Left column: Navigation + Given Payload types where content_html can be null Typescript throws an error + @ts-expect-error */} + {/* Middle column: Content block*/} diff --git a/src/app/_blocks/BlogpostChapters/index.tsx b/src/app/_blocks/BlogpostChapters/index.tsx index 077621a..3a3abda 100644 --- a/src/app/_blocks/BlogpostChapters/index.tsx +++ b/src/app/_blocks/BlogpostChapters/index.tsx @@ -1,49 +1,59 @@ "use client"; -import { useEffect, useState } from "react"; +import { JSX, useEffect, useState } from "react"; import styles from "./styles.module.css"; +import { getChapters, sanitizeAndAddChapters } from "@/app/_utilities/sanitizeAndAddChapters"; -export default function BlogpostChapters() { +export default function BlogpostChapters({ content_html }: { content_html: string }): JSX.Element { const [visibleChapter, setVisibleChapter] = useState(""); - const [chapters, setChapters] = useState([]); - // TODO: Fix chapter navigator - useEffect(() => { - const extractHeadings = () => { - // Query all headings in the DOM (e.g., h1, h2, h3...) - const chapterList: string[] = []; - const headings = Array.from(document.querySelectorAll("h1, h2, h3")); - headings.map(i => chapterList.push(i.innerHTML.trim())); - setChapters(chapterList); - }; + const chapters = getChapters(content_html); +useEffect(() => { + const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6'); - extractHeadings(); + const observer = new IntersectionObserver((entries) => { + let visibleId = ""; + entries.forEach((entry) => { + if (entry.isIntersecting || entry.boundingClientRect.bottom < 0) { + visibleId = entry.target.id; + } + }); - }, []); + setVisibleChapter(visibleId); + }, { + root: null, + threshold: 0.5, + }); + + headings.forEach((heading) => observer.observe(heading)); + + return () => { + headings.forEach((heading) => observer.unobserve(heading)); + }; +}, [chapters]); return (
- {/*{
{JSON.stringify(chapters, null, 2)}
}*/}

CHAPTER

diff --git a/src/app/_utilities/sanitizeAndAddChapters.ts b/src/app/_utilities/sanitizeAndAddChapters.ts index cfaa98b..07257f8 100644 --- a/src/app/_utilities/sanitizeAndAddChapters.ts +++ b/src/app/_utilities/sanitizeAndAddChapters.ts @@ -2,29 +2,33 @@ import DOMPurify from 'isomorphic-dompurify' // TODO: add the actual chapter names instead chapter1, 2, 3... export function sanitizeAndAddChapters(content_html: string) { - let sectionCounter = 1 + let sectionCounter = 1; return DOMPurify.sanitize(content_html) - .replace(//g, () => { - return `
` - }) + .replace(//g, () => `
`) .replace(/<\/h[1-6]>/g, '
') .replace(/%nbsp;/g, ' ') - .replace(/

\s*<\/p>/g, '') + .replace(/

\s*<\/p>/g, ''); } export function getChapters(content_html: string): { id: string; title: string }[] { - const sanitizedContent = sanitizeAndAddChapters(content_html) - const regex = /(.*?)<\/h[1-6]>/g - const chapters: { id: string; title: string }[] = [] - let match: RegExpExecArray | null + const sanitizedContent = sanitizeAndAddChapters(content_html); + const regex = /(.*?)<\/h[1-6]>/g; + const chapters: { id: string; title: string }[] = []; + let match: RegExpExecArray | null; while ((match = regex.exec(sanitizedContent)) !== null) { + const titleWithTags = match[2]; + + // Remove any HTML tags inside the title + const title = titleWithTags.replace(/<[^>]+>/g, '').trim(); + chapters.push({ id: match[1], - title: match[2], - }) + title: title, + }); } - return chapters + return chapters; } + diff --git a/src/components/RichText/serialize.tsx b/src/components/RichText/serialize.tsx index 5969f32..4ce335a 100644 --- a/src/components/RichText/serialize.tsx +++ b/src/components/RichText/serialize.tsx @@ -1,7 +1,7 @@ -import { CodeBlock, CodeBlockProps } from '@/blocks/Code/Component' -import React, { Fragment, JSX } from 'react' -import { CMSLink } from '@/components/Link' -import { DefaultNodeTypes, SerializedBlockNode } from '@payloadcms/richtext-lexical' +import { CodeBlock, CodeBlockProps } from "@/blocks/Code/Component"; +import React, { Fragment, JSX } from "react"; +import { CMSLink } from "@/components/Link"; +import { DefaultNodeTypes, SerializedBlockNode } from "@payloadcms/richtext-lexical"; import { IS_BOLD, @@ -11,60 +11,66 @@ import { IS_SUBSCRIPT, IS_SUPERSCRIPT, IS_UNDERLINE, -} from './nodeFormat' +} from "./nodeFormat"; export type NodeTypes = | DefaultNodeTypes | SerializedBlockNode< - | CodeBlockProps - > + | CodeBlockProps +> type Props = { nodes: NodeTypes[] } + export function serializeLexical({ nodes }: Props): JSX.Element { + // add a chapter number to incrementally + // serialize headings as chapters + let chapterNumber = 1; + + return ( {/* @ts-expect-error */} {nodes?.map((node, index): JSX.Element | null => { if (node == null) { - return null + return null; } - if (node.type === 'text') { - let text = {node.text} + if (node.type === "text") { + let text = {node.text}; if (node.format & IS_BOLD) { - text = {text} + text = {text}; } if (node.format & IS_ITALIC) { - text = {text} + text = {text}; } if (node.format & IS_STRIKETHROUGH) { text = ( - + {text} - ) + ); } if (node.format & IS_UNDERLINE) { text = ( - + {text} - ) + ); } if (node.format & IS_CODE) { - text = {node.text} + text = {node.text}; } if (node.format & IS_SUBSCRIPT) { - text = {text} + text = {text}; } if (node.format & IS_SUPERSCRIPT) { - text = {text} + text = {text}; } - return text + return text; } // NOTE: Hacky fix for @@ -72,65 +78,66 @@ export function serializeLexical({ nodes }: Props): JSX.Element { // which does not return checked: false (only true - i.e. there is no prop for false) const serializedChildrenFn = (node: NodeTypes): JSX.Element | null => { if (node.children == null) { - return null + return null; } else { - if (node?.type === 'list' && node?.listType === 'check') { + if (node?.type === "list" && node?.listType === "check") { for (const item of node.children) { - if ('checked' in item) { + if ("checked" in item) { if (!item?.checked) { - item.checked = false + item.checked = false; } } } } - return serializeLexical({ nodes: node.children as NodeTypes[] }) + return serializeLexical({ nodes: node.children as NodeTypes[] }); } - } + }; - const serializedChildren = 'children' in node ? serializedChildrenFn(node) : '' + const serializedChildren = "children" in node ? serializedChildrenFn(node) : ""; - if (node.type === 'block') { - const block = node.fields + if (node.type === "block") { + const block = node.fields; - const blockType = block?.blockType + const blockType = block?.blockType; if (!block || !blockType) { - return null + return null; } } else { switch (node.type) { - case 'linebreak': { - return
+ case "linebreak": { + return
; } - case 'paragraph': { + case "paragraph": { return (

{serializedChildren}

- ) + ); } - case 'heading': { - const Tag = node?.tag + case "heading": { + const Tag = node?.tag; + const id = `chapter${chapterNumber++}`; return ( - + {serializedChildren} - ) + ); } - case 'list': { - const Tag = node?.tag + case "list": { + const Tag = node?.tag; return ( {serializedChildren} - ) + ); } - case 'listitem': { + case "listitem": { if (node?.checked != null) { return (
  • {serializedChildren}
  • - ) + ); } else { return (
  • {serializedChildren}
  • - ) + ); } } - case 'quote': { + case "quote": { return (
    {serializedChildren}
    - ) + ); } - case 'link': { - const fields = node.fields + case "link": { + const fields = node.fields; return ( {serializedChildren} - ) + ); } default: - return null + return null; } } })} - ) + ); }