feat: memorized markdown components (#411)
This commit is contained in:
parent
a5aa9d9182
commit
5ccc52bb41
|
|
@ -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"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue