【element-tiptap】导入word并解析成HTML

时间:2024-11-14 09:17:05

前言:实现导入 word 并解析成 HTML 的功能,使用的是一个开源的代码库 word-file-transform。但其中也有修改的部分,所以还是把代码放到项目中,而不是使用 npm 下载。
实现功能:

  • 增加一个菜单项,使用 element-plus 的 el-upload 组件,上传 docx 文件
  • 将 word 文件解析成 HTML ,并且替换整个文档
1、svg小图标

图标可以从 fontawesome 这个网站找,这个网站挺好用,还都提供的有svg图标。
src/icons/import.svg

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="24" height="24">
  <path d="M128 64c0-35.3 28.7-64 64-64L352 0l0 128c0 17.7 14.3 32 32 32l128 0 0 288c0 35.3-28.7 64-64 64l-256 0c-35.3 0-64-28.7-64-64l0-112 174.1 0-39 39c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l80-80c9.4-9.4 9.4-24.6 0-33.9l-80-80c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l39 39L128 288l0-224zm0 224l0 48L24 336c-13.3 0-24-10.7-24-24s10.7-24 24-24l104 0zM512 128l-128 0L384 0 512 128z"/>
</svg>
2、下载word-file-transform

将其中的 src 目录放到编辑器的 utils 目录下。
在这里插入图片描述

其中用到了另一个依赖,需要下载一下 npm install --save mammoth。并且,引入方式要从 require 改成 import。在 src/utils/word-file-transform/config/transformFn.js 组件中修改。
其实这个库还用到了另外一个依赖 nanoajax,是用来上传图片的,但是经过我的测试,用它上传图片总是报错,所以关于上传图片的代码我有一些修改。但是上传图片主要还是要依赖于你的项目中后端提供的接口,所以说,如果没后端接口的话,图片就解析不了了。
主要修改了
src/utils/word-file-transform/config/transformFn.js

import mammoth from "mammoth";
import { defaultConfig } from "../config/config";
import { uploadFile } from "@/utils/uploader";

export default {
    "image": async params => {
        try {
            const {
                imageUploadUrl,
                imageUrlTransformFn,
                imageTransformErrorFn,
                imageTransformSuccessFn,
                headers = {}
            } = params;

            return await mammoth.images.imgElement(async image => {
                const { imageFilterType, imageTypeErrorMsg } = defaultConfig;
                const imageType = image.contentType;

                if (imageFilterType.test(imageType)) {
                    const errObj = imageTypeErrorMsg(imageType);
                    imageTransformErrorFn && imageTransformErrorFn(errObj);
                    return false;
                }

                try {
                    // 将图片 buffer 转换为 File 对象
                    const imageBuffer = await image.read("base64");
                    const byteCharacters = atob(imageBuffer);
                    const byteArrays = [];

                    for (let offset = 0; offset < byteCharacters.length; offset += 512) {
                        const slice = byteCharacters.slice(offset, offset + 512);
                        const byteNumbers = new Array(slice.length);

                        for (let i = 0; i < slice.length; i++) {
                            byteNumbers[i] = slice.charCodeAt(i);
                        }

                        const byteArray = new Uint8Array(byteNumbers);
                        byteArrays.push(byteArray);
                    }

                    const blob = new Blob(byteArrays, { type: imageType });
                    const file = new File([blob], `word-image-${Date.now()}.${imageType.split('/')[1]}`, { type: imageType });

                    // 使用 uploader.ts 中的方法上传图片
                    const uploadResult = await uploadFile({
                        file,
                        url: imageUploadUrl,
                        headers,
                        data: {
                            // 可选的额外表单数据
                            type: 'image'
                          },
                        onProgress: (progress) => {
                            console.log('Upload progress:', progress);
                        }
                    });

                    // 处理上传结果
                    imageTransformSuccessFn && imageTransformSuccessFn(uploadResult);

                    // 返回图片 URL
                    const imageUrl = imageUrlTransformFn ? imageUrlTransformFn(uploadResult) : uploadResult;
                    return { src: imageUrl };
                } catch (error) {
                    imageTransformErrorFn && imageTransformErrorFn({
                        msg: '图片上传失败:' + (error.message || '未知错误')
                    });
                    return false;
                }
            });
        } catch (error) {
            return false;
        }
    },

    "html": params => {
        const { input, options, config } = params;
        mammoth.convertToHtml(input, options)
            .then(res => {
                config.transformSuccessFn({
                    content: res.value,
                    title: config.wordTitle
                });
            })
            .catch(err => {
                config.transformErrorFn && config.transformErrorFn(err);
            });
    }
};

其中上传的方法:
src/utils/uploader.ts

export interface UploadOptions {
  file: File
  url: string
  headers?: Record<string, string>
  onProgress?: (progress: number) => void
  data?: Record<string, any>
}

export interface UploadResult {
  url: string
  [key: string]: any
}

export async function uploadFile(options: UploadOptions): Promise<UploadResult> {
  const { file, url, headers = {}, onProgress, data = {} } = options

  const formData = new FormData()
  formData.append('file', file)

  // 添加额外的表单数据
  Object.entries(data).forEach(([key, value]) => {
    formData.append(key, value)
  })

  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        ...headers
      },
      body: formData
    })

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    return await response.json();

  } catch (error) {
    throw new Error(`文件上传失败: ${error instanceof Error ? error.message : '未知错误'}`)
  }
}
3、上传文件的组件

上传文件使用的是 element-plus 的 el-upload 组件
其中的 beforeUpload 方法,在这个开源库中有示例,在这个函数中获取文件并且进行转换操作。最终返回的是 reject() ,也就是阻止后续的上传操作。在我的代码中,进行了一些修改,主要是在解析成功的回调函数中,去调用命令,重新设置编辑器的内容。其中还使用了 el-loading 组件,在上传的过程中增加一个覆盖框,解析完毕后给出提示
src/components/menu-commands/word-import.upload.vue

<template>
  <div>
    <el-upload ref="upload" class="word-upload" :show-file-list="false" accept=".docx" :before-upload="beforeUpload">
      <command-button :tooltip="t('editor.extensions.WordImport.tooltip')" icon="import" />
    </el-upload>
  </div>
</template>

<script lang="ts">
import { defineComponent, inject, ref } from 'vue';
import { ElUpload, ElButton, ElLoading, ElMessage } from 'element-plus';
import WordFileTransform from '@/utils/word-file-transform';
import { Editor } from '@tiptap/core';
import CommandButton from './command.button.vue';

export default defineComponent({
  components: {
    CommandButton,
    ElUpload,
    ElButton
  },

  props: {
    editor: {
      type: Editor,
      required: true,
    },
    buttonIcon: {
      default: '',
      type: String
    },
    uploadUrl: {
      type: String,
      required: true,
    },
  },

  setup() {
    const t = inject('t');
    const enableTooltip = inject('enableTooltip', true);
    const fileInput = ref<HTMLInputElement | null>(null);
    const wordHtml = ref<string>('');
    const loading = ref(false);

    return { t, enableTooltip, fileInput, wordHtml, loading };
  },

  methods: {
    beforeUpload(file, fileList) {
      return new Promise(async (resolve, reject) => {
        const loadingInstance = ElLoading.service({
          lock: true,
          text: '正在解析文档...',
          background: 'rgba(0, 0, 0, 0.7)',
        });

        const _this = this;
        const transformSuccessFn = function (data) {
          try {
            _this.editor.commands.setWordContent(data.content);
            loadingInstance.close();
            ElMessage.success('文档导入成功!');
          } catch (error) {
            loadingInstance.close();
            ElMessage.error('文档处理失败:' + error.message);
          }
        };

        const transformErrorFn = function (err) {
          loadingInstance.close();
          ElMessage.error('文档解析失败:' + (err?.message || '未知错误'));
        };

        try {
          const userToken = '';
          const props = {
            file,
            config: {
              imageTransform: {
                imageUploadUrl: this.uploadUrl,
                axiosPostConfig: { headers: { token: userToken } }
              },
              wordTransform: {
                transformSuccessFn,
                transformErrorFn
              }
            }
          };
          let WordTransform = new WordFileTransform(props);
          await WordTransform.wordTransform();
        } catch (error) {
          loadingInstance.close();
          ElMessage.error('文档处理出错:' + error.message);
        }
        reject();
      });
    },
  },
});
</script>

<style>
.word-upload .el-upload {
  display: inline-block;
  vertical-align: top;
}
</style>
4、扩展文件

扩展文件需要提供修改编辑器内容的方法,其中需要自行设置图片上传地址 uploadUrl

import { Extension } from '@tiptap/core';
import { Editor } from '@tiptap/vue-3';
import WordImportUpload from '@/components/menu-commands/word-import.upload.vue';
const uploadUrl = '你的图片上传的地址';
export interface WordImportOptions {
  uploadUrl: string;
}

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    wordImport: {
      setWordContent: (content: string) => ReturnType;
    };
  }

  interface EditorOptions {
    wordImport?: Partial<WordImportOptions>;
  }
}

const WordImport = Extension.create<WordImportOptions>({
  name: 'wordImport',

  addCommands() {
    return {
      setWordContent:
        (content: string) =>
        ({ commands }) => {
          if (content) {
            // 替换整个文档,第二个参数表示是否触发更新的监听器
            return commands.setContent(content, true);
          }
          return false;
        },
    };
  },

  addOptions() {
    return {
      button({ editor }: { editor: Editor }) {
        return {
          component: WordImportUpload,
          componentProps: {
            editor,
            uploadUrl
          },
        };
      },
    };
  },
});

export default WordImport;

还有其他的文件的修改,例如 tooltip 的设置、扩展在根组件的引入等,就不一一列举了,相信看到这里的童鞋对这些常规修改已经灰常的熟悉了,不熟悉的童鞋可以看看我之前的文章哦。