Преглед изворни кода

添加编辑器:wangEditor

@link: https://www.wangeditor.com/
cc12458 пре 7 месеци
родитељ
комит
90d8319850

+ 5 - 0
package.json

@@ -13,6 +13,7 @@
     "format": "prettier --write src/"
   },
   "dependencies": {
+    "@alova/adapter-xhr": "^2.2.1",
     "@ant-design/icons-vue": "^7.0.1",
     "@logicflow/core": "^2.1.2",
     "@logicflow/extension": "^2.1.4",
@@ -22,6 +23,10 @@
     "@vueuse/components": "^10.11.0",
     "@vueuse/core": "^10.11.0",
     "@vueuse/router": "^10.11.1",
+    "@wangeditor/core": "^1.1.19",
+    "@wangeditor/editor": "^5.1.23",
+    "@wangeditor/plugin-ctrl-enter": "^1.1.2",
+    "@wangeditor/plugin-md": "^1.0.0",
     "alova": "^3.2.10",
     "ant-design-vue": "^4.2.3",
     "china-division": "^2.7.0",

Разлика између датотеке није приказан због своје велике величине
+ 269 - 124
pnpm-lock.yaml


+ 109 - 0
src/pages/editor/config.ts

@@ -0,0 +1,109 @@
+import groupIndentRaw from './icon/group-indent.svg?raw';
+
+export const toolbar = [
+  'bold',
+  'underline',
+  'italic',
+  'through',
+  'code',
+  'sub',
+  'sup',
+  'clearStyle',
+  'color',
+  'bgColor',
+  'fontSize',
+  'fontFamily',
+  'indent',
+  'delIndent',
+  'justifyLeft',
+  'justifyRight',
+  'justifyCenter',
+  'justifyJustify',
+  'lineHeight',
+  'insertImage',
+  'deleteImage',
+  'editImage',
+  'viewImageLink',
+  'imageWidth30',
+  'imageWidth50',
+  'imageWidth100',
+  'divider',
+  'emotion',
+  'insertLink',
+  'editLink',
+  'unLink',
+  'viewLink',
+  'codeBlock',
+  'blockquote',
+  'headerSelect',
+  'header1',
+  'header2',
+  'header3',
+  'header4',
+  'header5',
+  'todo',
+  'redo',
+  'undo',
+  'fullScreen',
+  'enter',
+  'bulletedList',
+  'numberedList',
+  'insertTable',
+  'deleteTable',
+  'insertTableRow',
+  'deleteTableRow',
+  'insertTableCol',
+  'deleteTableCol',
+  'tableHeader',
+  'tableFullWidth',
+  'insertVideo',
+  'uploadVideo',
+  'editVideoSize',
+  'uploadImage',
+  'codeSelectLang',
+] as const;
+
+export function getDefaultToolbarConfig() {
+  return [
+    'headerSelect',
+    'fontSize',
+    'fontFamily',
+    'lineHeight',
+    '|',
+    'bold',
+    'italic',
+    'underline',
+    'through',
+    'sup',
+    'sub',
+    'color',
+    'bgColor',
+    'clearStyle',
+    '|',
+    'justifyLeft',
+    'justifyCenter',
+    'justifyRight',
+    // 'justifyJustify',
+    {
+      key: 'group-indent',
+      title: '缩进',
+      iconSvg: groupIndentRaw,
+      menuKeys: ['indent', 'delIndent'],
+    },
+    '|',
+    'bulletedList',
+    'numberedList',
+    'todo',
+    '|',
+    'uploadImage',
+    'uploadVideo',
+    '|',
+    'insertTable',
+    'divider',
+    '|',
+    'undo',
+    'redo',
+    '|',
+    'fullScreen',
+  ];
+}

+ 1 - 0
src/pages/editor/icon/delete.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 1024 1024"><path d="M826.8032 356.5312c-19.328 0-36.3776 15.6928-36.3776 35.0464v524.2624c0 19.328-16 34.56-35.328 34.56H264.9344c-19.328 0-35.5072-15.3088-35.5072-34.56V390.0416c0-19.328-14.1568-35.0464-33.5104-35.0464s-33.5104 15.6928-33.5104 35.0464V915.712c0 57.9328 44.6208 108.288 102.528 108.288H755.2c57.9328 0 108.0832-50.4576 108.0832-108.288V391.4752c-0.1024-19.2512-17.1264-34.944-36.48-34.944z" p-id="9577"></path><path d="M437.1712 775.7568V390.6048c0-19.328-14.1568-35.0464-33.5104-35.0464s-33.5104 15.616-33.5104 35.0464v385.152c0 19.328 14.1568 35.0464 33.5104 35.0464s33.5104-15.7184 33.5104-35.0464zM649.7024 775.7568V390.6048c0-19.328-17.0496-35.0464-36.3776-35.0464s-36.3776 15.616-36.3776 35.0464v385.152c0 19.328 17.0496 35.0464 36.3776 35.0464s36.3776-15.7184 36.3776-35.0464zM965.0432 217.0368h-174.6176V145.5104c0-57.9328-47.2064-101.76-104.6528-101.76h-350.976c-57.8304 0-105.3952 43.8528-105.3952 101.76v71.5264H54.784c-19.4304 0-35.0464 14.1568-35.0464 33.5104 0 19.328 15.616 33.5104 35.0464 33.5104h910.3616c19.328 0 35.0464-14.1568 35.0464-33.5104 0-19.3536-15.6928-33.5104-35.1488-33.5104z m-247.3728 0H297.3952V145.5104c0-19.328 18.2016-34.7648 37.4272-34.7648h350.976c19.1488 0 31.872 15.1296 31.872 34.7648v71.5264z"></path></svg>

+ 1 - 0
src/pages/editor/icon/group-image.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 1024 1024"><path d="M959.877 128l0.123 0.123v767.775l-0.123 0.122H64.102l-0.122-0.122V128.123l0.122-0.123h895.775zM960 64H64C28.795 64 0 92.795 0 128v768c0 35.205 28.795 64 64 64h896c35.205 0 64-28.795 64-64V128c0-35.205-28.795-64-64-64zM832 288.01c0 53.023-42.988 96.01-96.01 96.01s-96.01-42.987-96.01-96.01S682.967 192 735.99 192 832 234.988 832 288.01zM896 832H128V704l224.01-384 256 320h64l224.01-192z"></path></svg>

+ 1 - 0
src/pages/editor/icon/group-indent.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 1024 1024"><path d="M0 64h1024v128H0z m384 192h640v128H384z m0 192h640v128H384z m0 192h640v128H384zM0 832h1024v128H0z m0-128V320l256 192z"></path></svg>

+ 1 - 0
src/pages/editor/icon/group-video.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 1024 1024"><path d="M981.184 160.096C837.568 139.456 678.848 128 512 128S186.432 139.456 42.816 160.096C15.296 267.808 0 386.848 0 512s15.264 244.16 42.816 351.904C186.464 884.544 345.152 896 512 896s325.568-11.456 469.184-32.096C1008.704 756.192 1024 637.152 1024 512s-15.264-244.16-42.816-351.904zM384 704V320l320 192-320 192z"></path></svg>

+ 5 - 0
src/pages/editor/index.ts

@@ -0,0 +1,5 @@
+import __index_vue from './index.vue';
+
+export type * from './types';
+export { isEditorInstance } from './tools';
+export { __index_vue as Editor };

+ 94 - 0
src/pages/editor/index.vue

@@ -0,0 +1,94 @@
+<script setup lang="ts">
+import '@wangeditor/editor/dist/css/style.css';
+
+import { message, notification } from 'ant-design-vue';
+import type { NotificationArgsProps } from 'ant-design-vue/es/notification';
+import type { MessageArgsProps } from 'ant-design-vue/es/message';
+import type { EditorEmits, EditorInstance, EditorProps } from './types';
+import { useEditor } from './useEditor';
+
+defineOptions({
+  name: 'EditorContainer',
+});
+
+const props = defineProps<EditorProps>();
+const emits = defineEmits<EditorEmits>();
+
+let lastText = '';
+let lastContent = '';
+const [model, modifiers] = defineModel<string, 'html' | 'text'>('content', {
+  set(value) {
+    return modifiers.text ? lastText : value;
+  },
+});
+
+const container = useTemplateRef('container');
+
+const instance = useEditor(container, props, {
+  onCreated(editor) {
+    emits('created', editor);
+    monitor(editor);
+  },
+  onChange(editor, content) {
+    emits('change', editor, content);
+    lastText = content.text;
+    lastContent = content.html;
+    model.value = content.html;
+  },
+});
+
+watchEffect(() => {
+  if (modifiers.text) return;
+  const value = model.value;
+  if (instance.editor.value && value != null && value !== lastContent) {
+    instance.editor.value.setHtml(value ?? '');
+  }
+});
+
+const n = (type: 'info' | 'success' | 'warning' | 'error', payload: NotificationArgsProps) => notification[type]({
+  ...payload,
+  getContainer: () => (container.value as HTMLElement) ?? document.body,
+});
+const m = (payload: MessageArgsProps) => message[payload.type ?? 'info'](payload);
+
+function monitor(editor: EditorInstance) {
+  editor.on('upload:progress', (event) => n('info', event));
+  editor.on('upload:success', (event) => n('success', event));
+  editor.on('upload:failed', (event) => n('error', event));
+  editor.on('message', (event) => m(event));
+}
+
+defineExpose(instance);
+</script>
+
+<template>
+  <div class="editor-container" ref="container"></div>
+</template>
+
+<style scoped lang="scss">
+.editor-container {
+  display: flex;
+  flex-direction: column-reverse !important;
+  height: 100%;
+  &.w-e-full-screen-container {
+    background-color: #fff;
+  }
+  &:not(.w-e-full-screen-container) {
+    max-height: var(--height, 100%);
+  }
+  overflow: hidden;
+
+  position: relative;
+  :deep(.ant-notification) {
+    top: 40px !important;
+    position: absolute !important;
+  }
+}
+:deep(.toolbar-container) {
+  flex: none;
+}
+:deep(.content-container) {
+  flex: auto;
+  overflow-y: auto;
+}
+</style>

+ 42 - 0
src/pages/editor/menus/delete-video.ts

@@ -0,0 +1,42 @@
+import { DomEditor, type IButtonMenu, type IDomEditor } from '@wangeditor/core';
+import { SlateTransforms } from '@wangeditor/editor';
+import deleteRaw from '../icon/delete.svg?raw';
+
+class DeleteVideoButtonMenu implements IButtonMenu {
+  readonly title = '删除视频';
+  readonly iconSvg = deleteRaw;
+  readonly tag = 'button';
+
+  getValue(editor: IDomEditor): string | boolean {
+    // 无需获取 val
+    return '';
+  }
+
+  isActive(editor: IDomEditor): boolean {
+    // 无需 active
+    return false;
+  }
+
+  isDisabled(editor: IDomEditor): boolean {
+    if (editor.selection == null) return true;
+
+    const videoNode = DomEditor.getSelectedNodeByType(editor, 'video');
+    return videoNode == null;
+  }
+
+  exec(editor: IDomEditor, value: string | boolean) {
+    if (this.isDisabled(editor)) return;
+
+    // 删除视频
+    SlateTransforms.removeNodes(editor, {
+      match: (n) => DomEditor.checkNodeType(n, 'video'),
+    });
+  }
+}
+
+export default {
+  key: 'deleteVideo',
+  factory(): DeleteVideoButtonMenu {
+    return new DeleteVideoButtonMenu();
+  },
+};

+ 1 - 0
src/pages/editor/menus/index.ts

@@ -0,0 +1 @@
+export { default as deleteVideoMenu } from './delete-video';

+ 3 - 0
src/pages/editor/module/index.ts

@@ -0,0 +1,3 @@
+import ctrlEnterModule from '@wangeditor/plugin-ctrl-enter';
+import markdownModule from '@wangeditor/plugin-md';
+export { ctrlEnterModule, markdownModule };

+ 106 - 0
src/pages/editor/tools.ts

@@ -0,0 +1,106 @@
+import { Boot } from '@wangeditor/editor';
+import type { EditorInstance, EditorUploadProps } from './types';
+import { ctrlEnterModule, markdownModule } from './module';
+import { deleteVideoMenu } from './menus';
+
+export function upload(this: EditorInstance, props: EditorUploadProps) {
+  const accept = props.accept?.split(',').map((v) => v.trim()) ?? [];
+  if (accept.includes('*/*')) accept.length = 0;
+
+  const maxFileSize = props.maxFileSize;
+  const minFileSize = props.minFileSize;
+
+  return {
+    customUpload: async (file: File, insertFn: (url: string, altOrPoster: string) => void) => {
+      const isVideo = file.type.startsWith('video');
+      const progress = (value: number, event?: { loaded: number; total: number }) => {
+        this.showProgressBar(value);
+        const total = event?.total ?? file.size;
+        const loaded = event?.loaded ?? (value ? total : 0);
+        this.emit('upload:progress', {
+          key: `upload-${file.name}`,
+          message: value ? `${file.name} 文件已上传:${value}%` : `${file.name} 文件:开始上传 `,
+          description: `请等待上传完成在操作编辑器`,
+          total,
+          loaded,
+          progress: value,
+        });
+      };
+      const success = (data: { url: string; name?: string; poster?: string }) => {
+        this.emit('upload:success', {
+          key: `upload-${file.name}`,
+          message: `${file.name} 文件:上传成功`,
+          description: `请操作编辑器`,
+          ...data,
+        });
+        this.alert(`上传成功`, 'success');
+      };
+      const failed = (error: { message: string }) => {
+        this.emit('upload:failed', {
+          key: `upload-${file.name}`,
+          message: `${file.name} 文件:上传失败`,
+          description: error.message,
+        });
+        this.alert(`上传失败(${error.message})`, 'error');
+      };
+      let off: any;
+      try {
+        if (props.onBeforeUpload) props.onBeforeUpload?.call(this, file, props);
+        else {
+          const type = file.type;
+          if (/*type && */ accept.length && !(accept.includes(type) || accept.includes(type.split('/')[0] + '/*'))) {
+            throw { message: `文件类型(${type || '未知'})不允许, 仅允许: ${accept.join('; ')}` };
+          }
+
+          const size = file.size;
+          if (size && minFileSize && size < minFileSize) throw { message: `${file.name} 文件太小` };
+          if (size && maxFileSize && size > maxFileSize) throw { message: `${file.name} 文件太大` };
+        }
+        const method = props.handle(file);
+        off = method.onUpload((event) => {
+          const decimal = 10 ** 2;
+          const value = Math.floor((event.loaded * 100 * decimal) / event.total) / decimal;
+          progress?.(Math.min(value, 99), event);
+        });
+        progress(0);
+        const { url, name = file.name, poster = '' } = await method;
+        insertFn(url, isVideo ? poster : name);
+        progress(100);
+        success({ url, name, poster });
+      } catch (error: any) {
+        failed(error);
+      }
+      off?.();
+    },
+  };
+}
+
+export function getResources(this: EditorInstance, type?: 'image' | 'video'): string[] {
+  if (type === 'image') return this.getElemsByType('image').map((item: any) => item.src);
+  if (type === 'video') return this.getElemsByType('video').flatMap((item: any) => [item.src, item.poster]);
+  return [...this.getElemsByType('image'), ...this.getElemsByType('video')].flatMap((item: any) => [item.src, item.poster]).filter(Boolean);
+}
+
+let booted = false;
+export function register() {
+  if (booted) return;
+  Boot.registerModule(markdownModule);
+  // Boot.registerModule(ctrlEnterModule);
+  Boot.registerMenu(deleteVideoMenu);
+  booted = true;
+}
+
+export function isEditorInstance(instance: unknown): instance is EditorInstance {
+  return instance != null && typeof instance === 'object' && typeof (instance as any)['getHtml'] === 'function';
+}
+
+export function getElement(parent: HTMLElement, selector: `#${string}` | `.${string}`): HTMLElement {
+  let element = parent.querySelector<HTMLElement>(selector);
+  if (!element) {
+    element = document.createElement('div');
+    if (selector.startsWith('#')) element.id = selector.slice(1);
+    else if (selector.startsWith('.')) element.classList.add(selector.slice(1));
+    parent.appendChild(element);
+  }
+  return element;
+}

+ 57 - 0
src/pages/editor/types.ts

@@ -0,0 +1,57 @@
+import type { IDomEditor, Toolbar } from '@wangeditor/core';
+import type { SlateElement } from '@wangeditor/editor';
+import { toolbar } from './config';
+
+type ToolbarKey = (typeof toolbar)[number];
+
+export interface EditorProps {
+  placeholder?: string;
+  disable?: boolean;
+  defaultValue?: string;
+
+  uploadPicture?: EditorUploadProps<'image/*' | `image/${string}`>;
+  uploadVideo?: EditorUploadProps<'video/*' | `video/${string}`>;
+
+  toolbar?: Array<
+    | ToolbarKey
+    | '|'
+    | {
+        key: `group-${string}`;
+        title: string;
+        iconSvg: string;
+        menuKeys: ToolbarKey[];
+      }
+  >;
+}
+
+export interface EditorEmits {
+  created: [editor: IDomEditor];
+  change: [editor: IDomEditor, content: { html: string; text: string }];
+}
+
+export type EditorInstance = IDomEditor;
+export type ToolbarInstance = Toolbar;
+
+export interface EditorUploadProps<Accept extends string = string> {
+  accept: Accept;
+  handle: EditorUploadHandle;
+  onBeforeUpload?: (this: EditorInstance, file: File, props: Pick<EditorUploadProps, 'accept' | 'minFileSize' | 'maxFileSize'>) => void;
+  maxFileSize?: number;
+  minFileSize?: number;
+}
+
+export interface EditorUploadHandle {
+  (file: File): Promise<{ url: string; name?: string; poster?: string }> & { onUpload(callback: (event: { loaded: number; total: number }) => void): () => void };
+}
+
+export type EditorImageElement = SlateElement & {
+  src: string;
+  alt: string;
+  url?: string;
+  href?: string;
+};
+
+export type EditorVideoElement = SlateElement & {
+  src: string;
+  poster?: string;
+};

+ 134 - 0
src/pages/editor/useEditor.ts

@@ -0,0 +1,134 @@
+import { type MaybeComputedElementRef, tryOnMounted, tryOnUnmounted, unrefElement, useElementSize } from '@vueuse/core';
+import { createEditor, createToolbar } from '@wangeditor/editor';
+import { getDefaultToolbarConfig } from './config';
+import { getElement, getResources, isEditorInstance, register, upload } from './tools';
+import type { EditorEmits, EditorImageElement, EditorInstance, EditorProps, EditorVideoElement, ToolbarInstance } from './types';
+
+export type EmitsToProps<T extends Record<string, any>> = {
+  [K in keyof T as `on${Capitalize<string & K>}`]?: T[K] extends [infer U] ? (event: U) => void : T[K] extends [...infer Args] ? (...args: Args) => void : never;
+};
+
+export function useEditor(target: MaybeComputedElementRef, props?: EditorProps, emits?: EmitsToProps<EditorEmits>) {
+  register();
+
+  const editor = shallowRef<EditorInstance>();
+  const toolbar = shallowRef<ToolbarInstance>();
+
+  const handle1 = useElementSize(target, { height: 0, width: 0 });
+  const handle2 = watch(
+    [editor, () => props?.defaultValue],
+    ([editor, html]) => {
+      if (!isEditorInstance(editor)) return;
+      const value = toValue(html);
+      if (value) editor.dangerouslyInsertHtml(value);
+    },
+    { immediate: true }
+  );
+  const handle3 = watch(
+    [editor, () => props?.disable],
+    ([editor, disable = false]) => {
+      if (!isEditorInstance(editor)) return;
+      if (disable) editor.disable();
+      else editor.enable();
+    },
+    { immediate: true }
+  );
+
+  const stop = () => {
+    handle1.stop();
+    handle2.stop();
+    handle3.stop();
+  };
+
+  const resources = new Set<string>();
+
+  tryOnMounted(() => {
+    const container = unrefElement(target);
+    if (container) {
+      const height = handle1.height.value;
+      if (container && height > 0 && /* 全屏 */ height < window.innerHeight) container.style.setProperty('--height', `${height}px`);
+
+      const querySelector = getElement.bind(null, container as HTMLElement);
+      editor.value = createEditor({
+        selector: querySelector('.content-container')!,
+        html: toValue(props?.defaultValue ?? ''),
+        config: {
+          placeholder: toValue(props?.placeholder ?? '请输入内容...'),
+          scroll: false,
+          MENU_CONF: {
+            insertImage: {
+              onInsertedImage(node: EditorImageElement | null) {
+                if (node?.src) resources.add(node.src);
+              },
+            },
+            insertVideo: {
+              onInsertedVideo(node: EditorVideoElement | null) {
+                if (node?.src) resources.add(node.src);
+              },
+            },
+            deleteVideo: {},
+          },
+          hoverbarKeys: {
+            text: {
+              menuKeys: ['headerSelect', '|', 'bold', 'italic', 'underline', 'through', 'sup', 'sub', 'color', 'bgColor', 'clearStyle'],
+            },
+            video: {
+              menuKeys: ['enter', 'editVideoSize', 'deleteVideo'],
+            },
+          },
+          onCreated(editor) {
+            emits?.onCreated?.call(editor, editor);
+          },
+          onChange(editor) {
+            const text = editor.getText();
+            const html = text ? editor.getHtml() : '';
+            emits?.onChange?.call(editor, editor, { html, text });
+          },
+          customAlert(message, type) {
+            editor.value?.emit('message', { type, content: message });
+          },
+        },
+      });
+      toolbar.value = createToolbar({
+        selector: querySelector('.toolbar-container')!,
+        editor: editor.value,
+        config: {
+          toolbarKeys: toValue(props?.toolbar) ?? getDefaultToolbarConfig(),
+          excludeKeys: [],
+        },
+      });
+
+      if (props?.uploadPicture) Object.assign(editor.value.getMenuConfig('uploadImage'), upload.call(editor.value, props.uploadPicture));
+      else toolbar.value?.getConfig().excludeKeys?.push('uploadImage');
+
+      if (props?.uploadVideo) Object.assign(editor.value.getMenuConfig('uploadVideo'), upload.call(editor.value, props.uploadVideo));
+      else toolbar.value?.getConfig().excludeKeys?.push('uploadVideo');
+    }
+  });
+  tryOnUnmounted(() => {
+    stop();
+    editor.value?.destroy();
+    toolbar.value = void 0;
+    editor.value = void 0;
+    resources.clear();
+  });
+
+  function save() {
+    if (!editor.value) throw { message: `编辑器未初始化` };
+
+    const text = editor.value.getText();
+    if (!text?.trim().length) throw { message: `编辑器内容为空` };
+
+    const lapsedResources: string[] = [];
+    if (resources.size) {
+      const value = getResources.call(editor.value);
+      resources.forEach((r) => {
+        if (!value.includes(r)) lapsedResources.push(r);
+      });
+    }
+    const html = editor.value?.getHtml();
+    return { text, html, lapsedResources };
+  }
+
+  return { editor, toolbar, save };
+}

+ 45 - 0
src/request/upload.ts

@@ -0,0 +1,45 @@
+import { createAlova } from 'alova';
+import VueHook from 'alova/vue';
+import { xhrRequestAdapter } from '@alova/adapter-xhr';
+import { getToken } from '@/request/tools';
+
+import { notification } from 'ant-design-vue';
+
+const request = createAlova({
+  baseURL: '/manager/',
+  statesHook: VueHook,
+  requestAdapter: xhrRequestAdapter(),
+  async beforeRequest(method) {
+    if (!method.config.meta?.ignoreToken) method.config.headers.Authorization ??= getToken();
+  },
+  responded: {
+    async onSuccess(response, method) {
+      try {
+        if (response.status >= 400) throw new Error(`${response.statusText}(${response.status})`);
+        const result = await response.data;
+        /* 接口修正 code */
+        if (result.success !== false && result.code === 200) result.code = 0;
+        const { error = false, warn = false, code = error || warn ? -1 : 0, msg: message = '未知错误', data } = result;
+        if (code === 0) return data;
+        throw new Error(`${message}(${code})`);
+      } catch (e: any) {
+        if (!method.meta?.ignoreException) {
+          notification.error({
+            message: method.url,
+            description: e?.message,
+            key: method.url,
+          });
+        }
+        throw e;
+      }
+    },
+  },
+});
+
+export default function upload(file: File, ignoreException = false) {
+  const formData = new FormData();
+  formData.append('file', file);
+  return request.Post<{ url: string; name: string }>(`/file/upload`, formData, {
+    meta: { ignoreException },
+  });
+}

Неке датотеке нису приказане због велике количине промена