Editor.vue 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. <template>
  2. <div :class="prefixCls" :style="{ width: containerWidth }">
  3. <ImgUpload
  4. :fullscreen="fullscreen"
  5. @uploading="handleImageUploading"
  6. @done="handleDone"
  7. v-if="showImageUpload"
  8. v-show="editorRef"
  9. :disabled="disabled"
  10. />
  11. <textarea :id="tinymceId" ref="elRef" :style="{ visibility: 'hidden' }"></textarea>
  12. </div>
  13. </template>
  14. <script lang="ts">
  15. import type { RawEditorSettings } from 'tinymce';
  16. import tinymce from 'tinymce/tinymce';
  17. import 'tinymce/themes/silver';
  18. import 'tinymce/icons/default/icons';
  19. import 'tinymce/plugins/advlist';
  20. import 'tinymce/plugins/anchor';
  21. import 'tinymce/plugins/autolink';
  22. import 'tinymce/plugins/autosave';
  23. import 'tinymce/plugins/code';
  24. import 'tinymce/plugins/codesample';
  25. import 'tinymce/plugins/directionality';
  26. import 'tinymce/plugins/fullscreen';
  27. import 'tinymce/plugins/hr';
  28. import 'tinymce/plugins/insertdatetime';
  29. import 'tinymce/plugins/link';
  30. import 'tinymce/plugins/lists';
  31. import 'tinymce/plugins/media';
  32. import 'tinymce/plugins/nonbreaking';
  33. import 'tinymce/plugins/noneditable';
  34. import 'tinymce/plugins/pagebreak';
  35. import 'tinymce/plugins/paste';
  36. import 'tinymce/plugins/preview';
  37. import 'tinymce/plugins/print';
  38. import 'tinymce/plugins/save';
  39. import 'tinymce/plugins/searchreplace';
  40. import 'tinymce/plugins/spellchecker';
  41. import 'tinymce/plugins/tabfocus';
  42. // import 'tinymce/plugins/table';
  43. import 'tinymce/plugins/template';
  44. import 'tinymce/plugins/textpattern';
  45. import 'tinymce/plugins/visualblocks';
  46. import 'tinymce/plugins/visualchars';
  47. import 'tinymce/plugins/wordcount';
  48. import {
  49. defineComponent,
  50. computed,
  51. nextTick,
  52. ref,
  53. unref,
  54. watch,
  55. onUnmounted,
  56. onDeactivated,
  57. } from 'vue';
  58. import ImgUpload from './ImgUpload.vue';
  59. import { toolbar, plugins } from './tinymce';
  60. import { buildShortUUID } from '/@/utils/uuid';
  61. import { bindHandlers } from './helper';
  62. import { onMountedOrActivated } from '/@/hooks/core/onMountedOrActivated';
  63. import { useDesign } from '/@/hooks/web/useDesign';
  64. import { isNumber } from '/@/utils/is';
  65. import { useLocale } from '/@/locales/useLocale';
  66. import { useAppStore } from '/@/store/modules/app';
  67. const tinymceProps = {
  68. options: {
  69. type: Object as PropType<Partial<RawEditorSettings>>,
  70. default: {},
  71. },
  72. value: {
  73. type: String,
  74. },
  75. toolbar: {
  76. type: Array as PropType<string[]>,
  77. default: toolbar,
  78. },
  79. plugins: {
  80. type: Array as PropType<string[]>,
  81. default: plugins,
  82. },
  83. modelValue: {
  84. type: String,
  85. },
  86. height: {
  87. type: [Number, String] as PropType<string | number>,
  88. required: false,
  89. default: 400,
  90. },
  91. width: {
  92. type: [Number, String] as PropType<string | number>,
  93. required: false,
  94. default: 'auto',
  95. },
  96. showImageUpload: {
  97. type: Boolean,
  98. default: true,
  99. },
  100. };
  101. export default defineComponent({
  102. name: 'Tinymce',
  103. components: { ImgUpload },
  104. inheritAttrs: false,
  105. props: tinymceProps,
  106. emits: ['change', 'update:modelValue'],
  107. setup(props, { emit, attrs }) {
  108. const editorRef = ref();
  109. const fullscreen = ref(false);
  110. const tinymceId = ref<string>(buildShortUUID('tiny-vue'));
  111. const elRef = ref<Nullable<HTMLElement>>(null);
  112. const { prefixCls } = useDesign('tinymce-container');
  113. const appStore = useAppStore();
  114. const tinymceContent = computed(() => props.modelValue);
  115. const containerWidth = computed(() => {
  116. const width = props.width;
  117. if (isNumber(width)) {
  118. return `${width}px`;
  119. }
  120. return width;
  121. });
  122. const skinName = computed(() => {
  123. return appStore.getDarkMode === 'light' ? 'oxide' : 'oxide-dark';
  124. });
  125. const langName = computed(() => {
  126. const lang = useLocale().getLocale.value;
  127. return ['zh_CN', 'en'].includes(lang) ? lang : 'zh_CN';
  128. });
  129. const initOptions = computed((): RawEditorSettings => {
  130. const { height, options, toolbar, plugins } = props;
  131. const publicPath = import.meta.env.VITE_PUBLIC_PATH || '/';
  132. return {
  133. selector: `#${unref(tinymceId)}`,
  134. height,
  135. toolbar,
  136. menubar: 'file edit insert view format table',
  137. plugins,
  138. language_url: publicPath + 'resource/tinymce/langs/' + langName.value + '.js',
  139. language: langName.value,
  140. branding: false,
  141. default_link_target: '_blank',
  142. link_title: false,
  143. object_resizing: false,
  144. auto_focus: true,
  145. skin: skinName.value,
  146. skin_url: publicPath + 'resource/tinymce/skins/ui/' + skinName.value,
  147. content_css:
  148. publicPath + 'resource/tinymce/skins/ui/' + skinName.value + '/content.min.css',
  149. ...options,
  150. setup: (editor) => {
  151. editorRef.value = editor;
  152. editor.on('init', (e) => initSetup(e));
  153. },
  154. };
  155. });
  156. const disabled = computed(() => {
  157. const { options } = props;
  158. const getdDisabled = options && Reflect.get(options, 'readonly');
  159. const editor = unref(editorRef);
  160. if (editor) {
  161. editor.setMode(getdDisabled ? 'readonly' : 'design');
  162. }
  163. return getdDisabled ?? false;
  164. });
  165. watch(
  166. () => attrs.disabled,
  167. () => {
  168. const editor = unref(editorRef);
  169. if (!editor) {
  170. return;
  171. }
  172. editor.setMode(attrs.disabled ? 'readonly' : 'design');
  173. }
  174. );
  175. onMountedOrActivated(() => {
  176. tinymceId.value = buildShortUUID('tiny-vue');
  177. nextTick(() => {
  178. setTimeout(() => {
  179. initEditor();
  180. }, 30);
  181. });
  182. });
  183. onUnmounted(() => {
  184. destory();
  185. });
  186. onDeactivated(() => {
  187. destory();
  188. });
  189. function destory() {
  190. if (tinymce !== null) {
  191. tinymce?.remove?.(unref(editorRef));
  192. }
  193. }
  194. function initEditor() {
  195. const el = unref(elRef);
  196. if (el) {
  197. el.style.visibility = '';
  198. }
  199. tinymce.init(unref(initOptions));
  200. }
  201. function initSetup(e) {
  202. const editor = unref(editorRef);
  203. if (!editor) {
  204. return;
  205. }
  206. const value = props.modelValue || '';
  207. editor.setContent(value);
  208. bindModelHandlers(editor);
  209. bindHandlers(e, attrs, unref(editorRef));
  210. }
  211. function setValue(editor: Recordable, val: string, prevVal?: string) {
  212. if (
  213. editor &&
  214. typeof val === 'string' &&
  215. val !== prevVal &&
  216. val !== editor.getContent({ format: attrs.outputFormat })
  217. ) {
  218. editor.setContent(val);
  219. }
  220. }
  221. function bindModelHandlers(editor: any) {
  222. const modelEvents = attrs.modelEvents ? attrs.modelEvents : null;
  223. const normalizedEvents = Array.isArray(modelEvents) ? modelEvents.join(' ') : modelEvents;
  224. watch(
  225. () => props.modelValue,
  226. (val: string, prevVal: string) => {
  227. setValue(editor, val, prevVal);
  228. }
  229. );
  230. watch(
  231. () => props.value,
  232. (val: string, prevVal: string) => {
  233. setValue(editor, val, prevVal);
  234. },
  235. {
  236. immediate: true,
  237. }
  238. );
  239. editor.on(normalizedEvents ? normalizedEvents : 'change keyup undo redo', () => {
  240. const content = editor.getContent({ format: attrs.outputFormat });
  241. emit('update:modelValue', content);
  242. emit('change', content);
  243. });
  244. editor.on('FullscreenStateChanged', (e) => {
  245. fullscreen.value = e.state;
  246. });
  247. }
  248. function handleImageUploading(name: string) {
  249. const editor = unref(editorRef);
  250. if (!editor) {
  251. return;
  252. }
  253. const content = editor?.getContent() ?? '';
  254. setValue(editor, `${content}\n${getUploadingImgName(name)}`);
  255. }
  256. function handleDone(name: string, url: string) {
  257. const editor = unref(editorRef);
  258. if (!editor) {
  259. return;
  260. }
  261. const content = editor?.getContent() ?? '';
  262. const val = content?.replace(getUploadingImgName(name), `<img src="${url}"/>`) ?? '';
  263. setValue(editor, val);
  264. }
  265. function getUploadingImgName(name: string) {
  266. return `[uploading:${name}]`;
  267. }
  268. return {
  269. prefixCls,
  270. containerWidth,
  271. initOptions,
  272. tinymceContent,
  273. elRef,
  274. tinymceId,
  275. handleImageUploading,
  276. handleDone,
  277. editorRef,
  278. fullscreen,
  279. disabled,
  280. };
  281. },
  282. });
  283. </script>
  284. <style lang="less" scoped></style>
  285. <style lang="less">
  286. @prefix-cls: ~'@{namespace}-tinymce-container';
  287. .@{prefix-cls} {
  288. position: relative;
  289. line-height: normal;
  290. textarea {
  291. z-index: -1;
  292. visibility: hidden;
  293. }
  294. }
  295. </style>