commit
be80cbab2d
@ -0,0 +1,4 @@ |
||||
export default definePageConfig({ |
||||
navigationBarTitleText: '链接', |
||||
onReachBottomDistance: 30 |
||||
}) |
@ -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 <WebView src={url}></WebView> |
||||
} |
||||
export default Web |
@ -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<TabLinkProps> = (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 ( |
||||
<View onClick={linkTab} className="font-28" style={{display:"inline-block",color:'#45D4A8'}}>22222</View> |
||||
) |
||||
} |
||||
|
||||
export default TabLink |
@ -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; |
||||
} |
||||
} |
@ -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 (<View>{children}</View>) |
||||
* } |
||||
* |
||||
* <Markdown source={'# heading {#custom-id}'} /> |
||||
* ``` |
||||
* |
||||
* @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<P>(type: ComponentType<P>, props?: Props<P>): ReactElement<P> { |
||||
props ??= {} as Props<P> |
||||
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<P> = Attributes & P |
||||
|
||||
interface RenderContext { |
||||
createReactElement<P>(type: ComponentType<P>, props?: Props<P>): ReactElement<P> |
||||
|
||||
ensureHeading(token: Tokens.Heading): string |
||||
} |
||||
|
||||
function render(ctx: RenderContext, tokens: Token[]) { |
||||
return walkTokens<ReactNode>(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 ? 对应 <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 |
||||
}, |
||||
} |
@ -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<T = void>(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<Props>(type: ComponentType<Props>, props?: Attributes & Props): ReactElement<Props> { |
||||
props ??= {} as Attributes & Props |
||||
props.key ??= ++nextKey |
||||
return createElement(type, props) |
||||
} |
Loading…
Reference in new issue