背景
-
最近公司需要使用 antv/s2 去实现网页在线编辑表格的功能,在开发过程中发现 s2 自带的框选和复制 与 excel 的功能不一样, 不太能满足公司的需求,所以我就根据 s2 原本的方法进行功能增加
-
主要增加功能
- 自定义框选范围
- 复制
- 粘贴
- 删除框选单元格值
-
demo体验地址:idd5pr.csb.app/
自定义框选范围
-
s2 的自定义目录树在收起父级框选单元格的时候,并不会像 excel 一样会框选住收起的子级
-
原因是因为s2 的渲染机制是 只展示视图中有的,包括取单元格值的机制也是,所以需要我们自定义框选范围。
-
s2 原本的框选效果:
- 这是 s2 收起父级后的框选
- 展开父级我们发现,子级没有被选中
- 这是 s2 收起父级后的框选
-
自定义框选效果展示:
- 在收起父级的时候框选单元格
- 展开父级,里面的子级也全部选中了
- 在收起父级的时候框选单元格
-
自定义框选思路:
- 通过 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 键点击一行的两个单元格,会选中他们之间列的所有单元格
- 如果是不同行的两个单元格
- 在 excel 的话,是这样的
- 但同时按 ctrl + c 复制的时候, excel 的会报错
- 如果,按住 ctrl 选一个行头和一个列头的时候,会全选所有单元格
- 在 excel 里
- 当然,ctrl + c 复制的时候依然会报错
- 这个问题,可以解决多重选择区域无法复制的问题(也可以说是少一个检验多重选取的校验),但单拎出来就是选区不对的问题,就看需求接受度如何了
总结
- antv/s2 的版本我使用的是 1.43.0
- antv/s2-react 的版本是 1.36.0
- 使用demo:idd5pr.csb.app/
- demo源码:codesandbox.io/s/tender-ma…
- 上面这些功能都是在不改动源码的基础上实现的,如果有需要的可以直接复制拿过去用了,只要不在意上面提到的那个小瑕疵。
- 上面的代码可能会有更好的方式、更省性能的方式,这里我就借我抛出的这个砖,引出大家的玉,如果大家有更好的解决方式,可以指正我一下。说不定 s2 团队看到了,在后续版本里增加了这些功能也说不定。
- 最后非常感谢 s2 提供了这么一个表格组件,能让我在此基础上进行增加改造,如果是我自己实现的话,可能会需要很长时间,非常感谢!