From f4d12ce780af7f19120689023265aacec747dde0 Mon Sep 17 00:00:00 2001 From: sunlizhou <296190577@qq.com> Date: Thu, 24 Aug 2023 16:32:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=93=81=E7=89=8C=E8=AF=A6=E6=83=85=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=EF=BC=8C=E6=96=87=E7=AB=A0=E8=AF=A6=E6=83=85=E6=B8=B2?= =?UTF-8?q?=E6=9F=93markdown=EF=BC=8C=E6=96=87=E7=AB=A0=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E5=86=85=E5=AF=BC=E8=88=AA=EF=BC=8C=E6=96=87=E7=AB=A0=E5=86=85?= =?UTF-8?q?webView=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 29 +- src/api/brand.ts | 4 +- src/app.config.ts | 1 + src/pages/preview/brand/article/article.tsx | 4 +- src/pages/preview/brand/info/info.module.scss | 10 +- src/pages/preview/brand/info/info.tsx | 131 +++---- .../illness/article/article.module.scss | 25 ++ src/pages/preview/illness/article/article.tsx | 52 ++- src/pages/preview/illness/list/list.tsx | 11 +- src/pages/preview/webView/webView.config.ts | 4 + src/pages/preview/webView/webView.tsx | 9 + src/utils/marked/components/Tablink.tsx | 30 ++ src/utils/marked/marked.scss | 174 ++++++++++ src/utils/marked/marked.ts | 319 ++++++++++++++++++ src/utils/marked/utils.ts | 86 +++++ 16 files changed, 779 insertions(+), 111 deletions(-) create mode 100644 src/pages/preview/webView/webView.config.ts create mode 100644 src/pages/preview/webView/webView.tsx create mode 100644 src/utils/marked/components/Tablink.tsx create mode 100644 src/utils/marked/marked.scss create mode 100644 src/utils/marked/marked.ts create mode 100644 src/utils/marked/utils.ts diff --git a/package.json b/package.json index d2525ab..77426b6 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@tarojs/shared": "3.6.8", "@tarojs/taro": "3.6.8", "dayjs": "^1.11.9", + "marked": "^7.0.4", "react": "^18.0.0", "react-dom": "^18.0.0", "react-refresh": "^0.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eae9f48..bdbe307 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ dependencies: dayjs: specifier: ^1.11.9 version: 1.11.9 + marked: + specifier: ^7.0.4 + version: 7.0.4 react: specifier: ^18.0.0 version: 18.0.0 @@ -4993,6 +4996,13 @@ packages: - supports-color dev: true + /babel-runtime@6.26.0: + resolution: {integrity: sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==} + dependencies: + core-js: 2.6.12 + regenerator-runtime: 0.11.1 + dev: false + /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: true @@ -5831,6 +5841,12 @@ packages: resolution: {integrity: sha512-/AnE9Y4OsJZicCzIe97JP5XoPKQJfTuEG43aEVLFJGOJpyqELod+pE6LEl63DfG1Mp8wX97LDaDpy1GmLEUxlg==} requiresBuild: true + /core-js@2.6.12: + resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + requiresBuild: true + dev: false + /core-js@3.31.0: resolution: {integrity: sha512-NIp2TQSGfR6ba5aalZD+ZQ1fSxGhDo/s1w0nx3RYzf2pnJxt7YynxFlFScP6eV7+GZsKO95NSjGxyJsU3DZgeQ==} requiresBuild: true @@ -9291,6 +9307,12 @@ packages: engines: {node: '>=8'} dev: true + /marked@7.0.4: + resolution: {integrity: sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ==} + engines: {node: '>= 16'} + hasBin: true + dev: false + /mathml-tag-names@2.1.3: resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} dev: true @@ -11037,7 +11059,6 @@ packages: /regenerator-runtime@0.11.1: resolution: {integrity: sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==} - dev: true /regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} @@ -12093,6 +12114,12 @@ packages: postcss-value-parser: 3.3.1 dev: true + /taro-parse@1.1.5: + resolution: {integrity: sha512-OS8O9gPyFNQCvQuaGPMshNtxpmvSGVNTNkT/YPhozs629/f+PDEIWt1Ahlqu5uOik58x4piiaogiUdon218Rfg==} + dependencies: + babel-runtime: 6.26.0 + dev: false + /terser-webpack-plugin@5.3.9(@swc/core@1.3.23)(esbuild@0.14.54)(webpack@5.78.0): resolution: {integrity: sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==} engines: {node: '>= 10.13.0'} diff --git a/src/api/brand.ts b/src/api/brand.ts index 4e636c3..5fd8a85 100644 --- a/src/api/brand.ts +++ b/src/api/brand.ts @@ -30,11 +30,11 @@ export const brandApi = { return request(`/home/v1/brand/${id}`, "GET") }, /** 文章列表 */ - articleList(owner_id: number ) { + articleList(owner_id: number,page:number) { return request<{ list: ArticleRecord[], total: number - }>(`/home/v1/article/list?owner_id=${owner_id}&page=1&page_size=1000` , "GET") + }>(`/home/v1/article/list?owner_id=${owner_id}&page=${page}&page_size=10` , "GET") }, articleInfo(id: number ) { return request(`/home/v1/article/${id}` , "GET") diff --git a/src/app.config.ts b/src/app.config.ts index 95305a4..db352ba 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -89,6 +89,7 @@ export default defineAppConfig({ 'illness/sort/sort', 'illness/list/list', 'illness/article/article', + 'webView/webView', ] }, ], diff --git a/src/pages/preview/brand/article/article.tsx b/src/pages/preview/brand/article/article.tsx index b0c348e..a0f341f 100644 --- a/src/pages/preview/brand/article/article.tsx +++ b/src/pages/preview/brand/article/article.tsx @@ -8,7 +8,7 @@ import {Profile} from "@/store"; const article:FC = () => { - const {token} = Profile.useContainer() + const {token,empty} = Profile.useContainer() const {id} = useRouter().params as unknown as { id: number} const [articleInfo,setArticleInfo] = useState() useEffect(() => { @@ -35,7 +35,7 @@ const article:FC = () => { - + 登录查看更多内容 diff --git a/src/pages/preview/brand/info/info.module.scss b/src/pages/preview/brand/info/info.module.scss index 9362555..aebea31 100644 --- a/src/pages/preview/brand/info/info.module.scss +++ b/src/pages/preview/brand/info/info.module.scss @@ -7,7 +7,7 @@ page{ } .curIndexBox{ position: absolute; - top:450rpx; + top:500rpx; right:30rpx; background-color: rgba(0,0,0,0.5); border-radius: 30rpx; @@ -15,16 +15,16 @@ page{ color:#fff; } .body{ - border-radius: 32rpx 32rpx 0 0; background-color:#f1f8f6; width: 750rpx; box-sizing: border-box; display: flex; flex-direction: column; - min-height: 600rpx; - position: absolute; - top: 520rpx; + min-height: 500rpx; .top{ + position: sticky; + position: -webkit-sticky; + top: 0; padding:40rpx 30rpx 30rpx 30rpx; border-radius: 32rpx; background-color: #fff; diff --git a/src/pages/preview/brand/info/info.tsx b/src/pages/preview/brand/info/info.tsx index 8a923ab..10cd2c7 100644 --- a/src/pages/preview/brand/info/info.tsx +++ b/src/pages/preview/brand/info/info.tsx @@ -1,8 +1,8 @@ -import {FC, useEffect, useState} from "react"; +import {FC, useCallback, useEffect, useState} from "react"; import {Image, Swiper, SwiperItem, Text, Video, View} from "@tarojs/components"; import {ArticleRecord, brandApi, BrandRecord} from "@/api"; import styles from './info.module.scss' -import Taro, {useRouter} from "@tarojs/taro"; +import Taro, {useReachBottom, useRouter} from "@tarojs/taro"; import LineEllipsis from "@/components/textCollapse/collapse"; import Empty from "@/components/empty/empty"; @@ -12,10 +12,19 @@ type Params = { const BrandInfo: FC = () => { const {id} = useRouter().params as unknown as Params const [brandInfo, setBrandInfo] = useState() - const [articleList, setArticleList] = useState() + const [articleList, setArticleList] = useState([]) const [curIndex,setCurIndex] = useState(1) + const [page, setPage] = useState(1) + const [total, setTotal] = useState(0) useEffect(() => { + Taro.showLoading({ + title: '加载中', + mask: true + }) + setTimeout(function () { + Taro.hideLoading() + }, 1000) getData() }, [id]) @@ -24,26 +33,48 @@ const BrandInfo: FC = () => { const data = await brandApi.info(id) Taro.setNavigationBarTitle({title: data.name}) setBrandInfo(data) - const data1 = await brandApi.articleList(id) - setArticleList(data1.list) + const data1 = await brandApi.articleList(id,page) + setTotal(data1.total) + setArticleList([ + ...(articleList || []), + ...data1.list + ]) } catch (e) { // setBrandInfo({disabled: 0, graphic_introduction: "", id: 0, introductory_video: "", name: "", brand_album:['1','2','3']}) } } + useReachBottom(useCallback(() => { + if (articleList?.length < total) { + setPage(page + 1) + } + }, [total, articleList])) + + useEffect(() => { + brandApi.articleList(id,page).then(res => { + setTotal(res.total) + setArticleList([ + ...(articleList || []), + ...res.list + ]) + }) + + }, [page]) + function onChange(e){ console.log(e) setCurIndex(+e.detail.current+1) } return ( - + { brandInfo?.introductory_video_resource?.url && diff --git a/src/pages/preview/illness/article/article.module.scss b/src/pages/preview/illness/article/article.module.scss index e9d84a6..6b00ae0 100644 --- a/src/pages/preview/illness/article/article.module.scss +++ b/src/pages/preview/illness/article/article.module.scss @@ -1,5 +1,30 @@ +.botmBox{ + z-index: 99; + position: fixed; + bottom: 0; + width: 750rpx; + box-sizing: border-box; + height: 180rpx; + padding-bottom: 60rpx; + display: flex; + justify-content: center; + align-items: center; + background: #F5F8F7; + view{ + width: 560rpx; + height: 76rpx; + background: #45D4A8; + border-radius: 38rpx 38rpx 38rpx 38rpx; + color: #fff; + font-weight: 500; + font-size: 40rpx; + text-align: center; + line-height: 76rpx; + } +} .fixedBox{ position: fixed; + z-index: 100; top:0; width: 100vw; height: 100vh; diff --git a/src/pages/preview/illness/article/article.tsx b/src/pages/preview/illness/article/article.tsx index 882c6ed..3670f1a 100644 --- a/src/pages/preview/illness/article/article.tsx +++ b/src/pages/preview/illness/article/article.tsx @@ -1,16 +1,22 @@ -import {FC, useEffect, useState} from "react"; -import {Image, Text, View} from "@tarojs/components"; +import {FC, useEffect, useMemo, useState} from "react"; +import {Image, PageContainer, Text, View} from "@tarojs/components"; import Taro, {useRouter} from "@tarojs/taro"; import {ArticleRecord, brandApi} from "@/api"; import styles from './article.module.scss' import down from '@/static/img/doubleDown.png' import {Profile} from "@/store"; - +import {parse} from "@/utils/marked/marked"; const article:FC = () => { - const {token} = Profile.useContainer() + const {token, empty} = Profile.useContainer() const {id} = useRouter().params as unknown as { id: number} + const [show,setShow] = useState(false) const [articleInfo,setArticleInfo] = useState() + const { children, headings } = useMemo(() => parse(articleInfo?.content || ''), [articleInfo]) + console.log(headings,'headings') + const query = Taro.createSelectorQuery() + + useEffect(() => { getData() }, [id]) @@ -24,11 +30,33 @@ const article:FC = () => { } catch (e) { } } + function mao(id: string){ + console.log(id) + setShow(false) + Taro.nextTick(() => { + query.select(`#${id}`).boundingClientRect() + query.exec((res) => { + if(res.length){ + Taro.pageScrollTo({ + scrollTop: res[res.length-1].top, + duration: 300} + ) + } + }) + }) + } function helloWorld() { - const html = articleInfo?.content; return ( <> - + {/**/} + {/* { children }*/} + {/**/} + {setShow(true)}}> + 文章导航 + + + { children } + { !token && @@ -36,13 +64,23 @@ const article:FC = () => { - + 登录查看更多内容 } + + + {headings.length > 0 && + headings.map((d) => + {mao(d.id)}}>{d.text} + ) + } + + + ) } diff --git a/src/pages/preview/illness/list/list.tsx b/src/pages/preview/illness/list/list.tsx index 17503b9..2fae725 100644 --- a/src/pages/preview/illness/list/list.tsx +++ b/src/pages/preview/illness/list/list.tsx @@ -11,8 +11,17 @@ const BrandList: FC = () => { const [page, setPage] = useState(1) const [brands, setBrands] = useState([]) const [total, setTotal] = useState(0) + const [fetchDone,setFetchDone] = useState(false) useEffect(() => { + Taro.showLoading({ + title: '加载中', + mask: true + }) + setTimeout(function () { + Taro.hideLoading() + setFetchDone(true) + }, 800) getData() }, [page]) @@ -37,7 +46,7 @@ const BrandList: FC = () => { return ( - + { brands.length > 0 ? brands.map((d) => diff --git a/src/pages/preview/webView/webView.config.ts b/src/pages/preview/webView/webView.config.ts new file mode 100644 index 0000000..808161a --- /dev/null +++ b/src/pages/preview/webView/webView.config.ts @@ -0,0 +1,4 @@ +export default definePageConfig({ + navigationBarTitleText: '链接', + onReachBottomDistance: 30 +}) diff --git a/src/pages/preview/webView/webView.tsx b/src/pages/preview/webView/webView.tsx new file mode 100644 index 0000000..4c459ef --- /dev/null +++ b/src/pages/preview/webView/webView.tsx @@ -0,0 +1,9 @@ +import {FC} from "react"; +import {WebView} from "@tarojs/components"; +import {useRouter} from "@tarojs/taro"; + +const Web:FC = () => { + const {url} = useRouter().params as unknown as { url: string } + return +} +export default Web diff --git a/src/utils/marked/components/Tablink.tsx b/src/utils/marked/components/Tablink.tsx new file mode 100644 index 0000000..93e61dd --- /dev/null +++ b/src/utils/marked/components/Tablink.tsx @@ -0,0 +1,30 @@ +import { FC, ReactNode} from "react"; +import {View} from "@tarojs/components"; +import Taro from "@tarojs/taro"; + + + +interface TabLinkProps{ + className:string + url:string + children: ReactNode[] +} + +const TabLink:FC = (props:TabLinkProps) => { + function linkTab(){ + console.log(props.url) + let webExp= new RegExp(/http(s)?:\/\/([\w-]+\.)+[\w-]+(\/[\w- .\/?%&=]*)?/) + if(webExp.test(props.url)){ + //跳转webview + Taro.navigateTo({url:`/pages/preview/webView/webView?url=${props.url}`}) + }else{ + //跳转小程序页面 + } + } + + return ( + 22222 + ) +} + +export default TabLink diff --git a/src/utils/marked/marked.scss b/src/utils/marked/marked.scss new file mode 100644 index 0000000..e526abf --- /dev/null +++ b/src/utils/marked/marked.scss @@ -0,0 +1,174 @@ +.code {} + +.h1 { + font-size: 48rpx; + margin-top: 20rpx; + margin-bottom: 40rpx; +} + +.h2 { + font-size: 44rpx; + margin-top: 20rpx; + margin-bottom: 40rpx; +} + +.h3 { + font-size: 40rpx; + margin-top: 20rpx; + margin-bottom: 40rpx; +} + +.h4 { + font-size: 36rpx; + margin-top: 20rpx; + margin-bottom: 40rpx; +} + +.h5 { + font-size: 32rpx; + margin-top: 20rpx; + margin-bottom: 40rpx; +} + +.h6 { + font-size: 28rpx; + margin-top: 20rpx; + margin-bottom: 40rpx; +} + +.hr { + border-bottom: 1px solid #aaa; + margin-top: 12rpx; + margin-bottom: 12rpx; +} + +.blockquote { + border-left: 8rpx solid #aaa; + padding: 16rpx; +} + +.list, +.taskList, +.orderedList { + display: block; + margin-bottom: 12rpx; +} + +.listItem, +.taskListItem, +.orderedListItem { + padding-left: 1rem; + position: relative; +} + +.taskListItem { + padding-left: 1.5rem; +} + +.checkedTaskIndicator, +.uncheckTaskIndicator, +.listItemIndicator { + position: absolute; + top: 0; + left: 0; + user-select: none; +} + +.checkedTaskIndicator, +.uncheckTaskIndicator { + width: 1rem; + height: 1rem; + overflow: hidden; + border: 2px solid #ddd; + display: flex; + justify-content: center; + align-items: center; + font-size: 0.65rem; + border-radius: 8rpx; + color: #999; +} + +.paragraph { + margin-top: 4rpx; + margin-bottom: 12rpx; + font-size: 28rpx; + font-weight: 500; + color: #444444; + line-height: 35rpx; +} + +.blockHtml {} + +.inlineHtml {} + +.text { +} + +.def {} + +.strong { + font-weight: bold; +} + +.em { + font-style: italic; +} + +.del { + text-decoration: line-through; +} + +.codespan { + padding: 0 12rpx; + font-size: 85%; + border: 1px solid #aaa; + background-color: #ccc; + border-radius: 8rpx; +} + +//.escape {} +//.br {} + +.link { + display: inline; + text-decoration: underline; + color: blue; + // todo 取消点击时的背景 +} + +.image { + display: inline-block; + max-width: 100%; + height: auto; +} + +.video { + display: block; + max-width: 100%; +} + +// todo 自动换行 +.table { + max-width: 100%; + overflow-x: auto; + border: 1px solid #dee2e6; +} + +.tableBodyRow, +.tableHeader { + display: flex; + + &:not(:last-child) { + border-bottom: 1px solid #dee2e6; + } +} + +.tableHeaderCell, +.tableBodyCell { + flex: 1; + padding: 8rpx 12rpx; + + &:not(:last-child) { + border-right: 1px solid #dee2e6; + } +} diff --git a/src/utils/marked/marked.ts b/src/utils/marked/marked.ts new file mode 100644 index 0000000..73563a4 --- /dev/null +++ b/src/utils/marked/marked.ts @@ -0,0 +1,319 @@ +import {Token, Tokens, lexer} from "marked" +import {Attributes, ComponentType, createElement, ReactElement, ReactNode} from "react" +import {View, Text, Image, Video, Audio} from "@tarojs/components" +import {parseHeadingId, walkTokens, unescape} from "./utils" +import TabLink from "./components/Tablink"; +import './marked.scss' + +/** + * 解析 Markdown 文本 + * + * ```ts + * const Markdown: FC<{ source: string }> = ({ source }) => { + * const { children, headings } = useMemo(() => parse(source), [source]) + * console.log(headings) // => [{level: 1, text: 'heading', id: 'custom-id'}] + * return ({children}) + * } + * + * + * ``` + * + * @param {string} content 原始文本 + * + * @see {@link https://www.markdownguide.org} + * @see {@link https://marked.js.org} + */ +export const parse = (content: string) => { + const headings: Heading[] = [] + let nextKey = 0 + + const ctx: RenderContext = { + createReactElement

(type: ComponentType

, props?: Props

): ReactElement

{ + props ??= {} as Props

+ props.key ??= ++nextKey + return createElement(type, props) + }, + + ensureHeading(token: Tokens.Heading): string { + // todo(hupeh): id 不能重复 + // todo(hupeh): 第一层标题级别必须一致 + const id = parseHeadingId(token) + headings.push({id, text: token.text, level: token.depth}) + return id + }, + } + + const tokens = lexer(content) + const children = render(ctx, tokens) + + return {children, headings} +} + +export interface Heading { + level: number + text: string + id: string +} + +type Props

= Attributes & P + +interface RenderContext { + createReactElement

(type: ComponentType

, props?: Props

): ReactElement

+ + ensureHeading(token: Tokens.Heading): string +} + +function render(ctx: RenderContext, tokens: Token[]) { + return walkTokens(tokens, (token) => renderToken(ctx, token)) +} + +function renderToken(ctx: RenderContext, token: Token) { + return (renderer[token.type] ?? renderer.generic)(ctx, token) +} + +const renderer = { + space(_: RenderContext, tk: Tokens.Space) { + // todo(hupeh): 测试是否需要 Text 实现 + return tk.raw + }, + + code(ctx: RenderContext, tk: Tokens.Code) { + return ctx.createReactElement(View, { + className: 'code', + children: tk.escaped ? escape(tk.text) : tk.text, + }) + }, + + heading(ctx: RenderContext, tk: Tokens.Heading) { + const id = ctx.ensureHeading(tk) + + // todo(hupeh): 是否需要调整标题级别保证顺序一致 + return ctx.createReactElement(View, { + id, + className: `h${tk.depth}`, + children: render(ctx, tk.tokens), + }) + }, + + hr(ctx: RenderContext, _: Tokens.Hr) { + return ctx.createReactElement(View, { + className: 'hr', + }) + }, + + blockquote(ctx: RenderContext, tk: Tokens.Blockquote) { + return ctx.createReactElement(View, { + className: 'blockquote', + children: render(ctx, tk.tokens), + }) + }, + + list(ctx: RenderContext, tk: Tokens.List) { + const isTaskList = tk.items[0]?.task == true; + // todo(hupeh): 什么是 tk.loose + return ctx.createReactElement(View, { + className: isTaskList ? 'taskList' : tk.ordered ? 'orderedList' : 'list', + children: tk.items.map((item, index) => { + return ctx.createReactElement(View, { + className: isTaskList ? 'taskListItem' : tk.ordered ? 'orderedListItem' : "listItem", + children: [ + !isTaskList ? ctx.createReactElement(View, { + className: 'listItemIndicator', + children: tk.ordered ? index + 1 : '•', + }) : null, + item.task ? ctx.createReactElement(View, { + className: item.checked ? 'checkedTaskIndicator' : 'uncheckTaskIndicator', + children: item.checked ? ctx.createReactElement(Text, { + children: '✔', + }) : null, + }) : null, + ...render(ctx, item.tokens), + ], + }); + }) + }); + }, + + paragraph(ctx: RenderContext, tk: Tokens.Paragraph) { + // todo(hupeh): tk.pre + return ctx.createReactElement(View, { + className: 'paragraph', + children: render(ctx, tk.tokens), + }); + }, + + html(ctx: RenderContext, tk: Tokens.HTML) { + // todo(hupeh): 什么是 tk.pre ? 对应

 标签吗?
+    // if (tk.pre) {}
+    if (tk.block) {
+      return ctx.createReactElement(View, {
+        className: 'blockHtml',
+        children: tk.text.split(/\r?\n/).map(line => {
+          return ctx.createReactElement(View, {
+            className: 'blockHtmlItem',
+            children: ctx.createReactElement(Text, {
+              space: 'nbsp', // 空格符号渲染方式
+              decode: true, // 正确显示实体
+              children: line.trimEnd(),
+            }),
+          })
+        }),
+      })
+    }
+    return ctx.createReactElement(Text, {
+      className: 'inlineHtml',
+      children: tk.text,
+    })
+  },
+
+  text(ctx: RenderContext, tk: Tokens.Text) {
+    if (tk.tokens?.length) {
+      return render(ctx, tk.tokens)
+    }
+    // todo 转义 HTML 实体
+    return ctx.createReactElement(Text, {
+      className: 'text',
+      children: unescape(tk.text),
+      decode: true,
+    })
+  },
+
+  def(ctx: RenderContext, tk: Tokens.Def) {
+    return ctx.createReactElement(Text, {
+      className: 'def',
+      children: tk.title,
+    })
+  },
+
+  escape(ctx: RenderContext, tk: Tokens.Escape) {
+    return ctx.createReactElement(Text, {
+      className: 'escape',
+      children: escape(tk.text),
+    })
+  },
+
+  link(ctx: RenderContext, tk: Tokens.Link) {
+    // todo(hupeh): 页面内部跳转,根据 heading 的 ID 实现
+    return ctx.createReactElement(TabLink, {
+      className: 'link',
+      url: tk.href,
+      children: render(ctx, tk.tokens),
+    })
+  },
+
+  image(ctx: RenderContext, tk: Tokens.Image) {
+    // todo(hupeh): 相关控制属性可以通过 QueryString 解析出来
+    const ext = tk.href.split('?').shift()!.split('.').pop()!.toLowerCase();
+    switch (ext) {
+      case 'mp4':
+      case 'm4v':
+      case 'mov':
+      case 'qt':
+      case 'avi':
+      case 'flv':
+      case 'wmv':
+      case 'asf':
+      case 'mkv':
+      case 'rm':
+      case 'rmvb':
+      case 'vob':
+      case 'ts':
+      case 'dat':
+        return ctx.createReactElement(Video, {
+          className: 'video',
+          src: tk.href,
+        })
+      case 'mp3':
+      case 'wma':
+      case 'flac':
+      case 'wav':
+        // todo(hupeh): 使用 Taro.createInnerAudioContext 接口封装组件
+        return ctx.createReactElement(Audio, {
+          className: 'audio',
+          src: tk.href
+        })
+      default:
+        return ctx.createReactElement(Image, {
+          className: 'image',
+          src: tk.href,
+          mode: "widthFix",
+          webp: ext === 'web',
+        })
+    }
+  },
+
+  strong(ctx: RenderContext, tk: Tokens.Strong) {
+    return ctx.createReactElement(Text, {
+      className: 'strong',
+      children: render(ctx, tk.tokens),
+    })
+  },
+
+  em(ctx: RenderContext, tk: Tokens.Em) {
+    return ctx.createReactElement(Text, {
+      className: 'em',
+      children: render(ctx, tk.tokens),
+    })
+  },
+
+  codespan(ctx: RenderContext, tk: Tokens.Codespan) {
+    return ctx.createReactElement(Text, {
+      className: 'codespan',
+      children: tk.text,
+    })
+  },
+
+  br(ctx: RenderContext, _: Tokens.Br) {
+    return ctx.createReactElement(View, {
+      className: 'br',
+    })
+  },
+
+  del(ctx: RenderContext, tk: Tokens.Del) {
+    return ctx.createReactElement(Text, {
+      className: 'del',
+      children: render(ctx, tk.tokens),
+    })
+  },
+
+  table(ctx: RenderContext, tk: Tokens.Table) {
+    // todo(hupeh): 使用自定义 table 组件,检测单元格宽度
+    // return "暂不支持表格渲染";
+    return ctx.createReactElement(View, {
+      className: 'table',
+      children: [
+        ctx.createReactElement(View, {
+          className: 'tableHeader',
+          children: tk.header.map((cell, index) => {
+            return ctx.createReactElement(View, {
+              className: 'tableHeaderCell',
+              style: {textAlign: tk.align[index] || 'left'},
+              children: render(ctx, cell.tokens),
+            })
+          }),
+        }),
+        ctx.createReactElement(View, {
+          className: 'tableBody',
+          children: tk.rows.map(rows => {
+            return ctx.createReactElement(View, {
+              className: 'tableBodyRow',
+              children: rows.map((cell, index) => {
+                return ctx.createReactElement(View, {
+                  className: 'tableBodyCell',
+                  style: {textAlign: tk.align[index] || 'left'},
+                  children: render(ctx, cell.tokens),
+                })
+              })
+            })
+          }),
+        }),
+      ],
+    })
+  },
+
+  generic(ctx: RenderContext, tk: Tokens.Generic) {
+    // todo(hupeh): 验证 tk.type
+    // todo(hupeh): 调试模式下不返回 null
+    return tk.tokens?.length ? render(ctx, tk.tokens) : null
+  },
+}
diff --git a/src/utils/marked/utils.ts b/src/utils/marked/utils.ts
new file mode 100644
index 0000000..4c7c077
--- /dev/null
+++ b/src/utils/marked/utils.ts
@@ -0,0 +1,86 @@
+import {Token, Tokens, TokensList} from "@/utils/marked/marked"
+import {Attributes, ComponentType, createElement, ReactElement} from "react";
+
+
+const unescapeTest = /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig
+
+export function unescape(html: string) {
+  // explicitly match decimal, hex, and named HTML entities
+  return html.replace(unescapeTest, (_, n) => {
+    n = n.toLowerCase()
+    if (n === 'colon') return ':'
+    if (n.charAt(0) === '#') {
+      return n.charAt(1) === 'x'
+        ? String.fromCharCode(parseInt(n.substring(2), 16))
+        : String.fromCharCode(+n.substring(1))
+    }
+    return ''
+  })
+}
+
+// This alphabet uses `A-Za-z0-9_-` symbols.
+// The order of characters is optimized for better gzip and brotli compression.
+// Same as in non-secure/index.js
+const urlAlphabet = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict'
+
+export function hash(str: string) {
+  const parts = Math.abs(str.split("")
+    .reduce((a, b) => {
+      a = ((a << 5) - a) + b.charCodeAt(0);
+      return a & a;
+    }, 0))
+    .toString()
+    .split('')
+
+  let id = ''
+  // We are reading directly from the random pool to avoid creating new array
+  for (const part of parts) {
+    const size = Number.parseInt(part)
+    if (!Number.isNaN(size)) {
+      // It is incorrect to use bytes exceeding the alphabet size.
+      // The following mask reduces the random byte in the 0-255 value
+      // range to the 0-63 value range. Therefore, adding hacks, such
+      // as empty string fallback or magic numbers, is unneccessary because
+      // the bitmask trims bytes down to the alphabet size.
+      id += urlAlphabet[size & 63]
+    }
+  }
+  return id.replace(/^-+|-+$/g, '')
+}
+
+export function walkTokens(tokens: Token[] | TokensList, callback: (token: Token) => T | T[]) {
+  let values: T[] = [];
+  for (const token of tokens) {
+    values = values.concat(callback.call(this, token))
+  }
+  return values
+}
+
+export function parseHeadingId(tk: Tokens.Heading) {
+  const index = tk.tokens.length - 1;
+  if (tk.tokens[index].type !== 'text') {
+    return hash(tk.raw)
+  }
+
+  // https://github.com/markedjs/marked-custom-heading-id/blob/main/src/index.js
+  const child = tk.tokens[index] as Tokens.Text
+  const headingIdRegex = /(?: +|^)\{#([a-z][\w-]*)\}(?: +|$)/i;
+  const hasId = child.text.match(headingIdRegex);
+
+  if (!hasId) {
+    return hash(tk.raw)
+  }
+
+  child.text = child.text.replace(headingIdRegex, '')
+  tk.text = tk.text.replace(headingIdRegex, '')
+
+  return hasId[1]
+}
+
+let nextKey = 0
+
+export function h(type: ComponentType, props?: Attributes & Props): ReactElement {
+  props ??= {} as Attributes & Props
+  props.key ??= ++nextKey
+  return createElement(type, props)
+}