Files
t/uni_modules/sv-editor/components/sv-editor/sv-editor.vue
T
root 99c139d6ce fix: Vue2 到 Vue3 迁移兼容修复
- 修复 uni-popup-dialog/uni-data-picker keypress.js 中 $once 在 H5 端报错(改用生命周期钩子清理监听)
- main.js 补 $delete globalProperties polyfill(z-paging 自动高度逻辑依赖)
- App.vue onLaunch 补设备信息初始化,修复 X-Device-Id 恒为 unknown
- 68 处 ::v-deep、/deep/ 旧深度选择器替换为 :deep()(含 login-page.scss)
- zetank-personalpage 组件 .sync 修饰符改为 v-model:xxx(Vue3 已移除 .sync)
- vite.config.js HBuilderX 路径支持 HBUILDERX_HOME 环境变量覆盖
- 根 package.json 清理 image-grid 插件遗留元数据
- 收录此前未提交的 uView Vue3 兜底补丁(common/uview-init.js 及各组件 props.js)
2026-09-06 19:36:36 +08:00

450 lines
12 KiB
Vue

<template>
<view class="sv-editor-wrapper" @longpress="eLongpress">
<slot name="header"></slot>
<editor
:id="eid"
class="sv-editor-container"
show-img-size
show-img-toolbar
show-img-resize
:placeholder="placeholder"
:read-only="isReadOnly"
@statuschange="onStatusChange"
@ready="onEditorReady"
@input="onEditorInput"
@focus="onEditorFocus"
@blur="onEditorBlur"
></editor>
<view class="maxlength-tip" v-if="maxlength > 0 && !hideMax">{{ textlength }}/{{ maxlength }}</view>
<slot name="footer"></slot>
<!-- renderjs辅助插件 -->
<!-- #ifdef APP || H5 -->
<sv-editor-render ref="editorRenderRef" :eid="editorEID"></sv-editor-render>
<sv-editor-plugin ref="editorPluginRef" :sid="startID" :eid="editorEID" @epaste="ePaste"></sv-editor-plugin>
<!-- #endif -->
</view>
</template>
<script>
/**
* sv-editor
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
import store from '../common/store.js'
import { linkFlag, copyrightPrint } from '../common/utils.js'
import { parseHtmlWithVideo, replaceVideoWithImageRender } from '../common/parse.js'
import SvEditorRender from './sv-editor-render.vue'
import SvEditorPlugin from '../plugins/sv-editor-plugin.vue'
import wxplugin from '../plugins/sv-editor-wxplugin.js'
export default {
// #ifdef MP-WEIXIN
// 微信小程序特殊配置
options: {
addGlobalClass: true,
virtualHost: true,
styleIsolation: 'shared'
},
// #endif
components: {
SvEditorRender,
SvEditorPlugin
},
props: {
// 编辑器id可传入,以便循环组件使用,防止id重复
eid: {
type: String,
default: 'sv-editor' // 唯一,禁止重复
},
placeholder: {
type: String,
default: '写点什么吧 ~'
},
// 是否只读
readOnly: {
type: Boolean,
default: false
},
// 最大字数限制,<=0时表示不限
maxlength: {
type: Number,
default: -1
},
// 是否关闭最大字数显示
hideMax: {
type: Boolean,
default: false
},
// 粘贴模式,可选 text 纯文本(默认) | origin 尽可能保持原格式
pasteMode: {
type: String,
default: 'text'
}
},
emits: ['ready', 'input', 'statuschange', 'focus', 'blur', 'overmax', 'epaste'],
data() {
return {
textlength: 0, // 当前字数统计
startID: '',
// #ifdef VUE2
// #ifdef MP-WEIXIN
editorIns: null // 仅vue2环境下的微信小程序需要声明实例变量,否则报错,属实逆天
// #endif
// #endif
}
},
computed: {
editorEID: {
set(newVal) {
store.actions.setEID(newVal)
},
get() {
return store.actions.getEID()
}
},
editorCtx: {
set(newVal) {
store.actions.setEditor(newVal.eid, newVal.ctx)
// #ifdef VUE2
// #ifdef MP-WEIXIN
this.editorIns = newVal.ctx
this.editorIns.id = newVal.eid
// #endif
// #endif
},
get() {
let instance = store.actions.getEditor(this.eid)
// #ifdef VUE2
// #ifdef MP-WEIXIN
instance = store.actions.getEditor(this.eid) || this.editorIns
// #endif
// #endif
return instance
}
},
isReadOnly: {
set(newVal) {
store.actions.setReadOnly(newVal)
},
get() {
return store.actions.getReadOnly()
}
}
},
watch: {
readOnly(newVal) {
this.isReadOnly = newVal
}
},
mounted() {
// 首个实例初始化时执行
if (!store.state.firstInstanceFlag) {
this.editorEID = this.eid
store.state.firstInstanceFlag = this.eid
copyrightPrint()
}
},
destroyed() {
store.actions.destroy()
},
unmounted() {
store.actions.destroy()
},
methods: {
onEditorReady() {
this.$nextTick(() => {
uni
.createSelectorQuery()
.in(this)
.select('#' + this.eid)
.context((res) => {
// 存储上下文
this.editorCtx = { eid: this.eid, ctx: res.context }
// 挂载实例api
this.bindMethods()
// 初始化完成
this.$emit('ready', this.editorCtx)
// #ifdef APP || H5
if (this.pasteMode == 'origin') this.editorCtx.changePasteMode('origin')
// #endif
})
.exec()
})
},
/**
* 挂载实例api
*/
bindMethods() {
// ===== renderjs相关扩展api =====
// #ifdef APP || H5
/**
* 主动聚焦
* @returns {void}
*/
this.editorCtx.focus = this.$refs.editorRenderRef.focus
/**
* 退格
* @returns {void}
*/
this.editorCtx.backspace = this.$refs.editorRenderRef.backspace
/**
* 键盘输入模式
* @param {String} type 模式,可选:none | remove
* @returns {void}
*/
this.editorCtx.changeInputMode = this.$refs.editorRenderRef.changeInputMode
/**
* 粘贴模式
* @param {String} type 模式,可选:text纯文本(默认) | origin尽可能保持原格式
* @returns {void}
*/
this.editorCtx.changePasteMode = (type) => {
// 告知plugin启动
this.startID = this.eid
if (this.$refs.editorPluginRef?.changePasteMode) {
this.$refs.editorPluginRef.changePasteMode(type)
}
}
/**
* 生成视频封面图
* @param {String} url 封面图片地址
* @returns {Promise} 携带播放图标的封面图地址
*/
this.editorCtx.createCoverThumbnail = (url) => {
return new Promise((resolve) => {
if (this.$refs.editorPluginRef?.createCoverThumbnail) {
// 事件名必须唯一,否则会覆盖
uni.$once(`E_EDITOR_GET_COVER_THUMBNAIL_${url}`, (res) => {
resolve(res.cover)
})
setTimeout(() => {
this.$refs.editorPluginRef?.createCoverThumbnail(url)
})
}
})
}
/**
* 生成视频封面图
* @param {String} url 视频地址
* @returns {Promise} 封面图地址
*/
this.editorCtx.createVideoThumbnail = (url) => {
return new Promise((resolve) => {
if (this.$refs.editorPluginRef?.createVideoThumbnail) {
// 事件名必须唯一,否则会覆盖
uni.$once(`E_EDITOR_GET_VIDEO_THUMBNAIL_${url}`, (res) => {
resolve(res.cover)
})
setTimeout(() => {
this.$refs.editorPluginRef?.createVideoThumbnail(url)
})
}
})
}
// #endif
// ===== 微信小程序扩展api =====
// #ifdef MP-WEIXIN
/**
* 生成视频封面图
* @param {String} url 视频地址
* @returns {Promise} 封面图地址
*/
this.editorCtx.createCoverThumbnail = wxplugin?.wxCreateCoverThumbnail
// #endif
// ===== 通用扩展api =====
/**
* 主动触发input回调事件
* @returns {void}
*/
this.editorCtx.changeInput = () => {
this.editorCtx.getContents({
success: (res) => {
this.$emit('input', { ctx: this.editorCtx, html: res.html, text: res.text })
}
})
}
/**
* 获取最新内容
* @returns {Promise} 内容对象 { html, text... }
*/
this.editorCtx.getLastContent = async () => {
return new Promise((resolve) => {
this.editorCtx.getContents({
success: (res) => {
resolve(res)
}
})
})
}
/**
* 富文本内容初始化
* 注意:微信小程序会导致聚焦滚动,建议先将编辑器v-show=false,待initHtml内容初始化完成后再true
* 也正是因为微信小程序端会聚焦滚动,所以editorEID在初始阶段会默认保持最后一个实例eid,需要手动重新聚焦
* @param {String} html 初始化的富文本
* @param {Function<Promise>} customCallback 自定义处理封面回调,需要以Promise形式返回封面图片资源
* @returns {void}
*/
this.editorCtx.initHtml = async (html, customCallback) => {
let transHtml = await replaceVideoWithImageRender(html, customCallback)
// #ifdef APP || H5
this.editorCtx.changePasteMode('text') // text模式下可以防止初始化时对格式的影响
// #endif
setTimeout(() => {
this.editorCtx.setContents({
html: transHtml,
success: () => {
// 主动触发一次input回调事件
this.editorCtx.changeInput()
// #ifdef APP || H5
if (this.pasteMode == 'origin') this.editorCtx.changePasteMode('origin')
// #endif
}
})
})
}
/**
* 导出处理
* @param {String} html 要导出的富文本
* @returns {String} 处理后的富文本
*/
this.editorCtx.exportHtml = (html) => {
return parseHtmlWithVideo(html)
}
},
onEditorInput(e) {
// 注意不要使用getContents获取html和text,会导致重复触发onStatusChange从而失去toolbar工具的高亮状态
// 复制粘贴的时候detail会为空,此时应当直接return
if (Object.keys(e.detail).length <= 0) return
const { html, text } = e.detail
// 识别到链接特殊标识立即return
if (text.indexOf(linkFlag) !== -1) return
/**
* 因为uni-editor不提供最大字符限制,故需要手动进行以下特殊处理
*/
const maxlength = parseInt(this.maxlength)
const textStr = text.replace(/[ \t\r\n]/g, '')
this.textlength = textStr.length // 当前字符数
if (this.textlength >= maxlength && maxlength > 0) {
this.textlength = maxlength // 因为editor特性,需要手动赋阈值
if (!this.lockHtmlFlag) {
this.lockHtml = html // 锁定最后一次超出字数前的html
this.lockHtmlFlag = true // 锁定标志
// 首次到达最大限制时还需最后回调一次input事件
this.$emit('input', { ctx: this.editorCtx, html, text })
} else {
// 在超过字数时锁定,若再编辑则抛出超出事件
this.$emit('overmax', { ctx: this.editorCtx })
}
// 超过字数时,锁定最后一次超出字数前的html
this.editorCtx.setContents({ html: this.lockHtml })
} else {
// 正常输入
this.$emit('input', { ctx: this.editorCtx, html, text })
this.lockHtmlFlag = false // 锁定标志
}
},
/**
* 样式格式改变时触发
* 注意:微信小程序端在多编辑器实例下切换编辑器后可能不会及时触发onStatusChange
*/
onStatusChange(e) {
store.actions.setFormats(e.detail)
this.$emit('statuschange', { ...e, ctx: this.editorCtx })
uni.$emit('E_EDITOR_STATUSCHANGE', { ...e, ctx: this.editorCtx })
},
onEditorFocus(e) {
this.editorEID = this.eid
this.$emit('focus', { ...e, ctx: this.editorCtx })
},
onEditorBlur(e) {
this.$emit('blur', { ...e, ctx: this.editorCtx })
},
ePaste(e) {
this.$emit('epaste', { ...e, ctx: this.editorCtx })
uni.$emit('E_EDITOR_PASTE', { ...e, ctx: this.editorCtx })
},
/**
* 微信小程序官方editor的长按事件有bug,需要重写覆盖,不需做任何逻辑,可见下面小程序社区问题链接
* @tutorial https://developers.weixin.qq.com/community/develop/doc/000c04b3e1c1006f660065e4f61000
*/
eLongpress() {}
}
}
</script>
<style lang="scss">
.sv-editor-wrapper {
--maxlength-text-color: #666666;
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.sv-editor-container {
flex: 1;
}
.maxlength-tip {
position: absolute;
bottom: 0;
right: 0;
font-size: 24rpx;
color: var(--maxlength-text-color);
opacity: 0.6;
}
}
// placeholder字样
.sv-editor-container :deep(.ql-blank)::before {
font-style: normal;
color: #cccccc;
}
// 图片工具样式
:deep(.ql-container) {
min-height: unset;
.ql-image-overlay {
pointer-events: none;
.ql-image-size {
right: 28px !important;
}
.ql-image-toolbar {
// 删除按钮
pointer-events: auto;
}
.ql-image-handle {
// 四角缩放按钮
width: 30px;
height: 30px;
pointer-events: auto;
}
}
}
</style>