Editor.vue 9.3 KB

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