From 5ccc52bb413cb3eba9a64ff574903525dc998303 Mon Sep 17 00:00:00 2001 From: Xuezhou Dai Date: Mon, 24 Apr 2023 19:00:38 +0800 Subject: [PATCH] feat: memorized markdown components (#411) --- src/components/ui/APlayer.tsx | 14 +- src/components/ui/Mention.tsx | 62 +++++---- src/components/ui/Mermaid.tsx | 152 +++++++++++---------- src/markdown/index.ts | 24 +++- src/markdown/rehype-custom-wrapper.ts | 51 +++++++ src/pages/dashboard/[subdomain]/editor.tsx | 9 +- 6 files changed, 204 insertions(+), 108 deletions(-) create mode 100644 src/markdown/rehype-custom-wrapper.ts diff --git a/src/components/ui/APlayer.tsx b/src/components/ui/APlayer.tsx index 496059aa..45311365 100644 --- a/src/components/ui/APlayer.tsx +++ b/src/components/ui/APlayer.tsx @@ -1,4 +1,5 @@ import { APlayer as AplayerReact } from "aplayer-react" +import { memo } from "react" import { toGateway } from "~/lib/ipfs-parser" @@ -9,7 +10,16 @@ export const APlayer: React.FC< cover?: string lrc?: string } & React.AudioHTMLAttributes -> = ({ src, name, artist, cover, lrc, muted, autoPlay, loop }) => { +> = memo(function APlayer({ + src, + name, + artist, + cover, + lrc, + muted, + autoPlay, + loop, +}) { if (!src) return null src = toGateway(src) @@ -34,4 +44,4 @@ export const APlayer: React.FC< initialLoop={loop ? "one" : "none"} /> ) -} +}) diff --git a/src/components/ui/Mention.tsx b/src/components/ui/Mention.tsx index 05dfca2e..801516d5 100644 --- a/src/components/ui/Mention.tsx +++ b/src/components/ui/Mention.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { memo } from "react" import { getSiteLink } from "~/lib/helpers" import { useGetSite } from "~/queries/site" @@ -6,37 +6,45 @@ import { useGetSite } from "~/queries/site" import { CharacterFloatCard } from "../common/CharacterFloatCard" import { UniLink } from "./UniLink" -export const Mention: React.FC<{ - id: string - children?: any -}> = ({ id, children }) => { - let siteId +const getSiteId = ({ id, children }: { id: string; children?: any }) => { if ( children?.[0] && typeof children[0] === "string" && children[0].startsWith("@") ) { - siteId = children[0].replace(/^@/, "") + return children[0].replace(/^@/, "") } else if (id) { - siteId = id.replace(/^user-content-/, "") - } - - const site = useGetSite(siteId) - - if (siteId && site.data) { - return ( - - - @{siteId} - - - ) - } else { - return <>{children} + return id.replace(/^user-content-/, "") } } + +export const Mention: React.FC<{ + id: string + children?: any +}> = memo( + function Mention({ id, children }) { + let siteId = getSiteId({ id, children }) + + const site = useGetSite(siteId) + + if (siteId && site.data) { + return ( + + + @{siteId} + + + ) + } else { + return <>{children} + } + }, + (prevProps, nextProps) => { + return getSiteId(prevProps) === getSiteId(nextProps) + }, +) diff --git a/src/components/ui/Mermaid.tsx b/src/components/ui/Mermaid.tsx index f06204a6..6ec8120c 100644 --- a/src/components/ui/Mermaid.tsx +++ b/src/components/ui/Mermaid.tsx @@ -1,85 +1,95 @@ import { nanoid } from "nanoid" -import { FC, useEffect, useState } from "react" +import { FC, memo, useEffect, useState } from "react" import { useIsDark } from "~/hooks/useDarkMode" import { useIsUnmounted } from "~/hooks/useLifecycle" -import { Image } from "./Image" +import { ZoomedImage } from "./Image" export const Mermaid: FC<{ children: [string] -}> = (props) => { - const [loading, setLoading] = useState(true) - const [error, setError] = useState("") - const [svg, setSvg] = useState("") - const [width, setWidth] = useState() - const [height, setHeight] = useState() - const isUnmounted = useIsUnmounted() +}> = memo( + function Mermaid(props) { + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + const [svg, setSvg] = useState("") + const [width, setWidth] = useState() + const [height, setHeight] = useState() + const isUnmounted = useIsUnmounted() - const isDark = useIsDark() - - useEffect(() => { - import("mermaid").then(async (mo) => { - const mermaid = mo.default - mermaid.initialize({ - theme: isDark ? "dark" : "default", - }) - }) - }, [isDark]) - - useEffect(() => { - if (props.children?.[0]) { - setError("") - setLoading(true) + const isDark = useIsDark() + useEffect(() => { import("mermaid").then(async (mo) => { const mermaid = mo.default - const id = nanoid() - let result - try { - result = await mermaid.render(`mermaid-${id}`, props.children[0]) - } catch (error) { - if (error instanceof Error) { - setError(error.message) - } - setSvg("") - setWidth(undefined) - setHeight(undefined) - } - - if (isUnmounted()) return - - if (result) { - setSvg(result.svg) - - const match = result.svg.match(/viewBox="[^"]*\s([\d.]+)\s([\d.]+)"/) - if (match?.[1] && match?.[2]) { - setWidth(parseInt(match?.[1])) - setHeight(parseInt(match?.[2])) - } - setError("") - } - setLoading(false) + mermaid.initialize({ + theme: isDark ? "dark" : "default", + }) }) - } - }, [props.children, isDark]) + }, [isDark]) - return loading ? ( -
- Mermaid Loading... -
- ) : svg ? ( -
- mermaid -
- ) : ( -
- {error || "Error"} -
- ) -} + useEffect(() => { + if (props.children?.[0]) { + setError("") + setLoading(true) + + import("mermaid").then(async (mo) => { + const mermaid = mo.default + const id = nanoid() + let result + try { + result = await mermaid.render(`mermaid-${id}`, props.children[0]) + } catch (error) { + document.getElementById(`dmermaid-${id}`)?.remove() + if (error instanceof Error) { + setError(error.message) + } + setSvg("") + setWidth(undefined) + setHeight(undefined) + } + + if (isUnmounted()) return + + if (result) { + setSvg(result.svg) + + const match = result.svg.match( + /viewBox="[^"]*\s([\d.]+)\s([\d.]+)"/, + ) + if (match?.[1] && match?.[2]) { + setWidth(parseInt(match?.[1])) + setHeight(parseInt(match?.[2])) + } + setError("") + } + setLoading(false) + }) + } + }, [props.children, isDark]) + + return loading ? ( +
+ Mermaid Loading... +
+ ) : svg ? ( +
+ +
+ ) : ( +
+ {error || "Error"} +
+ ) + }, + (prevProps, nextProps) => { + return prevProps.children?.[0] === nextProps.children?.[0] + }, +) diff --git a/src/markdown/index.ts b/src/markdown/index.ts index 975ba12c..3449dce7 100644 --- a/src/markdown/index.ts +++ b/src/markdown/index.ts @@ -32,6 +32,11 @@ import { Mention } from "~/components/ui/Mention" import { Mermaid } from "~/components/ui/Mermaid" import { rehypeAudio } from "./rehype-audio" +import { + allowedCustomWrappers, + defaultRules, + rehypeCustomWrapper, +} from "./rehype-custom-wrapper" import { rehypeImage } from "./rehype-image" import { rehypeTable } from "./rehype-table" import { rehypeWrapCode } from "./rehype-wrap-code" @@ -84,7 +89,7 @@ export const renderPageContent = ( let contentHTML = "" let result: any = null try { - result = unified() + let pipeline = unified() .use(remarkParse) .use(remarkBreaks) .use(remarkFrontmatter, ["yaml"]) @@ -117,8 +122,16 @@ export const renderPageContent = ( }) .use(remarkPangu) .use(remarkRehype, { allowDangerousHtml: true }) + + if (!html) { + pipeline.use(rehypeCustomWrapper, { + rules: defaultRules, + }) + } + + pipeline .use(rehypeStringify) - .use(rehypeRaw) + .use(rehypeRaw, { passThrough: allowedCustomWrappers }) .use(rehypeImage, { env }) .use(rehypeAudio, { env }) .use(rehypeSlug) @@ -193,7 +206,9 @@ export const renderPageContent = ( }) // Move it to the end as it generates a lot of DOM and requires extensive traversal. .use(rehypeKatex) - .use(html ? () => (tree: any) => {} : rehypeReact, { + + if (!html) { + pipeline.use(rehypeReact, { createElement: createElement, components: { img: ZoomedImage, @@ -204,6 +219,9 @@ export const renderPageContent = ( style: Style, } as any, }) + } + + result = pipeline .use(() => (tree) => { env.tree = tree }) diff --git a/src/markdown/rehype-custom-wrapper.ts b/src/markdown/rehype-custom-wrapper.ts new file mode 100644 index 00000000..841b0656 --- /dev/null +++ b/src/markdown/rehype-custom-wrapper.ts @@ -0,0 +1,51 @@ +import { Root } from "hast" +import { visit } from "unist-util-visit" + +// WHAT IS THIS? +// Wrap some tags with a custom container so that they can +// be replaced by rehype-react and avoid being modified by +// intermediary processes (such as rehype-raw). + +export const allowedCustomWrappers = ["mermaid"] + +export interface IDefaultRulesItem { + test: (node: any) => boolean + handler: (node: any) => void +} + +export const defaultRules: IDefaultRulesItem[] = [ + // 1. mermaid + // rehype-raw will split the node into multiple nodes + // if it contains html tags. (e.g.
) + { + test: (node: any) => + node.type === "raw" && node.value.startsWith(""), + handler: (node: any) => { + node.type = "element" + node.tagName = "mermaid" + node.children = [ + { + type: "text", + value: /([\s\S]*)<\/mermaid>/.exec(node.value)?.[1], + }, + ] + }, + }, +] + +export const rehypeCustomWrapper = ( + options: { rules?: IDefaultRulesItem[] } = {}, +) => { + const rules = options && options.rules ? options.rules : defaultRules + + return function transformer(tree: Root) { + visit(tree, (node: any) => { + for (let rule of rules) { + if (rule.test(node)) { + rule.handler(node) + break + } + } + }) + } +} diff --git a/src/pages/dashboard/[subdomain]/editor.tsx b/src/pages/dashboard/[subdomain]/editor.tsx index f81a3c4f..48beff13 100644 --- a/src/pages/dashboard/[subdomain]/editor.tsx +++ b/src/pages/dashboard/[subdomain]/editor.tsx @@ -290,11 +290,9 @@ export default function SubdomainEditor() { useDebounceEffect( () => { - if (values.content) { - const result = renderPageContent(values.content) - setTree(result.tree) - setParsedContent(result) - } + const result = renderPageContent(values.content) + setTree(result.tree) + setParsedContent(result) }, [values.content], { @@ -391,6 +389,7 @@ export default function SubdomainEditor() { previewChildNodes?.[index] && (child as any).tagName !== "style" ) { + if (child.position.start.line > view.state.doc.lines) return const line = view.state?.doc.line(child.position.start.line) const block = view.lineBlockAt(line.from) if (block) {