Initial commit

This commit is contained in:
admin
2025-12-08 14:39:07 +08:00
commit 9d4f78656b
782 changed files with 66418 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
# 插件
本目录下是一些扩展插件,可以按照需要选用以实现更加丰富的功能

View File

@@ -0,0 +1,25 @@
# audio
功能:音乐播放器
大小:*≈4KB*
支持平台:
| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
|:---:|:---:|:---:|:---:|:---:|:---:|
| √ | √ | √ | √ | √ | √(nvue 不支持) |
百度小程序原生包在此 [问题](https://smartprogram.baidu.com/forum/topic/show/125787) 未解决前无法使用
说明:
在大多数小程序平台,*audio* 标签已被废弃或无法使用,本插件可以代替 *audio* 标签播放音乐,并实现以下优化:
1. *pause-video* 属性也可以应用于音频,即播放一个音视频时可以自动暂停其他正在播放的音视频
2. 增加了一个可以拖动的进度条
3. 组件大小可以根据页面宽度自动调整
4. 支持 *autoplay* 属性
5. 播放被后台打断时,页面显示后自动继续播放
基础库要求:
支付宝 *1.23.4+* ,其余平台满足最低要求即可
*5* 条仅微信 *2.2.3+* 、*QQ*、百度支持
?> 如果希望页面上使用本组件,组件的路径为 *path/to/mp-html/audio/audio*
属性和事件基本同 *audio* 组件,组件实例上提供了 *setSrc*、*play*、*seek*、*pause*、*stop* 方法可供控制播放状态

View File

@@ -0,0 +1,11 @@
module.exports = {
usingComponents: {
'my-audio': '../audio/audio'
},
handler (file) {
// 删去原来的 audio 标签
if (file.basename === 'node.wxml' || file.basename === 'node.vue') {
file.contents = Buffer.from(file.contents.toString().replace(/<audio[\s\S]+?>/, ''))
}
}
}

View File

@@ -0,0 +1,7 @@
const ctx = {}
module.exports = {
get: id => ctx[id],
set: (id, vm) => { ctx[id] = vm },
remove: id => { ctx[id] = undefined }
}

View File

@@ -0,0 +1,34 @@
/**
* @fileoverview audio 插件
*/
const context = require('./context')
let index = 0
function Audio (vm) {
this.vm = vm
}
Audio.prototype.onUpdate = function () {
this.audios = []
}
Audio.prototype.onParse = function (node) {
if (node.name === 'audio') {
if (!node.attrs.id) {
node.attrs.id = 'a' + index++
}
this.audios.push(node.attrs.id)
}
}
Audio.prototype.onLoad = function () {
setTimeout(() => {
for (let i = 0; i < this.audios.length; i++) {
const ctx = context.get(this.audios[i])
ctx.id = this.audios[i]
this.vm._videos.push(ctx)
}
}, 500)
}
module.exports = Audio

View File

@@ -0,0 +1,189 @@
/**
* @fileoverview audio 组件
*/
const context = require('./context')
Component({
data: {
time: '00:00'
},
properties: {
name: String, // 音乐名
author: String, // 作者
poster: String, // 海报图片地址
autoplay: Boolean, // 是否自动播放
controls: Boolean, // 是否显示控件
loop: Boolean, // 是否循环播放
src: { // 源地址
type: String,
observer (src) {
this.setSrc(src)
}
}
},
created () {
// 创建内部 context
this._ctx = wx.createInnerAudioContext()
this._ctx.onError(err => {
this.setData({
error: true
})
this.triggerEvent('error', err)
})
this._ctx.onTimeUpdate(() => {
const time = this._ctx.currentTime
const min = parseInt(time / 60)
const sec = Math.ceil(time % 60)
const data = {}
data.time = (min > 9 ? min : '0' + min) + ':' + (sec > 9 ? sec : '0' + sec)
// 不在拖动状态下需要更新进度条
if (!this.lastTime) {
data.value = time / this._ctx.duration * 100
}
this.setData(data)
})
this._ctx.onEnded(() => {
if (!this.properties.loop) {
this.setData({
playing: false
})
}
})
// #ifndef ALIPAY
},
attached () {
context.set(this.id, this)
// #endif
// #ifdef MP-ALIPAY
context.set(this.properties.id, this)
this.setSrc(this.properties.src)
// #endif
},
// #ifdef MP-ALIPAY
didUpdate (e) {
if (e.src !== this.properties.src) {
this.setSrc(this.properties.src)
}
},
// #endif
detached () {
this._ctx.destroy()
// #ifndef MP-ALIPAY
context.remove(this.id)
// #endif
// #ifdef MP_ALIPAY
context.remove(this.properties.id)
// #endif
},
// #ifndef ALIPAY | TOUTIAO
pageLifetimes: {
show () {
// 播放被后台打断时,页面显示后自动继续播放
if (this.data.playing && this._ctx.paused) {
this._ctx.play()
}
}
},
// #endif
methods: {
/**
* @description 设置源
* @param {string} src 源地址
*/
setSrc (src) {
this._ctx.autoplay = this.properties.autoplay
this._ctx.loop = this.properties.loop
this._ctx.src = src
if (this.properties.autoplay && !this.data.playing) {
this.setData({
playing: true
})
}
},
/**
* @description 播放音乐
*/
play () {
this._ctx.play()
this.setData({
playing: true
})
this.triggerEvent('play'
// #ifdef MP-ALIPAY
, {
target: {
id: this.props.id
}
}
// #endif
)
},
/**
* @description 暂停音乐
*/
pause () {
this._ctx.pause()
this.setData({
playing: false
})
this.triggerEvent('pause')
},
/**
* @description 设置播放速率
* @param {Number} rate 播放速率
*/
playbackRate (rate) {
this._ctx.playbackRate = rate
},
/**
* @description 停止音乐
*/
stop () {
this._ctx.stop()
this.setData({
playing: false,
time: '00:00'
})
this.triggerEvent('stop')
},
/**
* @description 控制进度
* @param {number} sec 秒数
*/
seek (sec) {
this._ctx.seek(sec)
},
/**
* @description 移动进度条
* @param {event} e
* @private
*/
_seeking (e) {
// 避免过于频繁 setData
if (e.timeStamp - this.lastTime < 200) return
const time = Math.round(e.detail.value / 100 * this._ctx.duration)
const min = parseInt(time / 60)
const sec = time % 60
this.setData({
time: (min > 9 ? min : '0' + min) + ':' + (sec > 9 ? sec : '0' + sec)
})
this.lastTime = e.timeStamp
},
/**
* @description 进度条移动完毕
* @param {event} e
* @private
*/
_seeked (e) {
this._ctx.seek(e.detail.value / 100 * this._ctx.duration)
this.lastTime = undefined
}
}
})

View File

@@ -0,0 +1,3 @@
{
"component": true
}

View File

@@ -0,0 +1,17 @@
<view wx:if="{{controls}}" class="_contain">
<!-- 海报和按钮 -->
<view class="_poster" style="background-image:url('{{poster}}')">
<view class="_button" bindtap="{{playing?'pause':'play'}}">
<view class="{{playing?'_pause':'_play'}}" />
</view>
</view>
<!-- 曲名和作者 -->
<view class="_title">
<view class="_name">{{name||'未知音频'}}</view>
<view class="_author">{{author||'未知作者'}}</view>
</view>
<!-- 进度条 -->
<slider class="_slider" activeColor="#585959" block-size="12" disabled="{{error}}" value="{{value}}" bindchanging="_seeking" bindchange="_seeked" />
<!--播放时间-->
<view class="_time">{{time}}</view>
</view>

View File

@@ -0,0 +1,127 @@
/* 顶层容器 */
._contain {
position: relative;
display: inline-flex;
width: 290px;
background-color: #fcfcfc;
border: 1px solid #e0e0e0;
border-radius: 2px;
}
/* 播放、暂停按钮 */
._button {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
overflow: hidden;
background-color: rgb(0, 0, 0, 0.2);
border: 1px solid white;
border-radius: 50%;
opacity: 0.9;
}
._play {
margin-left: 2px;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 8px solid white;
}
._pause {
width: 8px;
height: 8px;
background-color: white;
}
/* 海报 */
._poster {
display: flex;
align-items: center;
justify-content: center;
width: 70px;
height: 70px;
background-color: #e6e6e6;
background-size: contain;
}
/* 标题栏 */
._title {
flex: 1;
margin: 4px 0 0 14px;
text-align: left;
}
._author {
width: 45px;
font-size: 12px;
color: #888;
}
._name {
width: 140px;
font-size: 15px;
line-height: 39px;
}
._author,
._name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 进度条 */
._slider {
position: absolute;
right: 16px;
bottom: 8px;
width: 140px;
margin: 0;
}
/* 播放时间 */
._time {
margin: 7px 14px 0 0;
font-size: 12px;
color: #888;
}
/* 响应式布局,大屏幕用更大的尺寸 */
@media (min-width: 400px) {
._contain {
width: 380px;
}
._button {
width: 26px;
height: 26px;
}
._poster {
width: 90px;
height: 90px;
}
._author {
width: 60px;
font-size: 15px;
}
._name {
width: 180px;
font-size: 19px;
line-height: 55px;
}
._slider {
right: 20px;
bottom: 10px;
width: 180px;
}
._time {
font-size: 15px;
}
}

View File

@@ -0,0 +1,3 @@
module.exports = {
template: '<my-audio wx:if="{{n.name==\'audio\'}}" id="{{n.attrs.id}}" class="{{n.attrs.class}}" style="{{n.attrs.style}}" author="{{n.attrs.author}}" controls="{{n.attrs.controls}}" autoplay="{{n.attrs.autoplay}}" loop="{{n.attrs.loop}}" name="{{n.attrs.name}}" poster="{{n.attrs.poster}}" src="{{n.src[ctrl[i]||0]}}" data-i="{{i}}" data-source="audio" bindplay="play" binderror="mediaError" />'
}

View File

@@ -0,0 +1,269 @@
<template>
<view v-if="controls" @click="onClick" class="_contain">
<!-- 海报和按钮 -->
<view class="_poster" :style="'background-image:url('+poster+')'">
<view class="_button" @tap="_buttonTap">
<view :class="playing?'_pause':'_play'" />
</view>
</view>
<!-- 曲名和作者 -->
<view class="_title">
<view class="_name">{{name||'未知音频'}}</view>
<view class="_author">{{author||'未知作者'}}</view>
</view>
<!-- 进度条 -->
<slider class="_slider" activeColor="#585959" block-size="12" handle-size="12" :disabled="error" :value="value" @changing="_seeking" @change="_seeked" />
<!--播放时间-->
<view class="_time">{{time||'00:00'}}</view>
</view>
</template>
<script>
/**
* @fileoverview audio 组件
*/
import context from './context'
export default {
data () {
return {
error: false,
playing: false,
time: '00:00',
value: 0
}
},
props: {
aid: String,
name: String, // 音乐名
author: String, // 作者
poster: String, // 海报图片地址
autoplay: [Boolean, String], // 是否自动播放
controls: [Boolean, String], // 是否显示控件
loop: [Boolean, String], // 是否循环播放
src: String // 源地址
},
watch: {
src (src) {
this.setSrc(src)
}
},
mounted () {
this._ctx = uni.createInnerAudioContext()
this._ctx.onError((err) => {
this.error = true
this.$emit('error', err)
})
this._ctx.onTimeUpdate(() => {
const time = this._ctx.currentTime
const min = parseInt(time / 60)
const sec = Math.ceil(time % 60)
this.time = (min > 9 ? min : '0' + min) + ':' + (sec > 9 ? sec : '0' + sec)
if (!this.lastTime) {
this.value = time / this._ctx.duration * 100 // 不在拖动状态下
}
})
this._ctx.onEnded(() => {
if (!this.loop) {
this.playing = false
}
})
context.set(this.aid, this)
this.setSrc(this.src)
},
beforeDestroy () {
this._ctx.destroy()
context.remove(this.aid)
},
onPageShow () {
if (this.playing && this._ctx.paused) {
this._ctx.play()
}
},
methods: {
// 设置源
setSrc (src) {
this._ctx.autoplay = this.autoplay
this._ctx.loop = this.loop
this._ctx.src = src
if (this.autoplay && !this.playing) {
this.playing = true
}
},
// 播放
play () {
this._ctx.play()
this.playing = true
this.$emit('play', {
target: {
id: this.aid
}
})
},
// 暂停
pause () {
this._ctx.pause()
this.playing = false
this.$emit('pause')
},
// 设置播放速率
playbackRate (rate) {
this._ctx.playbackRate = rate
},
// 移动进度条
seek (sec) {
this._ctx.seek(sec)
},
// 内部方法
_buttonTap () {
if (this.playing) this.pause()
else this.play()
},
_seeking (e) {
// 避免过于频繁 setData
if (e.timeStamp - this.lastTime < 200) return
const time = Math.round(e.detail.value / 100 * this._ctx.duration)
const min = parseInt(time / 60)
const sec = time % 60
this.time = (min > 9 ? min : '0' + min) + ':' + (sec > 9 ? sec : '0' + sec)
this.lastTime = e.timeStamp
},
_seeked (e) {
this.seek(e.detail.value / 100 * this._ctx.duration)
this.lastTime = undefined
},
onClick(e) {
this.$emit('onClick', e)
}
}
}
</script>
<style>
/* 顶层容器 */
._contain {
position: relative;
display: inline-flex;
width: 290px;
background-color: #fcfcfc;
border: 1px solid #e0e0e0;
border-radius: 2px;
}
/* 播放、暂停按钮 */
._button {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
overflow: hidden;
background-color: rgb(0, 0, 0, 0.2);
border: 1px solid white;
border-radius: 50%;
opacity: 0.9;
}
._play {
margin-left: 2px;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 8px solid white;
}
._pause {
width: 8px;
height: 8px;
background-color: white;
}
/* 海报 */
._poster {
display: flex;
align-items: center;
justify-content: center;
width: 70px;
height: 70px;
background-color: #e6e6e6;
background-size: contain;
}
/* 标题栏 */
._title {
flex: 1;
margin: 4px 0 0 14px;
text-align: left;
}
._author {
width: 45px;
font-size: 12px;
color: #888;
}
._name {
width: 140px;
font-size: 15px;
line-height: 39px;
}
._author,
._name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 进度条 */
._slider {
position: absolute;
right: 16px;
bottom: 8px;
width: 140px;
margin: 0;
}
/* 播放时间 */
._time {
margin: 7px 14px 0 0;
font-size: 12px;
color: #888;
}
/* 响应式布局,大屏幕用更大的尺寸 */
@media (min-width: 400px) {
._contain {
width: 380px;
}
._button {
width: 26px;
height: 26px;
}
._poster {
width: 90px;
height: 90px;
}
._author {
width: 60px;
font-size: 15px;
}
._name {
width: 180px;
font-size: 19px;
line-height: 55px;
}
._slider {
right: 20px;
bottom: 10px;
width: 180px;
}
._time {
font-size: 15px;
}
}
</style>

View File

@@ -0,0 +1,3 @@
module.exports = {
template: '<my-audio v-if="n.name==\'audio\'" :class="n.attrs.class" :style="n.attrs.style" :aid="n.attrs.id" :author="n.attrs.author" :controls="n.attrs.controls" :autoplay="n.attrs.autoplay" :loop="n.attrs.loop" :name="n.attrs.name" :poster="n.attrs.poster" :src="n.src[ctrl[i]||0]" :data-i="i" data-source="audio" @play="play" @error="mediaError" />'
}

View File

@@ -0,0 +1,30 @@
# card
功能:商品(联络人)信息卡
大小:*≈7KB*
支持平台:
| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
|:---:|:---:|:---:|:---:|:---:|:---:|
| √ | √ | √ | √ | √ | √(nvue 不支持) |
### 效果图
![效果图](../../docs/assets/plugin/card.png)
### 参数列表
|参数名|是否必须|类型|说明|
|:---- |:---|:----- |----- |
|src|是|String|图片Url|
|title|是|String|标题|
|desc|是|String|描述|
|url|是|String|跳转url|
|color|是|String|文字颜色|
|bgcolor|是|String|卡片背景颜色|
|border|是|String|卡片边框颜色|
### 说明:
1. 可以显示商品信息卡片/联络人信息卡片
### 基础库要求:
满足最低要求即可
?> 如果希望页面上使用本组件,组件的路径为 *path/to/mp-html/card/card*

View File

@@ -0,0 +1,14 @@
module.exports = {
usingComponents: {
'my-card': '../card/card'
},
handler (file) {
if (file.isBuffer()) {
let content = file.contents.toString()
if (file.path.includes('parser.js')) {
content = content.replace(/trustTags\s*:\s*makeMap\('/, "trustTags: makeMap('card,").replace(/voidTags\s*:\s*makeMap\('/, "voidTags: makeMap('card,")
}
file.contents = Buffer.from(content)
}
}
}

View File

@@ -0,0 +1,7 @@
/**
* @fileoverview Card 插件
*/
function Card (vm) {
}
module.exports = Card

View File

@@ -0,0 +1,3 @@
module.exports = {
template: '<my-card wx:if="{{n.name==\'card\'}}" class="{{n.attrs.class}}" style="{{n.attrs.style}}" mode="{{opts[5]}}" src="{{n.attrs.src}}" title="{{n.attrs.title}}" desc="{{n.attrs.desc}}" url="{{n.attrs.url}}" color="{{n.attrs.color}}" bgcolor="{{n.attrs.bgcolor}}" border="{{n.attrs.border}}" name="{{n.attrs.name}}" data-i="{{i}}" data-source="card" />'
}

View File

@@ -0,0 +1,26 @@
/**
* @fileoverview card 组件
*/
Component({
properties: {
mode: {
type: Boolean,
default: false
},
src: String,
title: String,
desc: String,
url: String,
color: String,
bgcolor: String,
border: String
},
data: {},
methods: {
onClick (e) {
if (this.properties.url && this.properties.url.trim().length > 6 && !this.properties.mode) {
wx.navigateTo({ url: this.properties.url })
}
}
}
})

View File

@@ -0,0 +1,3 @@
{
"component": true
}

View File

@@ -0,0 +1,9 @@
<view class="card" bindtap="onClick" style="background-color:{{bgcolor||'#a4d0ff'}};border:{{border||'1px solid #FFF'}};color:{{color||'#000'}}">
<image class="card-img" mode="aspectFill" src="{{src}}" />
<view class="text-wrap text-wrap-width" wx:if="{{!!desc}}">
<view class="title one-t">{{title}}</view>
<view class="desc one-t">{{desc}}</view>
</view>
<view wx:else class="text-wrap-width title more-t">{{title}}</view>
<image class="card-icon" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAMAAADVRocKAAABCFBMVEUAAAC/v7+qqqqZmZmLi6KJnZ2ImZmHlpaGlKGMjJmSkp6Li5eQkJuKlZ+Pj5mOjpeJkpuNjZ6IkJmHj5eLi5uKkpmGjZqJj5uLi5eIjpmIjZiKj5qKj5mJjpiHjJqJjZaGj5iKj5eIjJmKjpqHi5eGjZeIi5eHjZiIi5eHjJiIjpaHjJeIjZiGjJeIjZiGjJaGjJeIi5aGjJeHi5eHi5aHjJeHjJaHjJeGi5eHjJaHjJeGi5aGi5aHjJeHjJaGjJeGjJeHjJaHi5eGi5eHjJaHjJeGjJaGjJeHi5aHjJeHjJeHi5aGjJeHi5aGjJaHi5eGjJaHi5eHjJaGi5eGi5aHjJeGi5aGi5apAvjmAAAAV3RSTlMABAYKCw0PERMUFRYXGBkbHB0eICEjJiksLS8wMjQ1ODk7PD9ATFZXWFlaW1xdXl+Hi6msu7/Dx8vMzs/R0tTV19na3N3f4uTn6evs7e7v8PHy9PX7/P18cCTXAAABEklEQVRo3u2YWU5CQRQFn4qCM4LzhIoDAorzrIgCigiCimf/O/Gj3UIlmJxaQFXSea/T90aRMcYYY4zpG0ZPu9cZMnAi6SsLBjqS9LnJBcqSpC53Sjs/kqSPNaxwGAqtFbrQXKILjQW68DpPF17m6EI9TRdqM3TheZouVCbpQnkcK5RC4T5BF27jdOFqhC5cDtOFixhdOB+iC2cDdOEoggttLrDbk6QW5/+WJB1T/r1e+FAHWT/2q/35scsiF/w3cdZ/R13Y+8H/MMb6Hycgfz74n6ZYfzXJ+mspyF8I/vos68cep0X4eV2EB4SD4H9bZP3vy+yTtL3KjrGddXgQ34BXCVvwMmT7P69zjDHGGGP6gF83lHISOctsKQAAAABJRU5ErkJggg=="></image>
</view>

View File

@@ -0,0 +1,55 @@
.one-t {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: all linear 0.2s;
}
.more-t {
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
transition: all linear 0.2s;
}
.card {
width: 80%;
margin: 10rpx auto;
max-width: 700rpx;
max-height: 140rpx;
box-sizing: border-box;
overflow: hidden;
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0 20rpx 10rpx;
border-radius: 12rpx;
}
.card-img {
width: 96rpx;
height: 96rpx;
border-radius: 12rpx;
flex: 0 0 96rpx;
}
.card-icon {
width: 30rpx;
height: 96rpx;
}
.card .text-wrap {
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card .text-wrap-width {
width: 72%;
}
.card .title {
font-weight: bold;
font-size: 34rpx;
line-height: 48rpx;
}
.card .desc {
font-size: 27rpx;
line-height: 37rpx;
}

View File

@@ -0,0 +1,3 @@
module.exports = {
template: '<my-card v-if="n.name==\'card\'" :class="n.attrs.class" :style="n.attrs.style" :mode="opts[5]" :src="n.attrs.src" :title="n.attrs.title" :desc="n.attrs.desc" :url="n.attrs.url" :color="n.attrs.color" :bgcolor="n.attrs.bgcolor" :border="n.attrs.border" :name="n.attrs.name" :data-i="i" data-source="card" />'
}

View File

@@ -0,0 +1,122 @@
<template>
<view class="card" @click="onClick" :style="[customStyle]" :data-i="$attrs['data-i']">
<image class="card-img" mode="aspectFill" :src="src" />
<view class="text-wrap text-wrap-width" v-if="!!desc">
<view class="title one-t">{{title}}</view>
<view class="desc one-t">{{desc}}</view>
</view>
<view v-else class="text-wrap-width title more-t">{{title}}</view>
<image class="card-icon" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAMAAADVRocKAAABCFBMVEUAAAC/v7+qqqqZmZmLi6KJnZ2ImZmHlpaGlKGMjJmSkp6Li5eQkJuKlZ+Pj5mOjpeJkpuNjZ6IkJmHj5eLi5uKkpmGjZqJj5uLi5eIjpmIjZiKj5qKj5mJjpiHjJqJjZaGj5iKj5eIjJmKjpqHi5eGjZeIi5eHjZiIi5eHjJiIjpaHjJeIjZiGjJeIjZiGjJaGjJeIi5aGjJeHi5eHi5aHjJeHjJaHjJeGi5eHjJaHjJeGi5aGi5aHjJeHjJaGjJeGjJeHjJaHi5eGi5eHjJaHjJeGjJaGjJeHi5aHjJeHjJeHi5aGjJeHi5aGjJaHi5eGjJaHi5eHjJaGi5eGi5aHjJeGi5aGi5apAvjmAAAAV3RSTlMABAYKCw0PERMUFRYXGBkbHB0eICEjJiksLS8wMjQ1ODk7PD9ATFZXWFlaW1xdXl+Hi6msu7/Dx8vMzs/R0tTV19na3N3f4uTn6evs7e7v8PHy9PX7/P18cCTXAAABEklEQVRo3u2YWU5CQRQFn4qCM4LzhIoDAorzrIgCigiCimf/O/Gj3UIlmJxaQFXSea/T90aRMcYYY4zpG0ZPu9cZMnAi6SsLBjqS9LnJBcqSpC53Sjs/kqSPNaxwGAqtFbrQXKILjQW68DpPF17m6EI9TRdqM3TheZouVCbpQnkcK5RC4T5BF27jdOFqhC5cDtOFixhdOB+iC2cDdOEoggttLrDbk6QW5/+WJB1T/r1e+FAHWT/2q/35scsiF/w3cdZ/R13Y+8H/MMb6Hycgfz74n6ZYfzXJ+mspyF8I/vos68cep0X4eV2EB4SD4H9bZP3vy+yTtL3KjrGddXgQ34BXCVvwMmT7P69zjDHGGGP6gF83lHISOctsKQAAAABJRU5ErkJggg=="></image>
</view>
</template>
<script>
export default {
props: {
mode: {
type: Boolean,
default: false
},
src: String,
title: String,
desc: String,
url: String,
color: String,
bgcolor: String,
border: String
},
data () {
return {
}
},
computed: {
customStyle () {
return {
'background-color': this.bgColor || '#a4d0ff',
border: this.border || '1px solid #FFF',
color: this.color || '#000'
}
}
},
methods: {
onClick (e) {
if (this.url && this.url.trim().length > 6 && !this.mode) {
uni.navigateTo({ url: this.url })
}
this.$emit('click', e)
}
}
}
</script>
<style lang="scss">
.one-t {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: all linear 0.2s;
}
.more-t {
overflow: hidden;
text-overflow: ellipsis;
word-break:break-all;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
transition: all linear 0.2s;
}
.card {
width: 80%;
margin: 10rpx auto;
max-width: 700rpx;
max-height: 140rpx;
box-sizing: border-box;
overflow: hidden;
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0 20rpx 10rpx;
border-radius: 12rpx;
&-img {
width: 96rpx;
height: 96rpx;
border-radius: 12rpx;
flex: 0 0 96rpx;
}
&-icon {
width: 30rpx;
height: 96rpx;
}
.text-wrap {
display: flex;
flex-direction: column;
justify-content: space-between;
&-width {
width: 72%;
}
}
.title {
font-weight: bold;
font-size: 34rpx;
line-height: 48rpx;
}
.desc {
font-size: 27rpx;
line-height: 37rpx;
}
}
</style>

View File

@@ -0,0 +1,137 @@
# editable
功能:富文本编辑
下表列出了本插件与原生 *editor* 组件的功能差异,可按需选用
| 组件 | 优点 | 缺点 |
|:---:|:---:|:---:|
| 原生 *editor* | 底层通过 *contenteditable* 实现,编辑流畅 | 支持标签少(不支持音视频、表格以及 *section* 等常用标签)、部分小程序平台不支持或低版本不兼容 |
| 本插件 | 支持标签全面、支持平台全面 | 编辑灵活性不够强 |
大小:*≈17.5KB*
支持平台:
| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
|:---:|:---:|:---:|:---:|:---:|:---:|
| √ | √ | √ | √ | √ | √(nvue 不支持) |
说明:
引入本插件后,会给组件添加以下属性:
| 属性名 | 类型 | 默认值 | 说明 |
|:---:|:---:|:---:|:---:|
| editable | Boolean | false | 是否开启内容编辑 |
| placeholder | String | 请输入 | 输入框为空时占位符(`2.1.0+` |
添加以下事件:
| 事件名 | 触发时机 | 用途 |
|:---:|:---:|:---:|
| remove`2.2.0+` | 删除图片/视频/音频标签时 | 删除已上传的线上文件 |
支持以下操作:
| 类型 | 操作 |
|:---:|:---:|
| 文本 | 修改 |
| 图片 | 更换链接、调整宽度、设置成超链接(`2.0.4+`)、设置预览图链接、禁用预览、删除 |
| 链接 | 更换链接、删除 |
| 音视频 | 设置封面、设置循环播放、设置自动播放(`2.2.0+`)、删除 |
| 普通标签 | 设置字体大小、斜体、粗体、下划线(`2.0.4+`)、居中、缩进、删除 |
> `2.2.1` 版本起所有标签支持上下移动操作,但仅限同级标签间移动,即在有同级标签且非第一个(或最后一个)时可以上移(或下移)
> 在支付宝小程序中使用时需要在页面样式中添加 *page { position: relative; }* 避免 *tooltip* 错位
> 菜单项可以通过编辑 *plugins/editable/config.js* 进行修改,仅可以删减或调整顺序,添加或更名无效
组件实例上提供了以下方法(*editable* 属性为 *true* 时才可以调用):
| 名称 | 功能 |
|:---:|:---:|
| undo | 撤销一个操作 |
| redo | 重做一个操作 |
| insertHtml | 在光标处插入指定 html 内容(`2.1.0+` |
| insertImg | 在光标处插入一张图片 |
| insertTable(rows, cols) | 在光标处插入一个 rows 行 cols 列的表格(`2.1.3+` |
| insertVideo | 在光标处插入一个视频 |
| insertAudio | 在光标处插入一个音频 |
| insertLink | 在光标处插入一个链接 |
| insertText | 在光标处插入一段文本 |
| clear | 清空内容 |
| getContent | 获取编辑后的 html 内容 |
> 考虑到不同场景下希望获取链接的方法不同,需要在初始时给组件设置一个 *getSrc* 方法(否则插入图片、音视频、链接或修改链接等操作无法使用),每次组件内需要链接时会调用此方法,开发者可在此方法中自行决定如何获取链接,返回 **线上地址** 即可(具体用法见下方示例)
编辑完成后,通过 *getContent* 方法获取编辑后的 *html*,最后将 *editable* 属性设置为 *false* 即可正常渲染
> 点击保存按钮时,部分平台 *tap* 事件早于 *blur* 事件触发,直接获取内容可能导致无法获取当前编辑的文本内容,因此建议设置一个小的延时后获取(可参考下方示例,[详细](https://github.com/jin-yufeng/mp-html/issues/368)
示例:
```javascript
Page({
onLoad () {
// ctx 为组件实例,获取方法见上
/**
* @description 设置获取链接的方法
* @param {String} type 链接的类型img/video/audio/link
* @param {String} value 修改链接时,这里会传入旧值
* @returns {Promise} 返回线上地址2.2.0 版本起设置了 domain 属性时,可以缺省主域名)
* type 为 audio/video 时,可以返回一个源地址数组
* 2.1.3 版本起 type 为 audio 时,可以返回一个 object包含 src、name、author、poster 等字段
* 2.2.0 版本起 type 为 img 时,可以返回一个源地址数组,表示插入多张图片(修改链接时仅限一张)
*/
this.ctx.getSrc = (type, value) => {
return new Promise((resolve, reject) => {
// 以图片为例
if (type == 'img') {
wx.chooseImage({
count: value === undefined ? 9 : 1, // 2.2.0 版本起插入图片时支持多张(修改图片链接时仅限一张)
success: res => {
wx.showLoading({
title: '上传中'
});
(async ()=>{
const arr = []
for (let item of res.tempFilePaths) {
// 依次上传
const src = await upload(item)
arr.push(src)
}
return arr
})().then(res => {
wx.hideLoading()
resolve(res)
})
},
fail: reject
})
}
})
}
},
finishEdit () {
setTimeout(() => {
var html = ctx.getContent() // 获取编辑好的 html
// 上传 html
wx.request({
url: 'xxx',
data: {
html
},
success: () => {
this.setData({
editable: false // 结束编辑
})
}
})
}, 50)
}
})
```
**示例项目**
微信小程序点击 [代码片段](https://developers.weixin.qq.com/s/GFbJKum77eBy) 即可在微信开发者工具中导入;*uni-app* 下载 [示例项目](https://mp-html.oss-cn-hangzhou.aliyuncs.com/editable.zip) 在 *HBuilder X* 中打开即可体验;注意示例项目中不一定包含最新版本,仅供参考使用方法
注意事项:
1. 不要在 *editable* 属性被设置为 *true* 前通过 *setContent* 方法(用 *content* 属性)设置内容,否则在切换为 *true* 后会变成空白
2. *editable* 属性为 *true* 时不支持在 *scroll-view* 中使用,否则提示框的位置可能不正确

View File

@@ -0,0 +1,15 @@
// 以下项目可以删减或更换顺序,但不能添加或更改名字
module.exports = {
// 普通标签的菜单项
node: ['大小', '颜色', '斜体', '粗体', '下划线', '居中', '缩进', '上移', '下移', '删除'],
// 可以设置的文字颜色,此项可以添加 css 颜色
color: ['red', 'yellow', 'blue', 'green', 'gray', 'white', 'black'],
// 图片的菜单项
img: ['换图', '宽度', '超链接', '预览图', '禁用预览', '上移', '下移', '删除'],
// 链接的菜单项
link: ['更换链接', '上移', '下移', '删除'],
// 音视频的菜单项
media: ['封面', '循环', '自动播放', '上移', '下移', '删除'],
// 卡片的菜单项
card: ['上移', '下移', '删除']
}

View File

@@ -0,0 +1,813 @@
const path = require('path')
/* global getTop */
module.exports = {
style: `/* #ifndef MP-ALIPAY */
._address,
._article,
._aside,
._body,
._caption,
._center,
._cite,
._footer,
._header,
._html,
._nav,
._pre,
._section {
display: block;
}
/* #endif */`,
methods: {
/**
* @description 开始编辑文本
* @param {Event} e
*/
editStart (e) {
if (this.properties.opts[5]) {
const i = e.currentTarget.dataset.i
if (!this.data.ctrl['e' + i] && this.properties.opts[5] !== 'simple') {
// 显示虚线框
this.setData({
['ctrl.e' + i]: 1
})
// 点击其他地方则取消虚线框
setTimeout(() => {
this.root._mask.push(() => {
this.setData({
['ctrl.e' + i]: 0
})
})
}, 50)
this.root._edit = this
this.i = i
this.cursor = this.getNode(i).text.length
} else {
if (this.properties.opts[5] === 'simple') {
this.root._edit = this
this.i = i
this.cursor = this.getNode(i).text.length
}
this.root._mask.pop()
this.root._maskTap()
// 将 text 转为 textarea
this.setData({
['ctrl.e' + i]: 2
})
// 延时对焦,避免高度错误
setTimeout(() => {
this.setData({
['ctrl.e' + i]: 3
})
}, 50)
}
}
},
/**
* @description 输入文本
* @param {Event} e
*/
editInput (e) {
const i = e.target.dataset.i
// 替换连续空格
const value = e.detail.value.replace(/ {2,}/, $ => {
let res = '\xa0'
for (let i = 1; i < $.length; i++) {
res += '\xa0'
}
return res
})
this.root._editVal('nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + '].text', this.getNode(i).text, value) // 记录编辑历史
this.cursor = e.detail.cursor
},
/**
* @description 完成编辑文本
* @param {Event} e
*/
editEnd (e) {
const i = e.target.dataset.i
// 更新到视图
this.setData({
['ctrl.e' + i]: 0
})
this.root.setData({
['nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + '].text']: e.detail.value.replace(/ {2}/g, '\xa0 ')
})
if (e.detail.cursor !== undefined) {
this.cursor = e.detail.cursor
}
},
/**
* @description 插入一个标签
* @param {Object} node 要插入的标签
*/
insert (node) {
setTimeout(() => {
const arr = this.i.split('_')
const i = parseInt(arr.pop())
let path = arr.join('_')
const children = path ? this.getNode(path).children : this.properties.childs
const childs = children.slice(0)
if (!childs[i]) {
childs.push(node)
} else if (childs[i].text) {
// 在文本中插入
const text = childs[i].text
if (node.type === 'text') {
if (this.cursor) {
childs[i].text = text.substring(0, this.cursor) + node.text + text.substring(this.cursor)
} else {
childs[i].text += node.text
}
} else {
const list = []
if (this.cursor) {
list.push({
type: 'text',
text: text.substring(0, this.cursor)
})
}
list.push(node)
if (this.cursor < text.length) {
list.push({
type: 'text',
text: text.substring(this.cursor)
})
}
childs.splice(i, 1, ...list)
}
} else {
childs.splice(i + 1, 0, node)
}
path = this.properties.opts[7] + path
if (path[path.length - 1] === '_') {
path = path.slice(0, -1)
}
this.root._editVal('nodes' + (path ? '[' + path.replace(/_/g, '].children[') + '].children' : ''), children, childs, true)
this.i = arr.join('_') + '_' + (i + 1)
}, 200)
},
/**
* @description 移除第 i 个标签
* @param {Number} i
*/
remove (i) {
const arr = i.split('_')
const j = arr.pop()
let path = arr.join('_')
const children = path ? this.getNode(path).children : this.properties.childs
const childs = children.slice(0)
const delEle = childs.splice(j, 1)[0]
if (delEle.name === 'img' || delEle.name === 'video' || delEle.name === 'audio') {
let src = delEle.attrs.src
if (delEle.src) {
src = delEle.src.length === 1 ? delEle.src[0] : delEle.src
}
this.root.triggerEvent('remove', {
type: delEle.name,
src
})
}
this.root._edit = undefined
this.root._maskTap()
path = this.properties.opts[7] + path
if (path[path.length - 1] === '_') {
path = path.slice(0, -1)
}
this.root._editVal('nodes' + (path ? '[' + path.replace(/_/g, '].children[') + '].children' : ''), children, childs, true)
},
/**
* @description 标签被点击
* @param {Event} e
*/
nodeTap (e) {
if (this.properties.opts[5]) {
const i = e.currentTarget.dataset.i
if (this.root._table) {
const node = this.getNode(i)
if (node.name === 'table') {
this.root._table = undefined
this.root._remove_table = () => {
this.remove(i)
}
}
}
if (this.root._lock) return
// 阻止上层出现点击态
this.root._lock = true
setTimeout(() => {
this.root._lock = false
}, 50)
const node = this.getNode(i)
if (node.name === 'td' || node.name === 'th') {
this.root._table = true
}
if (this.data.ctrl['e' + this.i] === 3) return
this.root._maskTap()
this.root._edit = this
if (this.properties.opts[5] === 'simple') return
const arr = i.split('_')
const j = parseInt(arr.pop())
let path = arr.join('_')
const siblings = path ? this.getNode(path).children : this.properties.childs
// 显示实线框
this.setData({
['ctrl.e' + i]: 1
})
this.root._mask.push(() => {
this.setData({
['ctrl.e' + i]: 0
})
})
if (node.children.length === 1 && node.children[0].type === 'text') {
const ii = i + '_0'
if (!this.data.ctrl['e' + ii]) {
this.setData({
['ctrl.e' + ii]: 1
})
this.root._mask.push(() => {
this.setData({
['ctrl.e' + ii]: 0
})
})
this.cursor = node.children[0].text.length
}
this.i = ii
} else if (!(this.i || '').includes(i)) {
this.i = i + '_'
}
const items = this.root._getItem(node, j !== 0, j !== siblings.length - 1)
this.root._tooltip({
top: getTop(e),
items,
success: tapIndex => {
if (items[tapIndex] === '大小') {
// 改变字体大小
const style = node.attrs.style || ''
let value = style.match(/;font-size:([0-9]+)px/)
if (value) {
value = parseInt(value[1])
} else {
value = 16
}
this.root._slider({
min: 10,
max: 30,
value,
top: getTop(e),
changing: val => {
if (Math.abs(val - value) > 2) {
// 字号变换超过 2 时更新到视图
this.changeStyle('font-size', i, val + 'px', value + 'px')
value = e.detail.value
}
},
change: val => {
if (val !== value) {
this.changeStyle('font-size', i, val + 'px', value + 'px')
}
this.root._editVal('nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + '].attrs.style', style, this.getNode(i).attrs.style)
}
})
} else if (items[tapIndex] === '颜色') {
// 改变文字颜色
const items = this.root._getItem('color')
this.root._color({
top: getTop(e),
items,
success: tapIndex => {
const style = node.attrs.style || ''
const value = style.match(/;color:([^;]+)/)
this.changeStyle('color', i, items[tapIndex], value ? value[1] : undefined)
this.root._editVal('nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + '].attrs.style', style, this.getNode(i).attrs.style)
}
})
} else if (items[tapIndex] === '上移' || items[tapIndex] === '下移') {
const arr = siblings.slice(0)
const item = arr[j]
if (items[tapIndex] === '上移') {
arr[j] = arr[j - 1]
arr[j - 1] = item
} else {
arr[j] = arr[j + 1]
arr[j + 1] = item
}
path = this.properties.opts[7] + path
if (path[path.length - 1] === '_') {
path = path.slice(0, -1)
}
this.root._editVal('nodes' + (path ? '[' + path.replace(/_/g, '].children[') + '].children' : ''), siblings, arr, true)
} else if (items[tapIndex] === '删除') {
if ((node.name === 'td' || node.name === 'th') && this.root._remove_table) {
this.root._remove_table()
this.root._remove_table = undefined
} else {
this.remove(i)
}
} else {
const style = node.attrs.style || ''
let newStyle = ''
const item = items[tapIndex]
let name
let value
if (item === '斜体') {
name = 'font-style'
value = 'italic'
} else if (item === '粗体') {
name = 'font-weight'
value = 'bold'
} else if (item === '下划线') {
name = 'text-decoration'
value = 'underline'
} else if (item === '居中') {
name = 'text-align'
value = 'center'
} else if (item === '缩进') {
name = 'text-indent'
value = '2em'
}
if (style.includes(name + ':')) {
// 已有则取消
newStyle = style.replace(new RegExp(name + ':[^;]+'), '')
} else {
// 没有则添加
newStyle = style + ';' + name + ':' + value
}
this.root._editVal('nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + '].attrs.style', style, newStyle, true)
}
}
})
}
},
/**
* @description 音视频被点击
* @param {Event} e
*/
mediaTap (e) {
if (this.properties.opts[5]) {
const i = e.target.dataset.i
const node = this.getNode(i)
const items = this.root._getItem(node)
this.root._maskTap()
this.root._edit = this
this.i = i
this.root._tooltip({
top: e.target.offsetTop - 30,
items,
success: tapIndex => {
switch (items[tapIndex]) {
case '封面':
// 设置封面
this.root.getSrc('img', node.attrs.poster || '').then(url => {
this.root._editVal('nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + '].attrs.poster', node.attrs.poster, url instanceof Array ? url[0] : url, true)
}).catch(() => { })
break
case '删除':
this.remove(i)
break
case '循环':
case '不循环':
// 切换循环播放
this.root.setData({
['nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + '].attrs.loop']: !node.attrs.loop
})
wx.showToast({
title: '成功'
})
break
case '自动播放':
case '不自动播放':
// 切换自动播放播放
this.root.setData({
['nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + '].attrs.autoplay']: !node.attrs.autoplay
})
wx.showToast({
title: '成功'
})
break
}
}
})
// 避免上层出现点击态
this.root._lock = true
setTimeout(() => {
this.root._lock = false
}, 50)
}
},
/**
* 改变样式
* @param {String} name 属性名
* @param {Number} i 第几个标签
* @param {String} value 新值
* @param {String} oldVal 旧值
*/
changeStyle (name, i, value, oldVal) {
let style = this.getNode(i).attrs.style || ''
if (style.includes(';' + name + ':' + oldVal)) {
// style 中已经有
style = style.replace(';' + name + ':' + oldVal, ';' + name + ':' + value)
} else {
// 没有则新增
style += ';' + name + ':' + value
}
this.root.setData({
['nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + '].attrs.style']: style
})
}
},
handler (file) {
if (file.isBuffer()) {
let content = file.contents.toString()
if (file.path.includes('miniprogram' + path.sep + 'index.wxml')) {
// 传递 editable 属性和路径
content = content.replace(/opts\s*=\s*"{{\[([^\]]+)\]}}"/, 'opts="{{[$1,editable,placeholder,\'\']}}"')
.replace(/<view(.*?)style\s*=\s*"{{containerStyle}}"/, '<view$1style="{{editable?\'min-height:200px;\':\'\'}}{{containerStyle}}" bindtap="_containTap"')
// 工具弹窗
.replace('</view>', ` <view wx:if="{{tooltip}}" class="_tooltip_contain" style="top:{{tooltip.top}}px">
<view class="_tooltip">
<view wx:for="{{tooltip.items}}" wx:key="index" class="_tooltip_item" data-i="{{index}}" bindtap="_tooltipTap">{{item}}</view>
</view>
</view>
<view wx:if="{{slider}}" class="_slider" style="top:{{slider.top}}px">
<slider value="{{slider.value}}" min="{{slider.min}}" max="{{slider.max}}" block-size="14" show-value activeColor="white" mp-alipay:style="padding:10px" bindchanging="_sliderChanging" bindchange="_sliderChange" />
</view>
<view wx:if="{{color}}" class="_tooltip_contain" style="top:{{color.top}}px">
<view class="_tooltip" style="overflow-y: hidden;">
<view wx:for="{{color.items}}" wx:key="index" class="_color_item" style="background-color:{{item}}" data-i="{{index}}" bindtap="_colorTap"></view>
</view>
</view>
</view>`)
} else if (file.path.includes('miniprogram' + path.sep + 'index.js')) {
// 添加 editable 属性,发生变化时重新解析
content = content.replace(/properties\s*:\s*{/, `properties: {
editable: {
type: null,
observer (val) {
if (this.properties.content) {
this.setContent(val ? this.properties.content : this.getContent())
} else if (val) {
this.setData({
nodes: [{
name: 'p',
attrs: {},
children: [{
type: 'text',
text: ''
}]
}]
})
// #ifdef MP-TOUTIAO
this.selectComponent('#_root', child => {
child.root = this
})
// #endif
}
if (!val) {
this._maskTap()
}
}
},
placeholder: String,`)
.replace(/didUpdate\s*\(e\)\s*{/, `didUpdate (e) {
if (e.editable !== this.properties.editable) {
const val = this.properties.editable
if (this.properties.content) {
this.setContent(val ? this.properties.content : this.getContent())
} else if (val) {
this.setData({
nodes: [{
name: 'p',
attrs: {},
children: [{
type: 'text',
text: ''
}]
}]
})
}
if (!val) {
this._maskTap()
}
}`)
// 处理各类弹窗的事件
.replace(/methods\s*:\s*{/, `methods: {
_containTap() {
if (!this._lock && !this.data.slider && !this.data.color) {
this._edit = undefined
this._maskTap()
}
},
_tooltipTap(e) {
this._tooltipcb(e.currentTarget.dataset.i)
this.setData({
tooltip: null
})
},
_sliderChanging(e) {
this._slideringcb(e.detail.value)
},
_sliderChange(e) {
this._slidercb(e.detail.value)
},
_colorTap(e) {
this._colorcb(e.currentTarget.dataset.i)
this.setData({
color: null
})
},`)
} else if (file.path.includes('miniprogram' + path.sep + 'index.wxss')) {
// 工具弹窗的样式
content += `/* 提示条 */
._tooltip_contain {
position: absolute;
right: 20px;
left: 20px;
text-align: center;
}
._tooltip {
box-sizing: border-box;
display: inline-block;
width: auto;
max-width: 100%;
height: 30px;
padding: 0 3px;
overflow: scroll;
font-size: 14px;
line-height: 30px;
white-space: nowrap;
}
._tooltip_item {
display: inline-block;
width: auto;
padding: 0 2vw;
line-height: 30px;
background-color: black;
color: white;
}
._color_item {
display: inline-block;
width: 18px;
height: 18px;
margin: 5px 2vw;
border:1px solid #dfe2e5;
border-radius: 50%;
}
/* 图片宽度滚动条 */
._slider {
position: absolute;
left: 20px;
width: 220px;
}
._tooltip,
._slider {
background-color: black;
border-radius: 3px;
opacity: 0.75;
}`
} else if (file.path.includes('parser.js')) {
content = content.replace(/popNode\s*=\s*function\s*\(\)\s*{/, 'popNode = function () {\n const editable = this.options.editable')
// 不转换标签名
.replace(/if\s*\(config.blockTags\[node.name\]\)\s*{[\s\S]+?}/, `if (config.blockTags[node.name]) {
if (!editable) {
node.name = 'div'
}
}`)
// 转换表格和列表
.replace(/node.c(\)|\s*&&|\s*\n)/g, '(node.c || editable)$1')
.replace(/while\s*\(map\[row\s*\+\s*'.'\s*\+\s*col\]\)\s*{[\s\S]+?}/, `while (map[row + '.' + col]) {
col++
}
if (editable) {
td.r = row
}`)
// 不做 expose 处理
.replace(/parser.prototype.expose\s*=\s*function\s*\(\)\s*{/, `parser.prototype.expose = function () {
if (this.options.editable) return`)
} else if (file.path.includes('node.wxml')) {
content = content.replace(/opts\s*=\s*"{{opts}}"/, 'opts="{{[opts[0],opts[1],opts[2],opts[3],opts[4],opts[5],opts[6],opts[7]+i+\'_\']}}"')
.replace(/opts\s*=\s*"{{opts}}"/, 'opts="{{[opts[0],opts[1],opts[2],opts[3],opts[4],opts[5],opts[6],opts[7]+i1+\'_\'+i2+\'_\'+i3+\'_\'+i4+\'_\'+i5+\'_\']}}"')
.replace('!n.c', "opts[5]?(!n.children||n.name=='a'):!n.c")
.replace(/!(n.?)\.c(?![a-z])/g, '(opts[5]?true:!$1.c)')
.replace(/isInline\((.*?)\)/g, '(opts[5]?true:isInline($1))')
// 修改普通标签
.replace(/<view\s*wx:else\s*id(.+?)style="/, '<view wx:else data-i="{{path+i}}" bindtap="nodeTap" id$1style="{{ctrl[\'e\'+path+i]&&opts[5]!==\'simple\'?\'border:1px solid black;padding:5px;display:block;\':\'\'}}')
.replace(/<view\s*wx:else\s*id(.+?)style="/, '<view wx:else data-i="{{\'\'+i1}}" bindtap="nodeTap" id$1style="{{ctrl[\'e\'+i1]&&opts[5]!==\'simple\'?\'border:1px solid black;padding:5px;display:block;\':\'\'}}')
.replace(/<view\s*wx:else\s*id(.+?)style="/, '<view wx:else data-i="{{i1+\'_\'+i2}}" bindtap="nodeTap" id$1style="{{ctrl[\'e\'+i1+\'_\'+i2]&&opts[5]!==\'simple\'?\'border:1px solid black;padding:5px;display:block;\':\'\'}}')
.replace(/<view\s*wx:else\s*id(.+?)style="/, '<view wx:else data-i="{{i1+\'_\'+i2+\'_\'+i3}}" bindtap="nodeTap" id$1style="{{ctrl[\'e\'+i1+\'_\'+i2+\'_\'+i3]&&opts[5]!==\'simple\'?\'border:1px solid black;padding:5px;display:block;\':\'\'}}')
.replace(/<view\s*wx:else\s*id(.+?)style="/, '<view wx:else data-i="{{i1+\'_\'+i2+\'_\'+i3+\'_\'+i4}}" bindtap="nodeTap" id$1style="{{ctrl[\'e\'+i1+\'_\'+i2+\'_\'+i3+\'_\'+i4]&&opts[5]!==\'simple\'?\'border:1px solid black;padding:5px;display:block;\':\'\'}}')
// 修改文本块
.replace(/<!--\s*文本\s*-->[\s\S]+?<!--\s*链接\s*-->/,
`<block wx:elif="{{n.type==='text'}}">
<text wx:if="{{!ctrl['e'+i]}}" data-i="{{i}}" mp-weixin:user-select="{{opts[4]}}" decode="{{!opts[5]}}" bindtap="editStart">{{n.text}}
<text wx:if="{{!n.text}}" style="color:gray">{{opts[6]||'请输入'}}</text>
</text>
<text wx:elif="{{ctrl['e'+i]===1}}" data-i="{{i}}" style="border:1px dashed black;min-width:50px;width:auto;padding:5px;display:block" catchtap="editStart">{{n.text}}
<text wx:if="{{!n.text}}" style="color:gray">{{opts[6]||'请输入'}}</text>
</text>
<textarea wx:else style="{{opts[5]==='simple'?'':'border:1px dashed black;'}}min-width:50px;width:auto;padding:5px" auto-height maxlength="-1" focus="{{ctrl['e'+i]===3}}" value="{{n.text}}" data-i="{{i}}" bindinput="editInput" bindblur="editEnd" />
</block>
<text wx:elif="{{n.name==='br'}}">\\n</text>`)
// 修改图片
.replace(/<image(.+?)id="\{\{n.attrs.id/, '<image$1id="{{n.attrs.id||(\'n\'+i)')
.replace('height:1px', "height:{{ctrl['h'+i]||1}}px")
.replace('style="{{ctrl[i]', 'style="{{ctrl[\'e\'+i]&&opts[5]!==\'simple\'?\'border:1px dashed black;padding:3px;\':\'\'}}{{ctrl[i]')
.replace(/weixin:show-menu-by-longpress\s*=\s*"{{(\S+?)}}"\s*baidu:image-menu-prevent\s*=\s*"{{(\S+?)}}"/, 'weixin:show-menu-by-longpress="{{!opts[5]&&$1}}" baidu:image-menu-prevent="{{opts[5]||$2}}"')
// 修改音视频
.replace('<video', '<video bindtap="mediaTap"')
.replace('audio ', 'audio bindtap="mediaTap" ')
.replace('card', 'card bindtap="mediaTap"')
} else if (file.path.includes('node.js') && file.extname === '.js') {
content = `
const Parser = require('../parser')
function getTop(e) {
let top
// #ifndef MP-ALIPAY
top = e.detail.y
// #endif
// #ifdef MP-ALIPAY
top = top = e.detail.pageY
// #endif
if (top - e.currentTarget.offsetTop < 150 || top < 600) {
top = e.currentTarget.offsetTop
}
if (top < 30) {
top += 70
}
return top - 30
}` + content.replace('methods:', `detached () {
if (this.root && this.root._edit === this) {
this.root._edit = undefined
}
},
methods:`)
// 记录图片宽度
.replace(/imgLoad\s*\(e\)\s*{/, `imgLoad (e) {
// #ifdef MP-WEIXIN || MP-QQ
if (this.properties.opts[5]) {
setTimeout(() => {
const id = this.getNode(i).attrs.id || ('n' + i)
wx.createSelectorQuery().in(this).select('#' + id).boundingClientRect().exec(res => {
this.setData({
['ctrl.h'+i]: res[0].height
})
})
}, 50)
}
// #endif`)
.replace(/if\s*\(!node.w\)\s*{[\s\S]+?}/,
`if (!node.w) {
val = e.detail.width
if (this.properties.opts[5]) {
const data = {}
const path = 'nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + '].attrs.'
if (val < 150) {
data[path + 'ignore'] = 'T'
}
data[path + 'width'] = val.toString()
this.root.setData(data)
}
}`)
// 处理图片点击
.replace(/imgTap\s*\(e\)\s*{([\s\S]+?)},\s*\/\*/,
`imgTap (e) {
if (!this.properties.opts[5]) {$1} else {
const i = e.target.dataset.i
const node = this.getNode(i)
const items = this.root._getItem(node)
this.root._edit = this
const parser = new Parser(this.root)
this.i = i
this.root._maskTap()
this.setData({
['ctrl.e' + i]: 1
})
this.root._mask.push(() => {
this.setData({
['ctrl.e' + i]: 0
})
})
this.root._tooltip({
top: getTop(e),
items,
success: tapIndex => {
if (items[tapIndex] === '换图') {
// 换图
this.root.getSrc('img', node.attrs.src || '').then(url => {
this.root._editVal('nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + '].attrs.src', node.attrs.src, parser.getUrl(url instanceof Array ? url[0] : url), true)
}).catch(() => { })
} else if (items[tapIndex] === '宽度') {
// 更改宽度
const style = node.attrs.style || ''
let value = style.match(/max-width:([0-9]+)%/)
if (value) {
value = parseInt(value[1])
} else {
value = 100
}
this.root._slider({
min: 0,
max: 100,
value,
top: getTop(e),
changing: val => {
// 变化超过 5% 更新时视图
if (Math.abs(val - value) > 5) {
this.changeStyle('max-width', i, val + '%', value + '%')
value = val
}
},
change: val => {
if (val !== value) {
this.changeStyle('max-width', i, val + '%', value + '%')
value = val
}
this.root._editVal('nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + '].attrs.style', style, this.getNode(i).attrs.style)
}
})
} else if (items[tapIndex] === '超链接') {
// 将图片设置为链接
this.root.getSrc('link', node.a ? node.a.href : '').then(url => {
// 如果有 a 标签则替换 href
if (node.a) {
this.root._editVal('nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + '].a.href', node.a.href, parser.getUrl(url), true)
} else {
const link = {
name: 'a',
attrs: {
href: parser.getUrl(url)
},
children: [node]
}
node.a = link.attrs
this.root._editVal('nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + ']', node, link, true)
}
wx.showToast({
title: '成功'
})
}).catch(() => { })
} else if (items[tapIndex] === '预览图') {
// 设置预览图链接
this.root.getSrc('img', node.attrs['original-src'] || '').then(url => {
this.root._editVal('nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + '].attrs.original-src', node.attrs['original-src'], parser.getUrl(url instanceof Array ? url[0] : url), true)
wx.showToast({
title: '成功'
})
}).catch(() => { })
} else if (items[tapIndex] === '删除') {
this.remove(i)
} else {
// 禁用 / 启用预览
this.root.setData({
['nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + '].attrs.ignore']: !node.attrs.ignore
})
wx.showToast({
title: '成功'
})
}
}
})
this.root._lock = true
setTimeout(() => {
this.root._lock = false
}, 50)
}
},
/*`)
// 处理链接点击
.replace(/linkTap\s*\(e\)\s*{([\s\S]+?)},\s*\/\*/,
`linkTap (e) {
if (!this.properties.opts[5]) {$1} else {
const i = e.currentTarget.dataset.i
const node = this.getNode(i)
const items = this.root._getItem(node)
this.root._tooltip({
top: getTop(e),
items,
success: tapIndex => {
if (items[tapIndex] === '更换链接') {
this.root.getSrc('link', node.attrs.href).then(url => {
this.root._editVal('nodes[' + (this.properties.opts[7] + i).replace(/_/g, '].children[') + '].attrs.href', node.attrs.href, url, true)
wx.showToast({
title: '成功'
})
}).catch(() => { })
} else {
this.remove(i)
}
}
})
}
},
/*`)
}
file.contents = Buffer.from(content)
}
}
}

View File

@@ -0,0 +1,551 @@
/**
* @fileoverview editable 插件
*/
const config = require('./config')
const Parser = require('../parser')
function Editable (vm) {
this.vm = vm
this.editHistory = [] // 历史记录
this.editI = -1 // 历史记录指针
vm._mask = [] // 蒙版被点击时进行的操作
/**
* @description 移动历史记录指针
* @param {Number} num 移动距离
*/
const move = num => {
const item = this.editHistory[this.editI + num]
if (item) {
this.editI += num
vm.setData({
[item.key]: item.value
})
}
}
vm.undo = () => move(-1) // 撤销
vm.redo = () => move(1) // 重做
/**
* @description 更新记录
* @param {String} path 路径
* @param {*} oldVal 旧值
* @param {*} newVal 新值
* @param {Boolean} set 是否更新到视图
* @private
*/
vm._editVal = (path, oldVal, newVal, set) => {
// 当前指针后的内容去除
while (this.editI < this.editHistory.length - 1) {
this.editHistory.pop()
}
// 最多存储 30 条操作记录
while (this.editHistory.length > 30) {
this.editHistory.pop()
this.editI--
}
const last = this.editHistory[this.editHistory.length - 1]
if (!last || last.key !== path) {
if (last) {
// 去掉上一次的新值
this.editHistory.pop()
this.editI--
}
// 存入这一次的旧值
this.editHistory.push({
key: path,
value: oldVal
})
this.editI++
}
// 存入本次的新值
this.editHistory.push({
key: path,
value: newVal
})
this.editI++
// 更新到视图
if (set) {
vm.setData({
[path]: newVal
})
}
}
/**
* @description 获取菜单项
* @private
*/
vm._getItem = function (node, up, down) {
let items
let i
if (node === 'color') {
return config.color
}
if (node.name === 'img') {
items = config.img.slice(0)
if (!vm.getSrc) {
i = items.indexOf('换图')
if (i !== -1) {
items.splice(i, 1)
}
i = items.indexOf('超链接')
if (i !== -1) {
items.splice(i, 1)
}
i = items.indexOf('预览图')
if (i !== -1) {
items.splice(i, 1)
}
}
i = items.indexOf('禁用预览')
if (i !== -1 && node.attrs.ignore) {
items[i] = '启用预览'
}
} else if (node.name === 'a') {
items = config.link.slice(0)
if (!vm.getSrc) {
i = items.indexOf('更换链接')
if (i !== -1) {
items.splice(i, 1)
}
}
} else if (node.name === 'video' || node.name === 'audio') {
items = config.media.slice(0)
i = items.indexOf('封面')
if (!vm.getSrc && i !== -1) {
items.splice(i, 1)
}
i = items.indexOf('循环')
if (node.attrs.loop && i !== -1) {
items[i] = '不循环'
}
i = items.indexOf('自动播放')
if (node.attrs.autoplay && i !== -1) {
items[i] = '不自动播放'
}
} else if (node.name === 'card') {
items = config.card.slice(0)
} else {
items = config.node.slice(0)
}
if (!up) {
i = items.indexOf('上移')
if (i !== -1) {
items.splice(i, 1)
}
}
if (!down) {
i = items.indexOf('下移')
if (i !== -1) {
items.splice(i, 1)
}
}
return items
}
/**
* @description 显示 tooltip
* @param {object} obj
* @private
*/
vm._tooltip = function (obj) {
vm.setData({
tooltip: {
top: obj.top,
items: obj.items
}
})
vm._tooltipcb = obj.success
}
/**
* @description 显示滚动条
* @param {object} obj
* @private
*/
vm._slider = function (obj) {
vm.setData({
slider: {
min: obj.min,
max: obj.max,
value: obj.value,
top: obj.top
}
})
vm._slideringcb = obj.changing
vm._slidercb = obj.change
}
/**
* @description 显示颜色选择
* @param {object} obj
* @private
*/
vm._color = function (obj) {
vm.setData({
color: {
items: obj.items,
top: obj.top
}
})
vm._colorcb = obj.success
}
/**
* @description 点击蒙版
* @private
*/
vm._maskTap = function () {
// 隐藏所有悬浮窗
while (this._mask.length) {
(this._mask.pop())()
}
const data = {}
if (this.data.tooltip) {
data.tooltip = null
}
if (this.data.slider) {
data.slider = null
}
if (this.data.color) {
data.color = null
}
if (this.data.tooltip || this.data.slider || this.data.color) {
this.setData(data)
}
}
/**
* @description 插入节点
* @param {Object} node
*/
function insert (node) {
if (vm._edit) {
vm._edit.insert(node)
} else {
const nodes = vm.data.nodes.slice(0)
nodes.push(node)
vm._editVal('nodes', vm.data.nodes, nodes, true)
}
}
/**
* @description 在光标处插入指定 html 内容
* @param {String} html 内容
*/
vm.insertHtml = html => {
this.inserting = true
const arr = new Parser(vm).parse(html)
this.inserting = undefined
for (let i = 0; i < arr.length; i++) {
insert(arr[i])
}
}
/**
* @description 在光标处插入图片
*/
vm.insertImg = function () {
vm.getSrc && vm.getSrc('img').then(src => {
if (typeof src === 'string') {
src = [src]
}
const parser = new Parser(vm)
for (let i = 0; i < src.length; i++) {
insert({
name: 'img',
attrs: {
src: parser.getUrl(src[i])
}
})
}
}).catch(() => { })
}
/**
* @description 在光标处插入一个链接
*/
vm.insertLink = function () {
vm.getSrc && vm.getSrc('link').then(url => {
insert({
name: 'a',
attrs: {
href: url
},
children: [{
type: 'text',
text: url
}]
})
}).catch(() => { })
}
/**
* @description 在光标处插入一个表格
* @param {Number} rows 行数
* @param {Number} cols 列数
*/
vm.insertTable = function (rows, cols) {
const table = {
name: 'table',
attrs: {
style: 'display:table;width:100%;margin:10px 0;text-align:center;border-spacing:0;border-collapse:collapse;border:1px solid gray'
},
children: []
}
for (let i = 0; i < rows; i++) {
const tr = {
name: 'tr',
attrs: {},
children: []
}
for (let j = 0; j < cols; j++) {
tr.children.push({
name: 'td',
attrs: {
style: 'padding:2px;border:1px solid gray'
},
children: [{
type: 'text',
text: ''
}]
})
}
table.children.push(tr)
}
insert(table)
}
/**
* @description 插入视频/音频
* @param {Object} node
*/
function insertMedia (node) {
if (typeof node.src === 'string') {
node.src = [node.src]
}
const parser = new Parser(vm)
// 拼接主域名
for (let i = 0; i < node.src.length; i++) {
node.src[i] = parser.getUrl(node.src[i])
}
insert({
name: 'div',
attrs: {
style: 'text-align:center'
},
children: [node]
})
}
/**
* @description 在光标处插入一个视频
*/
vm.insertVideo = function () {
vm.getSrc && vm.getSrc('video').then(src => {
insertMedia({
name: 'video',
attrs: {
controls: 'T'
},
src
})
}).catch(() => { })
}
/**
* @description 在光标处插入一个音频
*/
vm.insertAudio = function () {
vm.getSrc && vm.getSrc('audio').then(attrs => {
let src
if (attrs.src) {
src = attrs.src
attrs.src = undefined
} else {
src = attrs
attrs = {}
}
attrs.controls = 'T'
insertMedia({
name: 'audio',
attrs,
src
})
}).catch(() => { })
}
/**
* @description 在光标处插入一段文本
*/
vm.insertText = function () {
insert({
name: 'p',
attrs: {},
children: [{
type: 'text',
text: ''
}]
})
}
/**
* @description 清空内容
*/
vm.clear = function () {
vm._maskTap()
vm._edit = undefined
vm.setData({
nodes: [{
name: 'p',
attrs: {},
children: [{
type: 'text',
text: ''
}]
}]
})
}
/**
* @description 获取编辑后的 html
*/
vm.getContent = function () {
let html = '';
// 递归遍历获取
(function traversal (nodes, table) {
for (let i = 0; i < nodes.length; i++) {
let item = nodes[i]
if (item.type === 'text') {
// 编码实体
html += item.text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>').replace(/\xa0/g, '&nbsp;')
} else {
// 还原被转换的 svg
if (item.name === 'img' && (item.attrs.src || '').includes('data:image/svg+xml;utf8,')) {
html += item.attrs.src.substr(24).replace(/%23/g, '#').replace('<svg', '<svg style="' + (item.attrs.style || '') + '"')
continue
} else if (item.name === 'video' || item.name === 'audio') {
// 还原 video 和 audio 的 source
if (item.src.length > 1) {
item.children = []
for (let j = 0; j < item.src.length; j++) {
item.children.push({
name: 'source',
attrs: {
src: item.src[j]
}
})
}
} else {
item.attrs.src = item.src[0]
}
} else if (item.name === 'div' && (item.attrs.style || '').includes('overflow:auto') && (item.children[0] || {}).name === 'table') {
// 还原滚动层
item = item.children[0]
}
// 还原 table
if (item.name === 'table') {
table = item.attrs
if ((item.attrs.style || '').includes('display:grid')) {
item.attrs.style = item.attrs.style.split('display:grid')[0]
const children = [{
name: 'tr',
attrs: {},
children: []
}]
for (let j = 0; j < item.children.length; j++) {
item.children[j].attrs.style = item.children[j].attrs.style.replace(/grid-[^;]+;*/g, '')
if (item.children[j].r !== children.length) {
children.push({
name: 'tr',
attrs: {},
children: [item.children[j]]
})
} else {
children[children.length - 1].children.push(item.children[j])
}
}
item.children = children
}
}
html += '<' + item.name
for (const attr in item.attrs) {
let val = item.attrs[attr]
if (!val) continue
// bool 型省略值
if (val === 'T' || val === true) {
html += ' ' + attr
continue
} else if (item.name[0] === 't' && attr === 'style' && table) {
// 取消为了显示 table 添加的 style
val = val.replace(/;*display:table[^;]*/, '')
if (table.border) {
val = val.replace(/border[^;]+;*/g, $ => $.includes('collapse') ? $ : '')
}
if (table.cellpadding) {
val = val.replace(/padding[^;]+;*/g, '')
}
if (!val) continue
}
html += ' ' + attr + '="' + val.replace(/"/g, '&quot;') + '"'
}
html += '>'
if (item.children) {
traversal(item.children, table)
html += '</' + item.name + '>'
}
}
}
})(vm.data.nodes)
// 其他插件处理
for (let i = vm.plugins.length; i--;) {
if (vm.plugins[i].onGetContent) {
html = vm.plugins[i].onGetContent(html) || html
}
}
return html
}
}
Editable.prototype.onUpdate = function (content, config) {
if (this.vm.properties.editable) {
this.vm._maskTap()
config.entities.amp = '&'
if (!this.inserting) {
this.vm._edit = undefined
if (!content) {
setTimeout(() => {
this.vm.setData({
nodes: [{
name: 'p',
attrs: {},
children: [{
type: 'text',
text: ''
}]
}]
})
}, 0)
}
}
}
}
Editable.prototype.onParse = function (node) {
// 空白单元格可编辑
if (this.vm.properties.editable && (node.name === 'td' || node.name === 'th') && !this.vm.getText(node.children)) {
node.children.push({
type: 'text',
text: ''
})
}
}
module.exports = Editable

View File

@@ -0,0 +1,744 @@
/* global getTop */
module.exports = {
style: `/* #ifndef H5 || MP-ALIPAY || APP-PLUS */
._address,
._article,
._aside,
._body,
._caption,
._center,
._cite,
._footer,
._header,
._html,
._nav,
._pre,
._section {
display: block;
}
/* #endif */
._video {
width: 300px;
height: 225px;
display: inline-block;
background-color: black;
}`,
methods: {
/**
* @description 开始编辑文本
* @param {Event} e
*/
editStart (e) {
if (this.opts[5]) {
const i = e.currentTarget.dataset.i
if (!this.ctrl['e' + i] && this.opts[5] !== 'simple') {
// 显示虚线框
this.$set(this.ctrl, 'e' + i, 1)
setTimeout(() => {
this.root._mask.push(() => this.$set(this.ctrl, 'e' + i, 0))
}, 50)
this.root._edit = this
this.i = i
this.cursor = this.childs[i].text.length
} else {
if (this.opts[5] === 'simple') {
this.root._edit = this
this.i = i
this.cursor = this.childs[i].text.length
}
this.root._mask.pop()
this.root._maskTap()
// 将 text 转为 textarea
this.$set(this.ctrl, 'e' + i, 2)
// 延时对焦,避免高度错误
setTimeout(() => {
this.$set(this.ctrl, 'e' + i, 3)
}, 50)
}
}
},
/**
* @description 输入文本
* @param {Event} e
*/
editInput (e) {
const i = e.target.dataset.i
// 替换连续空格
const value = e.detail.value.replace(/ {2,}/, $ => {
let res = '\xa0'
for (let i = 1; i < $.length; i++) {
res += '\xa0'
}
return res
})
this.root._editVal(`${this.opts[7]}.${i}.text`, this.childs[i].text, value) // 记录编辑历史
this.cursor = e.detail.cursor
},
/**
* @description 完成编辑文本
* @param {Event} e
*/
editEnd (e) {
const i = e.target.dataset.i
this.$set(this.ctrl, 'e' + i, 0)
// 更新到视图
this.root._setData(`${this.opts[7]}.${i}.text`, e.detail.value.replace(/ {2}/g, '\xa0 '))
if (e.detail.cursor !== undefined) {
this.cursor = e.detail.cursor
}
},
/**
* @description 插入一个标签
* @param {Object} node 要插入的标签
*/
insert (node) {
setTimeout(() => {
const childs = this.childs.slice(0)
if (!childs[this.i]) {
childs.push(node)
} else if (childs[this.i].text) {
// 在文本中插入
const text = childs[this.i].text
if (node.type === 'text') {
if (this.cursor) {
childs[this.i].text = text.substring(0, this.cursor) + node.text + text.substring(this.cursor)
} else {
childs[this.i].text += node.text
}
} else {
const list = []
if (this.cursor) {
list.push({
type: 'text',
text: text.substring(0, this.cursor)
})
}
list.push(node)
if (this.cursor < text.length) {
list.push({
type: 'text',
text: text.substring(this.cursor)
})
}
childs.splice(this.i, 1, ...list)
}
} else {
childs.splice(parseInt(this.i) + 1, 0, node)
}
this.root._editVal(this.opts[7], this.childs, childs, true)
this.i = parseInt(this.i) + 1
}, 200)
},
/**
* @description 移除第 i 个标签
* @param {Number} i
*/
remove (i) {
const arr = this.childs.slice(0)
const delEle = arr.splice(i, 1)[0]
if (delEle.name === 'img' || delEle.name === 'video' || delEle.name === 'audio') {
let src = delEle.attrs.src
if (delEle.src) {
src = delEle.src.length === 1 ? delEle.src[0] : delEle.src
}
this.root.$emit('remove', {
type: delEle.name,
src
})
}
this.root._edit = undefined
this.root._maskTap()
this.root._editVal(this.opts[7], this.childs, arr, true)
},
/**
* @description 标签被点击
* @param {Event} e
*/
nodeTap (e) {
if (this.opts[5]) {
if (this.root._lock) return
this.root._lock = true
setTimeout(() => {
this.root._lock = false
}, 50)
if (this.ctrl['e' + this.i] === 3) return
this.root._maskTap()
this.root._edit = this
if (this.opts[5] === 'simple') return
let start = this.opts[7].lastIndexOf('children.')
if (start !== -1) {
start += 9
} else {
start = 6
}
const i = parseInt(this.opts[7].substring(start, this.opts[7].lastIndexOf('.children')))
let parent = this.$parent
while (parent && parent.$options.name !== 'node') {
parent = parent.$parent
}
let remove = () => {
parent.remove(i)
}
if (this.opts[7].length - parent.opts[7].length > 15) {
const parts = this.opts[7].split('.')
let childs = parent.childs
const i = parseInt(parts[parent.opts[7].split('.').length])
const oldParent = parent
// 删除整个表格
remove = () => {
oldParent.remove(i)
}
for (let i = parent.opts[7].split('.').length; i < parts.length - 2; i++) {
childs = childs[parts[i]]
}
const that = this
parent = {
childs,
opts: [undefined, undefined, undefined, undefined, undefined, undefined, undefined, parts.slice(0, parts.length - 2).join('.')],
changeStyle (name, i, value, oldVal) {
let style = this.childs[i].attrs.style || ''
if (style.includes(';' + name + ':' + oldVal)) {
style = style.replace(';' + name + ':' + oldVal, ';' + name + ':' + value)
} else {
style += ';' + name + ':' + value
}
that.root._setData(`${this.opts[7]}.${i}.attrs.style`, style)
}
}
}
if (!parent) return
// 显示实线框
this.$set(this.ctrl, 'root', 1)
this.root._mask.push(() => this.$set(this.ctrl, 'root', 0))
if (this.childs.length === 1 && this.childs[0].type === 'text' && !this.ctrl.e0) {
this.$set(this.ctrl, 'e0', 1)
this.root._mask.push(() => this.$set(this.ctrl, 'e0', 0))
this.i = 0
this.cursor = this.childs[0].text.length
}
const items = this.root._getItem(parent.childs[i], i !== 0, i !== parent.childs.length - 1)
this.root._tooltip({
top: getTop(e),
items,
success: tapIndex => {
if (items[tapIndex] === '大小') {
// 改变字体大小
const style = parent.childs[i].attrs.style || ''
let value = style.match(/;font-size:([0-9]+)px/)
if (value) {
value = parseInt(value[1])
} else {
value = 16
}
this.root._slider({
min: 10,
max: 30,
value,
top: getTop(e),
changing: val => {
if (Math.abs(val - value) > 2) {
// 字号变换超过 2 时更新到视图
parent.changeStyle('font-size', i, val + 'px', value + 'px')
value = e.detail.value
}
},
change: val => {
if (val !== value) {
parent.changeStyle('font-size', i, val + 'px', value + 'px')
}
this.root._editVal(`${parent.opts[7]}.${i}.attrs.style`, style, parent.childs[i].attrs.style)
}
})
} else if (items[tapIndex] === '颜色') {
// 改变文字颜色
const items = this.root._getItem('color')
this.root._color({
top: getTop(e),
items,
success: tapIndex => {
const style = parent.childs[i].attrs.style || ''
const value = style.match(/;color:([^;]+)/)
parent.changeStyle('color', i, items[tapIndex], value ? value[1] : undefined)
this.root._editVal(`${parent.opts[7]}.${i}.attrs.style`, style, parent.childs[i].attrs.style)
}
})
} else if (items[tapIndex] === '上移' || items[tapIndex] === '下移') {
const arr = parent.childs.slice(0)
const item = arr[i]
if (items[tapIndex] === '上移') {
arr[i] = arr[i - 1]
arr[i - 1] = item
} else {
arr[i] = arr[i + 1]
arr[i + 1] = item
}
this.root._editVal(parent.opts[7], parent.childs, arr, true)
} else if (items[tapIndex] === '删除') {
remove()
} else {
const style = parent.childs[i].attrs.style || ''
let newStyle = ''
const item = items[tapIndex]
let name
let value
if (item === '斜体') {
name = 'font-style'
value = 'italic'
} else if (item === '粗体') {
name = 'font-weight'
value = 'bold'
} else if (item === '下划线') {
name = 'text-decoration'
value = 'underline'
} else if (item === '居中') {
name = 'text-align'
value = 'center'
} else if (item === '缩进') {
name = 'text-indent'
value = '2em'
}
if (style.includes(name + ':')) {
// 已有则取消
newStyle = style.replace(new RegExp(name + ':[^;]+'), '')
} else {
// 没有则添加
newStyle = style + ';' + name + ':' + value
}
this.root._editVal(`${parent.opts[7]}.${i}.attrs.style`, style, newStyle, true)
}
}
})
}
},
/**
* @description 音视频被点击
* @param {Event} e
*/
mediaTap (e, index) {
if (this.opts[5]) {
const i = e.target.dataset.i || index
const node = this.childs[i]
const items = this.root._getItem(node)
this.root._maskTap()
this.root._edit = this
this.i = i
this.root._tooltip({
top: e.currentTarget.offsetTop - 30,
items,
success: tapIndex => {
switch (items[tapIndex]) {
case '封面':
// 设置封面
this.root.getSrc('img', node.attrs.poster || '').then(url => {
this.root._editVal(`${this.opts[7]}.${i}.attrs.poster`, node.attrs.poster, url instanceof Array ? url[0] : url, true)
}).catch(() => { })
break
case '删除':
this.remove(i)
break
case '循环':
case '不循环':
// 切换循环播放
this.root._setData(`${this.opts[7]}.${i}.attrs.loop`, !node.attrs.loop)
uni.showToast({
title: '成功'
})
break
case '自动播放':
case '不自动播放':
// 切换自动播放播放
this.root._setData(`${this.opts[7]}.${i}.attrs.autoplay`, !node.attrs.autoplay)
uni.showToast({
title: '成功'
})
break
}
}
})
// 避免上层出现点击态
this.root._lock = true
setTimeout(() => {
this.root._lock = false
}, 50)
}
},
/**
* 改变样式
* @param {String} name 属性名
* @param {Number} i 第几个标签
* @param {String} value 新值
* @param {String} oldVal 旧值
*/
changeStyle (name, i, value, oldVal) {
let style = this.childs[i].attrs.style || ''
if (style.includes(';' + name + ':' + oldVal)) {
// style 中已经有
style = style.replace(';' + name + ':' + oldVal, ';' + name + ':' + value)
} else {
// 没有则新增
style += ';' + name + ':' + value
}
this.root._setData(`${this.opts[7]}.${i}.attrs.style`, style)
}
},
handler (file) {
if (file.isBuffer()) {
let content = file.contents.toString()
if (file.path.includes('mp-html.vue')) {
// 传递 editable 属性和路径
content = content.replace(/opts\s*=\s*"\[([^\]]+)\]"/, 'opts="[$1,editable,placeholder,\'nodes\']"')
.replace(/<view(.*?):style\s*=\s*"containerStyle"/, '<view$1:style="(editable?\'min-height:200px;\':\'\')+containerStyle" @tap="_containTap"')
// 工具弹窗
.replace(/<\/view>\s*<\/template>/, ` <view v-if="tooltip" class="_tooltip_contain" :style="'top:'+tooltip.top+'px'">
<view class="_tooltip">
<view v-for="(item, index) in tooltip.items" v-bind:key="index" class="_tooltip_item" :data-i="index" @tap="_tooltipTap">{{item}}</view>
</view>
</view>
<view v-if="slider" class="_slider" :style="'top:'+slider.top+'px'">
<slider :value="slider.value" :min="slider.min" :max="slider.max" handle-size="14" block-size="14" show-value activeColor="white" style="padding:3px" @changing="_sliderChanging" @change="_sliderChange" />
</view>
<view v-if="color" class="_tooltip_contain" :style="'top:'+color.top+'px'">
<view class="_tooltip" style="overflow-y: hidden;">
<view v-for="(item, index) in color.items" v-bind:key="index" class="_color_item" :style="'background-color:'+item" :data-i="index" @tap="_colorTap"></view>
</view>
</view>
</view>
</template>`)
// 添加 data
.replace(/data\s*\(\)\s*{\s*return\s*{/, `data() {
return {
tooltip: null,
slider: null,
color: null,`)
// 添加 editable 属性
.replace(/props\s*:\s*{/, `props: {
editable: [Boolean, String],
placeholder: String,`)
// 添加 watch
.replace(/watch\s*:\s*{/, `watch: {
editable(val) {
this.setContent(val ? this.content : this.getContent())
if (!val)
this._maskTap()
},`)
.replace(/if\s*\(this.content/, 'if ((this.content || this.editable)')
// 处理各类弹窗的事件
.replace(/methods\s*:\s*{/, `methods: {
_containTap() {
if (!this._lock && !this.slider && !this.color) {
this._edit = undefined
this._maskTap()
}
},
_tooltipTap(e) {
this._tooltipcb(e.currentTarget.dataset.i)
this.$set(this, 'tooltip', null)
},
_sliderChanging(e) {
this._slideringcb(e.detail.value)
},
_sliderChange(e) {
this._slidercb(e.detail.value)
},
_colorTap(e) {
this._colorcb(e.currentTarget.dataset.i)
this.$set(this, 'color', null)
},`)
// 工具弹窗的样式
.replace('</style>', `
/* 提示条 */
._tooltip_contain {
position: absolute;
right: 20px;
left: 20px;
text-align: center;
}
._tooltip {
box-sizing: border-box;
display: inline-block;
width: auto;
max-width: 100%;
height: 30px;
padding: 0 3px;
overflow: scroll;
font-size: 14px;
line-height: 30px;
white-space: nowrap;
}
._tooltip_item {
display: inline-block;
width: auto;
padding: 0 2vw;
line-height: 30px;
background-color: black;
color: white;
}
._color_item {
display: inline-block;
width: 18px;
height: 18px;
margin: 5px 2vw;
border:1px solid #dfe2e5;
border-radius: 50%;
}
/* 图片宽度滚动条 */
._slider {
position: absolute;
left: 20px;
width: 220px;
}
._tooltip,
._slider {
background-color: black;
border-radius: 3px;
opacity: 0.75;
}
</style>`)
} else if (file.path.includes('parser.js')) {
// 不做 expose 处理
content = content.replace(/parser.prototype.expose\s*=\s*function\s*\(\)\s*{/, `parser.prototype.expose = function () {
if (this.options.editable) return`)
.replace(/popNode\s*=\s*function\s*\(\)\s*{/, 'popNode = function () {\n const editable = this.options.editable')
// 不转换标签名
.replace(/if\s*\(config.blockTags\[node.name\]\)\s*{[\s\S]+?}/, `if (config.blockTags[node.name]) {
if (!editable) {
node.name = 'div'
}
}`)
// 转换表格和列表
.replace(/else\s*if\s*\(node.c\)/, 'else if (!editable && node.c )')
.replace(/node.c(\)|\s*&&|\s*\n)/g, '(node.c || editable)$1')
.replace(/while\s*\(map\[row\s*\+\s*'.'\s*\+\s*col\]\)\s*{[\s\S]+?}/, `while (map[row + '.' + col]) {
col++
}
if (editable) {
td.r = row
}`)
.replace(/let\s+str\s*=\s*'<video style="width:100%;height:100%"'/, `let str = '<video style="width:100%;height:100%"'
if (editable) {
attrs.controls = ''
}`)
} else if (file.path.includes('node.vue')) {
content =
// 传递 opts
content.replace(/:childs\s*=\s*"tbody.children"\s*:opts="opts"/, ':childs="tbody.children" :opts="[opts[0],opts[1],opts[2],opts[3],opts[4],opts[5],opts[6],opts[7]+\'.\'+i+\'.children.\'+x+\'.children\']"')
.replace(/:childs\s*=\s*"n2.children"\s*:opts="opts"/, ':childs="n2.children" :opts="[opts[0],opts[1],opts[2],opts[3],opts[4],opts[5],opts[6],opts[7]+\'.\'+i+\'.children.\'+j+\'.children\']"')
.replace(/:childs\s*=\s*"tr.children"\s*:opts="opts"/, ':childs="tr.children" :opts="[opts[0],opts[1],opts[2],opts[3],opts[4],opts[5],opts[6],opts[7]+\'.\'+i+\'.children.\'+x+\'.children.\'+y+\'.children\']"')
.replace(/:childs\s*=\s*"td.children"\s*:opts="opts"/, ':childs="td.children" :opts="[opts[0],opts[1],opts[2],opts[3],opts[4],opts[5],opts[6],opts[7]+\'.\'+i+\'.children.\'+x+\'.children.\'+y+\'.children.\'+z+\'.children\']"')
.replace(/opts\s*=\s*"opts"/g, 'opts="[opts[0],opts[1],opts[2],opts[3],opts[4],opts[5],opts[6],opts[7]+\'.\'+i+\'.children\']"')
// 不使用 rich-text
.replace(/!n.c/g, '!opts[5]&&!n.c').replace('&&n.c', '&&(n.c||opts[5])')
// 修改普通标签
.replace(/<view\s+:id(.+?)style="/, '<view @tap="nodeTap" :id$1style="(ctrl.root&&opts[5]!==\'simple\'?\'border:1px solid black;padding:5px;display:block;\':\'\')+')
// 修改文本块
.replace(/<!--\s*文本\s*-->[\s\S]+?<!--\s*链接\s*-->/,
`<!-- 文本 -->
<text v-else-if="n.type==='text'&&!ctrl['e'+i]" :data-i="i" :user-select="opts[4]" :decode="!opts[5]" @tap="editStart">{{n.text}}
<text v-if="!n.text" style="color:gray">{{opts[6]||'请输入'}}</text>
</text>
<text v-else-if="n.type==='text'&&ctrl['e'+i]===1" :data-i="i" style="border:1px dashed black;min-width:50px;width:auto;padding:5px;display:block" @tap.stop="editStart">{{n.text}}
<text v-if="!n.text" style="color:gray">{{opts[6]||'请输入'}}</text>
</text>
<textarea v-else-if="n.type==='text'" :style="opts[5]==='simple'?'':'border:1px dashed black;'+'min-width:50px;width:auto;padding:5px'" auto-height maxlength="-1" :focus="ctrl['e'+i]===3" :value="n.text" :data-i="i" @input="editInput" @blur="editEnd" />
<text v-else-if="n.name==='br'">\\n</text>
<!-- 链接 -->`)
// 修改图片
.replace(/<image(.+?)id="n.attrs.id/, '<image$1id="n.attrs.id||(\'n\'+i)')
.replace('height:1px', "height:'+(ctrl['h'+i]||1)+'px")
.replace(/:style\s*=\s*"\(ctrl\[i\]/g, ':style="(ctrl[\'e\'+i]&&opts[5]!==\'simple\'?\'border:1px dashed black;padding:3px;\':\'\')+(ctrl[i]')
.replace(/show-menu-by-longpress\s*=\s*"(\S+?)"\s*:image-menu-prevent\s*=\s*"(\S+?)"/, 'show-menu-by-longpress="!opts[5]&&$1" :image-menu-prevent="opts[5]||$2"')
// 修改音视频
.replace('v-else-if="n.html"', 'v-else-if="n.html" :data-i="i" @tap="mediaTap"')
.replace('<video', '<video :show-center-play-btn="!opts[5]" @tap="mediaTap"')
.replace('<audio ', '<audio @tap="mediaTap" ')
.replace('<my-audio ', '<my-audio @onClick="mediaTap($event, i)" ')
.replace('card ', 'card @click="mediaTap($event, i)" ')
.replace('<script>',
`<script>
import Parser from '../parser'
function getTop(e) {
let top
// #ifdef H5 && VUE3
top = e.pageY
// #endif
// #ifdef (H5 && VUE2) || APP-PLUS
top = e.touches[0].pageY
// #endif
// #ifdef MP-ALIPAY
top = e.detail.pageY
// #endif
// #ifndef H5 || MP-ALIPAY || APP-PLUS
top = e.detail.y
// #endif
if (top - e.currentTarget.offsetTop < 150 || top < 600) {
top = e.currentTarget.offsetTop
}
if (top < 30) {
top += 70
}
return top - 30
}`)
// 周期处理
.replace(/beforeDestroy\s*\(\)\s*{/, `beforeDestroy () {
if (this.root && this.root._edit === this) {
this.root._edit = undefined
}`)
// 记录图片宽度
.replace(/imgLoad\s*\(e\)\s*{/, `imgLoad(e) {
// #ifdef MP-WEIXIN || MP-QQ
if (this.opts[5])
this.$nextTick(() => {
const id = this.childs[i].attrs.id || ('n' + i)
uni.createSelectorQuery().in(this).select('#' + id).boundingClientRect().exec(res => {
this.$set(this.ctrl, 'h'+i, res[0].height)
})
})
// #endif`)
.replace(/if\s*\(!this.childs\[i\].w\)\s*{[\s\S]+?}/,
`if (!this.childs[i].w) {
this.$set(this.ctrl, i, e.detail.width)
if (this.opts[5]) {
const path = this.opts[7] + '.' + i + '.attrs.'
if (e.detail.width < 150)
this.root._setData(path + 'ignore', 'T')
this.root._setData(path + 'width', e.detail.width.toString())
}
}`)
// 处理图片长按
.replace(/imgLongTap\s*\(\)\s*{/, `imgLongTap() {
if (this.opts[5]) return`)
// 处理图片点击
.replace(/imgTap\s*\(e\)\s*{([\s\S]+?)},\s*\/\*/,
`imgTap (e) {
if (!this.opts[5]) {$1} else {
const i = e.currentTarget.dataset.i
const node = this.childs[i]
const items = this.root._getItem(node)
const parser = new Parser(this.root)
this.root._edit = this
this.i = i
this.root._maskTap()
this.$set(this.ctrl, 'e' + i, 1)
this.root._mask.push(() => this.$set(this.ctrl, 'e' + i, 0))
this.root._tooltip({
top: getTop(e),
items,
success: tapIndex => {
if (items[tapIndex] === '换图') {
// 换图
this.root.getSrc('img', node.attrs.src || '').then(url => {
this.root._editVal(this.opts[7] + '.' + i + '.attrs.src', node.attrs.src, parser.getUrl(url instanceof Array ? url[0] : url), true)
}).catch(() => { })
} else if (items[tapIndex] === '宽度') {
// 更改宽度
const style = node.attrs.style || ''
let value = style.match(/max-width:([0-9]+)%/)
if (value) {
value = parseInt(value[1])
} else {
value = 100
}
this.root._slider({
min: 0,
max: 100,
value,
top: getTop(e),
changing: val => {
// 变化超过 5% 更新时视图
if (Math.abs(val - value) > 5) {
this.changeStyle('max-width', i, val + '%', value + '%')
value = val
}
},
change: val => {
if (val !== value) {
this.changeStyle('max-width', i, val + '%', value + '%')
value = val
}
this.root._editVal(this.opts[7] + '.' + i + '.attrs.style', style, this.childs[i].attrs.style)
}
})
} else if (items[tapIndex] === '超链接') {
// 将图片设置为链接
this.root.getSrc('link', node.a ? node.a.href : '').then(url => {
// 如果有 a 标签则替换 href
if (node.a) {
this.root._editVal(this.opts[7] + '.' + i + '.a.href', node.a.href, parser.getUrl(url), true)
} else {
const link = {
name: 'a',
attrs: {
href: parser.getUrl(url)
},
children: [node]
}
node.a = link.attrs
this.root._editVal(this.opts[7] + '.' + i, node, link, true)
}
wx.showToast({
title: '成功'
})
}).catch(() => { })
} else if (items[tapIndex] === '预览图') {
// 设置预览图链接
this.root.getSrc('img', node.attrs['original-src'] || '').then(url => {
this.root._editVal(this.opts[7] + '.' + i + '.attrs.original-src', node.attrs['original-src'], parser.getUrl(url instanceof Array ? url[0] : url), true)
uni.showToast({
title: '成功'
})
}).catch(() => { })
} else if (items[tapIndex] === '删除') {
this.remove(i)
} else {
// 禁用 / 启用预览
this.root._setData(this.opts[7] + '.' + i + '.attrs.ignore', !node.attrs.ignore)
uni.showToast({
title: '成功'
})
}
}
})
this.root._lock = true
setTimeout(() => {
this.root._lock = false
}, 50)
}
},
/*`)
// 处理链接点击
.replace(/linkTap\s*\(e\)\s*{([\s\S]+?)},\s*\/\*/,
`linkTap (e) {
if (!this.opts[5]) {$1} else {
const i = e.currentTarget.dataset.i
const node = this.childs[i]
const items = this.root._getItem(node)
this.root._tooltip({
top: getTop(e),
items,
success: tapIndex => {
if (items[tapIndex] === '更换链接') {
this.root.getSrc('link', node.attrs.href).then(url => {
this.root._editVal(this.opts[7] + '.' + i + '.attrs.href', node.attrs.href, url, true)
uni.showToast({
title: '成功'
})
}).catch(() => { })
} else {
this.remove(i)
}
}
})
}
},
/*`)
}
file.contents = Buffer.from(content)
}
}
}

View File

@@ -0,0 +1,553 @@
/**
* @fileoverview editable 插件
*/
const config = require('./config')
const Parser = require('../parser')
function Editable (vm) {
this.vm = vm
this.editHistory = [] // 历史记录
this.editI = -1 // 历史记录指针
vm._mask = [] // 蒙版被点击时进行的操作
vm._setData = function (path, val) {
const paths = path.split('.')
let target = vm
for (let i = 0; i < paths.length - 1; i++) {
target = target[paths[i]]
}
vm.$set(target, paths.pop(), val)
}
/**
* @description 移动历史记录指针
* @param {Number} num 移动距离
*/
const move = num => {
setTimeout(() => {
const item = this.editHistory[this.editI + num]
if (item) {
this.editI += num
vm._setData(item.key, item.value)
}
}, 200)
}
vm.undo = () => move(-1) // 撤销
vm.redo = () => move(1) // 重做
/**
* @description 更新记录
* @param {String} path 更新内容路径
* @param {*} oldVal 旧值
* @param {*} newVal 新值
* @param {Boolean} set 是否更新到视图
* @private
*/
vm._editVal = (path, oldVal, newVal, set) => {
// 当前指针后的内容去除
while (this.editI < this.editHistory.length - 1) {
this.editHistory.pop()
}
// 最多存储 30 条操作记录
while (this.editHistory.length > 30) {
this.editHistory.pop()
this.editI--
}
const last = this.editHistory[this.editHistory.length - 1]
if (!last || last.key !== path) {
if (last) {
// 去掉上一次的新值
this.editHistory.pop()
this.editI--
}
// 存入这一次的旧值
this.editHistory.push({
key: path,
value: oldVal
})
this.editI++
}
// 存入本次的新值
this.editHistory.push({
key: path,
value: newVal
})
this.editI++
// 更新到视图
if (set) {
vm._setData(path, newVal)
}
}
/**
* @description 获取菜单项
* @private
*/
vm._getItem = function (node, up, down) {
let items
let i
if (node === 'color') {
return config.color
}
if (node.name === 'img') {
items = config.img.slice(0)
if (!vm.getSrc) {
i = items.indexOf('换图')
if (i !== -1) {
items.splice(i, 1)
}
i = items.indexOf('超链接')
if (i !== -1) {
items.splice(i, 1)
}
i = items.indexOf('预览图')
if (i !== -1) {
items.splice(i, 1)
}
}
i = items.indexOf('禁用预览')
if (i !== -1 && node.attrs.ignore) {
items[i] = '启用预览'
}
} else if (node.name === 'a') {
items = config.link.slice(0)
if (!vm.getSrc) {
i = items.indexOf('更换链接')
if (i !== -1) {
items.splice(i, 1)
}
}
} else if (node.name === 'video' || node.name === 'audio') {
items = config.media.slice(0)
i = items.indexOf('封面')
if (!vm.getSrc && i !== -1) {
items.splice(i, 1)
}
i = items.indexOf('循环')
if (node.attrs.loop && i !== -1) {
items[i] = '不循环'
}
i = items.indexOf('自动播放')
if (node.attrs.autoplay && i !== -1) {
items[i] = '不自动播放'
}
} else if (node.name === 'card') {
items = config.card.slice(0)
} else {
items = config.node.slice(0)
}
if (!up) {
i = items.indexOf('上移')
if (i !== -1) {
items.splice(i, 1)
}
}
if (!down) {
i = items.indexOf('下移')
if (i !== -1) {
items.splice(i, 1)
}
}
return items
}
/**
* @description 显示 tooltip
* @param {object} obj
* @private
*/
vm._tooltip = function (obj) {
vm.$set(vm, 'tooltip', {
top: obj.top,
items: obj.items
})
vm._tooltipcb = obj.success
}
/**
* @description 显示滚动条
* @param {object} obj
* @private
*/
vm._slider = function (obj) {
vm.$set(vm, 'slider', {
min: obj.min,
max: obj.max,
value: obj.value,
top: obj.top
})
vm._slideringcb = obj.changing
vm._slidercb = obj.change
}
/**
* @description 显示颜色选择
* @param {object} obj
* @private
*/
vm._color = function (obj) {
vm.$set(vm, 'color', {
items: obj.items,
top: obj.top
})
vm._colorcb = obj.success
}
/**
* @description 点击蒙版
* @private
*/
vm._maskTap = function () {
// 隐藏所有悬浮窗
while (vm._mask.length) {
(vm._mask.pop())()
}
if (vm.tooltip) {
vm.$set(vm, 'tooltip', null)
}
if (vm.slider) {
vm.$set(vm, 'slider', null)
}
if (vm.color) {
vm.$set(vm, 'color', null)
}
}
/**
* @description 插入节点
* @param {Object} node
*/
function insert (node) {
if (vm._edit) {
vm._edit.insert(node)
} else {
const nodes = vm.nodes.slice(0)
nodes.push(node)
vm._editVal('nodes', vm.nodes, nodes, true)
}
}
/**
* @description 在光标处插入指定 html 内容
* @param {String} html 内容
*/
vm.insertHtml = html => {
this.inserting = true
const arr = new Parser(vm).parse(html)
this.inserting = undefined
for (let i = 0; i < arr.length; i++) {
insert(arr[i])
}
}
/**
* @description 在光标处插入图片
*/
vm.insertImg = function () {
vm.getSrc && vm.getSrc('img').then(src => {
if (typeof src === 'string') {
src = [src]
}
const parser = new Parser(vm)
for (let i = 0; i < src.length; i++) {
insert({
name: 'img',
attrs: {
src: parser.getUrl(src[i])
}
})
}
}).catch(() => { })
}
/**
* @description 在光标处插入一个链接
*/
vm.insertLink = function () {
vm.getSrc && vm.getSrc('link').then(url => {
insert({
name: 'a',
attrs: {
href: url
},
children: [{
type: 'text',
text: url
}]
})
}).catch(() => { })
}
/**
* @description 在光标处插入一个表格
* @param {Number} rows 行数
* @param {Number} cols 列数
*/
vm.insertTable = function (rows, cols) {
const table = {
name: 'table',
attrs: {
style: 'display:table;width:100%;margin:10px 0;text-align:center;border-spacing:0;border-collapse:collapse;border:1px solid gray'
},
children: []
}
for (let i = 0; i < rows; i++) {
const tr = {
name: 'tr',
attrs: {},
children: []
}
for (let j = 0; j < cols; j++) {
tr.children.push({
name: 'td',
attrs: {
style: 'padding:2px;border:1px solid gray'
},
children: [{
type: 'text',
text: ''
}]
})
}
table.children.push(tr)
}
insert(table)
}
/**
* @description 插入视频/音频
* @param {Object} node
*/
function insertMedia (node) {
if (typeof node.src === 'string') {
node.src = [node.src]
}
const parser = new Parser(vm)
// 拼接主域名
for (let i = 0; i < node.src.length; i++) {
node.src[i] = parser.getUrl(node.src[i])
}
insert({
name: 'div',
attrs: {
style: 'text-align:center'
},
children: [node]
})
}
/**
* @description 在光标处插入一个视频
*/
vm.insertVideo = function () {
vm.getSrc && vm.getSrc('video').then(src => {
insertMedia({
name: 'video',
attrs: {
controls: 'T'
},
children: [],
src,
// #ifdef APP-PLUS
html: `<video src="${src}" style="width:100%;height:100%"></video>`
// #endif
})
}).catch(() => { })
}
/**
* @description 在光标处插入一个音频
*/
vm.insertAudio = function () {
vm.getSrc && vm.getSrc('audio').then(attrs => {
let src
if (attrs.src) {
src = attrs.src
attrs.src = undefined
} else {
src = attrs
attrs = {}
}
attrs.controls = 'T'
insertMedia({
name: 'audio',
attrs,
children: [],
src
})
}).catch(() => { })
}
/**
* @description 在光标处插入一段文本
*/
vm.insertText = function () {
insert({
name: 'p',
attrs: {},
children: [{
type: 'text',
text: ''
}]
})
}
/**
* @description 清空内容
*/
vm.clear = function () {
vm._maskTap()
vm._edit = undefined
vm.$set(vm, 'nodes', [{
name: 'p',
attrs: {},
children: [{
type: 'text',
text: ''
}]
}])
}
/**
* @description 获取编辑后的 html
*/
vm.getContent = function () {
let html = '';
// 递归遍历获取
(function traversal (nodes, table) {
for (let i = 0; i < nodes.length; i++) {
let item = nodes[i]
if (item.type === 'text') {
html += item.text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>').replace(/\xa0/g, '&nbsp;') // 编码实体
} else {
if (item.name === 'img') {
item.attrs.i = ''
// 还原被转换的 svg
if ((item.attrs.src || '').includes('data:image/svg+xml;utf8,')) {
html += item.attrs.src.substr(24).replace(/%23/g, '#').replace('<svg', '<svg style="' + (item.attrs.style || '') + '"')
continue
}
} else if (item.name === 'video' || item.name === 'audio') {
// 还原 video 和 audio 的 source
item = JSON.parse(JSON.stringify(item))
if (item.src.length > 1) {
item.children = []
for (let j = 0; j < item.src.length; j++) {
item.children.push({
name: 'source',
attrs: {
src: item.src[j]
}
})
}
} else {
item.attrs.src = item.src[0]
}
} else if (item.name === 'div' && (item.attrs.style || '').includes('overflow:auto') && (item.children[0] || {}).name === 'table') {
// 还原滚动层
item = item.children[0]
}
// 还原 table
if (item.name === 'table') {
item = JSON.parse(JSON.stringify(item))
table = item.attrs
if ((item.attrs.style || '').includes('display:grid')) {
item.attrs.style = item.attrs.style.split('display:grid')[0]
const children = [{
name: 'tr',
attrs: {},
children: []
}]
for (let j = 0; j < item.children.length; j++) {
item.children[j].attrs.style = item.children[j].attrs.style.replace(/grid-[^;]+;*/g, '')
if (item.children[j].r !== children.length) {
children.push({
name: 'tr',
attrs: {},
children: [item.children[j]]
})
} else {
children[children.length - 1].children.push(item.children[j])
}
}
item.children = children
}
}
html += '<' + item.name
for (const attr in item.attrs) {
let val = item.attrs[attr]
if (!val) continue
if (val === 'T' || val === true) {
// bool 型省略值
html += ' ' + attr
continue
} else if (item.name[0] === 't' && attr === 'style' && table) {
// 取消为了显示 table 添加的 style
val = val.replace(/;*display:table[^;]*/, '')
if (table.border) {
val = val.replace(/border[^;]+;*/g, $ => $.includes('collapse') ? $ : '')
}
if (table.cellpadding) {
val = val.replace(/padding[^;]+;*/g, '')
}
if (!val) continue
}
html += ' ' + attr + '="' + val.replace(/"/g, '&quot;') + '"'
}
html += '>'
if (item.children) {
traversal(item.children, table)
html += '</' + item.name + '>'
}
}
}
})(vm.nodes)
// 其他插件处理
for (let i = vm.plugins.length; i--;) {
if (vm.plugins[i].onGetContent) {
html = vm.plugins[i].onGetContent(html) || html
}
}
return html
}
}
Editable.prototype.onUpdate = function (content, config) {
if (this.vm.editable) {
this.vm._maskTap()
config.entities.amp = '&'
if (!this.inserting) {
this.vm._edit = undefined
if (!content) {
setTimeout(() => {
this.vm.$set(this.vm, 'nodes', [{
name: 'p',
attrs: {},
children: [{
type: 'text',
text: ''
}]
}])
}, 0)
}
}
}
}
Editable.prototype.onParse = function (node) {
// 空白单元格可编辑
if (this.vm.editable && (node.name === 'td' || node.name === 'th') && !this.vm.getText(node.children)) {
node.children.push({
type: 'text',
text: ''
})
}
}
module.exports = Editable

View File

@@ -0,0 +1,15 @@
# emoji
功能:解析 *emoji*
大小:*≈3KB*
支持平台:
| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
|:---:|:---:|:---:|:---:|:---:|:---:|
| √ | √ | √ | √ | √ | √ |
说明:
将形如 *[笑脸]* 的文本替换为 *emoji* 字符 😄
匹配模式可以通过修改 *reg* 变量实现
默认配置了 *177* 个常用的 *emoji* 小表情,可以自行按照需要修改 *data* 变量
?> 与 *editable* 插件共用时,导出编辑好的 *html* 内容,会将 *emoji* 字符编码为文本形式,便于存储

View File

@@ -0,0 +1,203 @@
/**
* @fileoverview emoji 插件
*/
const reg = /\[(\S+?)\]/g
const data = {
笑脸: '😄',
生病: '😷',
破涕为笑: '😂',
吐舌: '😝',
脸红: '😳',
恐惧: '😱',
失望: '😔',
无语: '😒',
眨眼: '😉',
: '😎',
: '😭',
痴迷: '😍',
: '😘',
思考: '🤔',
困惑: '😕',
颠倒: '🙃',
: '🤑',
惊讶: '😲',
白眼: '🙄',
叹气: '😤',
睡觉: '😴',
书呆子: '🤓',
愤怒: '😡',
面无表情: '😑',
张嘴: '😮',
量体温: '🤒',
呕吐: '🤮',
光环: '😇',
幽灵: '👻',
外星人: '👽',
机器人: '🤖',
捂眼镜: '🙈',
捂耳朵: '🙉',
捂嘴: '🙊',
婴儿: '👶',
男孩: '👦',
女孩: '👧',
男人: '👨',
女人: '👩',
老人: '👴',
老妇人: '👵',
警察: '👮',
王子: '🤴',
公主: '🤴',
举手: '🙋',
跑步: '🏃',
家庭: '👪',
眼睛: '👀',
鼻子: '👃',
耳朵: '👂',
舌头: '👅',
: '👄',
: '❤️',
心碎: '💔',
雪人: '☃️',
情书: '💌',
大便: '💩',
闹钟: '⏰',
眼镜: '👓',
雨伞: '☂️',
音乐: '🎵',
话筒: '🎤',
游戏机: '🎮',
喇叭: '📢',
耳机: '🎧',
礼物: '🎁',
电话: '📞',
电脑: '💻',
打印机: '🖨️',
手电筒: '🔦',
灯泡: '💡',
书本: '📖',
信封: '✉️',
药丸: '💊',
口红: '💄',
手机: '📱',
相机: '📷',
电视: '📺',
: '🀄',
垃圾桶: '🚮',
厕所: '🚾',
感叹号: '❗',
: '🈲',
: '🉑',
彩虹: '🌈',
旋风: '🌀',
雷电: '⚡',
雪花: '❄️',
星星: '⭐',
水滴: '💧',
玫瑰: '🌹',
加油: '💪',
: '👈',
: '👉',
: '👆',
: '👇',
手掌: '🖐️',
好的: '👌',
: '👍',
: '👎',
胜利: '✌',
拳头: '👊',
挥手: '👋',
鼓掌: '👏',
猴子: '🐒',
: '🐶',
: '🐺',
: '🐱',
老虎: '🐯',
: '🐎',
独角兽: '🦄',
斑马: '🦓',
鹿: '🦌',
: '🐮',
: '🐷',
: '🐏',
长颈鹿: '🦒',
大象: '🐘',
老鼠: '🐭',
蝙蝠: '🦇',
刺猬: '🦔',
熊猫: '🐼',
鸽子: '🕊️',
鸭子: '🦆',
兔子: '🐇',
老鹰: '🦅',
青蛙: '🐸',
: '🐍',
: '🐉',
鲸鱼: '🐳',
海豚: '🐬',
足球: '⚽',
棒球: '⚾',
篮球: '🏀',
排球: '🏐',
橄榄球: '🏉',
网球: '🎾',
骰子: '🎲',
鸡腿: '🍗',
蛋糕: '🎂',
啤酒: '🍺',
饺子: '🥟',
汉堡: '🍔',
薯条: '🍟',
意大利面: '🍝',
干杯: '🥂',
筷子: '🥢',
糖果: '🍬',
奶瓶: '🍼',
爆米花: '🍿',
邮局: '🏤',
医院: '🏥',
银行: '🏦',
酒店: '🏨',
学校: '🏫',
城堡: '🏰',
火车: '🚂',
高铁: '🚄',
地铁: '🚇',
公交: '🚌',
救护车: '🚑',
消防车: '🚒',
警车: '🚓',
出租车: '🚕',
汽车: '🚗',
货车: '🚛',
自行车: '🚲',
摩托: '🛵',
红绿灯: '🚥',
帆船: '⛵',
游轮: '🛳️',
轮船: '⛴️',
飞机: '✈️',
直升机: '🚁',
缆车: '🚠',
警告: '⚠️',
禁止: '⛔'
}
function Emoji () {
}
Emoji.prototype.onUpdate = function (content) {
return content.replace(reg, ($, $1) => {
if (data[$1]) return data[$1]
return $
})
}
Emoji.prototype.onGetContent = function (content) {
for (const item in data) {
content = content.replace(new RegExp(data[item], 'g'), '[' + item + ']')
}
return content
}
module.exports = Emoji

View File

@@ -0,0 +1,26 @@
# highlight
功能:代码块高亮显示
支持平台:
| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
|:---:|:---:|:---:|:---:|:---:|:---:|
| √ | √ | √ | √ | √ | √ |
说明:
大小:*≈16KB*
编辑 *plugins/highlight/config.js* 顶部的选项,可以选择是否需要以下功能:
- *copyByLongPress* 是否需要长按代码块时显示复制代码内容菜单(*uni-app nvue* 暂不支持)
- *showLanguageName* 是否在代码块右上角显示语言的名称
- *showLineNumber* 是否在左侧显示行号
> 修改该配置后需要重新生成组件包,在构建后的组件包中修改配置无法生效
引入本插件后,*html* 中符合以下格式的 *pre* 将被高亮处理:
```html
<!-- pre 中内含一个 code并在 pre 或 code 的 class 中设置 language- -->
<pre><code class="language-css">p { color: red }</code></pre>
```
> 与 *editable* 插件共用时,编辑状态下,不会进行高亮,可以直接修改代码文本
> 本插件的高亮功能依赖于 [prismjs](https://prismjs.com/),默认配置中仅支持 *html*、*css*、*c-like*、*javascript* 语言和 *Tomorrow Night* 主题,如果需要更多语言或更换主题请前往 [官网](https://prismjs.com/download.html) 下载对应的 *prism.min.js* 和 *prism.css* 并替换 *plugins/highlight/* 目录下的文件

View File

@@ -0,0 +1,5 @@
module.exports = {
copyByLongPress: false, // 是否需要长按代码块时显示复制代码内容菜单
showLanguageName: false, // 是否在代码块右上角显示语言的名称
showLineNumber: false // 是否显示行号
}

View File

@@ -0,0 +1,96 @@
/**
* @fileoverview highlight 插件
* Include prismjs (https://prismjs.com)
*/
const prism = require('./prism.min')
const config = require('./config')
const Parser = require('../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'
if (config.showLanguageName) {
node.children.push({
name: 'div',
attrs: {
class: 'hl-language',
style: 'user-select:none'
},
children: [{
type: 'text',
text: lang
}]
})
}
if (config.copyByLongPress) {
node.attrs.style += (node.attrs.style || '') + ';user-select:none'
node.attrs['data-content'] = 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
})
}
}
}
}
module.exports = Highlight

View File

@@ -0,0 +1,88 @@
const config = require('../config')
const build = {
import: 'prism.css',
handler (file) {
if (file.path.includes('prism.css')) {
// 将标签名选择器和属性选择器转为 class 选择器(组件内仅支持 class 选择器)
file.contents = Buffer.from(file.contents.toString().replace(/pre([[)])/g, '.hl-pre$1').replace(/code/g, '.hl-code').replace(/\[class\*="?language-"?\]/g, '').replace(/:not[^,}]+[,}]*/g, '').replace(/\.token\./g, '.hl-'))
}
}
}
if (config.showLanguageName || config.showLineNumber) {
// pre 内部的 code 进行滚动,避免行号和语言名称跟随滚动
build.style = `.hl-pre {
position: relative;
}
.hl-code {
overflow: auto;
display: block;
}`
}
if (config.copyByLongPress) {
build.template = '<rich-text wx:if="{{n.attrs[\'data-content\']}}" nodes="{{[n]}}" data-content="{{n.attrs[\'data-content\']}}" data-lang="{{n.attrs[\'data-lang\']}}" bindlongpress="copyCode" />'
build.methods = {
copyCode (e) {
wx.showActionSheet({
itemList: ['复制代码'],
success: () =>
wx.setClipboardData({
data: e.currentTarget.dataset.content
})
})
}
}
}
if (config.showLanguageName) {
build.style = (build.style || '') +
`.hl-language {
font-size: 12px;
font-weight: 600;
position: absolute;
right: 8px;
text-align: right;
top: 3px;
}
.hl-pre {
padding-top: 1.5em;
}`
}
if (config.showLineNumber) {
build.style = (build.style || '') +
`.hl-pre {
font-size: 14px;
padding-left: 3.8em;
counter-reset: linenumber;
}
.line-numbers-rows {
position: absolute;
pointer-events: none;
top: ${config.showLanguageName ? 1.5 : 1}em;
font-size: 100%;
left: 0;
width: 3em; /* works for line-numbers below 1000 lines */
letter-spacing: -1px;
border-right: 1px solid #999;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.line-numbers-rows .span {
display: block;
counter-increment: linenumber;
}
.line-numbers-rows .span:before {
content: counter(linenumber);
color: #999;
display: block;
padding-right: 0.8em;
text-align: right;
}`
}
module.exports = build

View File

@@ -0,0 +1,125 @@
/* PrismJS 1.22.0
https://prismjs.com/download.html#themes=prism-tomorrow&languages=markup+css+clike+javascript */
/**
* prism.js tomorrow night eighties for JavaScript, CoffeeScript, CSS and HTML
* Based on https://github.com/chriskempson/tomorrow-theme
* @author Rose Pritchard
*/
code[class*="language-"],
pre[class*="language-"] {
color: #ccc;
background: none;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: .5em 0;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #2d2d2d;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: .1em;
border-radius: .3em;
white-space: normal;
}
.token.comment,
.token.block-comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #999;
}
.token.punctuation {
color: #ccc;
}
.token.tag,
.token.attr-name,
.token.namespace,
.token.deleted {
color: #e2777a;
}
.token.function-name {
color: #6196cc;
}
.token.boolean,
.token.number,
.token.function {
color: #f08d49;
}
.token.property,
.token.class-name,
.token.constant,
.token.symbol {
color: #f8c555;
}
.token.selector,
.token.important,
.token.atrule,
.token.keyword,
.token.builtin {
color: #cc99cd;
}
.token.string,
.token.char,
.token.attr-value,
.token.regex,
.token.variable {
color: #7ec699;
}
.token.operator,
.token.entity,
.token.url {
color: #67cdcc;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.token.inserted {
color: green;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,88 @@
const config = require('../config')
const build = {
import: 'prism.css',
handler (file) {
if (file.path.includes('prism.css')) {
// 将标签名选择器和属性选择器转为 class 选择器(组件内仅支持 class 选择器)
file.contents = Buffer.from(file.contents.toString().replace(/pre([[)])/g, '.hl-pre$1').replace(/code/g, '.hl-code').replace(/\[class\*="?language-"?\]/g, '').replace(/:not[^,}]+[,}]*/g, '').replace(/\.token\./g, '.hl-'))
}
}
}
if (config.showLanguageName || config.showLineNumber) {
// pre 内部的 code 进行滚动,避免行号和语言名称跟随滚动
build.style = `.hl-pre {
position: relative;
}
.hl-code {
overflow: auto;
display: block;
}`
}
if (config.copyByLongPress) {
build.template = '<rich-text v-if="n.attrs&&n.attrs[\'data-content\']" :nodes="[n]" :data-content="n.attrs[\'data-content\']" :data-lang="n.attrs[\'data-lang\']" @longpress="copyCode" />'
build.methods = {
copyCode (e) {
uni.showActionSheet({
itemList: ['复制代码'],
success: () =>
uni.setClipboardData({
data: e.currentTarget.dataset.content
})
})
}
}
}
if (config.showLanguageName) {
build.style = (build.style || '') +
`.hl-language {
font-size: 12px;
font-weight: 600;
position: absolute;
right: 8px;
text-align: right;
top: 3px;
}
.hl-pre {
padding-top: 1.5em;
}`
}
if (config.showLineNumber) {
build.style = (build.style || '') +
`.hl-pre {
font-size: 14px;
padding-left: 3.8em;
counter-reset: linenumber;
}
.line-numbers-rows {
position: absolute;
pointer-events: none;
top: ${config.showLanguageName ? 1.5 : 1}em;
font-size: 100%;
left: 0;
width: 3em; /* works for line-numbers below 1000 lines */
letter-spacing: -1px;
border-right: 1px solid #999;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.line-numbers-rows .span {
display: block;
counter-increment: linenumber;
}
.line-numbers-rows .span:before {
content: counter(linenumber);
color: #999;
display: block;
padding-right: 0.8em;
text-align: right;
}`
}
module.exports = build

View File

@@ -0,0 +1,24 @@
功能:图片本地缓存
大小:*≈4KB*
作者:[@PentaTea](https://github.com/PentaTea)
支持平台:
| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
|:---:|:---:|:---:|:---:|:---:|:---:|
| | | | | | √(仅支持 app 的 vue 页面) |
说明:
引入本插件后,会给组件添加一个 *img-cache* 属性,将该属性设置为 *true* 后,将自动下载引用的图片并将 *src* 属性更换为本地地址
同时在组件实例上挂载了 *imgCache* 对象,扩充缓存控制能力
*imgCache* 对象属性和方法:
| 属性 | 功能 |
|:---:|:---:|
| list | 当前缓存的 url 列表 |
| get(url) | 传入 url 获得本地地址 |
| delete(url) | 传入 url 删除缓存记录 |
| add(url) | 传入 url 并下载目标为缓存 |
| clear() | 清空所有缓存 |
!> 请尽量确保 *src* 中含有文件后缀名,不以后缀结尾也没关系,插件会从路径中推测合理的图片后缀,如果完全不包含后缀信息可能会无法保存到相册

View File

@@ -0,0 +1,16 @@
module.exports = {
main: 'index.js',
platform: ['uni-app'],
handler (file, platform) {
if (platform === 'uni-app') {
// 添加 img-cache 属性
if (file.path.includes('mp-html.vue')) {
file.contents = Buffer.from(
file.contents
.toString()
.replace(/props\s*:\s*{/, 'props: {\n ImgCache: Boolean,')
)
}
}
}
}

View File

@@ -0,0 +1,138 @@
const data = {
name: 'imgcache',
prefix: 'imgcache_'
}
function ImgCache (vm) {
this.vm = vm // 保存实例在其他周期使用
this.i = 0 // 用于标记第几张图
vm.imgCache = {
get list () {
return uni
.getStorageInfoSync()
.keys.filter((key) => key.startsWith(data.prefix))
.map((key) => key.split(data.prefix)[1])
},
get (url) {
return uni.getStorageSync(data.prefix + url)
},
delete (url) {
const path = uni.getStorageSync(data.prefix + url)
if (!path) return false
plus.io.resolveLocalFileSystemURL(path, (entry) => {
entry.remove()
})
uni.removeStorageSync(data.prefix + url)
return true
},
async add (url) {
const filename = await download(url)
if (filename) {
uni.setStorageSync(data.prefix + url, filename)
return 'file://' + plus.io.convertLocalFileSystemURL(filename)
}
return null
},
clear () {
uni
.getStorageInfoSync()
.keys.filter((key) => key.startsWith(data.prefix))
.forEach((key) => {
uni.removeStorageSync(key)
})
plus.io.resolveLocalFileSystemURL(`_doc/${data.name}/`, (entry) => {
entry.removeRecursively(
(entry) => {
console.log(`${data.name}缓存删除成功`, entry)
},
(e) => {
console.log(`${data.name}缓存删除失败`, e)
}
)
})
}
}
}
// #ifdef APP-PLUS
ImgCache.prototype.onParse = function (node, parser) {
// 启用本插件 && 解析图片标签 && 拥有src属性 && 是网络图片
if (
this.vm.ImgCache &&
node.name === 'img' &&
node.attrs.src &&
/^https?:\/\//.test(node.attrs.src)
) {
const src = node.attrs.src
node.attrs.src = ''
node.attrs.i = this.vm.imgList.length + this.i++
parser.expose()
async function getUrl (path) {
if (await resolveFile(path)) return path
const filename = await download(src)
filename && uni.setStorageSync(data.prefix + src, filename)
return filename
}
uni.getStorage({
key: data.prefix + src,
success: async (res) => {
const path = await getUrl(res.data)
const url = path
? 'file://' + plus.io.convertLocalFileSystemURL(path)
: src
node.attrs.src = url
this.vm.imgList[node.attrs.i] = path || src
},
fail: async () => {
const path = await getUrl()
const url = path
? 'file://' + plus.io.convertLocalFileSystemURL(path)
: src
node.attrs.src = url
this.vm.imgList[node.attrs.i] = path || src
}
})
}
}
const taskQueue = new Set()
function download (url) {
return new Promise((resolve) => {
if (taskQueue.has(url)) return
taskQueue.add(url)
const suffix = /.+\.(jpg|jpeg|png|bmp|gif|webp)/.exec(url)
const name = `${makeid(8)}_${Date.now()}${suffix ? '.' + suffix[1] : ''}`
const task = plus.downloader.createDownload(
url,
{ filename: `_doc/${data.name}/${name}` },
(download, status) => {
taskQueue.delete(url)
resolve(status === 200 ? download.filename : null)
}
)
task.start()
})
}
// 判断文件存在
function resolveFile (url) {
return new Promise((resolve) => {
plus.io.resolveLocalFileSystemURL(url, resolve, () => resolve(null))
})
}
// 生成uuid
function makeid (length) {
let result = ''
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length))
}
return result
}
// #endif
module.exports = ImgCache

View File

@@ -0,0 +1,16 @@
# latex
功能:渲染 *latex* 公式
大小:**≈300KB**
作者:[@Zeng-J](https://github.com/Zeng-J)
支持平台:
| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
|:---:|:---:|:---:|:---:|:---:|:---:|
| √ | √ | √ | √ | √ | √ |
说明:
引入本插件后,会将 *$xxx$* 的文本内容按照 *latex* 规则进行解析和渲染
> 与 *editable* 插件共用时,编辑状态下,公式不会渲染,可以直接修改公式文本
> 本插件通过 [katex-mini](https://github.com/rojer95/katex-mini) 解析 *latex* 文本,[字体文件](https://github.com/KaTeX/KaTeX/tree/main/fonts) 建议自行转存

View File

@@ -0,0 +1,14 @@
module.exports = {
import: 'katex.css',
handler (file) {
if (file.isBuffer()) {
let content = file.contents.toString()
if (file.basename === 'node.wxml') {
content = content.replace(/(n.?)\.name==='a'\|\|/g, "$1.name==='a'||$1.l||")
} else if (file.basename === 'node.vue') {
content = content.replace(/!handler.isInline\((.*?)\)/, '(n.l||!handler.isInline($1))')
}
file.contents = Buffer.from(content)
}
}
}

View File

@@ -0,0 +1,80 @@
/**
* @fileoverview latex 插件
* katex.min.js来源 https://github.com/rojer95/katex-mini
*/
const parse = require('./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
}
}
module.exports = Latex

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,17 @@
# markdown
功能:渲染 *markdown*
大小:*≈37KB*
支持平台:
| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
|:---:|:---:|:---:|:---:|:---:|:---:|
| √ | √ | √ | √ | √ | √ |
说明:
引入本插件后,会给组件添加一个 *markdown* 属性,将该属性设置为 *true* 后,即可通过 *content* 属性或 *setContent* 方法设置 *markdown* 内容即可
若开启 *use-anchor* 属性,所有标题 `*# xxx*` 都会被设置为锚点,通过链接 `[xxx](#xxx)` 可以直接跳转
> 本插件通过 [marked](https://github.com/markedjs/marked) 解析 *markdown* 文本,部分 *css* 摘选自 [github-markdown-css](https://github.com/sindresorhus/github-markdown-css)
> 本插件可以和 *highlight* 插件共用,实现 *markdown* 中代码块的高亮效果

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)
*/
const marked = require('./marked.min')
let index = 0
function Markdown (vm) {
this.vm = vm
vm._ids = {}
}
Markdown.prototype.onUpdate = function (content) {
if (this.vm.properties.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 || ''}`
}
}
}
module.exports = Markdown

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,70 @@
const path = require('path')
module.exports = {
style:
`.md-p {
margin-block-start: 1em;
margin-block-end: 1em;
}
.md-table,
.md-blockquote {
margin-bottom: 16px;
}
.md-table {
box-sizing: border-box;
width: 100%;
overflow: auto;
border-spacing: 0;
border-collapse: collapse;
}
.md-tr {
background-color: #fff;
border-top: 1px solid #c6cbd1;
}
.md-table .md-tr:nth-child(2n) {
background-color: #f6f8fa;
}
.md-th,
.md-td {
padding: 6px 13px !important;
border: 1px solid #dfe2e5;
}
.md-th {
font-weight: 600;
}
.md-blockquote {
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
}
.md-code {
padding: 0.2em 0.4em;
font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace;
font-size: 85%;
background-color: rgba(27, 31, 35, 0.05);
border-radius: 3px;
}
.md-pre .md-code {
padding: 0;
font-size: 100%;
background: transparent;
border: 0;
}`,
handler (file) {
// 添加 markdown 属性
if (file.path.includes('miniprogram' + path.sep + 'index.js')) {
file.contents = Buffer.from(file.contents.toString().replace(/properties\s*:\s*{/, 'properties: {\n markdown: Boolean,')
// 处理中文 id
.replace(/navigateTo\s*\(id,\s*offset\)\s*{/, 'navigateTo (id, offset) {\n id = this._ids[decodeURI(id)] || id'))
}
}
}

View File

@@ -0,0 +1,68 @@
module.exports = {
style:
`.md-p {
margin-block-start: 1em;
margin-block-end: 1em;
}
.md-table,
.md-blockquote {
margin-bottom: 16px;
}
.md-table {
box-sizing: border-box;
width: 100%;
overflow: auto;
border-spacing: 0;
border-collapse: collapse;
}
.md-tr {
background-color: #fff;
border-top: 1px solid #c6cbd1;
}
.md-table .md-tr:nth-child(2n) {
background-color: #f6f8fa;
}
.md-th,
.md-td {
padding: 6px 13px !important;
border: 1px solid #dfe2e5;
}
.md-th {
font-weight: 600;
}
.md-blockquote {
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
}
.md-code {
padding: 0.2em 0.4em;
font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace;
font-size: 85%;
background-color: rgba(27, 31, 35, 0.05);
border-radius: 3px;
}
.md-pre .md-code {
padding: 0;
font-size: 100%;
background: transparent;
border: 0;
}`,
handler (file) {
// 添加 markdown 属性
if (file.path.includes('mp-html.vue')) {
file.contents = Buffer.from(file.contents.toString().replace(/props\s*:\s*{/, 'props: {\n markdown: Boolean,')
// 处理中文 id
.replace(/navigateTo\s*\(id,\s*offset\)\s*{/, 'navigateTo (id, offset) {\n id = this._ids[decodeURI(id)] || id'))
}
}
}

View File

@@ -0,0 +1,46 @@
# search
功能:关键词搜索
大小:*≈1.5KB*
支持平台:
| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
|:---:|:---:|:---:|:---:|:---:|:---:|
| √ | √ | √ | √ | √ | √(nvue 不支持) |
说明:
引入后会在组件实例上挂载一个 *search* 方法,用于关键词搜索
输入值
| 参数名 | 类型 | 默认值 | 说明 |
|:---:|:---:|:---:|---|
| key | String 或 RegExp | - | 要搜索的关键词,支持字符串和正则 |
| anchor | Boolean | false | 是否将搜索结果设置为锚点 |
| style | String | background-color:yellow | 标记搜索结果的样式 |
返回值:*Promise*
| 属性 | 类型 | 说明 |
|:---:|:---:|---|
| num | Number | 搜索结果数量 |
| highlight | Function(i, style='background-color:#FF9632') | 高亮第 i1 ~ num个结果将其样式设置为 style |
| jump | Function(i, offset) | 跳转到第 i1 ~ num个结果偏移量为 offsetanchor 为 true 才可用 |
示例:
```javascript
function search (key) {
// ctx 为组件实例
ctx.search(key, true).then(res => {
res.highlight(1)
res.jump(1, -50) // 高亮第 1 个结果并跳转到该位置,偏移量 -50
})
}
```
附加说明:
1. 不传入 *key*(或为空)时即可取消搜索,取消所有的高亮,还原到原来的效果
2. 进行新的搜索时旧的搜索结果将被还原,旧的结果中的 *highlight* 等方法不再可用
3. 调用 *highlight* 方法高亮一个结果时,之前被高亮的结果会被还原,即始终只有一个结果被高亮
4. *key* 传入字符串时大小写敏感,如果要忽略大小写可以用正则的 *i*(字符串搜索效率高于正则)
5. 设置 *anchor**true* 会一定程度上降低效率,非必要不要开启
6. 暂不支持跨标签搜索,即只有一个文本节点内包含整个关键词才能被搜索到

View File

@@ -0,0 +1,137 @@
/**
* @fileoverview search 插件
*/
function Search (vm) {
/**
* @description 关键词搜索
* @param {regexp|string} key 要搜索的关键词
* @param {boolean} anchor 是否将搜索结果设置为锚点
* @param {string} style 搜索结果的样式
*/
vm.search = function (key, anchor, style = 'background-color:yellow') {
const obj = {}
const stack = []
const res = [];
// 遍历搜索
(function traversal (nodes, path) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
if (node.type === 'text' && key) {
const arr = node.text.split(key)
const children = []
if (arr.length > 1) {
// 找到关键词
for (let j = 0; j < arr.length; j++) {
if (arr[j]) {
children.push({
type: 'text',
text: arr[j]
})
}
if (j !== arr.length - 1) {
// 关键词转为一个 span
res.push(`${path}[${i}].children[${children.length}].attrs.style`)
children.push({
name: 'span',
attrs: {
id: anchor ? 'search' + res.length : undefined, // 用于锚点的 id
style
},
children: [{
type: 'text',
text: key instanceof RegExp ? key.exec(node.text)[0] : key
}]
})
}
}
if (key instanceof RegExp) {
key.exec(node.text)
}
if (anchor) {
for (let l = stack.length; l--;) {
// 给父组件做标记,将该标签暴露出来
if (stack[l].c) {
break
} else {
obj[stack[l].path] = 1
}
}
}
obj[`${path}[${i}]`] = {
name: 'span',
c: anchor ? 1 : undefined,
s: 1,
children
}
}
} else if (node.s) {
let text = ''
// 复原上一次的结果
for (let k = 0; k < node.children.length; k++) {
const child = node.children[k]
if (child.text) {
text += child.text
} else {
text += child.children[0].text
}
}
nodes[i] = {
type: 'text',
text
}
if (key && (key instanceof RegExp ? key.test(text) : text.includes(key))) {
i--
} else {
obj[`${path}[${i}]`] = nodes[i]
}
} else if (node.children) {
stack.push({
path: `${path}[${i}].c`,
c: node.c || node.name === 'table'
})
traversal(node.children, `${path}[${i}].children`)
stack.pop()
}
}
})(vm.data.nodes, 'nodes')
return new Promise(function (resolve) {
vm.setData(obj, () => {
resolve({
num: res.length, // 结果数量
/**
* @description 高亮某一个结果
* @param {number} i 第几个
* @param {string} hlstyle 高亮的样式
*/
highlight (i, hlstyle = 'background-color:#FF9632') {
if (i < 1 || i > res.length) return
const obj = {}
if (this.last) {
obj[res[this.last - 1]] = style
}
this.last = i
obj[res[i - 1]] = hlstyle
vm.setData(obj)
},
/**
* @description 跳转到搜索结果
* @param {number} i 第几个
* @param {number} offset 偏移量
*/
jump: anchor
? (i, offset) => {
if (i > 0 && i <= res.length) {
vm.navigateTo('search' + i, offset)
}
}
: undefined
})
})
})
}
}
module.exports = Search

View File

@@ -0,0 +1,132 @@
/**
* @fileoverview search 插件
*/
function Search (vm) {
/**
* @description 关键词搜索
* @param {regexp|string} key 要搜索的关键词
* @param {boolean} anchor 是否将搜索结果设置为锚点
* @param {string} style 搜索结果的样式
*/
vm.search = function (key, anchor, style = 'background-color:yellow') {
const res = []
const stack = [];
// 遍历搜索
(function traversal (nodes) {
for (let i = 0; i < nodes.length; i++) {
let node = nodes[i]
if (node.type === 'text' && key) {
const text = node.text
const arr = text.split(key)
if (arr.length > 1) {
node = {
name: 'span',
attrs: {},
type: 'node',
c: 1,
s: 1,
children: []
}
vm.$set(nodes, i, node)
for (let j = 0; j < arr.length; j++) {
if (arr[j]) {
node.children.push({
type: 'text',
text: arr[j]
})
}
if (j !== arr.length - 1) {
// 关键词转为一个 span
node.children.push({
name: 'span',
attrs: {
id: anchor ? 'search' + (res.length + 1) : undefined, // 用于锚点的 id
style: style
},
// #ifdef VUE3
c: 1,
// #endif
children: [{
type: 'text',
text: key instanceof RegExp ? key.exec(text)[0] : key
}]
})
res.push(node.children[node.children.length - 1].attrs)
}
}
if (key instanceof RegExp) {
key.exec(text)
}
if (anchor) {
for (let l = stack.length; l--;) {
if (stack[l].c) {
break
} else {
vm.$set(stack[l], 'c', 1)
}
}
}
}
} else if (node.s) {
let text = ''
// 复原上一次的结果
for (let k = 0; k < node.children.length; k++) {
const child = node.children[k]
if (child.text) {
text += child.text
} else {
text += child.children[0].text
}
}
vm.$set(nodes, i, {
type: 'text',
text
})
if (key && (key instanceof RegExp ? key.test(text) : text.includes(key))) {
i--
}
} else if (node.children) {
stack.push(node)
traversal(node.children)
stack.pop()
}
}
})(vm.nodes)
return new Promise(function (resolve) {
setTimeout(() => {
resolve({
num: res.length, // 结果数量
/**
* @description 高亮某一个结果
* @param {number} i 第几个
* @param {string} hlstyle 高亮的样式
*/
highlight (i, hlstyle = 'background-color:#FF9632') {
if (i < 1 || i > res.length) return
if (this.last) {
res[this.last - 1].style = style
}
this.last = i
res[i - 1].style = hlstyle
},
/**
* @description 跳转到搜索结果
* @param {number} i 第几个
* @param {number} offset 偏移量
*/
jump: anchor
? (i, offset) => {
if (i > 0 && i <= res.length) {
vm.navigateTo('search' + i, offset)
}
}
: undefined
})
}, 200)
})
}
}
module.exports = Search

View File

@@ -0,0 +1,30 @@
# style
功能:解析和匹配 *style* 标签中的样式
> 这里的 *style* 标签指的是传入 *content* 属性中的 *html* 里包含的 *style* 标签,且 *style* 标签要放在其他标签前面才能生效
大小:*≈3.5KB*
支持平台:
| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
|:---:|:---:|:---:|:---:|:---:|:---:|
| √ | √ | √ | √ | √ | √ (nvue 直接支持) |
说明:
支持以下选择器:
| 名称 | 示例 |
|:---:|---|
| 标签名选择器 | p {} |
| class 选择器 | .class {} |
| id 选择器 | #id {} |
| 多选择器交集 | p.class {} |
| 多选择器并集 | p, .class {} |
| 后代选择器 | .class1 .class2 {} |
| 子选择器 | .class1 > .class2 {} |
| 伪类 | .class::before {} |
伪类仅支持 *before**after*,支持 *attr* 方法
不支持的选择器(属性选择器等)将被忽略
> 由于小程序中无法动态写入 *css*,本插件的实现原理是通过解析,将匹配的样式添加到各标签的行内 *style* 中去,请慎用宽泛的选择器,以免大大增加解析结果大小,减慢渲染速度

View File

@@ -0,0 +1,129 @@
/**
* @fileoverview style 插件
*/
// #ifndef APP-PLUS-NVUE
const Parser = require('./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
module.exports = 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
}
module.exports = Parser

View File

@@ -0,0 +1,2 @@
# 模板
这是一个插件模板,需要开发插件时,可以从这个模板开始

View File

@@ -0,0 +1,65 @@
/**
* @description 插件构建文件模板
*/
module.exports = {
/**
* @description 入口文件
* @type {String}
* @default 'index.js'
*/
main: 'index.js',
/**
* @description 支持的平台
* @type {String[]}
* @default ['mp-weixin','mp-qq','mp-baidu','mp-alipay','mp-toutiao','uni-app']
*/
platform: ['mp-weixin', 'mp-qq', 'mp-baidu', 'mp-alipay', 'mp-toutiao', 'uni-app'],
/**
* @description 要被添加到模板文件中的标签(将被添加到 src/node/node.wxml
* 必须要有 wx:if 表明什么情况下使用该标签
* n 表示标签结构体,<node> 标签用于递归显示子节点(可参考源文件中的写法)
* @type {String}
*/
template: '',
/**
* @description 用于处理模板中事件的方法(将被添加到 src/node/node.js
* 需要触发顶层组件的事件请使用 this.root.triggerEvent
* @type {Object}
*/
methods: {
},
/**
* @description 用于模板文件的 css 样式(将被添加到 src/node/node.wxss
* @type {String}
*/
style: '',
/**
* @description 要被引入到模板文件的 css 文件路径(将被添加到 src/node/node.wxss
* @type {String|String[]}
*/
import: [],
/**
* @description 在模板中需要使用的组件或插件列表(将被添加到 src/node/node.json
* @type {Object}
*/
usingComponents: {
},
/**
* @description 自定义文件处理器
* 如果上述处理还无法满足要求,可以在此方法中进行处理
* 所有 src 目录下的文件和本插件目录下的文件都会经过此方法的处理
* @param {Vinyl} file 关于该文件对象的格式可参考 https://github.com/gulpjs/vinyl#instance-methods
* @param {String} platform 平台
*/
handler (file, platform) {
let content = file.contents.toString()
// 进行处理
if (platform === 'xxx') {
content = content.replace('aaa', 'bbb')
}
file.contents = Buffer.from(content)
}
}

View File

@@ -0,0 +1,67 @@
/**
* @fileoverview 插件入口文件模板
*/
const data = {} // 全局数据
/**
* @description 组件被创建时将实例化插件
* @param {Component} vm 组件实例
*/
function Plugin (vm) {
this.vm = vm // 保存实例在其他周期使用
this.compData = {} // 仅在单个组件中使用的数据
data.xxx = 'xxx' // 记录全局数据
}
/**
* @description html 数据更新时触发
* @param {string} content 要更新的 html 字符串
* @param {object} config 解析配置
* @returns {string|void} 如果要对 html 字符串进行一些预处理,则返回处理后的字符串
*/
Plugin.prototype.onUpdate = function (content, config) {
config.ignoreTags.xxx = true // 移除 xxx 标签
// 对 html 内容进行预处理并返回修改,没有修改则不需要返回
return content
}
/**
* @description 解析到一个标签时触发
* @param {object} node 标签
* @param {object} parser 解析器实例
* @returns {boolean|void} 如果返回 false 将移除该标签
*/
Plugin.prototype.onParse = function (node, parser) {
// 处理文本标签
if (node.type === 'text') {
// node.text 文本内容
} else {
// 处理元素标签
// node.name 标签名
// node.attrs 属性列表
// node.children 子节点(非自闭合标签有)
if (node.name === 'xxx') {
parser.expose() // 如果该标签不能被 rich-text 包含,需要调用此方法暴露出来
// parser.options 组件传入的一些解析属性
// parser.stack 可以从栈中获取祖先节点
}
}
}
/**
* @description dom 树加载完毕时触发load 事件)
*/
Plugin.prototype.onLoad = function () {
// 可以获取媒体 context 对象等
}
/**
* @description 组件被移除时触发
*/
Plugin.prototype.onDetached = function () {
// 可以释放一些必要的资源(计时器等)
}
module.exports = Plugin

View File

@@ -0,0 +1,18 @@
# txv-video
功能:使用腾讯视频
大小:*≈1KB*
支持平台:
| 微信小程序 | QQ 小程序 | 百度小程序 | 支付宝小程序 | 头条小程序 | uni-app |
|:---:|:---:|:---:|:---:|:---:|:---:|
| √ | √ | | | | √ (h5 和 app 直接支持) |
说明:
引入本插件后,*html* 中符合下方格式的 *iframe* 标签(*src* 中含有 *vid*)将被转为通过腾讯视频播放:
```html
<iframe src="https://v.qq.com/txp/iframe/player.html?vid=xxxxxx" allowFullScreen="true"></iframe>
```
同时,其可以被 *pause-video* 属性控制
!> 本插件仅用于将官方 [腾讯视频插件](https://github.com/tvfe/txv-miniprogram-plugin) 应用于本组件,仅在微信和 *qq* 平台有效,使用前请确认已经成功申请使用该插件并按要求在小程序 *app.json* 中配置完成,否则可能报错 **This application has not registered any plugins yet** 且无法生效

View File

@@ -0,0 +1,3 @@
module.exports = {
platform: ['mp-weixin', 'mp-qq', 'uni-app']
}

View File

@@ -0,0 +1,46 @@
/**
* @fileoverview txv-video 插件
* Include txv-video (https://github.com/tvfe/txv-miniprogram-plugin)
*/
const TxvVideo = function (vm) {
this.vm = vm
}
// #ifdef MP-WEIXIN || MP-QQ
try {
const TxvContext = requirePlugin('tencentvideo')
TxvVideo.prototype.onLoad = function () {
setTimeout(() => {
for (let i = 0; i < this.videos.length; i++) {
const ctx = TxvContext.getTxvContext(this.videos[i])
ctx.id = this.videos[i]
this.vm._videos.push(ctx)
}
}, 50)
}
} catch (e) {
console.error('使用txv-video扩展需注册腾讯视频插件')
}
TxvVideo.prototype.onUpdate = function (_, config) {
config.trustTags['txv-video'] = true
this.videos = []
}
TxvVideo.prototype.onParse = function (node, parser) {
if (node.name === 'iframe' && (node.attrs.src || '').includes('vid')) {
const vid = node.attrs.src.match(/vid=([^&\s]+)/)
if (vid) {
node.name = 'txv-video'
node.attrs.vid = vid[1]
this.videos.push(vid[1])
node.attrs.src = undefined
parser.expose()
}
}
}
// #endif
module.exports = TxvVideo

View File

@@ -0,0 +1,6 @@
module.exports = {
template: '<txv-video wx:if="{{n.name==\'txv-video\'}}" vid="{{n.attrs.vid}}" playerid="{{n.attrs.vid}}" id="{{n.attrs.vid}}" class="{{n.attrs.class}}" style="{{n.attrs.style}}" controls data-i="{{i}}" bindplay="play" binderror="mediaError" />',
usingComponents: {
'txv-video': 'plugin://tencentvideo/video'
}
}

View File

@@ -0,0 +1,3 @@
module.exports = {
template: '<txv-video v-if="n.name==\'txv-video\'" :vid="n.attrs.vid" :playerid="n.attrs.vid" :id="n.attrs.vid" :class="n.attrs.class" :style="n.attrs.style" controls :data-i="i" @play="play" @error="mediaError" />'
}