antv/s2 自定义目录树 自定义框选功能

1,345 阅读8分钟

背景

  • 最近公司需要使用 antv/s2 去实现网页在线编辑表格的功能,在开发过程中发现 s2 自带的框选和复制 与 excel 的功能不一样, 不太能满足公司的需求,所以我就根据 s2 原本的方法进行功能增加

  • 主要增加功能

    • 自定义框选范围
    • 复制
    • 粘贴
    • 删除框选单元格值
  • demo体验地址:idd5pr.csb.app/

自定义框选范围

  • s2 的自定义目录树在收起父级框选单元格的时候,并不会像 excel 一样会框选住收起的子级

  • 原因是因为s2 的渲染机制是 只展示视图中有的,包括取单元格值的机制也是,所以需要我们自定义框选范围。

  • s2 原本的框选效果:

    • 这是 s2 收起父级后的框选
      • 1679732734016.png
    • 展开父级我们发现,子级没有被选中
      • 1679732869430.png
  • 自定义框选效果展示:

    • 在收起父级的时候框选单元格
      • 1679904230057.png
    • 展开父级,里面的子级也全部选中了
      • 1679904303245.png
  • 自定义框选思路:

    • 通过 onSelected 方法获取 手动框选的单元格
    • 判断选的是否为数值单元格、行头还是列头
      • 选择的是数值型单元格时
        • 记录该单元格的列
        • 从 dataSource 查询 该行数据并记录
        • 查询该行是否收起
      • 选择的是行头时
        • 从 dataSource 查询 该行数据并记录
        • 记录所有列
      • 选择的是列头时
        • 记录该列
        • 将 dataSource 平铺记录,因为要选择该列的所有行
    • 根据记录的列和行数据,确定所选单元格的数据
    • 将所选单元格的数据处理成 cellMeta 形式
    • 再将 cellMeta 通过 new DataCell 方法形成新的单元格列表
    • 将 cellMeta 和 dataCell 拼装成 选中的设置选中状态的 cellState
    • 最后通过 spreadsheet.interaction.changeState(cellState) 设置上选中的状态即可
  • 自定义框选代码:

    •   // 框选单元格
        function onSelected(cellData) {
          const spreadsheet = s2Ref.current;
          let selectRows = [];    // 这是框选的行数据
          const selectCols = [];    // 这是框选的列范围, 如果使用 ctrl 进行多选会有问题 
          
          // 不知道为什么 s2 点击列头的时候,会触发两遍 onSelected
          // 我只能在这里做个筛选,相当于防抖,阻塞点列头的第二次触发,不影响点行头和正常框选单元格
          if (selectColLock) return;
          
        // 处理选中的 单元格数据
          cellData.forEach(cellInfo => {
            // 框选的单元格 是 数值单元格
            if (cellInfo.cellType === 'dataCell') {
              selectCols.push(cellInfo.meta.colIndex);
          
            // 看选择的 行 是不是已经存在
              const isExit = selectRows.filter(curRow => curRow.childValueCode === cellInfo.meta?.valueField).length;
          
            // 不存在 则去处理数据
              if (!isExit) {
                const curCell = findTreeItem(cellInfo.meta?.valueField, dataSource, 'childValueCode');
          
              // 该行是否展开
                const { isCollapsed } = spreadsheet.getRowNodes(curCell.nodeLevel - 1).filter(cur => cur.id === cellInfo.meta?.rowId)[0];
                curCell.isCollapsed = isCollapsed;
          
              selectRows.push(curCell)
              }
            } else if (cellInfo.cellType === 'rowCell') {   // 选中的单元格是 行头单元格
              // 给列头和列尾
              selectCols.push(0)
              selectCols.push(columns.length - 1);
          
              const curCell = findTreeItem(cellInfo.meta?.field, dataSource, 'childValueCode');
              selectRows.push(curCell)
            } else if (cellInfo.cellType === 'colCell') {   // 选中的单元格是 列头单元格
              selectCols.push(cellInfo.meta.colIndex);
          
            // 将 表格树数据 打平
              const tempList = [];
              formatTreeToTiled(dataSource, tempList);
          
            // 选择行是所有行
              selectRows = tempList;
              setSelectColLock(true);
            }
          });
          
        // 框选列的 最小值和最大值
          const selectColMap = {
            min: Math.min(...selectCols),
            max: Math.max(...selectCols),
          }
          
        // 处理 行数据成 cellMeta 以便 选中单元格用
          const selectCellMeta = [];
          formatCellMeta(selectRows, selectCellMeta, selectColMap);
          
        // 自定义选中单元格
          handleCustomSelectCell(selectCellMeta)
          
        // 阻塞完列头点击的第二次事件后,就可以放开了,要不然会影响其他点击
          setTimeout(() => {
            setSelectColLock(false);
          }, 100)
        }
        
        
        // 找节点
        function findTreeItem(id, list, key = 'id') {
          let i = 0;
          const len = list.length;
          for (;i < len;i += 1) {
            if (list[i][key] === id) return list[i];
            if (list[i].children) {
              const node = findTreeItem(id, list[i].children, key);
              if (node) return node;
            }
          }
        }
        
          
        // 将树形数据 转换为 平铺的格式
        function formatTreeToTiled(dataList, formatList) {
          dataList.forEach(item => {
            formatList.push(item);
        
            if (item.children) {
              formatTreeToTiled(item.children, formatList)
            }
          })
        }
      
  • 处理数据成 cellmeta 格式

    •   // 处理 行数据成 cellMeta 以便 选中单元格用
      function formatCellMeta(list, selectCellMeta, selectColMap, isParentCollapsed) {
        list.forEach(item => {
          const { meta, isCollapsed } = item;
        
          // 遍历 列范围
          for (let i = selectColMap.min;i <= selectColMap.max;i += 1) {
            // 拼接出 colId 和 id
            const colId = `root[&]${columns[i].title}`;
            const id = `${meta.rowId}-${colId}`;
        
            selectCellMeta.push({
              ...meta,
              colId,
              colIndex: i,
              cols: columns[i],
              id,
              spreadsheet: s2Ref.current,
            });
          }
        
          // 如果 父级展开 或者 子级展开,都直接去看存不存在子级
          if (isParentCollapsed ?? isCollapsed) {
            // 存在子级 也一起选中
            if (item.children) {
              formatCellMeta(item.children, selectCellMeta, selectColMap, (isParentCollapsed ?? item.isCollapsed))
            }
          }
        })
      }
      
  • 将 cellMeta 处理成 dataCell,并设置单元格的选中状态

    • // 自定义选中单元格
      function handleCustomSelectCell(selectCellMeta) {
        const spreadsheet = s2Ref.current;
        
        // 将 cellMeta 转变成 dataCell
        const selectDataCells = selectCellMeta.map(cellMeta => {
          return new DataCell(cellMeta, spreadsheet);
        })
        
        // 更新选中单元格的状态
        const cellState = {
          cells: selectCellMeta,
          interactedCells: selectDataCells,
          stateName: "selected",
        }
      
        spreadsheet.interaction.changeState(cellState);
      }
      

复制

  • 在自定义框选范围时,我们自己生成的 dataCell 的 rowIndex 其实算是有问题的。

    • 因为在 子级收起来的情况,子级是没有 rowIndex 的,而我之前所有的 dataCell.rowIndex 我都给了 0,是因为 s2 底层需要使用 rowIndex 确定框选的行范围,如果不给就会报错。
    • 而 dataCell.rowIndex 给了 0 就会导致 使用 s2 自带的复制会 获取错误的单元格数据
    • 所以需要我们自己处理一下数据
  • 复制思路:

    • 创建 html 模板,用于存复制的数据放入剪贴板中

      • 其实这里可以使用更简便的字符串,拼接 各单元格数据时,使用 \t 让同行数据跳格,使用 \n 让数据换行

      • let str = '';
        if (index > 0) {
            // 看与之前单元格是不是同行,不是就换行
            if (state.cells[index - 1]?.rowId !== rowId) {
                str += `\n\${val}`;		// \n 后的转义符没用,只是在 markdown 里展示会有问题才加上的
                } else {
                // 是就跳格
                str += `\t${val}`;
            }
        } else {
            // 是第一个单元格就啥都不加
            str += `${val}`;
        }
        
        // str = 1\t2\n3\t4\n 在表格中为
        // 1 2
        // 3 4
        
      • 虽然可以 通过这样的字符串 放到 excel 中,但如果粘贴到 在线编辑表格中,会与 从 excel 复制过来的数据格式不一样,导致需要两个不同的解析逻辑,这样逻辑不统一,所以建议还是选用 html 模板的形式

    • 通过 spreadsheet.interaction.getState() 获取当前表格选中的状态信息

    • state.cells 为框选的单元格,遍历它

      • 在 treeData 中找到当前单元格的数据(这里的 treeData 是 s2 自定义目录树使用的平铺数据,不是常规用的树形数据)
      • 将数据放到 td 中再放到 html 模板里
      • 如果 该数据 是第一个单元格 或 该单元格与上一个单元格的 rowId 不同时,需要新建一个 tr 来换行
    • 最后通过 navigator.clipboard.write() 来存入剪贴板

      • 这里使用 clipbord 会有点小坑,write 方法接收的参数是一个数组,且数组里只能由一个元素,且这个元素是 ClipboardItem 类型
      • ClipboardItem 里存入的一定得是个 blob 类型的数据,而且 type 类型要对应
        • 如果是 html,type 为 'text/html' ;
        • 如果是 字符串,type 为 'text/plain' ;
        • new window.ClipboardItem({
              "text/html": new Blob([html.innerHTML], { type: "text/html" }),
          });
          
  • 复制代码:

    • // 复制 表格数据
      function handleCopyData() {
          const spreadsheet = s2Ref.current;
          
          // 创建 html 模板
          const html = document.createElement('html');
          const body = document.createElement('body');
          const table = document.createElement('table');
          html.appendChild(body);
          body.appendChild(table);
         
          // 获取 框选的单元格
          const state = spreadsheet.interaction.getState();
      
          // 处理数据
          state.cells.forEach((item, index) => {
            const { rowField, cols, rowId } = item;
            const { title: colTitle } = cols;
            const rowClass = rowId.replaceAll('[&]', '-');
            let val = '';
      
            // 查找该单元格最新的数据
            treeData.forEach(cur => {
              if (
                colTitle === cur.type
                &&
                Object.keys(cur).includes(rowField)
              ) {
                val = cur[rowField] || '';
              }
            })
      
            // TODO: 这是比较简便的 可以复制到 excel 单元格的字符串,但有局限性,仅供思路参考
            // 如果 不是第一个单元格
            // let str = '';
            // if (index > 0) {
            //   // 看与之前单元格是不是同行,不是就换行
            //   if (state.cells[index - 1]?.rowId !== rowId) {
            //     str += `\n${val}`;
            //   } else {
            //     // 是就跳格
            //     str += `\t${val}`;
            //   }
            // } else {
            //   // 是第一个单元格就啥都不加
            //   str += `${val}`;
            // }
      
            // 如果是第一个单元格 或 该单元格与上一个单元格的 rowId 不同时,需要新建一个 tr 来换行
            if (index === 0 || state.cells[index - 1]?.rowId !== rowId) {
              const tr = document.createElement('tr');
              const td = document.createElement('td');
              td.innerText = val;
              tr.appendChild(td);
              table.appendChild(tr);
            } else {
              // 如果 该单元格与上一个单元格的 rowId 相同时,代表为同一行,直接 往上个单元格所在的 tr 里添加 td 即可
              const tr = html.querySelector(`.\${rowClass}`);		// 这个转义符没用,只是在 markdown 里展示会有问题才加上的
              const td = document.createElement('td');
              td.innerText = val;
              tr.appendChild(td);
            }
          })
      
          // 给 setTimeOut 是为了让这行代码 变成宏任务,保证先执行 s2 自带的复制,才执行这里的复制,覆盖掉s2的
          setTimeout(() => {
            const clipboardItem = new window.ClipboardItem({
              "text/html": new Blob([html.innerHTML], { type: "text/html" }),
              // "text/plain": new Blob([str], { type: "text/plain" }),
            });
      
            navigator.clipboard.write([clipboardItem]).then(() => {
              message.success('复制成功!');
            }).catch((error) => {
              console.error("Error copying value to clipboard:", error);
            });
          }, 200)
        }
      

粘贴

  • 因为之前在复制里使用了 html 模板,所以这里就可以统一用一套解析逻辑对剪贴板数据进行解析

  • 粘贴思路:

    • 通过 spreadsheet.interaction.getState() 获取当前表格选中的状态信息

    • 找到起始单元格为 state.cells[0]

      • 找到起始单元格 在 treeData 中的 index,设置为 focusCellIndex
      • 将 focusCellIndex 除以 columns 列配置的长度
        • 得到的值为 起始单元格 的行号 curRowIndex
        • 余下来的值为 起始单元格 的列号 offsetCol
    • 通过 navigator.clipboard.read() 获取剪贴板数据

      • read 方法是一个 promise 对象,所以在 .then 方法中拿到 html 的字符串

        • // 读取出详细数据
          const blob = await clipboardItem[0].getType('text/html');
          const htmlStr = await blob.text()
          
      • 将 html 字符串转回 html 标签,并从中 查询 tr 行

        • const doc = new DOMParser().parseFromString(htmlStr, 'text/html');
          const trs = Array.from(doc.querySelectorAll('table tr'));
          
      • 通过 tr 和 td 确定框选了多少行和多少列,行数为 rowLen,列数为 colLen

        • 当复制的列数 超出了 表格的范围时,框选列数修改为 从起始列到最后一列

        • if (colLen > (columns.length - offsetCol)) {
              colLen = columns.length - offsetCol;
          }
          
    • 根据框选的行数和列数进行遍历

      • 通过遍历到的当前单元格的 row 和 col,确定当前单元格在 treeData 中的 index

      • 当前单元格的 index 为 columns 的长度 * 当前行 + 当前列

        • 当前行为 curRowIndex + row

        • 当前列为 offsetCol + col

        • const index = columns.length * (curRowIndex + row) + (offsetCol + col)
          
    • 通过当前单元格的 index 去修改 treeData 的值

    • 最后和 自定义框选一样 框选住粘贴后的单元格

      • 确定粘贴后的数据前,需要将树形数据 dataSource 打平
      • 然后在刚刚根据行列遍历时,确定框选的行数据
      • 然后就和 自定义框选一样的逻辑,去处理成 cellMeta,然后设置状态即可
  • 粘贴代码:

    •   function handlePasteData() {
          const spreadsheet = s2Ref.current;
        
          // 获取 框选的单元格
          const state = spreadsheet.interaction.getState();
          // 找到 起始单元格
          const focusCell = state.cells[0];
          // 找到起始单元格 在 treeData 中的 index
          const focusCellIndex = treeData.findIndex(item => {
            const rowField = Object.keys(item).filter(key => key !== 'type')[0];
        
            return rowField === focusCell.rowField && item.type === focusCell.cols.title;
          })
        
          // 根据 起始单元格在 treeData 中的 index 除以 columns 的长度可以 得到 起始单元格的行号
          // 取余 可以得到 起始单元格的列号
          const curRowIndex = Math.floor(focusCellIndex / columns.length);
          const offsetCol = focusCellIndex % columns.length;
        
          // 获取 剪贴板的数据
          navigator.clipboard.read().then(async clipboardItem => {
            // 获取 剪贴板数据里的所有类型
            const type = clipboardItem[0].types;
        
            // 找到 html 类型的数据
            if (type.includes('text/html')) {
              const blob = await clipboardItem[0].getType('text/html');
              const htmlStr = await blob.text()
              // 将 html 字符串 转换成 html 标签
              const doc = new DOMParser().parseFromString(htmlStr, 'text/html');
              // 并查找 tr 元素
              const trs = Array.from(doc.querySelectorAll('table tr'));
        
              // tr有多少即 有多少行
              const rowLen = trs.length;
              // tr有多少个子级 即 有多少列
              let colLen = trs[0].children.length;
        
              // 当复制的列数 超出了 表格的范围时,框选列数修改为 从起始列到最后一列
              if (colLen > (columns.length - offsetCol)) {
                colLen = columns.length - offsetCol;
              }
        
              const colMap = {
                min: offsetCol,
                max: offsetCol + colLen - 1,
              }
        
              // 将 表格树数据 打平 用于 粘贴完进行选中
              const tileTreeData = [];
              formatTreeToTiled(dataSource, tileTreeData);
        
              const rowCellList = [];   //  根据 起始单元格 和 复制数据的范围 所确定框选粘贴后的 行数据
              const dataIndexList = [];   // 需要更改的单元格的 Index
        
              for (let row = 0;row < rowLen;row += 1) {
                // 找到 需要框选的 行数据
                const curCell = tileTreeData[curRowIndex + row];
                rowCellList.push(curCell);
        
                for (let col = 0;col < colLen;col += 1) {
                  // 获取 该单元格的 columns 信息
                  const cols = columns[offsetCol + col];
                  // 获取 该单元格 需要从复制数据里更新的 值
                  const val = trs[row].children[col].innerText;
        
                  // 当前单元格的 index 为 columns 的长度 * 当前行 + 当前列
                  const index = columns.length * (curRowIndex + row) + (offsetCol + col);
                  const temp = {
                    index,
                    val,
                    cols,
                  }
        
                  dataIndexList.push(temp);
                }
              }
        
              // 根据需要更改的单元格的 index 去更新值
              dataIndexList.forEach(item => {
                const { index, val, cols } = item;
        
                // 修改 data
                const data = treeData[index];
                // 如果不存在 data 即为复制的内容超出了 表格范围 直接 return 即可
                if (!data) return;
      
      
                const rowField = Object.keys(data).filter(key => key !== 'type')[0];
                data[rowField] = val;
              })
        
              // 处理 行数据成 cellMeta 以便 选中单元格用
              const selectCellMeta = [];
              formatCellMeta(rowCellList, selectCellMeta, colMap, false);
        
              // 自定义选中单元格
              handleCustomSelectCell(selectCellMeta);
        
              // 刷新表格
              reRenderTable();
            }
          })
        }
      

删除框选单元格值

  • 删除思路:

    • 通过 spreadsheet.interaction.getState() 获取当前表格选中的状态信息
    • state.cells 为框选的单元格,遍历它
    • 找到 treeData 中这个单元格的值,删除即可
  • 删除代码:

    • // 删除 单元格数据
      function handleDeleteData() {
          const spreadsheet = s2Ref.current;
      
          // 获取 框选的单元格
          const state = spreadsheet.interaction.getState();
      
          // 如果 框选的单元格都是 数值单元格
          if (state.cells.every(item => item.type === 'dataCell')) {
            state.cells.forEach(item => {
              const { rowField, cols } = item;
              const { title: colTitle } = cols;
      
              // 找到 该单元格的 data 删除
              treeData.forEach(cur => {
                if (
                  colTitle === cur.type
                  &&
                  Object.keys(cur).includes(rowField)
                ) {
                  cur[rowField] = null;
                }
              })
            })
      
            reRenderTable();
          } else {
            message.warning('框选区域存在不能删除的单元格!');
            setLoading(false);
          }
        }
      

会存在的问题

  • 在按住 ctrl 键点击一行的两个单元格,会选中他们之间列的所有单元格
    • 1679897614714.png
    • 如果是不同行的两个单元格
      • 1679897683586.png
      • 在 excel 的话,是这样的
      • 1679897823101.png
      • 但同时按 ctrl + c 复制的时候, excel 的会报错
      • 1679897859403.png
    • 如果,按住 ctrl 选一个行头和一个列头的时候,会全选所有单元格
      • 1679898701710.png
      • 在 excel 里
      • 1679898754413.png
      • 当然,ctrl + c 复制的时候依然会报错
      • 1679898803058.png
    • 这个问题,可以解决多重选择区域无法复制的问题(也可以说是少一个检验多重选取的校验),但单拎出来就是选区不对的问题,就看需求接受度如何了

总结

  • antv/s2 的版本我使用的是 1.43.0
  • antv/s2-react 的版本是 1.36.0
  • 使用demo:idd5pr.csb.app/
  • demo源码:codesandbox.io/s/tender-ma…
  • 上面这些功能都是在不改动源码的基础上实现的,如果有需要的可以直接复制拿过去用了,只要不在意上面提到的那个小瑕疵。
  • 上面的代码可能会有更好的方式、更省性能的方式,这里我就借我抛出的这个砖,引出大家的玉,如果大家有更好的解决方式,可以指正我一下。说不定 s2 团队看到了,在后续版本里增加了这些功能也说不定。
  • 最后非常感谢 s2 提供了这么一个表格组件,能让我在此基础上进行增加改造,如果是我自己实现的话,可能会需要很长时间,非常感谢!