index.vue 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. <template>
  2. <div class="wangeditor-wrapper">
  3. <div ref="toolbarContainer" class="toolbar-container"></div>
  4. <div ref="editorContainer" class="editor-container"></div>
  5. </div>
  6. </template>
  7. <script>
  8. import '@wangeditor/editor/dist/css/style.css'
  9. import { createEditor, createToolbar } from '@wangeditor/editor'
  10. import Cookies from 'js-cookie'
  11. const MAX_IMAGE_SIZE = 10 * 1024 * 1024
  12. export default {
  13. name: 'EditorVue',
  14. props: {
  15. content: {
  16. type: String,
  17. default: ''
  18. }
  19. },
  20. data() {
  21. return {}
  22. },
  23. watch: {
  24. content(newVal) {
  25. // 外部 prop 变化(异步加载详情)才回填,避免输入过程中光标跳动与循环触发
  26. // setHtml 在内部 Slate state 半销毁状态下会抛 "proxy has been revoked",
  27. // 用 try-catch 兜住,不让 watcher 异常把整个 Vue 组件标为 error 状态
  28. if (this._editor && newVal !== this._editor.getHtml()) {
  29. try { this._editor.setHtml(newVal || '') } catch (e) { /* swallow */ }
  30. }
  31. }
  32. },
  33. mounted() {
  34. this._createEditor()
  35. },
  36. beforeDestroy() {
  37. this._destroyEditor()
  38. },
  39. methods: {
  40. // 父组件 ref 调用的初始化入口(对齐老 Quill 实现的 init() 行为)
  41. // 不能再销毁重建:父组件 add.vue / detail.vue 会在 mounted 或切 tab 时通过
  42. // setTimeout 调用 init(),销毁旧的 wangEditor/Slate 实例会导致:
  43. // 1) 已渲染的工具栏 DOM 还指向被 revoke 的 slate proxy → 按钮 click 无响应
  44. // 2) 新建的 editor 工具栏被旧 DOM 遮挡,视觉上像"不可点击"
  45. // mounted 已经完成创建,这里只做幂等占位;内容回填由 watch content → setHtml 处理
  46. init() {
  47. if (!this._editor) {
  48. this._createEditor()
  49. }
  50. },
  51. _destroyEditor() {
  52. // 同步调用 destroy():coreCreateEditor 内部注册了 document 上的
  53. // selectionchange 监听器,只有 emit("destroyed") 才会走
  54. // removeEventListener。如果不调 destroy,旧监听器会永久留在
  55. // document 上,且其 closure 持有的 Slate state 代理在后续操作
  56. // (setHtml / 输入触发 change) 中会被 Slate 自动 revoke,下次用户
  57. // 一点页面就抛 "Cannot perform 'get' on a proxy that has been revoked"。
  58. // 同步调用 + try-catch 兜住 destroy 链内部任何抛错,保证 emit("destroyed")
  59. // 至少能走到,监听器能被摘掉。
  60. if (this._editor) {
  61. try { this._editor.destroy() } catch (e) { /* swallow */ }
  62. this._editor = null
  63. }
  64. if (this._toolbar) {
  65. try { this._toolbar.destroy() } catch (e) { /* swallow */ }
  66. this._toolbar = null
  67. }
  68. },
  69. _createEditor() {
  70. const editorConfig = {
  71. html: this.content || '',
  72. placeholder: '请在这里输入',
  73. readOnly: false,
  74. // 默认 false 会让 toolbar 按钮在用户点进编辑区之前一直处于
  75. // disabled 灰态,误以为是不可点击;开启后 toolbar 即刻可用
  76. autoFocus: true,
  77. // autoFocus 只会让 DOM 获得焦点,不会给 Slate editor 创建 selection。
  78. // wangEditor 的工具栏会在 selection 为空时统一禁用按钮,因此在
  79. // editor 创建完成后显式 focus,初始化一个可用的编辑选区。
  80. onCreated: (editor) => {
  81. editor.focus()
  82. },
  83. MENU_CONF: {
  84. uploadImage: {
  85. // 沿用项目原有的 OSS 上传端点,token 走 URL 参数
  86. server: `${window.SITE_CONFIG['apiURL']}/sys/oss/upload?token=${Cookies.get('token') || ''}`,
  87. fieldName: 'file',
  88. maxFileSize: MAX_IMAGE_SIZE,
  89. onBeforeUpload: (file) => {
  90. if (file.size > MAX_IMAGE_SIZE) {
  91. this.$message.error('图片大小不能超过10MB')
  92. return false
  93. }
  94. return file
  95. },
  96. onFailed: (file, res) => {
  97. this.$message.error((res && (res.msg || res.message)) || `${file.name}上传失败`)
  98. },
  99. onError: (file, xhr, error) => {
  100. this.$message.error(`${file.name}上传失败,请检查图片大小或网络连接`)
  101. },
  102. // 后端响应是项目老格式 { code: 0, data: 'url', msg: '...' },
  103. // wangEditor 默认期望 { errno, data: [{ url, alt, href }] },这里桥接一下
  104. customInsert: (res, insertFn) => {
  105. if (res && res.code === 0 && res.data) {
  106. insertFn(res.data, '', res.data)
  107. } else {
  108. this.$message.error((res && (res.msg || res.message)) || '图片上传失败')
  109. }
  110. }
  111. },
  112. insertImage: {
  113. checkImage: (src) => {
  114. const valid = /^https?:\/\/\S+$/i.test(src || '')
  115. if (!valid) this.$message.error('请输入有效的图片链接地址(需以 http:// 或 https:// 开头)')
  116. return valid
  117. },
  118. onInsertedImage: (image) => {
  119. if (!image || !image.src) this.$message.error('图片链接插入失败')
  120. }
  121. },
  122. fontFamily: {
  123. fontFamilyList: [
  124. { name: '宋体', value: 'SimSun' },
  125. { name: '黑体', value: 'SimHei' },
  126. { name: '微软雅黑', value: 'Microsoft YaHei' },
  127. { name: '楷体', value: 'KaiTi' },
  128. { name: '仿宋', value: 'FangSong' },
  129. { name: 'Arial', value: 'Arial' }
  130. ]
  131. }
  132. },
  133. onChange: (editor) => {
  134. this.$emit('EditorChange', editor.getHtml())
  135. }
  136. }
  137. // 不要把 wangEditor 实例放进 Vue data。Vue 2 会递归观察 Slate
  138. // 的 children/selection 等内部对象,生成响应式 Proxy;Slate 在
  139. // 下一次操作时会撤销旧 Proxy,随后 selectionchange/editor.focus
  140. // 就会报 "Cannot perform 'get' on a proxy that has been revoked"。
  141. // 以实例私有字段保存,避免 Vue 对编辑器状态做响应式代理。
  142. const editor = createEditor({
  143. selector: this.$refs.editorContainer,
  144. config: editorConfig,
  145. mode: 'default'
  146. })
  147. this._editor = editor
  148. // 工具栏:列出 wangEditor 5 已注册的全部有效菜单 key
  149. // 注:wangEditor 把删除线菜单命名为 "through","mergeCells" 不是注册菜单
  150. const toolbarConfig = {
  151. toolbarKeys: [
  152. 'undo', 'redo',
  153. '|',
  154. 'headerSelect',
  155. 'bold', 'italic', 'underline', 'through',
  156. 'code', 'sub', 'sup',
  157. '|',
  158. 'color', 'bgColor',
  159. '|',
  160. 'fontSize', 'fontFamily',
  161. 'lineHeight',
  162. '|',
  163. 'justifyLeft', 'justifyCenter', 'justifyRight', 'justifyJustify',
  164. '|',
  165. 'bulletedList', 'numberedList',
  166. 'indent', 'delIndent',
  167. '|',
  168. 'insertImage', 'uploadImage', 'deleteImage', 'editImage',
  169. '|',
  170. 'insertTable', 'tableFullWidth', 'deleteTable',
  171. 'insertTableRow', 'deleteTableRow',
  172. 'insertTableCol', 'deleteTableCol',
  173. 'tableHeader',
  174. '|',
  175. 'insertLink', 'editLink', 'unLink',
  176. '|',
  177. 'blockquote', 'codeBlock', 'todo',
  178. '|',
  179. 'divider', 'clearStyle',
  180. '|',
  181. 'emotion',
  182. '|',
  183. 'fullScreen'
  184. ]
  185. }
  186. this._toolbar = createToolbar({
  187. editor,
  188. selector: this.$refs.toolbarContainer,
  189. config: toolbarConfig,
  190. mode: 'default'
  191. })
  192. }
  193. }
  194. }
  195. </script>
  196. <style>
  197. .wangeditor-wrapper {
  198. border: 1px solid #dcdfe6;
  199. border-radius: 4px;
  200. background: #fff;
  201. }
  202. .wangeditor-wrapper .toolbar-container {
  203. border-bottom: 1px solid #dcdfe6;
  204. }
  205. .wangeditor-wrapper .editor-container {
  206. min-height: 600px;
  207. height: 600px !important;
  208. }
  209. </style>