Tinymce插件设计——文件上传插件(1)

1,067 阅读4分钟

1 动机

尽管tinymce已经有link插件,但是不能完全满足客户的需求,因此采取自行编写插件的方式来设计。 首先,明确自己的需求。

  1. 实现图片、视频、文件的上传,上传至七牛云
  2. 有预览区,对于视频、图片可以设置宽高
  3. 根据返回URL,以及不同的文件类型,以HTML的形式插入到editor
  4. 对于大文件、视频,实现分片上传,支持断点续传

本文主要是实现1(除上传至七牛云)2以及3,并且实现插件打包

2 实现过程

2.1 预备工作

新建一个文件夹,根据Yeoman生成器的步骤新建一个项目并且npm start运行(别忘了npm install),我创建的插件名称为media-7-n

接下来的修改都会在plugin.ts中进行,该文件的初始内容如下

import { Editor, TinyMCE } from 'tinymce';

declare const tinymce: TinyMCE;

const setup = (editor: Editor, url: string): void => {
  //注册按钮
  editor.ui.registry.addButton('media-7-n', {
    text: 'media-7-n button',
    //初始的行为
    onAction: () => {
      editor.setContent('<p>content added from media-7-n</p>');
    }
  });
};

export default (): void => {
  tinymce.PluginManager.add('media-7-n', setup);
};

2.2 插件UI设置

根据需求,我们的界面需要五个部分,文件上传按钮,宽度设置、高度设置、注释填写、预览区。根据tinymce提供的文档,设计了如下五个区域。并且设计弹窗的标题以及按钮。

在plugin.ts中修改

  editor.ui.registry.addButton('media-7-n', {
    text: '文件上传',
    icon: 'link',
    onAction: () => {
      const dialogConfig: any = {
        title: '文件上传',
        body: {
          type: 'panel',
          items: [     
            {
              type: 'urlinput',
              name: 'source',
              filetype: 'media',
              label: 'Source'
            },
            {
              type: 'input',
              name: 'width',
              label: '宽度'
            },
            {
              type: 'input',
              name: 'height',
              label: '高度'
            },
            {
              type: 'input', // component type
              name: 'note', // identifier
              label: '注释 (图片和视频)', // text for the iframe's title attribute
            },
            {
              type: 'iframe', // component type
              name: 'iframe', // identifier
              label: '预览', // text for the iframe's title attribute
              sandboxed: true,
              transparent: true
            },

          ],
        },
        buttons: [
          {
              type: 'cancel',
              text: '取消',
          },
          {
              type: 'submit',
              text: '确定',
              primary: true
          }
      ],
    }
    }
})

注意:如果使用了urlinput,一定要在tinymce的初始化函数中加入file_picker_callback函数,才会出现文件上传按钮的

2.3 触发事件onChange

注册一个onChange事件,用于文件上传后,依据不同的文件类型更新到预览区。使用api.setData更新到iframe区域。

// 文件后缀识别
const fileType = {
  image:['jpg','jpeg','png','tif','svg'],
  video:['mp4',"rmvb", "rm", "asf", "divx", "mpg", "mpeg", "wmv", "mkv", "vob"]
}
const uploadFile = (api: any): void => {
  const data = api.getData();
  // 获取文件
  const file = data?.source;
  // 获取文件名后缀 如jpg
  const fileExt = file?.meta?.title?.split('.')[1]
  // 不同文件行为实现不同的预览
  if(fileType.image.indexOf(fileExt)!==-1){
    api.setData({
      iframe: '<!DOCTYPE html>' +
              '<html>' +
              '<head></head>' +
              '<body>'+
              `<img style="width:100%" src=${file?.value}></img>`+
              '</body>' +
              '</html>'
    })
  }
  else if(fileType.video.indexOf(fileExt)!==-1){
    api.setData({
      iframe: '<!DOCTYPE html>' +
              '<html>' +
              '<head></head>' +
              '<body>'+
              `<video style="width:100%" src=${file?.value}></video>`+
              '</body>' +
              '</html>'
    })
  }else{
    api.setData({
      iframe: '<!DOCTYPE html>' +
              '<html>' +
              '<head></head>' +
              '<body>'+
              '<div style="display:flex;width:calc(100% - 20px);height:100px;padding:10px;background:#f5f5f5;border-radius:15px;">'+
              `<div style="width:90px;height:90px;margin-right:10px;background:#eeeeff;border-radius:13px;"><svg style="width:100%;height:100%; t="1683031868915" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2669" width="200" height="200"><path d="M912 208H427.872l-50.368-94.176A63.936 63.936 0 0 0 321.056 80H112c-35.296 0-64 28.704-64 64v736c0 35.296 28.704 64 64 64h800c35.296 0 64-28.704 64-64v-608c0-35.296-28.704-64-64-64z m-800-64h209.056l68.448 128H912v97.984c-0.416 0-0.8-0.128-1.216-0.128H113.248c-0.416 0-0.8 0.128-1.248 0.128V144z m0 736v-96l1.248-350.144 798.752 1.216V784h0.064v96H112z" fill="#020202" p-id="2670"></path></svg></div><div style="width:60%">${file?.value}</div>`+
              '</div>'+
              '</body>' +
              '</html>'
    })
  }
}

2.4 提交事件

注册一个提交事件onSubmit,主要是用editor.insertContenteditor中插入html。(代码中的editor变量的定义详见完整代码)

onSubmit(api:any) {
        const data = api.getData();
        // 获取文件
        const file = data?.source;
        // 获取文件名后缀 如jpg
        const fileExt = file?.meta?.title?.split('.')[1]
        // 插入到文档中
        if(fileType.image.indexOf(fileExt)!==-1){
          editor.insertContent(`<div><img style="width:${data.width?data.width+'px':'375px'};height:${data.height?data.height+'px':''}" src=${file?.value}></img><p style="text-align:center; margin-top: -10px;">${data.note}</p></div>`)
        }
        else if(fileType.video.indexOf(fileExt)!==-1){
          editor.insertContent(`<div><video style="width:${data.width?data.width+'px':'375px'};height:${data.height?data.height+'px':''}" src=${file?.value}></video><p style="text-align:center;margin-top: -10px;">${data.note}</p></div>`)
        }else{
          editor.insertContent(`<div style="display:flex;width:calc(100% - 20px);height:100px;padding:10px;background:#f5f5f5;border-radius:15px;"><div style="width:90px;height:90px;margin-right:10px;background:#eeeeff;border-radius:13px;"><svg style="width:100%;height:100%; t="1683031868915" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2669" width="200" height="200"><path d="M912 208H427.872l-50.368-94.176A63.936 63.936 0 0 0 321.056 80H112c-35.296 0-64 28.704-64 64v736c0 35.296 28.704 64 64 64h800c35.296 0 64-28.704 64-64v-608c0-35.296-28.704-64-64-64z m-800-64h209.056l68.448 128H912v97.984c-0.416 0-0.8-0.128-1.216-0.128H113.248c-0.416 0-0.8 0.128-1.248 0.128V144z m0 736v-96l1.248-350.144 798.752 1.216V784h0.064v96H112z" fill="#020202" p-id="2670"></path></svg></div><div style="width:60%">${file?.value}</div></div>`)
        }
        api.close();
      }

2.5 完整代码

demo.ts中也要修改代码用于测试,主要是定义了file_picker_callback,用于描述文件上传后的行为。 这个函数中主要是处理文件上传,这里只是作为一个测试函数,并未将文件上传到服务器,在实际使用过程中需要上传文件到服务器。

file_picker_callback函数会在点击上传文件的图标后被触发。这个函数主要的流程为:新增一个input元素->定义type为文件->绑定一个文件上传的事件onchange->点击input元素。最后的点击input元素会打开文件上传的界面。可以在onchange事件中对文件上传的进行按需处理。

以下是demo.ts的代码

import { TinyMCE } from 'tinymce';
import Plugin from '../../main/ts/Plugin';
declare let tinymce: TinyMCE;

Plugin();
const fileUpload =  (cb)=>{
  // 必须定义input,并且点击才会弹出文件上传的框
  const input = document.createElement('input');
  input.setAttribute('type', 'file');
  // 文件上传的事件
  input.onchange = function () {
    // 获取上传的文件
    console.log(this,typeof(this))
    const file = (this as HTMLInputElement).files[0];
    const reader = new FileReader();
    // 读取文件
    reader.onload = function () {
      const id = 'blobid' + (new Date()).getTime();
      const blobCache =  tinymce.activeEditor.editorUpload.blobCache;
      const base64 = (reader?.result as string).split(',')[1];
      const blobInfo = blobCache.create(id, file, base64);
      // tinymce要求的blobInfo
      blobCache.add(blobInfo);

      // cb是回调函数,传回名称和title
      cb(blobInfo.blobUri(), { title: file.name,file:file });
    };
    // readAsDataURL 选择图片文件后即使预览图片,这里用不到
    reader.readAsDataURL(file);
  };

  input.click();
}
tinymce.init({
  selector: 'textarea.tinymce',
  plugins: 'code media-7-n',
  toolbar: 'media-7-n',
  file_picker_types: 'file image media',
  width:'375px',
  height:'900px',
  file_picker_callback: fileUpload
});

以下是plugin.ts的完整代码

import { Editor, TinyMCE } from 'tinymce';
declare const tinymce: TinyMCE;

// 文件后缀识别
const fileType = {
  image:['jpg','jpeg','png','tif','svg'],
  video:['mp4',"rmvb", "rm", "asf", "divx", "mpg", "mpeg", "wmv", "mkv", "vob"]
}
// eslint-disable-next-line
const uploadFile = (api: any): void => {
  const data = api.getData();
  // 获取文件
  const file = data?.source;
  // 获取文件名后缀 如jpg
  const fileExt = (file?.meta?.title as string).split('.')[1]
  // 不同文件行为实现不同的预览
  if(fileType.image.indexOf(fileExt)!==-1){
    api.setData({
      iframe: '<!DOCTYPE html>' +
              '<html>' +
              '<head></head>' +
              '<body>'+
              `<img style="width:100%" src=${file?.value}></img>`+
              '</body>' +
              '</html>'
    })
  }
  else if(fileType.video.indexOf(fileExt)!==-1){
    api.setData({
      iframe: '<!DOCTYPE html>' +
              '<html>' +
              '<head></head>' +
              '<body>'+
              `<video style="width:100%" src=${file?.value}></video>`+
              '</body>' +
              '</html>'
    })
  }else{
    api.setData({
      iframe: '<!DOCTYPE html>' +
              '<html>' +
              '<head></head>' +
              '<body>'+
              '<div style="display:flex;width:calc(100% - 20px);height:100px;padding:10px;background:#f5f5f5;border-radius:15px;">'+
              `<div style="width:90px;height:90px;margin-right:10px;background:#eeeeff;border-radius:13px;"><svg style="width:100%;height:100%; t="1683031868915" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2669" width="200" height="200"><path d="M912 208H427.872l-50.368-94.176A63.936 63.936 0 0 0 321.056 80H112c-35.296 0-64 28.704-64 64v736c0 35.296 28.704 64 64 64h800c35.296 0 64-28.704 64-64v-608c0-35.296-28.704-64-64-64z m-800-64h209.056l68.448 128H912v97.984c-0.416 0-0.8-0.128-1.216-0.128H113.248c-0.416 0-0.8 0.128-1.248 0.128V144z m0 736v-96l1.248-350.144 798.752 1.216V784h0.064v96H112z" fill="#020202" p-id="2670"></path></svg></div><div style="width:60%">${file?.value}</div>`+
              '</div>'+
              '</body>' +
              '</html>'
    })
  }
}

const setup = (editor: Editor): void => {
  editor.ui.registry.addButton('media-7-n', {
    text: '文件上传',
    icon: 'link',
    onAction: () => {
      // 插件的样式
      // eslint-disable-next-line
      const dialogConfig: any = {
        title: '文件上传',
        body: {
          type: 'panel',
          items: [     
            {
              type: 'urlinput',
              name: 'source',
              filetype: 'media',
              label: 'Source'
            },
            {
              type: 'input',
              name: 'width',
              label: '宽度'
            },
            {
              type: 'input',
              name: 'height',
              label: '高度'
            },
            {
              type: 'input', // component type
              name: 'note', // identifier
              label: '注释 (图片和视频)', // text for the iframe's title attribute
            },
            {
              type: 'iframe', // component type
              name: 'iframe', // identifier
              label: '预览', // text for the iframe's title attribute
              sandboxed: true,
              transparent: true
            },

          ],
        },
        buttons: [
          {
              type: 'cancel',
              text: '取消',
          },
          {
              type: 'submit',
              text: '确定',
              primary: true
          }
      ],
      // eslint-disable-next-line
      onChange(api:any) {
        uploadFile(api);
      },
      // eslint-disable-next-line
      onSubmit(api:any) {
        const data = api.getData();
        // 获取文件
        const file = data?.source;
        // 获取文件名后缀 如jpg
        const fileExt = file?.meta?.title?.split('.')[1]
        // 插入到文档中
        if(fileType.image.indexOf(fileExt)!==-1){
          editor.insertContent(`<div><img style="width:${data.width?data.width+'px':'375px'};height:${data.height?data.height+'px':''}" src=${file?.value}></img><p style="text-align:center; margin-top: -10px;">${data.note}</p></div>`)
        }
        else if(fileType.video.indexOf(fileExt)!==-1){
          editor.insertContent(`<div><video style="width:${data.width?data.width+'px':'375px'};height:${data.height?data.height+'px':''}" src=${file?.value}></video><p style="text-align:center;margin-top: -10px;">${data.note}</p></div>`)
        }else{
          editor.insertContent(`<div style="display:flex;width:calc(100% - 20px);height:100px;padding:10px;background:#f5f5f5;border-radius:15px;"><div style="width:90px;height:90px;margin-right:10px;background:#eeeeff;border-radius:13px;"><svg style="width:100%;height:100%; t="1683031868915" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2669" width="200" height="200"><path d="M912 208H427.872l-50.368-94.176A63.936 63.936 0 0 0 321.056 80H112c-35.296 0-64 28.704-64 64v736c0 35.296 28.704 64 64 64h800c35.296 0 64-28.704 64-64v-608c0-35.296-28.704-64-64-64z m-800-64h209.056l68.448 128H912v97.984c-0.416 0-0.8-0.128-1.216-0.128H113.248c-0.416 0-0.8 0.128-1.248 0.128V144z m0 736v-96l1.248-350.144 798.752 1.216V784h0.064v96H112z" fill="#020202" p-id="2670"></path></svg></div><div style="width:60%">${file?.value}</div></div>`)
        }
        api.close();
      }
    };
    editor.windowManager.open(dialogConfig);
    },
    
  });
};

export default (): void => {
  tinymce.PluginManager.add('media-7-n', setup);
};

最后yarn build打包即可

3 结果展示

image.png image.png image.png

参考资料

  1. Yeoman生成器
  2. 设计自定义的组件
  3. 详细的UI文档