Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix blogpost chapter nav #65

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/app/(pages)/blogposts/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,11 @@ export default async function BlogpostPage({ params: paramsPromise }) {
</div>

<div className={styles.contentContainer}>
{/* Left column: Navigation*/}
<BlogpostChapters />

{/* Left column: Navigation
Given Payload types where content_html can be null Typescript throws an error
@ts-expect-error */}
<BlogpostChapters content_html={blogpost.content_html} />

{/* Middle column: Content block*/}
<BlogpostContent blogpost={blogpost} />
Expand Down
66 changes: 38 additions & 28 deletions src/app/_blocks/BlogpostChapters/index.tsx
Original file line number Diff line number Diff line change
@@ -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<string[]>([]);
// 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]);
Comment on lines +12 to +35
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davelange regarding blogpost chapter nav, IntersectionObserver seems a bit finicky
links are working fine, but the navbar is not working spectacularly. when there is more than 1 id on the viewport" it makes sense, but when there is only one it sometimes work and sometimes doesn't

couldn't find a better way to achieve this result, is there a better way to achieve this result?

demo of the behavior: https://github.com/user-attachments/assets/6c47308a-710a-4103-9a1b-ddc25baf9155



return (
<div className={styles.container}>
{/*{<pre>{JSON.stringify(chapters, null, 2)}</pre>}*/}
<div className={styles.navbar}>
<p className={`outline ${styles.title}`}>CHAPTER</p>
<ul>
{chapters.map((chapter, i) => (
// <a key={i} href={`#${chapter.id}`}>
<li
style={{
borderColor:
visibleChapter === chapter
? "var(--sub-purple-400)"
: "var(--soft-white-100)",
}}
key={i}
>
{chapter}
</li>
// </a>
<a key={i} href={`#${chapter.id}`}>
<li
style={{
borderColor:
visibleChapter === chapter.id
? "var(--sub-purple-400)"
: "var(--soft-white-100)",
}}
key={i}
>
{chapter.title}
</li>
</a>
))}
</ul>
</div>
Expand Down
28 changes: 16 additions & 12 deletions src/app/_utilities/sanitizeAndAddChapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<h[1-6]>/g, () => {
return `<h5 id="chapter${sectionCounter++}">`
})
.replace(/<h[1-6]>/g, () => `<h5 id="chapter${sectionCounter++}">`)
.replace(/<\/h[1-6]>/g, '</h5>')
.replace(/%nbsp;/g, ' ')
.replace(/<p>\s*<\/p>/g, '')
.replace(/<p>\s*<\/p>/g, '');
}

export function getChapters(content_html: string): { id: string; title: string }[] {
const sanitizedContent = sanitizeAndAddChapters(content_html)
const regex = /<h[1-6] id="([^"]*)">(.*?)<\/h[1-6]>/g
const chapters: { id: string; title: string }[] = []
let match: RegExpExecArray | null
const sanitizedContent = sanitizeAndAddChapters(content_html);
const regex = /<h[1-6] id="([^"]*)">(.*?)<\/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;
}

117 changes: 62 additions & 55 deletions src/components/RichText/serialize.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -11,126 +11,133 @@ 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 (
<Fragment>
{/* @ts-expect-error */}
{nodes?.map((node, index): JSX.Element | null => {
if (node == null) {
return null
return null;
}

if (node.type === 'text') {
let text = <React.Fragment key={index}>{node.text}</React.Fragment>
if (node.type === "text") {
let text = <React.Fragment key={index}>{node.text}</React.Fragment>;
if (node.format & IS_BOLD) {
text = <strong key={index}>{text}</strong>
text = <strong key={index}>{text}</strong>;
}
if (node.format & IS_ITALIC) {
text = <em key={index}>{text}</em>
text = <em key={index}>{text}</em>;
}
if (node.format & IS_STRIKETHROUGH) {
text = (
<span key={index} style={{ textDecoration: 'line-through' }}>
<span key={index} style={{ textDecoration: "line-through" }}>
{text}
</span>
)
);
}
if (node.format & IS_UNDERLINE) {
text = (
<span key={index} style={{ textDecoration: 'underline' }}>
<span key={index} style={{ textDecoration: "underline" }}>
{text}
</span>
)
);
}
if (node.format & IS_CODE) {
text = <code key={index}>{node.text}</code>
text = <code key={index}>{node.text}</code>;
}
if (node.format & IS_SUBSCRIPT) {
text = <sub key={index}>{text}</sub>
text = <sub key={index}>{text}</sub>;
}
if (node.format & IS_SUPERSCRIPT) {
text = <sup key={index}>{text}</sup>
text = <sup key={index}>{text}</sup>;
}

return text
return text;
}

// NOTE: Hacky fix for
// https://github.com/facebook/lexical/blob/d10c4e6e55261b2fdd7d1845aed46151d0f06a8c/packages/lexical-list/src/LexicalListItemNode.ts#L133
// 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 <br className="col-start-2" key={index} />
case "linebreak": {
return <br className="col-start-2" key={index} />;
}
case 'paragraph': {
case "paragraph": {
return (
<p className="col-start-2" key={index}>
{serializedChildren}
</p>
)
);
}
case 'heading': {
const Tag = node?.tag
case "heading": {
const Tag = node?.tag;
const id = `chapter${chapterNumber++}`;
return (
<Tag className="col-start-2" key={index}>
<Tag className="col-start-2" key={index} id={id}>
{serializedChildren}
</Tag>
)
);
}
case 'list': {
const Tag = node?.tag
case "list": {
const Tag = node?.tag;
return (
<Tag className="list col-start-2" key={index}>
{serializedChildren}
</Tag>
)
);
}
case 'listitem': {
case "listitem": {
if (node?.checked != null) {
return (
<li
aria-checked={node.checked ? 'true' : 'false'}
className={` ${node.checked ? '' : ''}`}
aria-checked={node.checked ? "true" : "false"}
className={` ${node.checked ? "" : ""}`}
key={index}
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
role="checkbox"
Expand All @@ -139,43 +146,43 @@ export function serializeLexical({ nodes }: Props): JSX.Element {
>
{serializedChildren}
</li>
)
);
} else {
return (
<li key={index} value={node?.value}>
{serializedChildren}
</li>
)
);
}
}
case 'quote': {
case "quote": {
return (
<blockquote className="col-start-2" key={index}>
{serializedChildren}
</blockquote>
)
);
}
case 'link': {
const fields = node.fields
case "link": {
const fields = node.fields;

return (
<CMSLink
key={index}
newTab={Boolean(fields?.newTab)}
reference={fields.doc as any}
type={fields.linkType === 'internal' ? 'reference' : 'custom'}
type={fields.linkType === "internal" ? "reference" : "custom"}
url={fields.url}
>
{serializedChildren}
</CMSLink>
)
);
}

default:
return null
return null;
}
}
})}
</Fragment>
)
);
}
Loading