| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217 |
- <template>
- <div class="wangeditor-wrapper">
- <div ref="toolbarContainer" class="toolbar-container"></div>
- <div ref="editorContainer" class="editor-container"></div>
- </div>
- </template>
- <script>
- import '@wangeditor/editor/dist/css/style.css'
- import { createEditor, createToolbar } from '@wangeditor/editor'
- import Cookies from 'js-cookie'
- const MAX_IMAGE_SIZE = 10 * 1024 * 1024
- export default {
- name: 'EditorVue',
- props: {
- content: {
- type: String,
- default: ''
- }
- },
- data() {
- return {}
- },
- watch: {
- content(newVal) {
- // 外部 prop 变化(异步加载详情)才回填,避免输入过程中光标跳动与循环触发
- // setHtml 在内部 Slate state 半销毁状态下会抛 "proxy has been revoked",
- // 用 try-catch 兜住,不让 watcher 异常把整个 Vue 组件标为 error 状态
- if (this._editor && newVal !== this._editor.getHtml()) {
- try { this._editor.setHtml(newVal || '') } catch (e) { /* swallow */ }
- }
- }
- },
- mounted() {
- this._createEditor()
- },
- beforeDestroy() {
- this._destroyEditor()
- },
- methods: {
- // 父组件 ref 调用的初始化入口(对齐老 Quill 实现的 init() 行为)
- // 不能再销毁重建:父组件 add.vue / detail.vue 会在 mounted 或切 tab 时通过
- // setTimeout 调用 init(),销毁旧的 wangEditor/Slate 实例会导致:
- // 1) 已渲染的工具栏 DOM 还指向被 revoke 的 slate proxy → 按钮 click 无响应
- // 2) 新建的 editor 工具栏被旧 DOM 遮挡,视觉上像"不可点击"
- // mounted 已经完成创建,这里只做幂等占位;内容回填由 watch content → setHtml 处理
- init() {
- if (!this._editor) {
- this._createEditor()
- }
- },
- _destroyEditor() {
- // 同步调用 destroy():coreCreateEditor 内部注册了 document 上的
- // selectionchange 监听器,只有 emit("destroyed") 才会走
- // removeEventListener。如果不调 destroy,旧监听器会永久留在
- // document 上,且其 closure 持有的 Slate state 代理在后续操作
- // (setHtml / 输入触发 change) 中会被 Slate 自动 revoke,下次用户
- // 一点页面就抛 "Cannot perform 'get' on a proxy that has been revoked"。
- // 同步调用 + try-catch 兜住 destroy 链内部任何抛错,保证 emit("destroyed")
- // 至少能走到,监听器能被摘掉。
- if (this._editor) {
- try { this._editor.destroy() } catch (e) { /* swallow */ }
- this._editor = null
- }
- if (this._toolbar) {
- try { this._toolbar.destroy() } catch (e) { /* swallow */ }
- this._toolbar = null
- }
- },
- _createEditor() {
- const editorConfig = {
- html: this.content || '',
- placeholder: '请在这里输入',
- readOnly: false,
- // 默认 false 会让 toolbar 按钮在用户点进编辑区之前一直处于
- // disabled 灰态,误以为是不可点击;开启后 toolbar 即刻可用
- autoFocus: true,
- // autoFocus 只会让 DOM 获得焦点,不会给 Slate editor 创建 selection。
- // wangEditor 的工具栏会在 selection 为空时统一禁用按钮,因此在
- // editor 创建完成后显式 focus,初始化一个可用的编辑选区。
- onCreated: (editor) => {
- editor.focus()
- },
- MENU_CONF: {
- uploadImage: {
- // 沿用项目原有的 OSS 上传端点,token 走 URL 参数
- server: `${window.SITE_CONFIG['apiURL']}/sys/oss/upload?token=${Cookies.get('token') || ''}`,
- fieldName: 'file',
- maxFileSize: MAX_IMAGE_SIZE,
- onBeforeUpload: (file) => {
- if (file.size > MAX_IMAGE_SIZE) {
- this.$message.error('图片大小不能超过10MB')
- return false
- }
- return file
- },
- onFailed: (file, res) => {
- this.$message.error((res && (res.msg || res.message)) || `${file.name}上传失败`)
- },
- onError: (file, xhr, error) => {
- this.$message.error(`${file.name}上传失败,请检查图片大小或网络连接`)
- },
- // 后端响应是项目老格式 { code: 0, data: 'url', msg: '...' },
- // wangEditor 默认期望 { errno, data: [{ url, alt, href }] },这里桥接一下
- customInsert: (res, insertFn) => {
- if (res && res.code === 0 && res.data) {
- insertFn(res.data, '', res.data)
- } else {
- this.$message.error((res && (res.msg || res.message)) || '图片上传失败')
- }
- }
- },
- insertImage: {
- checkImage: (src) => {
- const valid = /^https?:\/\/\S+$/i.test(src || '')
- if (!valid) this.$message.error('请输入有效的图片链接地址(需以 http:// 或 https:// 开头)')
- return valid
- },
- onInsertedImage: (image) => {
- if (!image || !image.src) this.$message.error('图片链接插入失败')
- }
- },
- fontFamily: {
- fontFamilyList: [
- { name: '宋体', value: 'SimSun' },
- { name: '黑体', value: 'SimHei' },
- { name: '微软雅黑', value: 'Microsoft YaHei' },
- { name: '楷体', value: 'KaiTi' },
- { name: '仿宋', value: 'FangSong' },
- { name: 'Arial', value: 'Arial' }
- ]
- }
- },
- onChange: (editor) => {
- this.$emit('EditorChange', editor.getHtml())
- }
- }
- // 不要把 wangEditor 实例放进 Vue data。Vue 2 会递归观察 Slate
- // 的 children/selection 等内部对象,生成响应式 Proxy;Slate 在
- // 下一次操作时会撤销旧 Proxy,随后 selectionchange/editor.focus
- // 就会报 "Cannot perform 'get' on a proxy that has been revoked"。
- // 以实例私有字段保存,避免 Vue 对编辑器状态做响应式代理。
- const editor = createEditor({
- selector: this.$refs.editorContainer,
- config: editorConfig,
- mode: 'default'
- })
- this._editor = editor
- // 工具栏:列出 wangEditor 5 已注册的全部有效菜单 key
- // 注:wangEditor 把删除线菜单命名为 "through","mergeCells" 不是注册菜单
- const toolbarConfig = {
- toolbarKeys: [
- 'undo', 'redo',
- '|',
- 'headerSelect',
- 'bold', 'italic', 'underline', 'through',
- 'code', 'sub', 'sup',
- '|',
- 'color', 'bgColor',
- '|',
- 'fontSize', 'fontFamily',
- 'lineHeight',
- '|',
- 'justifyLeft', 'justifyCenter', 'justifyRight', 'justifyJustify',
- '|',
- 'bulletedList', 'numberedList',
- 'indent', 'delIndent',
- '|',
- 'insertImage', 'uploadImage', 'deleteImage', 'editImage',
- '|',
- 'insertTable', 'tableFullWidth', 'deleteTable',
- 'insertTableRow', 'deleteTableRow',
- 'insertTableCol', 'deleteTableCol',
- 'tableHeader',
- '|',
- 'insertLink', 'editLink', 'unLink',
- '|',
- 'blockquote', 'codeBlock', 'todo',
- '|',
- 'divider', 'clearStyle',
- '|',
- 'emotion',
- '|',
- 'fullScreen'
- ]
- }
- this._toolbar = createToolbar({
- editor,
- selector: this.$refs.toolbarContainer,
- config: toolbarConfig,
- mode: 'default'
- })
- }
- }
- }
- </script>
- <style>
- .wangeditor-wrapper {
- border: 1px solid #dcdfe6;
- border-radius: 4px;
- background: #fff;
- }
- .wangeditor-wrapper .toolbar-container {
- border-bottom: 1px solid #dcdfe6;
- }
- .wangeditor-wrapper .editor-container {
- min-height: 600px;
- height: 600px !important;
- }
- </style>
|