word在线编辑

查看一些文档金格插件WebOffice2015、chrome浏览器插件、only-officeUEditorTinyMCECKEditorwangeditorcanvas-editor

最后选择了only-officecanvas-editor

only-office非常功能强大,word、ppt、excel都支持在线编辑预览,还支持协同,又有免费开源版。

附上本地运行demo:

一、安装docker

二、安装并启动 Onlyoffice 服务:

docker run -i -t -d -p 8701:80 onlyoffice/documentserver:版本号

如果是第 1 次执行这个命令,会先去下载 Onlyoffice,比较慢,约等待 3~10 分钟,网络畅通一点的会快一些。如果是已经安装过则直接进行启动。

三、启动内置服务

先执行 docker ps 查看 Onlyoffice 容器 ID:

# 注意这里要将 id 替换成自己的
docker exec -it f2a3eb675ad1 /bin/bash

然后执行 docker exec -it ID /bin/bash 进入容器,这里将获取到的 ID 替换一下:

# 启动所有的内置服务
supervisorctl restart all
# 退出容器
exit

最后访问 http://IP:8701/example 页面(这里要注意,IP 不能是 localhost 和 127.0.0.1,一定要用真实 IP 来访问)

因为开发周期,后端又比较懒不想花时间去看文档。这一方案被我放弃了

最后选择了canvas-editor

canvas-editor

为什么选它了,开发周期短,界面与word编辑器比较像,可以快速集成到vue,虽然作者没有开箱即用版。

在vue中主要实现方式就是采用开源项目代码。

在组件模块,新建vue文件,html采用开源项目代码,分3个部分,工具栏,侧边菜单,主要内容,底部工具,旁边批注。通过import引入开源样式,注意样式冲突。在onMounted,采用开源main.ts window.onload代码。

<div class="menu" editor-component="menu">
...
</div>
<div class="catalog" editor-component="catalog">
...
</div>
<div id="canvasEditor" class="canvas-editor"></div>
<div class="footer-canvas" editor-component="footer">
...
</div>
const instance = new Editor(
    document.querySelector('#canvasEditor'),
    {
      header: props.header,
      main: props.main,
      footer: props.footer
    },
    options
);
console.log('实例', instance);
editorRef.value = instance;

// 工具栏方法 例:
// 2. | 撤销 | 重做 | 格式刷 | 清除格式 |
const undoDom = document.querySelector('.menu-item__undo');
    undoDom.title = `撤销(${isApple ? '⌘' : 'Ctrl'}+Z)`;
    undoDom.onclick = function () {
    console.log('undo');
    instance.command.executeUndo();
};

<style lang="scss" scoped>
#canvasEditor {
  display: flex;
  justify-content: center;
  background: #f2f4f7;
}
@import url(@/assets/css/dialog.css);
@import url(@/assets/css/signature.css);
</style>

思路通过富文本编辑器实现在线编辑功能,通过插件提供的api获取图片base64和文本数据,通过接口保存至数据库,通过列表数据复显编辑,后端获取到图片数据转pdf或者其他格式都可以。自己转pdf则是通过html2canvas jspdf

完整示例

自定义组件结构

–editor

  • –Dialog.js // 源代码文件
  • –index.vue
  • –tool.js // 源代码文件

template部分的html代码

<div>
    <div class="menu" editor-component="menu">
      <div class="menu-item">
        <div class="menu-item__undo">
          <i></i>
        </div>
        <div class="menu-item__redo">
          <i></i>
        </div>
        <div class="menu-item__painter" title="格式刷(双击可连续使用)">
          <i></i>
        </div>
        <div class="menu-item__format" title="清除格式">
          <i></i>
        </div>
      </div>
      <div class="menu-divider"></div>
      <div class="menu-item">
        <div class="menu-item__font">
          <span class="select" title="字体">微软雅黑</span>
          <div class="options">
            <ul>
              <li data-family="Microsoft YaHei" style="font-family: 'Microsoft YaHei'">微软雅黑</li>
              <li data-family="宋体" style="font-family: '宋体'">宋体</li>
              <li data-family="黑体" style="font-family: '黑体'">黑体</li>
              <li data-family="仿宋" style="font-family: '仿宋'">仿宋</li>
              <li data-family="楷体" style="font-family: '楷体'">楷体</li>
              <li data-family="等线" style="font-family: '等线'">等线</li>
              <li data-family="华文琥珀" style="font-family: '华文琥珀'">华文琥珀</li>
              <li data-family="华文楷体" style="font-family: '华文楷体'">华文楷体</li>
              <li data-family="华文隶书" style="font-family: '华文隶书'">华文隶书</li>
              <li data-family="华文新魏" style="font-family: '华文新魏'">华文新魏</li>
              <li data-family="华文行楷" style="font-family: '华文行楷'">华文行楷</li>
              <li data-family="华文中宋" style="font-family: '华文中宋'">华文中宋</li>
              <li data-family="华文彩云" style="font-family: '华文彩云'">华文彩云</li>
              <li data-family="Arial" style="font-family: 'Arial'">Arial</li>
              <li data-family="Segoe UI" style="font-family: 'Segoe UI'">Segoe UI</li>
              <li data-family="Ink Free" style="font-family: 'Ink Free'">Ink Free</li>
              <li data-family="Fantasy" style="font-family: 'Fantasy'">Fantasy</li>
            </ul>
          </div>
        </div>
        <div class="menu-item__size">
          <span class="select" title="字体">小四</span>
          <div class="options">
            <ul>
              <li data-size="56">初号</li>
              <li data-size="48">小初</li>
              <li data-size="34">一号</li>
              <li data-size="32">小一</li>
              <li data-size="29">二号</li>
              <li data-size="24">小二</li>
              <li data-size="21">三号</li>
              <li data-size="20">小三</li>
              <li data-size="18">四号</li>
              <li data-size="16">小四</li>
              <li data-size="14">五号</li>
              <li data-size="12">小五</li>
              <li data-size="10">六号</li>
              <li data-size="8">小六</li>
              <li data-size="7">七号</li>
              <li data-size="6">八号</li>
            </ul>
          </div>
        </div>
        <div class="menu-item__size-add">
          <i></i>
        </div>
        <div class="menu-item__size-minus">
          <i></i>
        </div>
        <div class="menu-item__bold">
          <i></i>
        </div>
        <div class="menu-item__italic">
          <i></i>
        </div>
        <div class="menu-item__underline">
          <i></i>
          <span class="select"></span>
          <div class="options">
            <ul>
              <li data-decoration-style="solid">
                <i></i>
              </li>
              <li data-decoration-style="double">
                <i></i>
              </li>
              <li data-decoration-style="dashed">
                <i></i>
              </li>
              <li data-decoration-style="dotted">
                <i></i>
              </li>
              <li data-decoration-style="wavy">
                <i></i>
              </li>
            </ul>
          </div>
        </div>
        <div class="menu-item__strikeout" title="删除线(Ctrl+Shift+X)">
          <i></i>
        </div>
        <div class="menu-item__superscript">
          <i></i>
        </div>
        <div class="menu-item__subscript">
          <i></i>
        </div>
        <div class="menu-item__color" title="字体颜色">
          <i></i>
          <span></span>
          <input type="color" id="color" />
        </div>
        <div class="menu-item__highlight" title="高亮">
          <i></i>
          <span></span>
          <input type="color" id="highlight" />
        </div>
      </div>
      <div class="menu-divider"></div>
      <div class="menu-item">
        <div class="menu-item__title">
          <i></i>
          <span class="select" title="切换标题">正文</span>
          <div class="options">
            <ul>
              <li style="font-size: 16px">正文</li>
              <li data-level="first" style="font-size: 26px">标题1</li>
              <li data-level="second" style="font-size: 24px">标题2</li>
              <li data-level="third" style="font-size: 22px">标题3</li>
              <li data-level="fourth" style="font-size: 20px">标题4</li>
              <li data-level="fifth" style="font-size: 18px">标题5</li>
              <li data-level="sixth" style="font-size: 16px">标题6</li>
            </ul>
          </div>
        </div>
        <div class="menu-item__left">
          <i></i>
        </div>
        <div class="menu-item__center">
          <i></i>
        </div>
        <div class="menu-item__right">
          <i></i>
        </div>
        <div class="menu-item__alignment">
          <i></i>
        </div>
        <div class="menu-item__row-margin">
          <i title="行间距"></i>
          <div class="options">
            <ul>
              <li data-rowmargin="1">1</li>
              <li data-rowmargin="1.25">1.25</li>
              <li data-rowmargin="1.5">1.5</li>
              <li data-rowmargin="1.75">1.75</li>
              <li data-rowmargin="2">2</li>
              <li data-rowmargin="2.5">2.5</li>
              <li data-rowmargin="3">3</li>
            </ul>
          </div>
        </div>
        <div class="menu-item__list">
          <i></i>
          <div class="options">
            <ul>
              <li>
                <label>取消列表</label>
              </li>
              <li data-list-type="ol" data-list-style="decimal">
                <label>有序列表:</label>
                <ol>
                  <li>________</li>
                </ol>
              </li>
              <li data-list-type="ul" data-list-style="disc">
                <label>实心圆点列表:</label>
                <ul style="list-style-type: disc">
                  <li>________</li>
                </ul>
              </li>
              <li data-list-type="ul" data-list-style="circle">
                <label>空心圆点列表:</label>
                <ul style="list-style-type: circle">
                  <li>________</li>
                </ul>
              </li>
              <li data-list-type="ul" data-list-style="square">
                <label>空心方块列表:</label>
                <ul style="list-style-type: square">
                  <li>________</li>
                </ul>
              </li>
            </ul>
          </div>
        </div>
      </div>
      <div class="menu-divider"></div>
      <div class="menu-item">
        <div class="menu-item__table">
          <i title="表格"></i>
        </div>
        <div class="menu-item__table__collapse">
          <div class="table-close">×</div>
          <div class="table-title">
            <span class="table-select">插入</span>
            <span>表格</span>
          </div>
          <div class="table-panel"></div>
        </div>
        <div class="menu-item__image">
          <i title="图片"></i>
          <input type="file" id="image" accept=".png, .jpg, .jpeg, .svg, .gif" />
        </div>
        <div class="menu-item__hyperlink">
          <i title="超链接"></i>
        </div>
        <div class="menu-item__separator">
          <i title="分割线"></i>
          <div class="options">
            <ul>
              <li data-separator="0,0">
                <i></i>
              </li>
              <li data-separator="1,1">
                <i></i>
              </li>
              <li data-separator="3,1">
                <i></i>
              </li>
              <li data-separator="4,4">
                <i></i>
              </li>
              <li data-separator="7,3,3,3">
                <i></i>
              </li>
              <li data-separator="6,2,2,2,2,2">
                <i></i>
              </li>
            </ul>
          </div>
        </div>
        <div class="menu-item__watermark">
          <i title="水印(添加、删除)"></i>
          <div class="options">
            <ul>
              <li data-menu="add">添加水印</li>
              <li data-menu="delete">删除水印</li>
            </ul>
          </div>
        </div>
        <!-- <div class="menu-item__codeblock" title="代码块">
          <i></i>
        </div> -->
        <div class="menu-item__page-break" title="分页符">
          <i></i>
        </div>
        <div class="menu-item__control">
          <i title="控件"></i>
          <div class="options">
            <ul>
              <li data-control="text">文本</li>
              <li data-control="select">列举</li>
              <li data-control="checkbox">复选框</li>
            </ul>
          </div>
        </div>
        <div class="menu-item__checkbox" title="复选框">
          <i></i>
        </div>
        <div class="menu-item__latex" title="LateX">
          <i></i>
        </div>
        <div class="menu-item__date">
          <i title="日期"></i>
          <div class="options">
            <ul>
              <li data-format="yyyy-MM-dd"></li>
              <li data-format="yyyy-MM-dd hh:mm:ss"></li>
            </ul>
          </div>
        </div>
        <div class="menu-item__block" title="内容块">
          <i></i>
        </div>
      </div>
      <div class="menu-divider"></div>
      <div class="menu-item">
        <div class="menu-item__search" data-menu="search">
          <i></i>
        </div>
        <div class="menu-item__search__collapse" data-menu="search">
          <div class="menu-item__search__collapse__search">
            <input type="text" />
            <label class="search-result"></label>
            <div class="arrow-left">
              <i></i>
            </div>
            <div class="arrow-right">
              <i></i>
            </div>
            <span>×</span>
          </div>
          <div class="menu-item__search__collapse__replace">
            <input type="text" />
            <button>替换</button>
          </div>
        </div>
        <div class="menu-item__print" data-menu="print">
          <i></i>
        </div>
      </div>
      <div class="menu-divider"></div>
      <!-- 自定义按钮 -->
      <div class="menu-item">
        <p class="btn-save" @click="onSave">保存</p>
        <p class="btn-back" @click="onBack">返回</p>
      </div>
    </div>
    <div style="height: 80px; width: 100%"></div>
    <div class="catalog" editor-component="catalog">
      <div class="catalog__header">
        <span>目录</span>
        <div class="catalog__header__close">
          <i></i>
        </div>
      </div>
      <div class="catalog__main"></div>
    </div>
    <div id="canvasEditor" class="canvas-editor"></div>
    <div class="footer-canvas" editor-component="footer">
      <div>
        <div class="catalog-mode" title="目录">
          <i></i>
        </div>
        <div class="page-mode">
          <i title="页面模式(分页、连页)"></i>
          <div class="options">
            <ul>
              <li data-page-mode="paging" class="active">分页</li>
              <li data-page-mode="continuity">连页</li>
            </ul>
          </div>
        </div>
        <span>可见页码:<span class="page-no-list">1</span></span>
        <span>页面:<span class="page-no">1</span>/<span class="page-size">1</span></span>
        <span>字数:<span class="word-count">0</span></span>
      </div>
      <div class="editor-mode" title="编辑模式(编辑、清洁、只读、表单)" style="display: none">编辑模式</div>
      <div>
        <div class="page-scale-minus" title="缩小(Ctrl+-)">
          <i></i>
        </div>
        <span class="page-scale-percentage" title="显示比例(点击可复原Ctrl+0)">100%</span>
        <div class="page-scale-add" title="放大(Ctrl+=)">
          <i></i>
        </div>
        <div class="paper-size">
          <i title="纸张类型"></i>
          <div class="options">
            <ul>
              <li data-paper-size="794*1123" class="active">A4</li>
              <li data-paper-size="1593*2251">A2</li>
              <li data-paper-size="1125*1593">A3</li>
              <li data-paper-size="565*796">A5</li>
              <li data-paper-size="412*488">5号信封</li>
              <li data-paper-size="450*866">6号信封</li>
              <li data-paper-size="609*862">7号信封</li>
              <li data-paper-size="862*1221">9号信封</li>
              <li data-paper-size="813*1266">法律用纸</li>
              <li data-paper-size="813*1054">信纸</li>
            </ul>
          </div>
        </div>
        <div class="paper-direction">
          <i title="纸张方向"></i>
          <div class="options">
            <ul>
              <li data-paper-direction="vertical" class="active">纵向</li>
              <li data-paper-direction="horizontal">横向</li>
            </ul>
          </div>
        </div>
        <div class="paper-margin" title="页边距">
          <i></i>
        </div>
        <div class="fullscreen" title="全屏显示">
          <i></i>
        </div>
      </div>
    </div>
  </div>

使用的是Vue3 组件核心代码

import { ref, reactive, toRefs, onMounted, onUnmounted, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import Editor from '@hufe921/canvas-editor';

import {
  splitText,
  nextTickEd,
  debounce,
  scrollIntoView,
  ControlType,
  BlockType,
  ListType,
  EditorMode,
  KeyMap,
  ListStyle,
  RowFlex,
  ElementType
} from './tool.js';
import { Dialog } from './Dialog.js';

export default {
  props: {
    header: {
      type: Array,
      default: []
    },
    main: {
      type: Array,
      default: []
    },
    footer: {
      type: Array,
      default: []
    },
    options: {
      type: Object,
      default: {}
    },
    commentList: {
      type: Array,
      default: []
    }
  },
  setup(props, ctx) {
    const editorRef = ref(null); // 编辑器实例
    const isCatalogShow = ref(true); // 目录控件
    const commentDomRef = ref(null); // 批注实例
    const tableCellList = ref(null); // 表格
    const tablePanelDom = ref(null); // 表格Dom
    const router = useRouter();
    const state = reactive({
      canvas: null,
      editor: null,
      commentList: props.commentList, // 批注
      onBack: () => {
        router.back();
      },
      onSave: async () => {
        console.log(editorRef.value.command.getHTML());
        const value = editorRef.value.command.getValue();
        const imgage = await editorRef.value.command.getImage();
        const options = editorRef.value.command.getOptions();
        console.log(value);
        return;
        ctx.emit('getCanvasEditorData', { value, imgage, options });
      },
      // 更新目录
      updateCatalog: async () => {
        const instance = editorRef.value;
        const catalog = await instance.command.getCatalog();
        const catalogMainDom = document.querySelector('.catalog__main');
        catalogMainDom.innerHTML = '';
        if (catalog) {
          const appendCatalog = (parent, catalogItems) => {
            for (let c = 0; c < catalogItems.length; c++) {
              const catalogItem = catalogItems[c];
              const catalogItemDom = document.createElement('div');
              catalogItemDom.classList.add('catalog-item');
              // 渲染
              const catalogItemContentDom = document.createElement('div');
              catalogItemContentDom.classList.add('catalog-item__content');
              const catalogItemContentSpanDom = document.createElement('span');
              catalogItemContentSpanDom.innerText = catalogItem.name;
              catalogItemContentDom.append(catalogItemContentSpanDom);
              // 定位
              catalogItemContentDom.onclick = () => {
                instance.command.executeLocationCatalog(catalogItem.id);
              };
              catalogItemDom.append(catalogItemContentDom);
              if (catalogItem.subCatalog && catalogItem.subCatalog.length) {
                appendCatalog(catalogItemDom, catalogItem.subCatalog);
              }
              // 追加
              parent.append(catalogItemDom);
            }
          };
          appendCatalog(catalogMainDom, catalog);
        }
        isCatalogShow.value = true;
        const catalogDom = document.querySelector('.catalog');
        const catalogModeDom = document.querySelector('.catalog-mode');
        const catalogHeaderCloseDom = document.querySelector('.catalog__header__close');
        const switchCatalog = () => {
          isCatalogShow.value = !isCatalogShow.value;
          if (isCatalogShow.value) {
            catalogDom.style.display = 'block';
            state.updateCatalog();
          } else {
            catalogDom.style.display = 'none';
          }
        };
        catalogModeDom.onclick = switchCatalog;
        catalogHeaderCloseDom.onclick = switchCatalog;

        const pageModeDom = document.querySelector('.page-mode');
        const pageModeOptionsDom = pageModeDom.querySelector('.options');
        pageModeDom.onclick = function () {
          pageModeOptionsDom.classList.toggle('visible');
        };
        pageModeOptionsDom.onclick = function (evt) {
          const li = evt.target;
          instance.command.executePageMode(li.dataset.pageMode);
        };

        document.querySelector('.page-scale-percentage').onclick = function () {
          console.log('page-scale-recovery');
          instance.command.executePageScaleRecovery();
        };

        document.querySelector('.page-scale-minus').onclick = function () {
          console.log('page-scale-minus');
          instance.command.executePageScaleMinus();
        };

        document.querySelector('.page-scale-add').onclick = function () {
          console.log('page-scale-add');
          instance.command.executePageScaleAdd();
        };
      },
      // 模拟批注
      updateComment: async () => {
        const instance = editorRef.value;
        const commentDom = document.querySelector('.comment');
        commentDomRef.value = commentDom;
        const groupIds = await instance.command.getGroupIds();
        for (const comment of state.commentList) {
          const activeCommentDom = commentDom?.querySelector(`.comment-item[data-id='${comment.id}']`);
          // 编辑器是否存在对应成组id
          if (groupIds.includes(comment.id)) {
            // 当前dom是否存在-不存在则追加
            if (!activeCommentDom) {
              const commentItem = document.createElement('div');
              commentItem.classList.add('comment-item');
              commentItem.setAttribute('data-id', comment.id);
              commentItem.onclick = () => {
                instance.command.executeLocationGroup(comment.id);
              };
              commentDom?.append(commentItem);
              // 选区信息
              const commentItemTitle = document.createElement('div');
              commentItemTitle.classList.add('comment-item__title');
              commentItemTitle.append(document.createElement('span'));
              const commentItemTitleContent = document.createElement('span');
              commentItemTitleContent.innerText = comment.rangeText;
              commentItemTitle.append(commentItemTitleContent);
              const closeDom = document.createElement('i');
              closeDom.onclick = () => {
                instance.command.executeDeleteGroup(comment.id);
              };
              commentItemTitle.append(closeDom);
              commentItem.append(commentItemTitle);
              // 基础信息
              const commentItemInfo = document.createElement('div');
              commentItemInfo.classList.add('comment-item__info');
              const commentItemInfoName = document.createElement('span');
              commentItemInfoName.innerText = comment.userName;
              const commentItemInfoDate = document.createElement('span');
              commentItemInfoDate.innerText = comment.createdDate;
              commentItemInfo.append(commentItemInfoName);
              commentItemInfo.append(commentItemInfoDate);
              commentItem.append(commentItemInfo);
              // 详细评论
              const commentItemContent = document.createElement('div');
              commentItemContent.classList.add('comment-item__content');
              commentItemContent.innerText = comment.content;
              commentItem.append(commentItemContent);
              commentDom?.append(commentItem);
            }
          } else {
            // 编辑器内不存在对应成组id则dom则移除
            activeCommentDom?.remove();
          }
        }
      }
    });
    onMounted(async () => {
      await nextTick(() => {});
      const isApple = typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent);
      const options = props.options;
      const instance = new Editor(
        document.querySelector('#canvasEditor'),
        {
          header: props.header,
          main: props.main,
          footer: props.footer
        },
        options
      );
      console.log('实例', instance);
      editorRef.value = instance;
      // 菜单弹窗销毁
      window.addEventListener(
        'click',
        evt => {
          const visibleDom = document.querySelector('.visible');
          if (!visibleDom || visibleDom.contains(evt.target)) return;
          visibleDom.classList.remove('visible');
        },
        {
          capture: true
        }
      );
      // 2. | 撤销 | 重做 | 格式刷 | 清除格式 |
      const undoDom = document.querySelector('.menu-item__undo');
      undoDom.title = `撤销(${isApple ? '⌘' : 'Ctrl'}+Z)`;
      undoDom.onclick = function () {
        console.log('undo');
        instance.command.executeUndo();
      };

      const redoDom = document.querySelector('.menu-item__redo');
      redoDom.title = `重做(${isApple ? '⌘' : 'Ctrl'}+Y)`;
      redoDom.onclick = function () {
        console.log('redo');
        instance.command.executeRedo();
      };

      const painterDom = document.querySelector('.menu-item__painter');

      let isFirstClick = true;
      let painterTimeout;
      painterDom.onclick = function () {
        if (isFirstClick) {
          isFirstClick = false;
          painterTimeout = window.setTimeout(() => {
            console.log('painter-click');
            isFirstClick = true;
            instance.command.executePainter({
              isDblclick: false
            });
          }, 200);
        } else {
          window.clearTimeout(painterTimeout);
        }
      };

      painterDom.ondblclick = function () {
        console.log('painter-dblclick');
        isFirstClick = true;
        window.clearTimeout(painterTimeout);
        instance.command.executePainter({
          isDblclick: true
        });
      };

      document.querySelector('.menu-item__format').onclick = function () {
        console.log('format');
        instance.command.executeFormat();
      };

      // 3. | 字体 | 字体变大 | 字体变小 | 加粗 | 斜体 | 下划线 | 删除线 | 上标 | 下标 | 字体颜色 | 背景色 |
      const fontDom = document.querySelector('.menu-item__font');
      const fontSelectDom = fontDom.querySelector('.select');
      const fontOptionDom = fontDom.querySelector('.options');
      fontDom.onclick = function () {
        console.log('font');
        fontOptionDom.classList.toggle('visible');
      };
      fontOptionDom.onclick = function (evt) {
        const li = evt.target;
        instance.command.executeFont(li.dataset.family);
      };

      const sizeSetDom = document.querySelector('.menu-item__size');
      const sizeSelectDom = sizeSetDom.querySelector('.select');
      const sizeOptionDom = sizeSetDom.querySelector('.options');
      sizeSetDom.title = `设置字号`;
      sizeSetDom.onclick = function () {
        console.log('size');
        sizeOptionDom.classList.toggle('visible');
      };
      sizeOptionDom.onclick = function (evt) {
        const li = evt.target;
        instance.command.executeSize(Number(li.dataset.size));
      };

      const sizeAddDom = document.querySelector('.menu-item__size-add');
      sizeAddDom.title = `增大字号(${isApple ? '⌘' : 'Ctrl'}+[)`;
      sizeAddDom.onclick = function () {
        console.log('size-add');
        instance.command.executeSizeAdd();
      };

      const sizeMinusDom = document.querySelector('.menu-item__size-minus');
      sizeMinusDom.title = `减小字号(${isApple ? '⌘' : 'Ctrl'}+])`;
      sizeMinusDom.onclick = function () {
        console.log('size-minus');
        instance.command.executeSizeMinus();
      };

      const boldDom = document.querySelector('.menu-item__bold');
      boldDom.title = `加粗(${isApple ? '⌘' : 'Ctrl'}+B)`;
      boldDom.onclick = function () {
        console.log('bold');
        instance.command.executeBold();
      };

      const italicDom = document.querySelector('.menu-item__italic');
      italicDom.title = `斜体(${isApple ? '⌘' : 'Ctrl'}+I)`;
      italicDom.onclick = function () {
        console.log('italic');
        instance.command.executeItalic();
      };

      const underlineDom = document.querySelector('.menu-item__underline');
      underlineDom.title = `下划线(${isApple ? '⌘' : 'Ctrl'}+U)`;
      const underlineOptionDom = underlineDom.querySelector('.options');
      underlineDom.querySelector('.select').onclick = function () {
        underlineOptionDom.classList.toggle('visible');
      };
      underlineDom.querySelector('i').onclick = function () {
        console.log('underline');
        instance.command.executeUnderline();
        underlineOptionDom.classList.remove('visible');
      };
      underlineDom.querySelector('ul').onmousedown = function (evt) {
        const li = evt.target;
        const decorationStyle = li.dataset.decorationStyle;
        instance.command.executeUnderline({
          style: decorationStyle
        });
        underlineOptionDom.classList.remove('visible');
      };

      const strikeoutDom = document.querySelector('.menu-item__strikeout');
      strikeoutDom.onclick = function () {
        console.log('strikeout');
        instance.command.executeStrikeout();
      };

      const superscriptDom = document.querySelector('.menu-item__superscript');
      superscriptDom.title = `上标(${isApple ? '⌘' : 'Ctrl'}+Shift+,)`;
      superscriptDom.onclick = function () {
        console.log('superscript');
        instance.command.executeSuperscript();
      };

      const subscriptDom = document.querySelector('.menu-item__subscript');
      subscriptDom.title = `下标(${isApple ? '⌘' : 'Ctrl'}+Shift+.)`;
      subscriptDom.onclick = function () {
        console.log('subscript');
        instance.command.executeSubscript();
      };

      const colorControlDom = document.querySelector('#color');
      colorControlDom.oninput = function () {
        instance.command.executeColor(colorControlDom.value);
      };
      const colorDom = document.querySelector('.menu-item__color');
      const colorSpanDom = colorDom.querySelector('span');
      colorDom.onclick = function () {
        console.log('color');
        colorControlDom.click();
      };

      const highlightControlDom = document.querySelector('#highlight');
      highlightControlDom.oninput = function () {
        instance.command.executeHighlight(highlightControlDom.value);
      };
      const highlightDom = document.querySelector('.menu-item__highlight');
      const highlightSpanDom = highlightDom.querySelector('span');
      highlightDom.onclick = function () {
        console.log('highlight');
        highlightControlDom?.click();
      };

      const titleDom = document.querySelector('.menu-item__title');
      const titleSelectDom = titleDom.querySelector('.select');
      const titleOptionDom = titleDom.querySelector('.options');
      titleOptionDom.querySelectorAll('li').forEach((li, index) => {
        li.title = `Ctrl+${isApple ? 'Option' : 'Alt'}+${index}`;
      });

      titleDom.onclick = function () {
        console.log('title');
        titleOptionDom.classList.toggle('visible');
      };
      titleOptionDom.onclick = function (evt) {
        const li = evt.target;
        const level = li.dataset.level;
        instance.command.executeTitle(level || null);
      };

      const leftDom = document.querySelector('.menu-item__left');
      leftDom.title = `左对齐(${isApple ? '⌘' : 'Ctrl'}+L)`;
      leftDom.onclick = function () {
        console.log('left');
        instance.command.executeRowFlex(RowFlex.LEFT);
      };

      const centerDom = document.querySelector('.menu-item__center');
      centerDom.title = `居中对齐(${isApple ? '⌘' : 'Ctrl'}+E)`;
      centerDom.onclick = function () {
        console.log('center');
        instance.command.executeRowFlex(RowFlex.CENTER);
      };

      const rightDom = document.querySelector('.menu-item__right');
      rightDom.title = `右对齐(${isApple ? '⌘' : 'Ctrl'}+R)`;
      rightDom.onclick = function () {
        console.log('right');
        instance.command.executeRowFlex(RowFlex.RIGHT);
      };

      const alignmentDom = document.querySelector('.menu-item__alignment');
      alignmentDom.title = `两端对齐(${isApple ? '⌘' : 'Ctrl'}+J)`;
      alignmentDom.onclick = function () {
        console.log('alignment');
        instance.command.executeRowFlex(RowFlex.ALIGNMENT);
      };

      const rowMarginDom = document.querySelector('.menu-item__row-margin');
      const rowOptionDom = rowMarginDom.querySelector('.options');
      rowMarginDom.onclick = function () {
        console.log('row-margin');
        rowOptionDom.classList.toggle('visible');
      };
      rowOptionDom.onclick = function (evt) {
        const li = evt.target;
        instance.command.executeRowMargin(Number(li.dataset.rowmargin));
      };

      const listDom = document.querySelector('.menu-item__list');
      listDom.title = `列表(${isApple ? '⌘' : 'Ctrl'}+Shift+U)`;
      const listOptionDom = listDom.querySelector('.options');
      listDom.onclick = function () {
        console.log('list');
        listOptionDom.classList.toggle('visible');
      };
      listOptionDom.onclick = function (evt) {
        const li = evt.target;
        const listType = li.dataset.listType || null;
        const listStyle = li.dataset.listStyle;
        instance.command.executeList(listType, listStyle);
      };

      // 4. | 表格 | 图片 | 超链接 | 分割线 | 水印 | 代码块 | 分隔符 | 控件 | 复选框 | LaTeX | 日期选择器
      const tableDom = document.querySelector('.menu-item__table');
      const tablePanelContainer = document.querySelector('.menu-item__table__collapse');
      const tableClose = document.querySelector('.table-close');
      const tableTitle = document.querySelector('.table-select');
      const tablePanel = document.querySelector('.table-panel');
      // 绘制行列
      // const tableCellList = [];
      tableCellList.value = [];
      for (let i = 0; i < 10; i++) {
        const tr = document.createElement('tr');
        tr.classList.add('table-row');
        const trCellList = [];
        for (let j = 0; j < 10; j++) {
          const td = document.createElement('td');
          td.classList.add('table-cel');
          tr.append(td);
          trCellList.push(td);
        }
        tablePanel.append(tr);
        tableCellList.value.push(trCellList);
      }
      tablePanelDom.value = tablePanel;
      let colIndex = 0;
      let rowIndex = 0;
      // 移除所有格选择
      function removeAllTableCellSelect() {
        tableCellList.value.forEach(tr => {
          tr.forEach(td => td.classList.remove('active'));
        });
      }
      // 设置标题内容
      function setTableTitle(payload) {
        tableTitle.innerText = payload;
      }
      // 恢复初始状态
      function recoveryTable() {
        // 还原选择样式、标题、选择行列
        removeAllTableCellSelect();
        setTableTitle('插入');
        colIndex = 0;
        rowIndex = 0;
        // 隐藏panel
        tablePanelContainer.style.display = 'none';
      }
      tableDom.onclick = function () {
        console.log('table');
        tablePanelContainer.style.display = 'block';
      };
      tablePanel.onmousemove = function (evt) {
        const celSize = 16;
        const rowMarginTop = 10;
        const celMarginRight = 6;
        const { offsetX, offsetY } = evt;
        // 移除所有选择
        removeAllTableCellSelect();
        colIndex = Math.ceil(offsetX / (celSize + celMarginRight)) || 1;
        rowIndex = Math.ceil(offsetY / (celSize + rowMarginTop)) || 1;
        // 改变选择样式
        tableCellList.value.forEach((tr, trIndex) => {
          tr.forEach((td, tdIndex) => {
            if (tdIndex < colIndex && trIndex < rowIndex) {
              td.classList.add('active');
            }
          });
        });
        // 改变表格标题
        setTableTitle(`${rowIndex}×${colIndex}`);
      };
      tableClose.onclick = function () {
        recoveryTable();
      };
      tablePanel.onclick = function () {
        // 应用选择
        instance.command.executeInsertTable(rowIndex, colIndex);
        recoveryTable();
      };

      const imageDom = document.querySelector('.menu-item__image');
      const imageFileDom = document.querySelector('#image');
      imageDom.onclick = function () {
        imageFileDom.click();
      };
      imageFileDom.onchange = function () {
        const file = imageFileDom.files[0];
        const fileReader = new FileReader();
        fileReader.readAsDataURL(file);
        fileReader.onload = function () {
          // 计算宽高
          const image = new Image();
          const value = fileReader.result;
          image.src = value;
          image.onload = function () {
            instance.command.executeImage({
              value,
              width: image.width,
              height: image.height
            });
            imageFileDom.value = '';
          };
        };
      };
      // 超链接
      const hyperlinkDom = document.querySelector('.menu-item__hyperlink');
      hyperlinkDom.onclick = function () {
        console.log('hyperlink');
        new Dialog({
          title: '超链接',
          data: [
            {
              type: 'text',
              label: '文本',
              name: 'name',
              required: true,
              placeholder: '请输入文本',
              value: instance.command.getRangeText()
            },
            {
              type: 'text',
              label: '链接',
              name: 'url',
              required: true,
              placeholder: '请输入链接'
            }
          ],
          onConfirm: payload => {
            const name = payload.find(p => p.name === 'name')?.value;
            if (!name) return;
            const url = payload.find(p => p.name === 'url')?.value;
            if (!url) return;
            instance.command.executeHyperlink({
              type: ElementType.HYPERLINK,
              value: '',
              url,
              valueList: splitText(name).map(n => ({
                value: n,
                size: 16
              }))
            });
          }
        });
      };
      // 分割线
      const separatorDom = document.querySelector('.menu-item__separator');
      const separatorOptionDom = separatorDom.querySelector('.options');
      separatorDom.onclick = function () {
        console.log('separator');
        separatorOptionDom.classList.toggle('visible');
      };
      separatorOptionDom.onmousedown = function (evt) {
        let payload = [];
        const li = evt.target;
        const separatorDash = li.dataset.separator?.split(',').map(Number);
        if (separatorDash) {
          const isSingleLine = separatorDash.every(d => d === 0);
          if (!isSingleLine) {
            payload = separatorDash;
          }
        }
        instance.command.executeSeparator(payload);
      };

      const pageBreakDom = document.querySelector('.menu-item__page-break');
      pageBreakDom.onclick = function () {
        console.log('pageBreak');
        instance.command.executePageBreak();
      };

      const watermarkDom = document.querySelector('.menu-item__watermark');
      const watermarkOptionDom = watermarkDom.querySelector('.options');
      watermarkDom.onclick = function () {
        console.log('watermark');
        watermarkOptionDom.classList.toggle('visible');
      };
      watermarkOptionDom.onmousedown = function (evt) {
        const li = evt.target;
        const menu = li.dataset.menu;
        watermarkOptionDom.classList.toggle('visible');
        if (menu === 'add') {
          new Dialog({
            title: '水印',
            data: [
              {
                type: 'text',
                label: '内容',
                name: 'data',
                required: true,
                placeholder: '请输入内容'
              },
              {
                type: 'color',
                label: '颜色',
                name: 'color',
                required: true,
                value: '#AEB5C0'
              },
              {
                type: 'number',
                label: '字体大小',
                name: 'size',
                required: true,
                value: '120'
              }
            ],
            onConfirm: payload => {
              const nullableIndex = payload.findIndex(p => !p.value);
              if (~nullableIndex) return;
              const watermark = payload.reduce((pre, cur) => {
                pre[cur.name] = cur.value;
                return pre;
              }, {});
              instance.command.executeAddWatermark({
                data: watermark.data,
                color: watermark.color,
                size: Number(watermark.size)
              });
            }
          });
        } else {
          instance.command.executeDeleteWatermark();
        }
      };

      // const codeblockDom = document.querySelector('.menu-item__codeblock');
      // codeblockDom.onclick = function () {
      //   console.log('codeblock');
      //   new Dialog({
      //     title: '代码块',
      //     data: [
      //       {
      //         type: 'textarea',
      //         name: 'codeblock',
      //         placeholder: '请输入代码',
      //         width: 500,
      //         height: 300
      //       }
      //     ],
      //     onConfirm: payload => {
      //       const codeblock = payload.find(p => p.name === 'codeblock')?.value;
      //       if (!codeblock) return;
      //       const tokenList = prism.tokenize(codeblock, prism.languages.javascript);
      //       const formatTokenList = formatPrismToken(tokenList);
      //       const elementList = [];
      //       for (let i = 0; i < formatTokenList.length; i++) {
      //         const formatToken = formatTokenList[i];
      //         const tokenStringList = splitText(formatToken.content);
      //         for (let j = 0; j < tokenStringList.length; j++) {
      //           const value = tokenStringList[j];
      //           const element = {
      //             value
      //           };
      //           if (formatToken.color) {
      //             element.color = formatToken.color;
      //           }
      //           if (formatToken.bold) {
      //             element.bold = true;
      //           }
      //           if (formatToken.italic) {
      //             element.italic = true;
      //           }
      //           elementList.push(element);
      //         }
      //       }
      //       elementList.unshift({
      //         value: '\n'
      //       });
      //       instance.command.executeInsertElementList(elementList);
      //     }
      //   });
      // };
      // 控件
      const controlDom = document.querySelector('.menu-item__control');
      const controlOptionDom = controlDom.querySelector('.options');
      controlDom.onclick = function () {
        console.log('control');
        controlOptionDom.classList.toggle('visible');
      };
      controlOptionDom.onmousedown = function (evt) {
        controlOptionDom.classList.toggle('visible');
        const li = evt.target;
        const type = li.dataset.control;
        switch (type) {
          case ControlType.TEXT:
            new Dialog({
              title: '文本控件',
              data: [
                {
                  type: 'text',
                  label: '占位符',
                  name: 'placeholder',
                  required: true,
                  placeholder: '请输入占位符'
                },
                {
                  type: 'text',
                  label: '默认值',
                  name: 'value',
                  placeholder: '请输入默认值'
                }
              ],
              onConfirm: payload => {
                const placeholder = payload.find(p => p.name === 'placeholder')?.value;
                if (!placeholder) return;
                const value = payload.find(p => p.name === 'value')?.value || '';
                instance.command.executeInsertElementList([
                  {
                    type: ElementType.CONTROL,
                    value: '',
                    control: {
                      type,
                      value: value
                        ? [
                            {
                              value
                            }
                          ]
                        : null,
                      placeholder
                    }
                  }
                ]);
              }
            });
            break;
          case ControlType.SELECT:
            new Dialog({
              title: '列举控件',
              data: [
                {
                  type: 'text',
                  label: '占位符',
                  name: 'placeholder',
                  required: true,
                  placeholder: '请输入占位符'
                },
                {
                  type: 'text',
                  label: '默认值',
                  name: 'code',
                  placeholder: '请输入默认值'
                },
                {
                  type: 'textarea',
                  label: '值集',
                  name: 'valueSets',
                  required: true,
                  height: 100,
                  placeholder: `请输入值集JSON,例:\n[{\n"value":"有",\n"code":"98175"\n}]`
                }
              ],
              onConfirm: payload => {
                const placeholder = payload.find(p => p.name === 'placeholder')?.value;
                if (!placeholder) return;
                const valueSets = payload.find(p => p.name === 'valueSets')?.value;
                if (!valueSets) return;
                const code = payload.find(p => p.name === 'code')?.value;
                instance.command.executeInsertElementList([
                  {
                    type: ElementType.CONTROL,
                    value: '',
                    control: {
                      type,
                      code,
                      value: null,
                      placeholder,
                      valueSets: JSON.parse(valueSets)
                    }
                  }
                ]);
              }
            });
            break;
          case ControlType.CHECKBOX:
            new Dialog({
              title: '复选框控件',
              data: [
                {
                  type: 'text',
                  label: '默认值',
                  name: 'code',
                  placeholder: '请输入默认值,多个值以英文逗号分割'
                },
                {
                  type: 'textarea',
                  label: '值集',
                  name: 'valueSets',
                  required: true,
                  height: 100,
                  placeholder: `请输入值集JSON,例:\n[{\n"value":"有",\n"code":"98175"\n}]`
                }
              ],
              onConfirm: payload => {
                const valueSets = payload.find(p => p.name === 'valueSets')?.value;
                if (!valueSets) return;
                const code = payload.find(p => p.name === 'code')?.value;
                instance.command.executeInsertElementList([
                  {
                    type: ElementType.CONTROL,
                    value: '',
                    control: {
                      type,
                      code,
                      value: null,
                      valueSets: JSON.parse(valueSets)
                    }
                  }
                ]);
              }
            });
            break;
          default:
            break;
        }
      };
      // 复选框
      const checkboxDom = document.querySelector('.menu-item__checkbox');
      checkboxDom.onclick = function () {
        console.log('checkbox');
        instance.command.executeInsertElementList([
          {
            type: ElementType.CHECKBOX,
            checkbox: {
              value: false
            },
            value: ''
          }
        ]);
      };
      // LateX文本
      const latexDom = document.querySelector('.menu-item__latex');
      latexDom.onclick = function () {
        console.log('LaTeX');
        new Dialog({
          title: 'LaTeX',
          data: [
            {
              type: 'textarea',
              height: 100,
              name: 'value',
              placeholder: '请输入LaTeX文本'
            }
          ],
          onConfirm: payload => {
            const value = payload.find(p => p.name === 'value')?.value;
            if (!value) return;
            instance.command.executeInsertElementList([
              {
                type: ElementType.LATEX,
                value
              }
            ]);
          }
        });
      };

      // 时间选择
      const dateDom = document.querySelector('.menu-item__date');
      const dateDomOptionDom = dateDom.querySelector('.options');
      dateDom.onclick = function () {
        console.log('date');
        dateDomOptionDom.classList.toggle('visible');
        // 定位调整
        const bodyRect = document.body.getBoundingClientRect();
        const dateDomOptionRect = dateDomOptionDom.getBoundingClientRect();
        if (dateDomOptionRect.left + dateDomOptionRect.width > bodyRect.width) {
          dateDomOptionDom.style.right = '0px';
          dateDomOptionDom.style.left = 'unset';
        } else {
          dateDomOptionDom.style.right = 'unset';
          dateDomOptionDom.style.left = '0px';
        }
        // 当前日期
        const date = new Date();
        const year = date.getFullYear().toString();
        const month = (date.getMonth() + 1).toString().padStart(2, '0');
        const day = date.getDate().toString().padStart(2, '0');
        const hour = date.getHours().toString().padStart(2, '0');
        const minute = date.getMinutes().toString().padStart(2, '0');
        const second = date.getSeconds().toString().padStart(2, '0');
        const dateString = `${year}-${month}-${day}`;
        const dateTimeString = `${dateString} ${hour}:${minute}:${second}`;
        dateDomOptionDom.querySelector('li:first-child').innerText = dateString;
        dateDomOptionDom.querySelector('li:last-child').innerText = dateTimeString;
      };
      dateDomOptionDom.onmousedown = function (evt) {
        const li = evt.target;
        const dateFormat = li.dataset.format;
        dateDomOptionDom.classList.toggle('visible');
        instance.command.executeInsertElementList([
          {
            type: ElementType.DATE,
            value: '',
            dateFormat,
            valueList: [
              {
                value: li.innerText.trim()
              }
            ]
          }
        ]);
      };
      // 内容块
      const blockDom = document.querySelector('.menu-item__block');
      blockDom.onclick = function () {
        console.log('block');
        new Dialog({
          title: '内容块',
          data: [
            {
              type: 'select',
              label: '类型',
              name: 'type',
              value: 'iframe',
              required: true,
              options: [
                {
                  label: '网址',
                  value: 'iframe'
                },
                {
                  label: '视频',
                  value: 'video'
                }
              ]
            },
            {
              type: 'number',
              label: '宽度',
              name: 'width',
              placeholder: '请输入宽度(默认页面内宽度)'
            },
            {
              type: 'number',
              label: '高度',
              name: 'height',
              required: true,
              placeholder: '请输入高度'
            },
            {
              type: 'textarea',
              label: '地址',
              height: 100,
              name: 'value',
              required: true,
              placeholder: '请输入地址'
            }
          ],
          onConfirm: payload => {
            const type = payload.find(p => p.name === 'type')?.value;
            if (!type) return;
            const value = payload.find(p => p.name === 'value')?.value;
            if (!value) return;
            const width = payload.find(p => p.name === 'width')?.value;
            const height = payload.find(p => p.name === 'height')?.value;
            if (!height) return;
            const block = {
              type: BlockType.IFRAME
            };
            if (block.type === BlockType.IFRAME) {
              block.iframeBlock = {
                src: value
              };
            } else if (block.type === BlockType.VIDEO) {
              block.videoBlock = {
                src: value
              };
            }
            const blockElement = {
              type: ElementType.BLOCK,
              value: '',
              height: Number(height),
              block
            };
            if (width) {
              blockElement.width = Number(width);
            }
            instance.command.executeInsertElementList([blockElement]);
          }
        });
      };
      // 5. | 搜索&替换 | 打印 |
      const searchCollapseDom = document.querySelector('.menu-item__search__collapse');
      const searchInputDom = document.querySelector('.menu-item__search__collapse__search input');
      const replaceInputDom = document.querySelector('.menu-item__search__collapse__replace input');
      const searchDom = document.querySelector('.menu-item__search');
      searchDom.title = `搜索与替换(${isApple ? '⌘' : 'Ctrl'}+F)`;
      const searchResultDom = searchCollapseDom.querySelector('.search-result');
      function setSearchResult() {
        const result = instance.command.getSearchNavigateInfo();
        if (result) {
          const { index, count } = result;
          searchResultDom.innerText = `${index}/${count}`;
        } else {
          searchResultDom.innerText = '';
        }
      }
      searchDom.onclick = function () {
        console.log('search');
        searchCollapseDom.style.display = 'block';
        const bodyRect = document.body.getBoundingClientRect();
        const searchRect = searchDom.getBoundingClientRect();
        const searchCollapseRect = searchCollapseDom.getBoundingClientRect();
        if (searchRect.left + searchCollapseRect.width > bodyRect.width) {
          searchCollapseDom.style.right = '0px';
          searchCollapseDom.style.left = 'unset';
        } else {
          searchCollapseDom.style.right = 'unset';
        }
        searchInputDom.focus();
      };
      searchCollapseDom.querySelector('span').onclick = function () {
        searchCollapseDom.style.display = 'none';
        searchInputDom.value = '';
        replaceInputDom.value = '';
        instance.command.executeSearch(null);
        setSearchResult();
      };
      searchInputDom.oninput = function () {
        instance.command.executeSearch(searchInputDom.value || null);
        setSearchResult();
      };
      searchInputDom.onkeydown = function (evt) {
        if (evt.key === 'Enter') {
          instance.command.executeSearch(searchInputDom.value || null);
          setSearchResult();
        }
      };
      searchCollapseDom.querySelector('button').onclick = function () {
        const searchValue = searchInputDom.value;
        const replaceValue = replaceInputDom.value;
        if (searchValue && replaceValue && searchValue !== replaceValue) {
          instance.command.executeReplace(replaceValue);
        }
      };
      searchCollapseDom.querySelector('.arrow-left').onclick = function () {
        instance.command.executeSearchNavigatePre();
        setSearchResult();
      };
      searchCollapseDom.querySelector('.arrow-right').onclick = function () {
        instance.command.executeSearchNavigateNext();
        setSearchResult();
      };

      const printDom = document.querySelector('.menu-item__print');
      printDom.title = `打印(${isApple ? '⌘' : 'Ctrl'}+P)`;
      printDom.onclick = function () {
        instance.command.executePrint();
      };

      // 6. 目录显隐 | 页面模式 | 纸张缩放 | 纸张大小 | 纸张方向 | 页边距 | 全屏
      state.updateCatalog();

      // 页面
      const pageModeDom = document.querySelector('.page-mode');
      const pageModeOptionsDom = pageModeDom.querySelector('.options');
      pageModeDom.onclick = function () {
        pageModeOptionsDom.classList.toggle('visible');
      };
      pageModeOptionsDom.onclick = function (evt) {
        const li = evt.target;
        instance.command.executePageMode(li.dataset.pageMode);
      };

      document.querySelector('.page-scale-percentage').onclick = function () {
        console.log('page-scale-recovery');
        instance.command.executePageScaleRecovery();
      };

      document.querySelector('.page-scale-minus').onclick = function () {
        console.log('page-scale-minus');
        instance.command.executePageScaleMinus();
      };

      document.querySelector('.page-scale-add').onclick = function () {
        console.log('page-scale-add');
        instance.command.executePageScaleAdd();
      };

      // 纸张大小
      const paperSizeDom = document.querySelector('.paper-size');
      const paperSizeDomOptionsDom = paperSizeDom.querySelector('.options');
      paperSizeDom.onclick = function () {
        paperSizeDomOptionsDom.classList.toggle('visible');
      };
      paperSizeDomOptionsDom.onclick = function (evt) {
        const li = evt.target;
        const paperType = li.dataset.paperSize;
        const [width, height] = paperType?.split('*').map(Number);
        instance.command.executePaperSize(width, height);
        // 纸张状态回显
        paperSizeDomOptionsDom.querySelectorAll('li').forEach(child => child.classList.remove('active'));
        li.classList.add('active');
      };

      // 纸张方向
      const paperDirectionDom = document.querySelector('.paper-direction');
      const paperDirectionDomOptionsDom = paperDirectionDom.querySelector('.options');
      paperDirectionDom.onclick = function () {
        paperDirectionDomOptionsDom.classList.toggle('visible');
      };
      paperDirectionDomOptionsDom.onclick = function (evt) {
        const li = evt.target;
        const paperDirection = li.dataset.paperDirection;
        instance.command.executePaperDirection(paperDirection);
        // 纸张方向状态回显
        paperDirectionDomOptionsDom.querySelectorAll('li').forEach(child => child.classList.remove('active'));
        li.classList.add('active');
      };

      // 页面边距
      const paperMarginDom = document.querySelector('.paper-margin');
      paperMarginDom.onclick = function () {
        const [topMargin, rightMargin, bottomMargin, leftMargin] = instance.command.getPaperMargin();
        new Dialog({
          title: '页边距',
          data: [
            {
              type: 'text',
              label: '上边距',
              name: 'top',
              required: true,
              value: `${topMargin}`,
              placeholder: '请输入上边距'
            },
            {
              type: 'text',
              label: '下边距',
              name: 'bottom',
              required: true,
              value: `${bottomMargin}`,
              placeholder: '请输入下边距'
            },
            {
              type: 'text',
              label: '左边距',
              name: 'left',
              required: true,
              value: `${leftMargin}`,
              placeholder: '请输入左边距'
            },
            {
              type: 'text',
              label: '右边距',
              name: 'right',
              required: true,
              value: `${rightMargin}`,
              placeholder: '请输入右边距'
            }
          ],
          onConfirm: payload => {
            const top = payload.find(p => p.name === 'top')?.value;
            if (!top) return;
            const bottom = payload.find(p => p.name === 'bottom')?.value;
            if (!bottom) return;
            const left = payload.find(p => p.name === 'left')?.value;
            if (!left) return;
            const right = payload.find(p => p.name === 'right')?.value;
            if (!right) return;
            instance.command.executeSetPaperMargin([Number(top), Number(right), Number(bottom), Number(left)]);
          }
        });
      };

      // 全屏
      const fullscreenDom = document.querySelector('.fullscreen');
      fullscreenDom.onclick = toggleFullscreen;
      window.addEventListener('keydown', evt => {
        if (evt.key === 'F11') {
          toggleFullscreen();
          evt.preventDefault();
        }
      });
      document.addEventListener('fullscreenchange', () => {
        fullscreenDom.classList.toggle('exist');
      });
      function toggleFullscreen() {
        console.log('fullscreen');
        if (!document.fullscreenElement) {
          document.documentElement.requestFullscreen();
        } else {
          document.exitFullscreen();
        }
      }

      // 7. 编辑器使用模式
      let modeIndex = 0;
      const modeList = [
        {
          mode: EditorMode.EDIT,
          name: '编辑模式'
        },
        {
          mode: EditorMode.CLEAN,
          name: '清洁模式'
        },
        {
          mode: EditorMode.READONLY,
          name: '只读模式'
        },
        {
          mode: EditorMode.FORM,
          name: '表单模式'
        },
        {
          mode: EditorMode.PRINT,
          name: '打印模式'
        }
      ];
      const modeElement = document.querySelector('.editor-mode');
      modeElement.onclick = function () {
        // 模式选择循环
        modeIndex === modeList.length - 1 ? (modeIndex = 0) : modeIndex++;
        // 设置模式
        const { name, mode } = modeList[modeIndex];
        modeElement.innerText = name;
        instance.command.executeMode(mode);
        // 设置菜单栏权限视觉反馈
        const isReadonly = mode === EditorMode.READONLY;
        const enableMenuList = ['search', 'print'];
        document.querySelectorAll('.menu-item>div').forEach(dom => {
          const menu = dom.dataset.menu;
          isReadonly && (!menu || !enableMenuList.includes(menu)) ? dom.classList.add('disable') : dom.classList.remove('disable');
        });
      };

      // 模拟批注
      state.updateComment();
      // 8. 内部事件监听
      instance.listener.rangeStyleChange = async function (payload) {
        // 控件类型
        payload.type === ElementType.SUBSCRIPT ? subscriptDom.classList.add('active') : subscriptDom.classList.remove('active');
        payload.type === ElementType.SUPERSCRIPT ? superscriptDom.classList.add('active') : superscriptDom.classList.remove('active');
        payload.type === ElementType.SEPARATOR ? separatorDom.classList.add('active') : separatorDom.classList.remove('active');
        separatorOptionDom.querySelectorAll('li').forEach(li => li.classList.remove('active'));
        if (payload.type === ElementType.SEPARATOR) {
          const separator = payload.dashArray.join(',') || '0,0';
          const curSeparatorDom = separatorOptionDom.querySelector(`[data-separator='${separator}']`);
          if (curSeparatorDom) {
            curSeparatorDom.classList.add('active');
          }
        }

        // 富文本
        fontOptionDom.querySelectorAll('li').forEach(li => li.classList.remove('active'));
        const curFontDom = fontOptionDom.querySelector(`[data-family='${payload.font}']`);
        if (curFontDom) {
          fontSelectDom.innerText = curFontDom.innerText;
          fontSelectDom.style.fontFamily = payload.font;
          curFontDom.classList.add('active');
        }
        sizeOptionDom.querySelectorAll('li').forEach(li => li.classList.remove('active'));
        const curSizeDom = sizeOptionDom.querySelector(`[data-size='${payload.size}']`);
        if (curSizeDom) {
          sizeSelectDom.innerText = curSizeDom.innerText;
          curSizeDom.classList.add('active');
        } else {
          sizeSelectDom.innerText = `${payload.size}`;
        }
        payload.bold ? boldDom.classList.add('active') : boldDom.classList.remove('active');
        payload.italic ? italicDom.classList.add('active') : italicDom.classList.remove('active');
        payload.underline ? underlineDom.classList.add('active') : underlineDom.classList.remove('active');
        payload.strikeout ? strikeoutDom.classList.add('active') : strikeoutDom.classList.remove('active');
        if (payload.color) {
          colorDom.classList.add('active');
          colorControlDom.value = payload.color;
          colorSpanDom.style.backgroundColor = payload.color;
        } else {
          colorDom.classList.remove('active');
          colorControlDom.value = '#000000';
          colorSpanDom.style.backgroundColor = '#000000';
        }
        if (payload.highlight) {
          highlightDom.classList.add('active');
          highlightControlDom.value = payload.highlight;
          highlightSpanDom.style.backgroundColor = payload.highlight;
        } else {
          highlightDom.classList.remove('active');
          highlightControlDom.value = '#ffff00';
          highlightSpanDom.style.backgroundColor = '#ffff00';
        }

        // 行布局
        leftDom.classList.remove('active');
        centerDom.classList.remove('active');
        rightDom.classList.remove('active');
        alignmentDom.classList.remove('active');
        if (payload.rowFlex && payload.rowFlex === 'right') {
          rightDom.classList.add('active');
        } else if (payload.rowFlex && payload.rowFlex === 'center') {
          centerDom.classList.add('active');
        } else if (payload.rowFlex && payload.rowFlex === 'alignment') {
          alignmentDom.classList.add('active');
        } else {
          leftDom.classList.add('active');
        }

        // 行间距
        rowOptionDom.querySelectorAll('li').forEach(li => li.classList.remove('active'));
        const curRowMarginDom = rowOptionDom.querySelector(`[data-rowmargin='${payload.rowMargin}']`);
        curRowMarginDom.classList.add('active');

        // 功能
        payload.undo ? undoDom.classList.remove('no-allow') : undoDom.classList.add('no-allow');
        payload.redo ? redoDom.classList.remove('no-allow') : redoDom.classList.add('no-allow');
        payload.painter ? painterDom.classList.add('active') : painterDom.classList.remove('active');

        // 标题
        titleOptionDom.querySelectorAll('li').forEach(li => li.classList.remove('active'));
        if (payload.level) {
          const curTitleDom = titleOptionDom.querySelector(`[data-level='${payload.level}']`);
          titleSelectDom.innerText = curTitleDom.innerText;
          curTitleDom.classList.add('active');
        } else {
          titleSelectDom.innerText = '正文';
          titleOptionDom.querySelector('li:first-child').classList.add('active');
        }

        // 列表
        listOptionDom.querySelectorAll('li').forEach(li => li.classList.remove('active'));
        if (payload.listType) {
          listDom.classList.add('active');
          const listType = payload.listType;
          const listStyle = payload.listType === ListType.OL ? ListStyle.DECIMAL : payload.listType;
          const curListDom = listOptionDom.querySelector(`[data-list-type='${listType}'][data-list-style='${listStyle}']`);
          if (curListDom) {
            curListDom.classList.add('active');
          }
        } else {
          listDom.classList.remove('active');
        }
        // 批注
        commentDomRef.value?.querySelectorAll('.comment-item').forEach(commentItemDom => {
          commentItemDom.classList.remove('active');
        });
        if (payload.groupIds) {
          const [id] = payload.groupIds;
          const activeCommentDom = commentDomRef.value?.querySelector(`.comment-item[data-id='${id}']`);
          if (activeCommentDom) {
            activeCommentDom.classList.add('active');
            scrollIntoView(commentDomRef.value, activeCommentDom);
          }
        }
      };

      instance.listener.visiblePageNoListChange = function (payload) {
        const text = payload.map(i => i + 1).join('、');
        document.querySelector('.page-no-list').innerText = text;
      };

      instance.listener.pageSizeChange = function (payload) {
        document.querySelector('.page-size').innerText = `${payload}`;
      };

      instance.listener.intersectionPageNoChange = function (payload) {
        document.querySelector('.page-no').innerText = `${payload + 1}`;
      };

      instance.listener.pageScaleChange = function (payload) {
        document.querySelector('.page-scale-percentage').innerText = `${Math.floor(payload * 10 * 10)}%`;
      };

      instance.listener.controlChange = function (payload) {
        const disableMenusInControlContext = ['table', 'hyperlink', 'separator', 'page-break'];
        // 菜单操作权限
        disableMenusInControlContext.forEach(menu => {
          const menuDom = document.querySelector(`.menu-item__${menu}`);
          payload ? menuDom.classList.add('disable') : menuDom.classList.remove('disable');
        });
      };

      instance.listener.pageModeChange = function (payload) {
        const activeMode = pageModeOptionsDom.querySelector(`[data-page-mode='${payload}']`);
        pageModeOptionsDom.querySelectorAll('li').forEach(li => li.classList.remove('active'));
        activeMode.classList.add('active');
      };

      const handleContentChange = async function () {
        // 字数
        const wordCount = await instance.command.getWordCount();
        document.querySelector('.word-count').innerText = `${wordCount || 0}`;
        // 目录
        if (isCatalogShow.value) {
          nextTickEd(() => {
            state.updateCatalog();
          });
        }
        // 批注
        nextTickEd(() => {
          state.updateComment();
        });
      };
      instance.listener.contentChange = debounce(handleContentChange, 200);
      handleContentChange();

      instance.listener.saved = function (payload) {
        console.log('elementList: ', payload);
      };

      // 10. 快捷键注册
      instance.register.shortcutList([
        {
          key: KeyMap.P,
          mod: true,
          isGlobal: true,
          callback: command => {
            command.executePrint();
          }
        },
        {
          key: KeyMap.F,
          mod: true,
          isGlobal: true,
          callback: command => {
            const text = command.getRangeText();
            searchDom.click();
            if (text) {
              searchInputDom.value = text;
              instance.command.executeSearch(text);
              setSearchResult();
            }
          }
        },
        {
          key: KeyMap.MINUS,
          ctrl: true,
          isGlobal: true,
          callback: command => {
            command.executePageScaleMinus();
          }
        },
        {
          key: KeyMap.EQUAL,
          ctrl: true,
          isGlobal: true,
          callback: command => {
            command.executePageScaleAdd();
          }
        },
        {
          key: KeyMap.ZERO,
          ctrl: true,
          isGlobal: true,
          callback: command => {
            command.executePageScaleRecovery();
          }
        }
      ]);
    });
    onUnmounted(() => {
      tableCellList.value.forEach(tr => {
        tr.forEach(td => td.classList.remove('active'));
      });
      while (tablePanelDom.value.firstChild) {
        tablePanelDom.value.removeChild(tablePanelDom.value.firstChild);
      }
      editorRef.value?.destroy();
    });
    const refState = toRefs(state);
    return {
      ...refState
    };
  }
};

css部分注意和自己组件库的冲突

<style>
.table-title span {
  font-size: 12px;
  color: #3d4757;
  display: inline;
  margin: 0;
}

.table-panel {
  cursor: pointer;
}

.table-panel .table-row {
  display: flex;
  flex-wrap: nowrap;
  margin-top: 10px;
  pointer-events: none;
}

.table-panel .table-cel {
  width: 16px;
  height: 16px;
  box-sizing: border-box;
  border: 1px solid #e2e6ed;
  background: #fff;
  position: relative;
  margin-right: 6px;
  pointer-events: none;
}

.table-panel .table-cel.active {
  border: 1px solid rgba(73, 145, 242, 0.2);
  background: rgba(73, 145, 242, 0.15);
}

.table-panel .table-row .table-cel:last-child {
  margin-right: 0;
}
</style>

<style lang="scss" scoped>
#canvasEditor {
  display: flex;
  justify-content: center;
  background: #f2f4f7;
}
.btn-save {
  position: absolute;
  width: 40px;
  height: 28px;
  line-height: 26px;
  font-size: 12px;
  text-align: center;
  color: #fff;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  background-color: #409eff;
  cursor: pointer;
}
.btn-back {
  position: absolute;
  width: 40px;
  height: 28px;
  line-height: 26px;
  font-size: 12px;
  text-align: center;
  color: #333;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  background-color: #fff;
  margin-left: 50px;
  cursor: pointer;
}
// @import url(@/assets/css/canvas-editor.css);
@import url(@/assets/css/dialog.css);
@import url(@/assets/css/signature.css);
</style>
<style lang="scss" scoped>
::-webkit-scrollbar {
  height: 16px;
  width: 16px;
  overflow: visible;
}

::-webkit-scrollbar-button {
  width: 0;
  height: 0;
}

::-webkit-scrollbar-corner {
  background: transparent;
}

::-webkit-scrollbar-thumb {
  background-color: #ddd;
  background-clip: padding-box;
  border: 4px solid #f2f4f7;
  border-radius: 8px;
  min-height: 24px;
}

::-webkit-scrollbar-thumb:hover {
  background-color: #c9c9c9;
}

::-webkit-scrollbar-track {
  background: #f2f4f7;
  background-clip: padding-box;
}

* {
  margin: 0;
  padding: 0;
}

/* body {
  background-color: #f2f4f7;
} */

ul {
  list-style: none;
}

.menu {
  width: 100%;
  height: 60px;
  top: 0;
  z-index: 9;
  position: fixed;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f2f4f7;
  box-shadow: 0 2px 4px 0 transparent;
}

.menu-divider {
  width: 1px;
  height: 16px;
  margin: 0 8px;
  display: inline-block;
  background-color: #cfd2d8;
}

.menu-item {
  height: 24px;
  display: flex;
  align-items: center;
  position: relative;
}

.menu-item > div {
  width: 24px;
  height: 24px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0 2px;
}

.menu-item > div:hover {
  background: rgba(25, 55, 88, 0.04);
}

.menu-item > div.active {
  background: rgba(25, 55, 88, 0.08);
}

.menu-item i {
  width: 16px;
  height: 16px;
  display: inline-block;
  background-repeat: no-repeat;
  background-size: 100% 100%;
}

.menu-item > div > span {
  width: 16px;
  height: 3px;
  display: inline-block;
  border: 1px solid #e2e6ed;
}

.menu-item .select {
  border: none;
  font-size: 12px;
  line-height: 24px;
  user-select: none;
}

.menu-item .select::after {
  position: absolute;
  content: '';
  top: 11px;
  width: 0;
  height: 0;
  right: 2px;
  border-color: #767c85 transparent transparent;
  border-style: solid solid none;
  border-width: 3px 3px 0;
}

.menu-item .options {
  min-width: 70px;
  position: absolute;
  left: 0;
  top: 25px;
  padding: 10px;
  background: #fff;
  font-size: 14px;
  box-shadow: 0 2px 12px 0 rgb(56 56 56 / 20%);
  border: 1px solid #e2e6ed;
  border-radius: 2px;
  display: none;
}

.menu-item .options.visible {
  display: block;
}

.menu-item .options li {
  padding: 5px;
  margin: 5px 0;
  user-select: none;
  transition: all 0.3s;
  white-space: nowrap;
}

.menu-item .options li:hover {
  background-color: #ebecef;
}

.menu-item .options li.active {
  background-color: #e2e6ed;
}

.menu-item .menu-item__font {
  width: 65px;
  position: relative;
}

.menu-item .menu-item__size {
  width: 50px;
  text-align: center;
  position: relative;
}

.menu-item__font .select,
.menu-item__size .select {
  width: 100%;
  height: 100%;
}

.menu-item__undo.no-allow,
.menu-item__redo.no-allow,
.menu-item > div.disable {
  color: #c0c4cc;
  cursor: not-allowed;
  opacity: 0.4;
  pointer-events: none;
}

.menu-item__undo i {
  background-image: url('@/assets/images/canvas-editor/undo.svg');
}

.menu-item__redo i {
  background-image: url('@/assets/images/canvas-editor/redo.svg');
}

.menu-item__painter i {
  background-image: url('@/assets/images/canvas-editor/painter.svg');
}

.menu-item__format i {
  background-image: url('@/assets/images/canvas-editor/format.svg');
}

.menu-item__size-add i {
  background-image: url('@/assets/images/canvas-editor/size-add.svg');
}

.menu-item__size-minus i {
  background-image: url('@/assets/images/canvas-editor/size-minus.svg');
}

.menu-item__bold i {
  background-image: url('@/assets/images/canvas-editor/bold.svg');
}

.menu-item__italic i {
  background-image: url('@/assets/images/canvas-editor/italic.svg');
}

.menu-item .menu-item__underline {
  width: 30px;
  position: relative;
}

.menu-item__underline > i {
  flex-shrink: 0;
  background-image: url('@/assets/images/canvas-editor/underline.svg');
}

.menu-item__underline .select {
  width: 100%;
  height: 100%;
}

.menu-item .menu-item__underline .options {
  width: 128px;
}

.menu-item .menu-item__underline li {
  padding: 1px 5px;
}

.menu-item__underline li i {
  pointer-events: none;
}

.menu-item__underline li[data-decoration-style='solid'] {
  background-image: url('@/assets/images/canvas-editor/line-single.svg');
}

.menu-item__underline li[data-decoration-style='double'] {
  background-image: url('@/assets/images/canvas-editor/line-double.svg');
}

.menu-item__underline li[data-decoration-style='dashed'] {
  background-image: url('@/assets/images/canvas-editor/line-dash-small-gap.svg');
}

.menu-item__underline li[data-decoration-style='dotted'] {
  background-image: url('@/assets/images/canvas-editor/line-dot.svg');
}

.menu-item__underline li[data-decoration-style='wavy'] {
  background-image: url('@/assets/images/canvas-editor/line-wavy.svg');
}

.menu-item__strikeout i {
  background-image: url('@/assets/images/canvas-editor/strikeout.svg');
}

.menu-item__superscript i {
  background-image: url('@/assets/images/canvas-editor/superscript.svg');
}

.menu-item__subscript i {
  background-image: url('@/assets/images/canvas-editor/subscript.svg');
}

.menu-item__color,
.menu-item__highlight {
  display: flex;
  flex-direction: column;
}

.menu-item__color #color,
.menu-item__highlight #highlight {
  width: 1px;
  height: 1px;
  visibility: hidden;
  outline: none;
  appearance: none;
}

.menu-item__color i {
  background-image: url('@/assets/images/canvas-editor/color.svg');
}

.menu-item__color span {
  background-color: #000000;
}

.menu-item__highlight i {
  background-image: url('@/assets/images/canvas-editor/highlight.svg');
}

.menu-item__highlight span {
  background-color: #ffff00;
}

.menu-item .menu-item__title {
  width: 60px;
  position: relative;
}

.menu-item__title .select {
  width: calc(100% - 20px);
  height: 100%;
}

.menu-item__title i {
  transform: translateX(-5px);
  background-image: url('@/assets/images/canvas-editor/title.svg');
}

.menu-item__title .options {
  width: 80px;
}

.menu-item__left i {
  background-image: url('@/assets/images/canvas-editor/left.svg');
}

.menu-item__center i {
  background-image: url('@/assets/images/canvas-editor/center.svg');
}

.menu-item__right i {
  background-image: url('@/assets/images/canvas-editor/right.svg');
}

.menu-item__alignment i {
  background-image: url('@/assets/images/canvas-editor/alignment.svg');
}

.menu-item__row-margin {
  position: relative;
}

.menu-item__row-margin i {
  background-image: url('@/assets/images/canvas-editor/row-margin.svg');
}

.menu-item__list {
  position: relative;
}

.menu-item__list i {
  background-image: url('@/assets/images/canvas-editor/list.svg');
}

.menu-item__list .options {
  width: 110px;
}

.menu-item__list .options > ul > li * {
  pointer-events: none;
}

.menu-item__list .options > ul > li li {
  margin-left: 18px;
}

.menu-item__image i {
  background-image: url('@/assets/images/canvas-editor/image.svg');
}

.menu-item__image input {
  display: none;
}

.menu-item__table {
  position: relative;
}

.menu-item__table i {
  background-image: url('@/assets/images/canvas-editor/table.svg');
}

.menu-item .menu-item__table__collapse {
  width: 270px;
  height: 310px;
  background: #fff;
  box-shadow: 0 2px 12px 0 rgb(56 56 56 / 20%);
  border: 1px solid #e2e6ed;
  box-sizing: border-box;
  border-radius: 2px;
  position: absolute;
  display: none;
  z-index: 99;
  top: 25px;
  left: 0;
  padding: 14px 27px;
  cursor: auto;
}

.menu-item .menu-item__table__collapse .table-close {
  position: absolute;
  right: 10px;
  top: 5px;
  cursor: pointer;
}

.menu-item .menu-item__table__collapse .table-close:hover {
  color: #7d7e80;
}

.menu-item .menu-item__table__collapse:hover {
  background: #fff;
}

.menu-item .menu-item__table__collapse .table-title {
  display: flex;
  justify-content: flex-start;
  padding-bottom: 5px;
  border-bottom: 1px solid #e2e6ed;
}

.menu-item__hyperlink i {
  background-image: url('@/assets/images/canvas-editor/hyperlink.svg');
}

.menu-item__separator {
  position: relative;
}

.menu-item__separator > i {
  background-image: url('@/assets/images/canvas-editor/separator.svg');
}

.menu-item .menu-item__separator .options {
  width: 128px;
}

.menu-item .menu-item__separator li {
  padding: 1px 5px;
}

.menu-item__separator li i {
  pointer-events: none;
}

.menu-item__separator li[data-separator='0,0'] {
  background-image: url('@/assets/images/canvas-editor/line-single.svg');
}

.menu-item__separator li[data-separator='1,1'] {
  background-image: url('@/assets/images/canvas-editor/line-dot.svg');
}

.menu-item__separator li[data-separator='3,1'] {
  background-image: url('@/assets/images/canvas-editor/line-dash-small-gap.svg');
}

.menu-item__separator li[data-separator='4,4'] {
  background-image: url('@/assets/images/canvas-editor/line-dash-large-gap.svg');
}

.menu-item__separator li[data-separator='7,3,3,3'] {
  background-image: url('@/assets/images/canvas-editor/line-dash-dot.svg');
}

.menu-item__separator li[data-separator='6,2,2,2,2,2'] {
  background-image: url('@/assets/images/canvas-editor/line-dash-dot-dot.svg');
}

.menu-item__watermark > i {
  background-image: url('@/assets/images/canvas-editor/watermark.svg');
}

.menu-item__watermark {
  position: relative;
}

.menu-item__codeblock i {
  background-image: url('@/assets/images/canvas-editor/codeblock.svg');
}

.menu-item__page-break i {
  background-image: url('@/assets/images/canvas-editor/page-break.svg');
}

.menu-item__control {
  position: relative;
}

.menu-item__control i {
  background-image: url('@/assets/images/canvas-editor/control.svg');
}

.menu-item__checkbox i {
  background-image: url('@/assets/images/canvas-editor/checkbox.svg');
}

.menu-item__latex i {
  background-image: url('@/assets/images/canvas-editor/latex.svg');
}

.menu-item__date {
  position: relative;
}

.menu-item__date i {
  background-image: url('@/assets/images/canvas-editor/date.svg');
}

.menu-item__date .options {
  width: 160px;
}

.menu-item__block i {
  background-image: url('@/assets/images/canvas-editor/block.svg');
}

.menu-item .menu-item__control .options {
  width: 55px;
}

.menu-item__search {
  position: relative;
}

.menu-item__search i {
  background-image: url('@/assets/images/canvas-editor/search.svg');
}

.menu-item .menu-item__search__collapse {
  width: 260px;
  height: 72px;
  box-sizing: border-box;
  position: absolute;
  display: none;
  z-index: 99;
  top: 25px;
  left: 0;
  background: #ffffff;
  box-shadow: 0px 5px 5px #e3dfdf;
}

.menu-item .menu-item__search__collapse:hover {
  background: #ffffff;
}

.menu-item .menu-item__search__collapse > div {
  width: 250px;
  height: 36px;
  padding: 0 5px;
  line-height: 36px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  border-radius: 4px;
}

.menu-item .menu-item__search__collapse > div input {
  width: 205px;
  height: 27px;
  appearance: none;
  background-color: #fff;
  background-image: none;
  border-radius: 4px;
  border: 1px solid #ebebeb;
  box-sizing: border-box;
  color: #606266;
  display: inline-block;
  line-height: 27px;
  outline: none;
  padding: 0 5px;
}

.menu-item .menu-item__search__collapse > div span {
  height: 100%;
  color: #dcdfe6;
  font-size: 25px;
  display: inline-block;
  border: 0;
  padding: 0 10px;
}

.menu-item .menu-item__search__collapse__replace button {
  display: inline-block;
  border: 1px solid #e2e6ed;
  border-radius: 2px;
  background: #fff;
  line-height: 22px;
  padding: 0 6px;
  white-space: nowrap;
  margin-left: 4px;
  cursor: pointer;
  font-size: 12px;
}

.menu-item .menu-item__search__collapse__replace button:hover {
  background: rgba(25, 55, 88, 0.04);
}

.menu-item .menu-item__search__collapse__search {
  position: relative;
}

.menu-item .menu-item__search__collapse__search label {
  right: 110px;
  font-size: 12px;
  color: #3d4757;
  position: absolute;
}

.menu-item .menu-item__search__collapse__search > input {
  padding: 5px 90px 5px 5px !important;
}

.menu-item .menu-item__search__collapse__search > div {
  width: 28px;
  height: 27px;
  display: flex;
  justify-content: center;
  align-items: center;
  position: absolute;
  border-left: 1px solid #e2e6ed;
  transition: all 0.5s;
}

.menu-item .menu-item__search__collapse__search > div:hover {
  background-color: rgba(25, 55, 88, 0.04);
}

.menu-item .menu-item__search__collapse__search i {
  width: 6px;
  height: 8px;
  transform: translateY(1px);
}

.menu-item .menu-item__search__collapse__search .arrow-left {
  right: 76px;
}

.menu-item .menu-item__search__collapse__search .arrow-left i {
  background: url(@/assets/images/canvas-editor/arrow-left.svg) no-repeat;
}

.menu-item .menu-item__search__collapse__search .arrow-right {
  right: 48px;
}

.menu-item .menu-item__search__collapse__search .arrow-right i {
  background: url(@/assets/images/canvas-editor/arrow-right.svg) no-repeat;
}

.menu-item__print i {
  background-image: url('@/assets/images/canvas-editor/print.svg');
}

.catalog {
  width: 250px;
  position: fixed;
  left: 0;
  bottom: 0;
  top: 100px;
  padding: 0 20px 40px 20px;
}

.catalog .catalog__header {
  height: 48px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  border-bottom: 1px solid #e2e6ed;
}

.catalog .catalog__header span {
  color: #3d4757;
  font-size: 14px;
  font-weight: bold;
}

.catalog .catalog__header i {
  width: 16px;
  height: 16px;
  cursor: pointer;
  display: inline-block;
  background: url(@/assets/images/canvas-editor/close.svg) no-repeat;
  transition: all 0.2s;
}

.catalog .catalog__header > div:hover {
  background: rgba(235, 238, 241);
}

.catalog__main {
  height: calc(100% - 60px);
  padding: 10px 0;
  overflow-y: auto;
  overflow-x: hidden;
}

.catalog__main .catalog-item {
  width: 100%;
  padding-left: 10px;
  box-sizing: border-box;
}

.catalog__main > .catalog-item {
  padding-left: 0;
}

.catalog__main .catalog-item .catalog-item__content {
  width: 100%;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.catalog__main .catalog-item .catalog-item__content:hover > span {
  color: #4991f2;
}

.catalog__main .catalog-item .catalog-item__content span {
  color: #3d4757;
  line-height: 30px;
  font-size: 12px;
  white-space: nowrap;
  cursor: pointer;
  user-select: none;
}

.editor > div {
  margin: 80px auto;
}

.ce-page-container canvas {
  box-shadow: rgb(158 161 165 / 40%) 0px 2px 12px 0px;
}

.comment {
  width: 250px;
  height: 650px;
  position: fixed;
  transform: translateX(420px);
  top: 200px;
  left: 50%;
  overflow-y: auto;
}

.comment-item {
  background: #ffffff;
  border: 1px solid #e2e6ed;
  position: relative;
  border-radius: 8px;
  padding: 15px;
  font-size: 14px;
  margin-bottom: 20px;
  cursor: pointer;
  transition: all 0.5s;
}

.comment-item:hover {
  border-color: #c0c6cf;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}

.comment-item.active {
  border-color: #e99d00;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}

.comment-item__title {
  height: 22px;
  position: relative;
  display: flex;
  align-items: center;
  color: #c1c6ce;
}

.comment-item__title span:first-child {
  background-color: #dbdbdb;
  width: 4px;
  height: 16px;
  margin-right: 5px;
  display: inline-block;
  border-radius: 999px;
}

.comment-item__title span:nth-child(2) {
  width: 200px;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.comment-item__title i {
  width: 16px;
  height: 16px;
  cursor: pointer;
  position: absolute;
  right: -8px;
  top: -8px;
  background: url(@/assets/images/canvas-editor/close.svg) no-repeat;
}

.comment-item__title i:hover {
  opacity: 0.6;
}

.comment-item__info {
  height: 28px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.comment-item__info > span:first-child {
  font-weight: 600;
}

.comment-item__info > span:last-child {
  color: #c1c6ce;
}

.comment-item__content {
  line-height: 22px;
}

.footer-canvas {
  width: 100%;
  height: 30px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: #f2f4f7;
  z-index: 9;
  position: fixed;
  bottom: 0;
  left: 0;
  font-size: 12px;
  padding: 0 4px 0 20px;
  box-sizing: border-box;
}

.footer-canvas > div:first-child {
  display: flex;
  align-items: center;
}

.footer-canvas .catalog-mode {
  padding: 1px;
  position: relative;
}

.footer-canvas .catalog-mode i {
  width: 16px;
  height: 16px;
  margin-right: 5px;
  cursor: pointer;
  display: inline-block;
  background-image: url('@/assets/images/canvas-editor/catalog.svg');
}

.footer-canvas .page-mode {
  padding: 1px;
  position: relative;
}

.footer-canvas .page-mode i {
  width: 16px;
  height: 16px;
  margin-right: 5px;
  cursor: pointer;
  display: inline-block;
  background-image: url('@/assets/images/canvas-editor/page-mode.svg');
}

.footer-canvas .options {
  width: 70px;
  position: absolute;
  left: 0;
  bottom: 25px;
  padding: 10px;
  background: #fff;
  font-size: 14px;
  box-shadow: 0 2px 12px 0 rgb(56 56 56 / 20%);
  border: 1px solid #e2e6ed;
  border-radius: 2px;
  display: none;
}

.footer-canvas .options.visible {
  display: block;
}

.footer-canvas .options li {
  padding: 5px;
  margin: 5px 0;
  user-select: none;
  transition: all 0.3s;
  text-align: center;
  cursor: pointer;
}

.footer-canvas .options li:hover {
  background-color: #ebecef;
}

.footer-canvas .options li.active {
  background-color: #e2e6ed;
}

.footer-canvas > div:first-child > span {
  display: inline-block;
  margin-right: 5px;
  letter-spacing: 1px;
}

.footer-canvas > div:last-child {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.footer-canvas > div:last-child > div {
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.footer-canvas > div:last-child > div:hover {
  background: rgba(25, 55, 88, 0.04);
}

.footer-canvas > div:last-child i {
  width: 16px;
  height: 16px;
  display: inline-block;
  cursor: pointer;
}

.footer-canvas .page-scale-minus i {
  background-image: url('@/assets/images/canvas-editor/page-scale-minus.svg');
}

.footer-canvas .page-scale-add i {
  background-image: url('@/assets/images/canvas-editor/page-scale-add.svg');
}

.footer-canvas .page-scale-percentage {
  cursor: pointer;
  user-select: none;
}

.footer-canvas .fullscreen i {
  background-image: url('@/assets/images/canvas-editor/request-fullscreen.svg');
}

.footer-canvas .fullscreen.exist i {
  background-image: url('@/assets/images/canvas-editor/exit-fullscreen.svg');
}

.footer-canvas .paper-margin i {
  background-image: url('@/assets/images/canvas-editor/paper-margin.svg');
}

.footer-canvas .editor-mode {
  cursor: pointer;
  user-select: none;
}

.footer-canvas .paper-size {
  position: relative;
}

.footer-canvas .paper-size i {
  background-image: url('@/assets/images/canvas-editor/paper-size.svg');
}

.footer-canvas .paper-size .options {
  right: 0;
  left: unset;
}

.footer-canvas .paper-direction {
  position: relative;
}

.footer-canvas .paper-direction i {
  background-image: url('@/assets/images/canvas-editor/paper-direction.svg');
}

.footer-canvas .paper-direction .options {
  right: 0;
  left: unset;
}

.ce-contextmenu-signature {
  background-image: url('@/assets/images/canvas-editor/signature.svg');
}

.ce-contextmenu-word-tool {
  background-image: url('@/assets/images/canvas-editor/word-tool.svg');
}
</style>

引入使用组件

例:
<canvas-editor
  v-if="!loadingInit"
  :header="canvasEditor.header"
  :main="canvasEditor.main"
  :footer="canvasEditor.footer"
  :options="canvasEditor.options"
  @getCanvasEditorData="getCanvasEditorData"
/>
import CanvasEditor from '@/components/editor/index.vue';

components: {
  CanvasEditor
},

canvasEditor: {
  main: [],
  options: [],
  header: [],
  footer: []
},

// getCanvasEditorData 事件

主要获取富文本 main内容 image的base64 options配置项 用于数据保存

getCanvasEditorData: ({ value, imgage, options }) => {  
},

image.png

主要配置

    // 页眉配置
    header: {
      type: Array,
      default: []
    },
    // 主要编辑内容
    main: {
      type: Array,
      default: []
    },
    // 页脚信息
    footer: {
      type: Array,
      default: []
    },
    // 
    options: {
      type: Object,
      default: {}
    },
    // 批注 TODO
    commentList: {
      type: Array,
      default: []
    }

完整配置

interface IEditorOption {
  mode?: EditorMode // 编辑器模式:编辑、清洁(不显示视觉辅助元素。如:分页符)、只读、表单(仅控件内可编辑)、打印(不显示辅助元素、未书写控件及前后括号)。默认:编辑
  defaultType?: string // 默认元素类型。默认:TEXT
  defaultColor?: string // 默认字体颜色。默认:#000000
  defaultFont?: string // 默认字体。默认:Microsoft YaHei
  defaultSize?: number // 默认字号。默认:16
  minSize?: number // 最小字号。默认:5
  maxSize?: number // 最大字号。默认:72
  defaultBasicRowMarginHeight?: number // 默认行高。默认:8
  defaultRowMargin?: number // 默认行间距。默认:1
  defaultTabWidth?: number // 默认tab宽度。默认:32
  width?: number // 纸张宽度。默认:794
  height?: number // 纸张高度。默认:1123
  scale?: number // 缩放比例。默认:1
  pageGap?: number // 纸张间隔。默认:20
  underlineColor?: string // 下划线颜色。默认:#000000
  strikeoutColor?: string // 删除线颜色。默认:#FF0000
  rangeColor?: string // 选区颜色。默认:#AECBFA
  rangeAlpha?: number // 选区透明度。默认:0.6
  rangeMinWidth?: number // 选区最小宽度。默认:5
  searchMatchColor?: string // 搜索高亮颜色。默认:#FFFF00
  searchNavigateMatchColor?: string // 搜索导航高亮颜色。默认:#AAD280
  searchMatchAlpha?: number // 搜索高亮透明度。默认:0.6
  highlightAlpha?: number // 高亮元素透明度。默认:0.6
  resizerColor?: string // 图片尺寸器颜色。默认:#4182D9
  resizerSize?: number // 图片尺寸器大小。默认:5
  marginIndicatorSize?: number // 页边距指示器长度。默认:35
  marginIndicatorColor?: string // 页边距指示器颜色。默认:#BABABA
  margins?: IMargin // 页面边距。默认:[100, 120, 100, 120]
  pageMode?: PageMode // 纸张模式:连页、分页。默认:分页
  tdPadding?: IPadding // 单元格内边距。默认:[0, 5, 5, 5]
  defaultTrMinHeight?: number // 默认表格行最小高度。默认:42
  defaultColMinWidth?: number // 默认表格列最小宽度(整体宽度足够时应用,否则会按比例缩小)。默认:40
  defaultHyperlinkColor?: string // 默认超链接颜色。默认:#0000FF
  header?: IHeader // 页眉信息。{top?:number; maxHeightRadio?:MaxHeightRatio;}
  footer?: IFooter // 页脚信息。{bottom?:number; maxHeightRadio?:MaxHeightRatio;}
  pageNumber?: IPageNumber // 页码信息。{bottom:number; size:number; font:string; color:string; rowFlex:RowFlex; format:string; numberType:NumberType;}
  paperDirection?: PaperDirection // 纸张方向:纵向、横向
  inactiveAlpha?: number // 正文内容失焦时透明度。默认值:0.6
  historyMaxRecordCount?: number // 历史(撤销重做)最大记录次数。默认:100次
  printPixelRatio?: number // 打印像素比率(值越大越清晰,但尺寸越大)。默认:3
  maskMargin?: IMargin // 编辑器上的遮盖边距(如悬浮到编辑器上的菜单栏、底部工具栏)。默认:[0, 0, 0, 0]
  letterClass?: string[] // 排版支持的字母类。默认:a-zA-Z。内置可选择的字母表类:LETTER_CLASS
  contextMenuDisableKeys?: string[] // 禁用的右键菜单。默认:[]
  scrollContainerSelector?: string // 滚动区域选择器。默认:document
  wordBreak?: WordBreak // 单词与标点断行:BREAK_WORD首行不出现标点&单词不拆分、BREAK_ALL按字符宽度撑满后折行。默认:BREAK_WORD
  watermark?: IWatermark // 水印信息。{data:string; color?:string; opacity?:number; size?:number; font?:string;}
  control?: IControlOption // 控件信息。 {placeholderColor?:string; bracketColor?:string; prefix?:string; postfix?:string; borderWidth?: number; borderColor?: string;}
  checkbox?: ICheckboxOption // 复选框信息。{width?:number; height?:number; gap?:number; lineWidth?:number; fillStyle?:string; strokeStyle?: string;}
  radio?: IRadioOption // 单选框信息。{width?:number; height?:number; gap?:number; lineWidth?:number; fillStyle?:string; strokeStyle?: string;}
  cursor?: ICursorOption // 光标样式。{width?: number; color?: string; dragWidth?: number; dragColor?: string;}
  title?: ITitleOption // 标题配置。{ defaultFirstSize?: number; defaultSecondSize?: number; defaultThirdSize?: number defaultFourthSize?: number; defaultFifthSize?: number; defaultSixthSize?: number;}
  placeholder?: IPlaceholder // 编辑器空白占位文本
  group?: IGroup // 成组配置。{opacity?:number; backgroundColor?:string; activeOpacity?:number; activeBackgroundColor?:string; disabled?:boolean}
  pageBreak?: IPageBreak // 分页符配置。{font?:string; fontSize?:number; lineDash?:number[];}
  zone?: IZoneOption // 编辑器区域配置。{tipDisabled?:boolean;}
  background?: IBackgroundOption // 背景配置。{color?:string; image?:string; size?:BackgroundSize; repeat?:BackgroundRepeat;}。默认:{color: '#FFFFFF'}
  lineBreak?: ILineBreakOption // 换行符配置。{disabled?:boolean; color?:string; lineWidth?:number;}
  separator?: ISeparatorOption // 分隔符配置。{lineWidth?:number; strokeStyle?:string;}
}

页眉配置

interface IHeader {
  top?: number // 距离页面顶部大小。默认:30
  maxHeightRadio?: MaxHeightRatio // 占页面最大高度比。默认:HALF
  disabled?: boolean // 是否禁用
}

页码配置

interface IPageNumber {
  bottom?: number // 距离页面底部大小。默认:60
  size?: number // 字体大小。默认:12
  font?: string // 字体。默认:Microsoft YaHei
  color?: string // 字体颜色。默认:#000000
  rowFlex?: RowFlex // 行对齐方式。默认:CENTER
  format?: string // 页码格式。默认:{pageNo}。示例:第{pageNo}页/共{pageCount}页
  numberType?: NumberType // 数字类型。默认:ARABIC
  disabled?: boolean // 是否禁用
  startPageNo?: number // 起始页码。默认:1
  fromPageNo?: number // 从第几页开始出现页码。默认:0
}

水印配置

interface IWatermark {
  data: string // 文本。
  color?: string // 颜色。默认:#AEB5C0
  opacity?: number // 透明度。默认:0.3
  size?: number // 字体大小。默认:200
  font?: string // 字体。默认:Microsoft YaHei
}

占位文本配置

interface IPlaceholder {
  data: string // 文本。
  color?: string // 颜色。默认:#DCDFE6
  opacity?: number // 透明度。默认:1
  size?: number // 字体大小。默认:16
  font?: string // 字体。默认:Microsoft YaHei
}

canvas-editor官方文档
git地址

Logo

前往低代码交流专区

更多推荐