feat: memorized markdown components (#411)

This commit is contained in:
Xuezhou Dai 2023-04-24 19:00:38 +08:00 committed by GitHub
parent a5aa9d9182
commit 5ccc52bb41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 204 additions and 108 deletions

View File

@ -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<HTMLAudioElement>
> = ({ 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"}
/>
)
}
})

View File

@ -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 (
<CharacterFloatCard siteId={siteId}>
<UniLink
href={getSiteLink({
subdomain: siteId,
})}
className="inline-block"
>
@{siteId}
</UniLink>
</CharacterFloatCard>
)
} 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 (
<CharacterFloatCard siteId={siteId}>
<UniLink
href={getSiteLink({
subdomain: siteId,
})}
className="inline-block"
>
@{siteId}
</UniLink>
</CharacterFloatCard>
)
} else {
return <>{children}</>
}
},
(prevProps, nextProps) => {
return getSiteId(prevProps) === getSiteId(nextProps)
},
)

View File

@ -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<number>()
const [height, setHeight] = useState<number>()
const isUnmounted = useIsUnmounted()
}> = memo(
function Mermaid(props) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const [svg, setSvg] = useState("")
const [width, setWidth] = useState<number>()
const [height, setHeight] = useState<number>()
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 ? (
<div className="h-[50px] rounded-lg flex items-center justify-center bg-[#ECECFD] dark:bg-[#1F2020] text-sm">
Mermaid Loading...
</div>
) : svg ? (
<div>
<Image
alt="mermaid"
src={"data:image/svg+xml;base64," + Buffer.from(svg).toString("base64")}
width={width}
height={height}
/>
</div>
) : (
<div className="h-[50px] rounded-lg flex items-center justify-center bg-red-100 text-sm">
{error || "Error"}
</div>
)
}
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 ? (
<div className="min-h-[50px] rounded-lg flex items-center justify-center bg-[#ECECFD] dark:bg-[#1F2020] text-sm">
Mermaid Loading...
</div>
) : svg ? (
<div>
<ZoomedImage
alt="mermaid"
src={
"data:image/svg+xml;base64," + Buffer.from(svg).toString("base64")
}
width={width}
height={height}
/>
</div>
) : (
<div className="min-h-[50px] rounded-lg flex items-center justify-center bg-red-100 text-sm">
{error || "Error"}
</div>
)
},
(prevProps, nextProps) => {
return prevProps.children?.[0] === nextProps.children?.[0]
},
)

View File

@ -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
})

View File

@ -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. <br />)
{
test: (node: any) =>
node.type === "raw" && node.value.startsWith("<mermaid>"),
handler: (node: any) => {
node.type = "element"
node.tagName = "mermaid"
node.children = [
{
type: "text",
value: /<mermaid>([\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
}
}
})
}
}

View File

@ -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) {