feat: 替换了markdown 的组件

This commit is contained in:
2025-07-26 10:22:41 +08:00
parent 5321b27176
commit cb22c0531c
25 changed files with 3697 additions and 5942 deletions

View File

@@ -0,0 +1,22 @@
## 3.0.02025-07-17
### 新增 aiMode 模式
### aiMode模式 优化流式输出以及排版
## 2.1.02025-05-23
- 新增latex公式支持
- 代码块高亮支持增加除js外的语法
- 注意:包体积增加了,建议在分包内使用
## 2.0.52024-04-24
- 流式输出代码块解决方案
## 2.0.42023-12-06
- 长按复制代码改为点击代码块复制
## 2.0.32023-10-30
doc: 文档说明
## 2.0.22023-10-30
- 新增长按复制代码-仅小程序可用
- 新增代码块语言显示
## 2.0.12023-10-27
##支持vue2,vue3
## 2.0.02022-11-01
使用mp-html自带的插件,重新生成uniapp包,大幅减少插件体积
## 1.0.02022-09-13
首次发布

View File

@@ -0,0 +1,6 @@
export default {
// copyByLongPress: false, // 是否需要长按代码块时显示复制代码内容菜单
copyByClickCode: true, // 点击代码块复制
showLanguageName: true, // 是否在代码块右上角显示语言的名称
// showLineNumber: false // 是否显示行号,需要重新打包mp-html
}

View File

@@ -0,0 +1,119 @@
/**
* @fileoverview highlight 插件
* Include prismjs (https://prismjs.com)
*/
import prism from "./prism.min";
import config from "./config";
import Parser from "../parser";
function Highlight(vm) {
this.vm = vm;
}
Highlight.prototype.onParse = function (node, vm) {
if (node.name === "pre") {
if (vm.options.editable) {
node.attrs.class = (node.attrs.class || "") + " hl-pre";
return;
}
let i;
for (i = node.children.length; i--; ) {
if (node.children[i].name === "code") break;
}
if (i === -1) return;
const code = node.children[i];
let className = code.attrs.class + " " + node.attrs.class;
i = className.indexOf("language-");
if (i === -1) {
i = className.indexOf("lang-");
if (i === -1) {
className = "language-text";
i = 9;
} else {
i += 5;
}
} else {
i += 9;
}
let j;
for (j = i; j < className.length; j++) {
if (className[j] === " ") break;
}
const lang = className.substring(i, j);
if (code.children.length) {
const text = this.vm.getText(code.children).replace(/&amp;/g, "&");
if (!text) return;
if (node.c) {
node.c = undefined;
}
if (prism.languages[lang]) {
code.children = new Parser(this.vm).parse(
// 加一层 pre 保留空白符
"<pre>" +
prism
.highlight(text, prism.languages[lang], lang)
.replace(/token /g, "hl-") +
"</pre>"
)[0].children;
}
node.attrs.class = "hl-pre";
code.attrs.class = "hl-code";
code.attrs.style = "display:block;overflow: auto;";
if (config.showLanguageName) {
node.children.push({
name: "div",
attrs: {
class: "hl-language",
style:
"user-select:none;position:absolute;top:0;right:2px;font-size:10px;",
},
children: [
{
type: "text",
text: lang,
},
],
});
}
if (config.copyByClickCode) {
node.attrs.style += (node.attrs.style || "") + ";user-select:none";
node.attrs["data-content"] = text;
node.children.push({
name: "div",
attrs: {
class: "hl-copy",
style:
"user-select:none;position:absolute;top:0;right:3px;font-size:10px;",
},
// children: [{
// type: 'text',
// text: '复制'
// }]
});
vm.expose();
}
if (config.showLineNumber) {
const line = text.split("\n").length;
const children = [];
for (let k = line; k--; ) {
children.push({
name: "span",
attrs: {
class: "span",
},
});
}
node.children.push({
name: "span",
attrs: {
class: "line-numbers-rows",
},
children,
});
}
}
}
};
export default Highlight;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,80 @@
/**
* @fileoverview latex 插件
* katex.min.js来源 https://github.com/rojer95/katex-mini
*/
import parse from './katex.min'
function Latex () {
}
Latex.prototype.onParse = function (node, vm) {
// $...$包裹的内容为latex公式
if (!vm.options.editable && node.type === 'text' && node.text.includes('$')) {
const part = node.text.split(/(\${1,2})/)
const children = []
let status = 0
for (let i = 0; i < part.length; i++) {
if (i % 2 === 0) {
// 文本内容
if (part[i]) {
if (status === 0) {
children.push({
type: 'text',
text: part[i]
})
} else {
if (status === 1) {
// 行内公式
const nodes = parse.default(part[i])
children.push({
name: 'span',
attrs: {},
l: 'T',
f: 'display:inline-block',
children: nodes
})
} else {
// 块公式
const nodes = parse.default(part[i], {
displayMode: true
})
children.push({
name: 'div',
attrs: {
style: 'text-align:center'
},
children: nodes
})
}
}
}
} else {
// 分隔符
if (part[i] === '$' && part[i + 2] === '$') {
// 行内公式
status = 1
part[i + 2] = ''
} else if (part[i] === '$$' && part[i + 2] === '$$') {
// 块公式
status = 2
part[i + 2] = ''
} else {
if (part[i] && part[i] !== '$$') {
// 普通$符号
part[i + 1] = part[i] + part[i + 1]
}
// 重置状态
status = 0
}
}
}
delete node.type
delete node.text
node.name = 'span'
node.attrs = {}
node.children = children
}
}
export default Latex

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,34 @@
/**
* @fileoverview markdown 插件
* Include marked (https://github.com/markedjs/marked)
* Include github-markdown-css (https://github.com/sindresorhus/github-markdown-css)
*/
import marked from './marked.min'
let index = 0
function Markdown (vm) {
this.vm = vm
vm._ids = {}
}
Markdown.prototype.onUpdate = function (content) {
if (this.vm.markdown) {
return marked(content)
}
}
Markdown.prototype.onParse = function (node, vm) {
if (vm.options.markdown) {
// 中文 id 需要转换,否则无法跳转
if (vm.options.useAnchor && node.attrs && /[\u4e00-\u9fa5]/.test(node.attrs.id)) {
const id = 't' + index++
this.vm._ids[node.attrs.id] = id
node.attrs.id = id
}
if (node.name === 'p' || node.name === 'table' || node.name === 'tr' || node.name === 'th' || node.name === 'td' || node.name === 'blockquote' || node.name === 'pre' || node.name === 'code') {
node.attrs.class = `md-${node.name} ${node.attrs.class || ''}`
}
}
}
export default Markdown

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,504 @@
<template>
<view id="_root" :class="(selectable?'_select ':'')+'_root'" :style="containerStyle">
<slot v-if="!nodes[0]" />
<!-- #ifndef APP-PLUS-NVUE -->
<node v-else :childs="nodes" :opts="[lazyLoad,loadingImg,errorImg,showImgMenu,selectable]" name="span" />
<!-- #endif -->
<!-- #ifdef APP-PLUS-NVUE -->
<web-view ref="web" src="/static/app-plus/mp-html/local.html" :style="'margin-top:-2px;height:' + height + 'px'" @onPostMessage="_onMessage" />
<!-- #endif -->
</view>
</template>
<script>
/**
* mp-html v2.5.1
* @description 富文本组件
* @tutorial https://github.com/jin-yufeng/mp-html
* @property {String} container-style 容器的样式
* @property {String} content 用于渲染的 html 字符串
* @property {Boolean} copy-link 是否允许外部链接被点击时自动复制
* @property {String} domain 主域名,用于拼接链接
* @property {String} error-img 图片出错时的占位图链接
* @property {Boolean} lazy-load 是否开启图片懒加载
* @property {string} loading-img 图片加载过程中的占位图链接
* @property {Boolean} pause-video 是否在播放一个视频时自动暂停其他视频
* @property {Boolean} preview-img 是否允许图片被点击时自动预览
* @property {Boolean} scroll-table 是否给每个表格添加一个滚动层使其能单独横向滚动
* @property {Boolean | String} selectable 是否开启长按复制
* @property {Boolean} set-title 是否将 title 标签的内容设置到页面标题
* @property {Boolean} show-img-menu 是否允许图片被长按时显示菜单
* @property {Object} tag-style 标签的默认样式
* @property {Boolean | Number} use-anchor 是否使用锚点链接
* @event {Function} load dom 结构加载完毕时触发
* @event {Function} ready 所有图片加载完毕时触发
* @event {Function} imgtap 图片被点击时触发
* @event {Function} linktap 链接被点击时触发
* @event {Function} play 音视频播放时触发
* @event {Function} error 媒体加载出错时触发
*/
// #ifndef APP-PLUS-NVUE
import node from './node/node'
// #endif
import Parser from './parser'
import markdown from './markdown/index.js'
import highlight from './highlight/index.js'
import latex from './latex/index.js'
import style from './style/index.js'
const plugins=[markdown,highlight,latex,style,]
// #ifdef APP-PLUS-NVUE
const dom = weex.requireModule('dom')
// #endif
export default {
name: 'mp-html',
data () {
return {
nodes: [],
// #ifdef APP-PLUS-NVUE
height: 3
// #endif
}
},
props: {
markdown: Boolean,
containerStyle: {
type: String,
default: ''
},
content: {
type: String,
default: ''
},
copyLink: {
type: [Boolean, String],
default: true
},
domain: String,
errorImg: {
type: String,
default: ''
},
lazyLoad: {
type: [Boolean, String],
default: false
},
loadingImg: {
type: String,
default: ''
},
pauseVideo: {
type: [Boolean, String],
default: true
},
previewImg: {
type: [Boolean, String],
default: true
},
scrollTable: [Boolean, String],
selectable: [Boolean, String],
setTitle: {
type: [Boolean, String],
default: true
},
showImgMenu: {
type: [Boolean, String],
default: true
},
tagStyle: Object,
useAnchor: [Boolean, Number]
},
// #ifdef VUE3
emits: ['load', 'ready', 'imgtap', 'linktap', 'play', 'error'],
// #endif
// #ifndef APP-PLUS-NVUE
components: {
node
},
// #endif
watch: {
content (content) {
this.setContent(content)
}
},
created () {
this.plugins = []
for (let i = plugins.length; i--;) {
this.plugins.push(new plugins[i](this))
}
},
mounted () {
if (this.content && !this.nodes.length) {
this.setContent(this.content)
}
},
beforeDestroy () {
this._hook('onDetached')
},
methods: {
/**
* @description 将锚点跳转的范围限定在一个 scroll-view 内
* @param {Object} page scroll-view 所在页面的示例
* @param {String} selector scroll-view 的选择器
* @param {String} scrollTop scroll-view scroll-top 属性绑定的变量名
*/
in (page, selector, scrollTop) {
// #ifndef APP-PLUS-NVUE
if (page && selector && scrollTop) {
this._in = {
page,
selector,
scrollTop
}
}
// #endif
},
/**
* @description 锚点跳转
* @param {String} id 要跳转的锚点 id
* @param {Number} offset 跳转位置的偏移量
* @returns {Promise}
*/
navigateTo (id, offset) {
id = this._ids[decodeURI(id)] || id
return new Promise((resolve, reject) => {
if (!this.useAnchor) {
reject(Error('Anchor is disabled'))
return
}
offset = offset || parseInt(this.useAnchor) || 0
// #ifdef APP-PLUS-NVUE
if (!id) {
dom.scrollToElement(this.$refs.web, {
offset
})
resolve()
} else {
this._navigateTo = {
resolve,
reject,
offset
}
this.$refs.web.evalJs('uni.postMessage({data:{action:"getOffset",offset:(document.getElementById(' + id + ')||{}).offsetTop}})')
}
// #endif
// #ifndef APP-PLUS-NVUE
let deep = ' '
// #ifdef MP-WEIXIN || MP-QQ || MP-TOUTIAO
deep = '>>>'
// #endif
const selector = uni.createSelectorQuery()
// #ifndef MP-ALIPAY
.in(this._in ? this._in.page : this)
// #endif
.select((this._in ? this._in.selector : '._root') + (id ? `${deep}#${id}` : '')).boundingClientRect()
if (this._in) {
selector.select(this._in.selector).scrollOffset()
.select(this._in.selector).boundingClientRect()
} else {
// 获取 scroll-view 的位置和滚动距离
selector.selectViewport().scrollOffset() // 获取窗口的滚动距离
}
selector.exec(res => {
if (!res[0]) {
reject(Error('Label not found'))
return
}
const scrollTop = res[1].scrollTop + res[0].top - (res[2] ? res[2].top : 0) + offset
if (this._in) {
// scroll-view 跳转
this._in.page[this._in.scrollTop] = scrollTop
} else {
// 页面跳转
uni.pageScrollTo({
scrollTop,
duration: 300
})
}
resolve()
})
// #endif
})
},
/**
* @description 获取文本内容
* @return {String}
*/
getText (nodes) {
let text = '';
(function traversal (nodes) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
if (node.type === 'text') {
text += node.text.replace(/&amp;/g, '&')
} else if (node.name === 'br') {
text += '\n'
} else {
// 块级标签前后加换行
const isBlock = node.name === 'p' || node.name === 'div' || node.name === 'tr' || node.name === 'li' || (node.name[0] === 'h' && node.name[1] > '0' && node.name[1] < '7')
if (isBlock && text && text[text.length - 1] !== '\n') {
text += '\n'
}
// 递归获取子节点的文本
if (node.children) {
traversal(node.children)
}
if (isBlock && text[text.length - 1] !== '\n') {
text += '\n'
} else if (node.name === 'td' || node.name === 'th') {
text += '\t'
}
}
}
})(nodes || this.nodes)
return text
},
/**
* @description 获取内容大小和位置
* @return {Promise}
*/
getRect () {
return new Promise((resolve, reject) => {
uni.createSelectorQuery()
// #ifndef MP-ALIPAY
.in(this)
// #endif
.select('#_root').boundingClientRect().exec(res => res[0] ? resolve(res[0]) : reject(Error('Root label not found')))
})
},
/**
* @description 暂停播放媒体
*/
pauseMedia () {
for (let i = (this._videos || []).length; i--;) {
this._videos[i].pause()
}
// #ifdef APP-PLUS
const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].pause()'
// #ifndef APP-PLUS-NVUE
let page = this.$parent
while (!page.$scope) page = page.$parent
page.$scope.$getAppWebview().evalJS(command)
// #endif
// #ifdef APP-PLUS-NVUE
this.$refs.web.evalJs(command)
// #endif
// #endif
},
/**
* @description 设置媒体播放速率
* @param {Number} rate 播放速率
*/
setPlaybackRate (rate) {
this.playbackRate = rate
for (let i = (this._videos || []).length; i--;) {
this._videos[i].playbackRate(rate)
}
// #ifdef APP-PLUS
const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].playbackRate=' + rate
// #ifndef APP-PLUS-NVUE
let page = this.$parent
while (!page.$scope) page = page.$parent
page.$scope.$getAppWebview().evalJS(command)
// #endif
// #ifdef APP-PLUS-NVUE
this.$refs.web.evalJs(command)
// #endif
// #endif
},
/**
* @description 设置内容
* @param {String} content html 内容
* @param {Boolean} append 是否在尾部追加
*/
setContent (content, append) {
if (!append || !this.imgList) {
this.imgList = []
}
const nodes = new Parser(this).parse(content)
// #ifdef APP-PLUS-NVUE
if (this._ready) {
this._set(nodes, append)
}
// #endif
this.$set(this, 'nodes', append ? (this.nodes || []).concat(nodes) : nodes)
// #ifndef APP-PLUS-NVUE
this._videos = []
this.$nextTick(() => {
this._hook('onLoad')
this.$emit('load')
})
if (this.lazyLoad || this.imgList._unloadimgs < this.imgList.length / 2) {
// 设置懒加载,每 350ms 获取高度,不变则认为加载完毕
let height = 0
const callback = rect => {
if (!rect || !rect.height) rect = {}
// 350ms 总高度无变化就触发 ready 事件
if (rect.height === height) {
this.$emit('ready', rect)
} else {
height = rect.height
setTimeout(() => {
this.getRect().then(callback).catch(callback)
}, 350)
}
}
this.getRect().then(callback).catch(callback)
} else {
// 未设置懒加载,等待所有图片加载完毕
if (!this.imgList._unloadimgs) {
this.getRect().then(rect => {
this.$emit('ready', rect)
}).catch(() => {
this.$emit('ready', {})
})
}
}
// #endif
},
/**
* @description 调用插件钩子函数
*/
_hook (name) {
for (let i = plugins.length; i--;) {
if (this.plugins[i][name]) {
this.plugins[i][name]()
}
}
},
// #ifdef APP-PLUS-NVUE
/**
* @description 设置内容
*/
_set (nodes, append) {
this.$refs.web.evalJs('setContent(' + JSON.stringify(nodes).replace(/%22/g, '') + ',' + JSON.stringify([this.containerStyle.replace(/(?:margin|padding)[^;]+/g, ''), this.errorImg, this.loadingImg, this.pauseVideo, this.scrollTable, this.selectable]) + ',' + append + ')')
},
/**
* @description 接收到 web-view 消息
*/
_onMessage (e) {
const message = e.detail.data[0]
switch (message.action) {
// web-view 初始化完毕
case 'onJSBridgeReady':
this._ready = true
if (this.nodes) {
this._set(this.nodes)
}
break
// 内容 dom 加载完毕
case 'onLoad':
this.height = message.height
this._hook('onLoad')
this.$emit('load')
break
// 所有图片加载完毕
case 'onReady':
this.getRect().then(res => {
this.$emit('ready', res)
}).catch(() => {
this.$emit('ready', {})
})
break
// 总高度发生变化
case 'onHeightChange':
this.height = message.height
break
// 图片点击
case 'onImgTap':
this.$emit('imgtap', message.attrs)
if (this.previewImg) {
uni.previewImage({
current: parseInt(message.attrs.i),
urls: this.imgList
})
}
break
// 链接点击
case 'onLinkTap': {
const href = message.attrs.href
this.$emit('linktap', message.attrs)
if (href) {
// 锚点跳转
if (href[0] === '#') {
if (this.useAnchor) {
dom.scrollToElement(this.$refs.web, {
offset: message.offset
})
}
} else if (href.includes('://')) {
// 打开外链
if (this.copyLink) {
plus.runtime.openWeb(href)
}
} else {
uni.navigateTo({
url: href,
fail () {
uni.switchTab({
url: href
})
}
})
}
}
break
}
case 'onPlay':
this.$emit('play')
break
// 获取到锚点的偏移量
case 'getOffset':
if (typeof message.offset === 'number') {
dom.scrollToElement(this.$refs.web, {
offset: message.offset + this._navigateTo.offset
})
this._navigateTo.resolve()
} else {
this._navigateTo.reject(Error('Label not found'))
}
break
// 点击
case 'onClick':
this.$emit('tap')
this.$emit('click')
break
// 出错
case 'onError':
this.$emit('error', {
source: message.source,
attrs: message.attrs
})
}
}
// #endif
}
}
</script>
<style>
/* #ifndef APP-PLUS-NVUE */
/* 根节点样式 */
._root {
padding: 1px 0;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
/* 长按复制 */
._select {
user-select: text;
}
/* #endif */
</style>

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
/**
* @fileoverview style 插件
*/
// #ifndef APP-PLUS-NVUE
import Parser from './parser'
// #endif
function Style () {
this.styles = []
}
// #ifndef APP-PLUS-NVUE
Style.prototype.onParse = function (node, vm) {
// 获取样式
if (node.name === 'style' && node.children.length && node.children[0].type === 'text') {
this.styles = this.styles.concat(new Parser().parse(node.children[0].text))
} else if (node.name) {
// 匹配样式(对非文本标签)
// 存储不同优先级的样式 name < class < id < 后代
let matched = ['', '', '', '']
for (let i = 0, len = this.styles.length; i < len; i++) {
const item = this.styles[i]
let res = match(node, item.key || item.list[item.list.length - 1])
let j
if (res) {
// 后代选择器
if (!item.key) {
j = item.list.length - 2
for (let k = vm.stack.length; j >= 0 && k--;) {
// 子选择器
if (item.list[j] === '>') {
// 错误情况
if (j < 1 || j > item.list.length - 2) break
if (match(vm.stack[k], item.list[j - 1])) {
j -= 2
} else {
j++
}
} else if (match(vm.stack[k], item.list[j])) {
j--
}
}
res = 4
}
if (item.key || j < 0) {
// 添加伪类
if (item.pseudo && node.children) {
let text
item.style = item.style.replace(/content:([^;]+)/, (_, $1) => {
text = $1.replace(/['"]/g, '')
// 处理 attr 函数
.replace(/attr\((.+?)\)/, (_, $1) => node.attrs[$1.trim()] || '')
// 编码 \xxx
.replace(/\\(\w{4})/, (_, $1) => String.fromCharCode(parseInt($1, 16)))
return ''
})
const pseudo = {
name: 'span',
attrs: {
style: item.style
},
children: [{
type: 'text',
text
}]
}
if (item.pseudo === 'before') {
node.children.unshift(pseudo)
} else {
node.children.push(pseudo)
}
} else {
matched[res - 1] += item.style + (item.style[item.style.length - 1] === ';' ? '' : ';')
}
}
}
}
matched = matched.join('')
if (matched.length > 2) {
node.attrs.style = matched + (node.attrs.style || '')
}
}
}
/**
* @description 匹配样式
* @param {object} node 要匹配的标签
* @param {string|string[]} keys 选择器
* @returns {number} 0不匹配1name 匹配2class 匹配3id 匹配
*/
function match (node, keys) {
function matchItem (key) {
if (key[0] === '#') {
// 匹配 id
if (node.attrs.id && node.attrs.id.trim() === key.substr(1)) return 3
} else if (key[0] === '.') {
// 匹配 class
key = key.substr(1)
const selectors = (node.attrs.class || '').split(' ')
for (let i = 0; i < selectors.length; i++) {
if (selectors[i].trim() === key) return 2
}
} else if (node.name === key) {
// 匹配 name
return 1
}
return 0
}
// 多选择器交集
if (keys instanceof Array) {
let res = 0
for (let j = 0; j < keys.length; j++) {
const tmp = matchItem(keys[j])
// 任意一个不匹配就失败
if (!tmp) return 0
// 优先级最大的一个作为最终优先级
if (tmp > res) {
res = tmp
}
}
return res
}
return matchItem(keys)
}
// #endif
export default Style

View File

@@ -0,0 +1,175 @@
const blank = {
' ': true,
'\n': true,
'\t': true,
'\r': true,
'\f': true
}
function Parser () {
this.styles = []
this.selectors = []
}
/**
* @description 解析 css 字符串
* @param {string} content css 内容
*/
Parser.prototype.parse = function (content) {
new Lexer(this).parse(content)
return this.styles
}
/**
* @description 解析到一个选择器
* @param {string} name 名称
*/
Parser.prototype.onSelector = function (name) {
// 不支持的选择器
if (name.includes('[') || name.includes('*') || name.includes('@')) return
const selector = {}
// 伪类
if (name.includes(':')) {
const info = name.split(':')
const pseudo = info.pop()
if (pseudo === 'before' || pseudo === 'after') {
selector.pseudo = pseudo
name = info[0]
} else return
}
// 分割交集选择器
function splitItem (str) {
const arr = []
let i, start
for (i = 1, start = 0; i < str.length; i++) {
if (str[i] === '.' || str[i] === '#') {
arr.push(str.substring(start, i))
start = i
}
}
if (!arr.length) {
return str
} else {
arr.push(str.substring(start, i))
return arr
}
}
// 后代选择器
if (name.includes(' ')) {
selector.list = []
const list = name.split(' ')
for (let i = 0; i < list.length; i++) {
if (list[i].length) {
// 拆分子选择器
const arr = list[i].split('>')
for (let j = 0; j < arr.length; j++) {
selector.list.push(splitItem(arr[j]))
if (j < arr.length - 1) {
selector.list.push('>')
}
}
}
}
} else {
selector.key = splitItem(name)
}
this.selectors.push(selector)
}
/**
* @description 解析到选择器内容
* @param {string} content 内容
*/
Parser.prototype.onContent = function (content) {
// 并集选择器
for (let i = 0; i < this.selectors.length; i++) {
this.selectors[i].style = content
}
this.styles = this.styles.concat(this.selectors)
this.selectors = []
}
/**
* @description css 词法分析器
* @param {object} handler 高层处理器
*/
function Lexer (handler) {
this.selector = ''
this.style = ''
this.handler = handler
}
Lexer.prototype.parse = function (content) {
this.i = 0
this.content = content
this.state = this.blank
for (let len = content.length; this.i < len; this.i++) {
this.state(content[this.i])
}
}
Lexer.prototype.comment = function () {
this.i = this.content.indexOf('*/', this.i) + 1
if (!this.i) {
this.i = this.content.length
}
}
Lexer.prototype.blank = function (c) {
if (!blank[c]) {
if (c === '/' && this.content[this.i + 1] === '*') {
this.comment()
return
}
this.selector += c
this.state = this.name
}
}
Lexer.prototype.name = function (c) {
if (c === '/' && this.content[this.i + 1] === '*') {
this.comment()
return
}
if (c === '{' || c === ',' || c === ';') {
this.handler.onSelector(this.selector.trimEnd())
this.selector = ''
if (c !== '{') {
while (blank[this.content[++this.i]]);
}
if (this.content[this.i] === '{') {
this.floor = 1
this.state = this.val
} else {
this.selector += this.content[this.i]
}
} else if (blank[c]) {
this.selector += ' '
} else {
this.selector += c
}
}
Lexer.prototype.val = function (c) {
if (c === '/' && this.content[this.i + 1] === '*') {
this.comment()
return
}
if (c === '{') {
this.floor++
} else if (c === '}') {
this.floor--
if (!this.floor) {
this.handler.onContent(this.style)
this.style = ''
this.state = this.blank
return
}
}
this.style += c
}
export default Parser

View File

@@ -0,0 +1,312 @@
<template>
<view class="zero-markdown-view">
<mp-html :key="mpkey" :selectable="selectable" :scroll-table='scrollTable' :tag-style="tagStyle"
:markdown="true" :content="contentAi">
</mp-html>
</view>
</template>
<script>
import mpHtml from '../mp-html/mp-html';
export default {
name: 'zero-markdown-view',
components: {
mpHtml
},
props: {
markdown: {
type: String,
default: ''
},
selectable: {
type: [Boolean, String],
default: true
},
scrollTable: {
type: Boolean,
default: true
},
themeColor: {
type: String,
default: '#007AFF'
},
codeBgColor: {
type: String,
default: '#2d2d2d'
},
aiMode: {
type: Boolean,
default: false
}
},
data() {
return {
content: '',
tagStyle: '',
mpkey: 'zero'
};
},
computed: {
contentAi() {
if (!this.content) {
return //处理特殊情况,比如网络异常导致的响应的 content 的值为空
}
let htmlString = ''
// 检查是否有未闭合的代码块
const codeBlocks = this.content.match(/```[\s\S]*?```|```[\s\S]*?$/g) || []
const lastBlock = codeBlocks[codeBlocks.length - 1]
if (lastBlock && !lastBlock.endsWith('```')) {
// 最后一个代码块未闭合,需要补上结束标识符
htmlString = this.content + '\n'
} else {
htmlString = this.content
}
return htmlString
},
},
watch: {
markdown: function (val) {
this.content = this.markdown
}
},
created() {
if (this.aiMode) {
this.initTagStyleForAi();
} else {
this.initTagStyle();
}
},
mounted() {
this.content = this.markdown
},
methods: {
initTagStyle() {
const themeColor = this.themeColor
const codeBgColor = this.codeBgColor
let zeroStyle = {
p: `
margin:5px 5px;
font-size: 15px;
line-height:1.75;
letter-spacing:0.2em;
word-spacing:0.1em;
`,
// 一级标题
h1: `
margin:25px 0;
font-size: 24px;
text-align: center;
font-weight: bold;
color: ${themeColor};
padding:3px 10px 1px;
border-bottom: 2px solid ${themeColor};
border-top-right-radius:3px;
border-top-left-radius:3px;
`,
// 二级标题
h2: `
margin:40px 0 20px 0;
font-size: 20px;
text-align:center;
color:${themeColor};
font-weight:bolder;
padding-left:10px;
// border:1px solid ${themeColor};
`,
// 三级标题
h3: `
margin:30px 0 10px 0;
font-size: 18px;
color: ${themeColor};
padding-left:10px;
border-left:3px solid ${themeColor};
`,
// 引用
blockquote: `
margin:15px 0;
font-size:15px;
color: #777777;
border-left: 4px solid #dddddd;
padding: 0 10px;
`,
// 列表
ul: `
margin: 10px 0;
color: #555;
`,
li: `
margin: 5px 0;
color: #555;
`,
// 链接
a: `
// color: ${themeColor};
`,
// 加粗
strong: `
font-weight: border;
color: ${themeColor};
`,
// 斜体
em: `
color: ${themeColor};
letter-spacing:0.3em;
`,
// 分割线
hr: `
height:1px;
padding:0;
border:none;
text-align:center;
background-image:linear-gradient(to right,rgba(248,57,41,0),${themeColor},rgba(248,57,41,0));
margin:10px 0;
`,
// 表格
table: `
border-spacing:0;
overflow:auto;
min-width:100%;
margin:10px 0;
border-collapse: collapse;
`,
th: `
border: 1px solid #202121;
color: #555;
`,
td: `
color:#555;
border: 1px solid #555555;
`,
pre: `
border-radius: 5px;
white-space: pre;
background: ${codeBgColor};
font-size:12px;
position: relative;
`,
}
this.tagStyle = zeroStyle
},
initTagStyleForAi() {
const themeColor = this.themeColor
const codeBgColor = this.codeBgColor
let zeroStyle = {
p: `
font-size: 16px;
`,
// 一级标题
h1: `
margin:18px 0 10px 0;
font-size: 24px;
color: ${themeColor};
`,
// 二级标题
h2: `
margin:14px 0 10px 0;
font-size: 20px;
color: ${themeColor};
`,
// 三级标题
h3: `
margin:12x 0 8px 0;
font-size: 18px;
color: ${themeColor};
`,
// 四级标题
h4: `
margin:12px 0 8px 0;
font-size: 16px;
color: ${themeColor};
`,
// 五级标题
h5: `
margin:10px 0 8px 0;
font-size: 16px;
color: ${themeColor};
`,
// 六级标题
h6: `
margin:8px 0 8px 0;
font-size: 16px;
color: ${themeColor}
`,
// 引用
blockquote: `
margin:15px 0;
font-size:15px;
color: #777777;
border-left: 4px solid #dddddd;
padding: 0 10px;
`,
// 列表
ul: `
margin: 10px 0;
color: #555;
`,
li: `
margin: 5px 0;
color: #555;
`,
// 链接
a: `
// color: ${themeColor};
`,
// 加粗
strong: `
font-weight: border;
color: ${themeColor};
`,
// 斜体
em: `
color: ${themeColor};
letter-spacing:0.3em;
`,
// 分割线
hr: `
height:1px;
padding:0;
border:none;
text-align:center;
background-image:linear-gradient(to right,rgba(248,57,41,0),${themeColor},rgba(248,57,41,0));
margin:10px 0;
`,
// 表格
table: `
border-spacing:0;
overflow:auto;
min-width:100%;
margin:10px 0;
border-collapse: collapse;
`,
th: `
border: 1px solid #202121;
color: #555;
`,
td: `
color:#555;
border: 1px solid #555555;
`,
pre: `
border-radius: 5px;
white-space: pre;
background: ${codeBgColor};
font-size:12px;
position: relative;
`,
}
this.tagStyle = zeroStyle
},
}
};
</script>
<style lang="scss">
.zero-markdown-view {
padding: 15rpx;
position: relative;
}
</style>

View File

@@ -0,0 +1,87 @@
{
"id": "zero-markdown-view",
"displayName": "zero-markdown-view(markdown解析)",
"version": "3.0.0",
"description": "一行代码即可实现markdown解析,支持自定义主题色,支持vue2,vue3支持流式输出。",
"keywords": [
"markdown",
"markdown解析",
"代码块",
"代码高亮",
"mp-html"
],
"repository": "",
"engines": {
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "n"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "u",
"app-nvue": "u",
"app-harmony": "u",
"app-uvue": "u"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "u",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,133 @@
# zero-markdown-view
## 一. 重要更新说明
### v3.0.0
- 优化了代码块流式输出
- 增加aiMode模式内容靠左对齐的样式区别于普通模式
### v2.1.0
- 新增 latex 公式支持
- 代码块高亮支持: languages=markup+css+clike+javascript+c+cpp+go+java+markup-templating+php+python+rust
- 代码包体积增加了不少(注意)
### v2.0.4 (仅需要基础功能可以使用此版本)
- 新增点击代码块复制代码-仅小程序可用
### v2.0.0
- 省去了 npm install marked
- 省去了 npm install highlight.js
- 使用 mp-html 自带的插件,重新生成 uniapp 包,大幅减少插件体积
传送门: [优化思路及详细过程](https://juejin.cn/post/7160995270476431373/) https://juejin.cn/post/7160995270476431373/
## 二. 使用方法
> 建议放到分包里手动引入该插件
**符合`easycom`组件模式, 导入 `uni_modules` 后直接使用即可 **
```html
<template>
<view class="container">
<!-- 默认用法 直接传入md文本即可 普通md展示-->
<zero-markdown-view :markdown="content"></zero-markdown-view>
<!-- ai对话模式 -->
<zero-markdown-view :markdown="msgContent" :aiMode='true'></zero-markdown-view>
</view>
</template>
<script>
export default {
data() {
return {
content: "## md格式的文本内容",
msgContent: "## ai回复的内容兼容流式输出",
};
},
created() {},
methods: {},
};
</script>
<style lang="scss" scoped></style>
```
## 三. 参数说明
| 参数 | 类型 | 默认值 | 描述 |
| ----------- | ------ | --------- | ------------- |
| markdown | String | | markdown 文本 |
| themeColor | String | '#007AFF' | 主题色 |
| codeBgColor | String | '#2d2d2d' | 代码块背景色 |
| aiMode | Boolean| false | 是否为 AI 模式(内容靠左对齐) |
## 四. 注意事项
### 关于代码块高亮
如果需要更多语言或更换主题请前往 prism 官网 下载对应的 prism.min.js 和 prism.css 并替换 plugins/highlight/ 目录下的文件,重新运行打包命令并替换 mp-html. 不建议这样做,因为本插件还修改过其他地方,可能会导致插件不能正常使用.
具体请参考 mp-html 的文档,在文末有链接
### 关于代码块流式输出闪烁,可以尝试 给代码块后增加 `\n`
#### 已经在组件内的 `aiMode` 解决,不需要自行处理,也就是说如何你是是用在ai上直接把ai回复内容传给组件就可以了
#### 已经在组件内的 `aiMode` 解决,不需要自行处理,也就是说如何你是是用在ai上直接把ai回复内容传给组件就可以了
#### 已经在组件内的 `aiMode` 解决,不需要自行处理,也就是说如何你是是用在ai上直接把ai回复内容传给组件就可以了
> 注意小程序上的流逝输出需要结合unipush2使用
> 感谢评论区的一个朋友提供的代码
````javascript
computed: {
// 流式输出时代码块处理 , 这时候请使用 msgContent 传入组件中
msgContent() {
if (!this.markdown) {
return
}
let htmlString = ''
// 检查是否有未闭合的代码块
const codeBlocks = this.markdown.match(/```[\s\S]*?```|```[\s\S]*?$/g) || []
const lastBlock = codeBlocks[codeBlocks.length - 1]
if (lastBlock && !lastBlock.endsWith('```')) {
// 最后一个代码块未闭合,需要补上结束标识符
htmlString = this.markdown + '\n'
} else {
htmlString = this.markdown
}
return htmlString
},
},
````
### 如何关闭点击代码块复制功能?
找到组件文件夹 `zero-markdown-view`-`mp-html`-`highlight`-`config.js`
**把 `copyByClickCode` 改成 false 即可**
```
export default {
copyByClickCode: true, // 点击代码块复制
showLanguageName: true, // 是否在代码块右上角显示语言的名称
}
```
### 感谢 mp-html 插件
插件地址: [https://ext.dcloud.net.cn/plugin?id=805](https://ext.dcloud.net.cn/plugin?id=805)
文档地址: [https://jin-yufeng.gitee.io/mp-html/#/overview/quickstart](https://jin-yufeng.gitee.io/mp-html/#/overview/quickstart)
插件预览:
![code](https://cdn.zerojs.cn/image/common/code-z_1722414660881_1.jpg?imageMogr2/thumbnail/200x)
> 小程序搜索: 零技术
> 预览的小程序不一定能及时更新当前插件