TuiEditor.vue 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. <script lang="ts">
  2. const editorEvents = ['load', 'change', 'caretChange', 'focus', 'blur', 'keydown', 'keyup', 'beforePreviewRender', 'beforeConvertWysiwygToMarkdown'];
  3. export default { name: 'TuiEditor' };
  4. </script>
  5. <script setup lang="ts">
  6. import { onMounted, ref, toRefs, watch, PropType, onUnmounted, nextTick } from 'vue';
  7. import { useFormItem } from 'element-plus';
  8. import { vOnClickOutside } from '@vueuse/components';
  9. import { decodeHTML } from 'entities';
  10. import Editor, { EditorType, PreviewStyle, EditorOptions } from '@toast-ui/editor';
  11. import chart from '@toast-ui/editor-plugin-chart';
  12. import codeSyntaxHighlight from '@toast-ui/editor-plugin-code-syntax-highlight';
  13. import tableMergedCell from '@toast-ui/editor-plugin-table-merged-cell';
  14. import uml from '@toast-ui/editor-plugin-uml';
  15. import Prism from 'prismjs';
  16. import { addImageBlobHook, toggleFullScreen, clickOutside } from './utils';
  17. import '@toast-ui/editor/dist/i18n/zh-cn';
  18. import '@toast-ui/editor/dist/i18n/zh-tw';
  19. import '@toast-ui/editor/dist/toastui-editor.css';
  20. import '@toast-ui/chart/dist/toastui-chart.css';
  21. import 'prismjs/themes/prism.css';
  22. import 'prismjs/components/prism-clojure.js';
  23. import '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css';
  24. import '@toast-ui/editor-plugin-table-merged-cell/dist/toastui-editor-plugin-table-merged-cell.css';
  25. const props = defineProps({
  26. modelValue: { type: String, default: '' },
  27. html: { type: String, default: '' },
  28. initialEditType: { type: String as PropType<EditorType>, default: 'markdown' },
  29. height: { type: String, default: '300px' },
  30. previewStyle: { type: String as PropType<PreviewStyle>, default: 'tab' },
  31. language: { type: String, default: 'en' },
  32. options: { type: Object, default: null },
  33. });
  34. const emit = defineEmits([...editorEvents, 'update:modelValue', 'update:html', 'different']);
  35. const { modelValue, html, initialEditType, height, previewStyle, language, options } = toRefs(props);
  36. const toastuiEditor = ref();
  37. let editor: Editor;
  38. const { formItem } = useFormItem();
  39. watch(previewStyle, () => {
  40. editor.changePreviewStyle(previewStyle.value);
  41. });
  42. watch(height, () => {
  43. editor.setHeight(height.value);
  44. });
  45. const eventOptions: any = {};
  46. // 内容为空时,默认生成以下HTML,应作为空串处理
  47. const emptyHtml = '<p><br class="ProseMirror-trailingBreak"></p>';
  48. editorEvents.forEach((event) => {
  49. eventOptions[event] = (...args: any[]) => {
  50. if (event === 'change') {
  51. const newHtml = editor.getHTML();
  52. if (newHtml !== html.value) {
  53. emit('update:html', newHtml !== emptyHtml ? newHtml : '');
  54. }
  55. const newMarkdown = editor.getMarkdown();
  56. if (newMarkdown !== modelValue.value) {
  57. emit('update:modelValue', newMarkdown);
  58. }
  59. formItem?.validate?.('change').catch((err: any) => {
  60. if (import.meta.env.MODE !== 'production') {
  61. console.warn(err);
  62. }
  63. });
  64. }
  65. emit(event, ...args);
  66. };
  67. });
  68. const createFullscreenButton = () => {
  69. const button = document.createElement('button');
  70. button.type = 'button';
  71. button.className = 'toastui-editor-toolbar-icons text-xl';
  72. button.style.backgroundImage = 'none';
  73. button.style.margin = '0';
  74. button.innerHTML = 'F';
  75. button.addEventListener('click', () => {
  76. toggleFullScreen(editor, toastuiEditor.value, height.value);
  77. });
  78. return button;
  79. };
  80. onMounted(() => {
  81. const chartOptions = {
  82. maxWidth: 800,
  83. maxHeight: 400,
  84. };
  85. const computedOptions: EditorOptions = {
  86. ...options?.value,
  87. initialValue: modelValue.value ?? '',
  88. initialEditType: initialEditType.value,
  89. height: height.value,
  90. previewStyle: previewStyle.value,
  91. language: language.value,
  92. autofocus: false,
  93. usageStatistics: false,
  94. el: toastuiEditor.value,
  95. events: eventOptions,
  96. hooks: { addImageBlobHook },
  97. plugins: [[chart, chartOptions], [codeSyntaxHighlight, { highlighter: Prism }], tableMergedCell, uml],
  98. toolbarItems: [
  99. [
  100. {
  101. name: 'fullscreen',
  102. el: createFullscreenButton(),
  103. tooltip: 'Fullscreen',
  104. },
  105. ],
  106. ['heading', 'bold', 'italic', 'strike'],
  107. ['hr', 'quote'],
  108. ['ul', 'ol', 'task', 'indent', 'outdent'],
  109. ['table', 'image', 'link'],
  110. ['code', 'codeblock'],
  111. ['scrollSync'],
  112. ],
  113. };
  114. editor = new Editor(computedOptions);
  115. // markdown无值,html有值,则用设置html
  116. if (!modelValue.value && html.value) {
  117. editor.setHTML(html.value);
  118. // 防止在切换编辑器时,因清空markdown值导致事件无效
  119. nextTick().then(() => {
  120. emit('update:modelValue', editor.getMarkdown());
  121. });
  122. return;
  123. }
  124. // 检查markdown生成的HTML和原HTML是否匹配
  125. const currHtml = editor.getHTML();
  126. if (modelValue.value && decodeHTML(html.value) !== currHtml) {
  127. // 触发不匹配事件
  128. emit('different', html.value, currHtml);
  129. emit('update:html', currHtml);
  130. }
  131. });
  132. onUnmounted(() => {
  133. editorEvents.forEach((event) => {
  134. editor.off(event);
  135. });
  136. editor.destroy();
  137. });
  138. const getHTML = () => editor.getHTML();
  139. const setHTML = (html: string): void => editor.setHTML(html);
  140. const getMarkdown = () => editor.getMarkdown();
  141. const setMarkdown = (markdown: string): void => editor.setMarkdown(markdown);
  142. const getRootElement = () => toastuiEditor.value;
  143. defineExpose({ getRootElement, getHTML, getMarkdown, setHTML, setMarkdown });
  144. </script>
  145. <template>
  146. <!-- 在ElementPlus的对话框中,“更多”工具条按钮点击后,点击其它地方不会关闭工具条 -->
  147. <div ref="toastuiEditor" v-on-click-outside="clickOutside"></div>
  148. </template>
  149. <style lang="scss" scoped>
  150. :deep(.ProseMirror),
  151. :deep(.toastui-editor-contents) {
  152. font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'PingFang SC', 'Hiragino Sans GB',
  153. 'Microsoft YaHei', 'WenQuanYi Micro Hei', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
  154. }
  155. </style>