From 94eaa90528b5d99d132bf7904b631089f512d20c Mon Sep 17 00:00:00 2001 From: stone Date: Fri, 22 Jul 2022 18:16:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BD=93=E5=89=8D=E5=8F=AA=E8=83=BD?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E6=95=B0=E5=80=BC=E5=8F=AF=E5=B8=A6=E8=A1=A8?= =?UTF-8?q?=E5=A4=B4=E5=A4=8D=E5=88=B6=20(#1590)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 添加通过 cellMetas 获取单元格对应的列头文本的方法 * feat: 添加通过 cellMetas 获取单元格对应的行头文本的方法 * feat: 只复制数据单元格时可以携带行列头 * feat: 选择某X行/列数据时可以携带行列头 * feat: 提取矩阵转换为字符串的方法 * test: 添加带行列头复制的单测 * test: 明细表添加带行列头复制的单测 * docs: 复制数据是否带表头信息 * feat(interaction): 行列宽高支持控制拖拽范围 (#1583) * feat(interaction): 行列宽高支持控制拖拽范围 * feat(interaction): 增加测试和文档 * feat(interaction): 增加主题色 * Update row-column-resize-spec.ts * Update packages/s2-core/src/interaction/row-column-resize.ts Co-authored-by: Wenjun Xu <906626481@qq.com> * fix: rename Co-authored-by: Wenjun Xu <906626481@qq.com> * docs: 复制数据是否带表头信息 * refactor: 评审细节修改 Co-authored-by: zishang Co-authored-by: Jinke Li Co-authored-by: Wenjun Xu <906626481@qq.com> --- .../__tests__/unit/utils/export/copy-spec.ts | 155 +++++++++++++- .../src/common/interface/interaction.ts | 2 + packages/s2-core/src/utils/export/copy.ts | 189 +++++++++++++++--- s2-site/docs/common/interaction.zh.md | 41 ++-- .../docs/manual/basic/analysis/export.zh.md | 13 ++ 5 files changed, 351 insertions(+), 49 deletions(-) diff --git a/packages/s2-core/__tests__/unit/utils/export/copy-spec.ts b/packages/s2-core/__tests__/unit/utils/export/copy-spec.ts index 4526edff29..d8522a7582 100644 --- a/packages/s2-core/__tests__/unit/utils/export/copy-spec.ts +++ b/packages/s2-core/__tests__/unit/utils/export/copy-spec.ts @@ -1,6 +1,7 @@ import { assembleDataCfg, assembleOptions, TOTALS_OPTIONS } from 'tests/util'; import { getContainer } from 'tests/util/helpers'; import { data as originalData, totalData } from 'tests/data/mock-dataset.json'; +import { map } from 'lodash'; import { TableSheet, PivotSheet } from '@/sheet-type'; import { @@ -102,6 +103,25 @@ describe('List Table Core Data Process', () => { expect(getSelectedData(s2).split('\n')[2].split('\t').length).toBe(5); }); + it('should copy normal data with header in table mode', () => { + s2.setOptions({ + interaction: { + copyWithHeader: true, + }, + }); + s2.render(); + + const cell = s2.interaction + .getAllCells() + .filter(({ cellType }) => cellType === CellTypes.DATA_CELL)[0]; + + s2.interaction.changeState({ + cells: [getCellMeta(cell)], + stateName: InteractionStateName.SELECTED, + }); + expect(getSelectedData(s2)).toEqual('province\r\n浙江省'); + }); + it('should copy format data', () => { const ss = new TableSheet( getContainer(), @@ -131,6 +151,13 @@ describe('List Table Core Data Process', () => { }); it('should copy correct data with data filtered', () => { + s2.setOptions({ + interaction: { + copyWithHeader: false, + }, + }); + s2.render(); + s2.emit(S2Event.RANGE_FILTER, { filterKey: 'province', filteredValues: ['浙江省'], @@ -221,6 +248,10 @@ describe('Pivot Table Core Data Process', () => { const ROW_COUNT = 7; // 11 = 8(维度节点) + 2(小计) + 1(总计) const COL_COUNT = 11; + // 3 = ['type', 'sub_type', 'number'].length 行头高度 + const COL_HEADER_HEIGHT = 3; + // 2 = ['province', 'city'].length 列头宽度 + const ROW_HEADER_WIDTH = 2; const s2 = new PivotSheet( getContainer(), @@ -363,11 +394,133 @@ describe('Pivot Table Core Data Process', () => { expect(getSelectedData(ss)).toEqual(`${originalData[0].number}元`); }); + it('should copy normal data with header in grid mode', () => { + s2.setOptions({ + interaction: { + copyWithHeader: true, + }, + }); + s2.render(); + + const allDataCells = s2.interaction + .getAllCells() + .filter(({ cellType }) => cellType === CellTypes.DATA_CELL); + + const hangzhouDeskCell = allDataCells[0]; + const zhejiangCityDeskSubTotalCell = allDataCells[4]; + + // 普通数据节点 + s2.interaction.changeState({ + cells: [getCellMeta(hangzhouDeskCell)], + stateName: InteractionStateName.SELECTED, + }); + + expect(getSelectedData(s2)).toEqual( + `\t\t家具\r\n\t\t桌子\r\n\t\tnumber\r\n浙江省\t杭州市\t7789`, + ); + + // 小计节点 + s2.interaction.changeState({ + cells: [getCellMeta(zhejiangCityDeskSubTotalCell)], + stateName: InteractionStateName.SELECTED, + }); + expect(getSelectedData(s2)).toEqual( + `\t\t家具\r\n\t\t桌子\r\n\t\tnumber\r\n浙江省\t小计\t18375`, + ); + }); + + // 看图更清晰 https://gw.alipayobjects.com/zos/antfincdn/zK68PhcnX/d852ffb8-603a-43e5-b841-dbf3c7577638.png + it('should copy col data with header in grid mode', () => { + s2.setOptions({ + interaction: { + copyWithHeader: true, + }, + }); + s2.render(); + + const cell = s2.interaction + .getAllCells() + .filter(({ cellType }) => cellType === CellTypes.COL_CELL)[0]; + + s2.interaction.changeState({ + cells: [getCellMeta(cell)], + stateName: InteractionStateName.SELECTED, + }); + + // 复制的数据高度 = 列头高度 + 数据高度 + expect(getSelectedData(s2).split('\n')).toHaveLength( + COL_COUNT + COL_HEADER_HEIGHT, + ); + // 复制的数据宽度 = 行头宽度 + 数据宽度 + expect(getSelectedData(s2).split('\n')[0].split('\t')).toHaveLength(5); + }); + + // https://gw.alipayobjects.com/zos/antfincdn/q3mBlV9Ii/1d68499a-b529-4594-93ce-8b04f8b4c4bc.png + it('should copy row data with header in grid mode', () => { + s2.setOptions({ + interaction: { + copyWithHeader: true, + }, + }); + s2.render(); + + const allRowCells = s2.interaction + .getAllCells() + .filter(({ cellType }) => cellType === CellTypes.ROW_CELL); + + const hangzhouDeskCell = allRowCells[1]; + const zhejiangCityDeskSubTotalCell = allRowCells[0]; + + // 选择某一行, city 维度下 + s2.interaction.changeState({ + cells: [getCellMeta(hangzhouDeskCell)], + stateName: InteractionStateName.SELECTED, + }); + + expect(getSelectedData(s2).split('\n')).toHaveLength(4); + expect(getSelectedData(s2).split('\n')[0].split('\t')).toHaveLength(9); + + // 选择某几行,province 维度 + s2.interaction.changeState({ + cells: [getCellMeta(zhejiangCityDeskSubTotalCell)], + stateName: InteractionStateName.SELECTED, + }); + + expect(getSelectedData(s2).split('\n')).toHaveLength(8); + expect(getSelectedData(s2).split('\n')[0].split('\t')).toHaveLength(9); + }); + + it('should copy all data with header in grid mode', () => { + s2.setOptions({ + interaction: { + copyWithHeader: true, + }, + }); + s2.render(); + + s2.interaction.changeState({ + stateName: InteractionStateName.ALL_SELECTED, + }); + + expect(getSelectedData(s2).split('\n').length).toBe( + COL_COUNT + COL_HEADER_HEIGHT, + ); + expect(getSelectedData(s2).split('\n')[1].split('\t').length).toBe( + ROW_COUNT + ROW_HEADER_WIDTH, + ); + }); + it('should copy correct data with data sorted in grid mode', () => { + s2.setOptions({ + interaction: { + copyWithHeader: false, + }, + }); const node = s2.getColumnNodes().find((node) => node.isLeaf); s2.groupSortByMethod('ASC' as SortMethodType, node); s2.setDataCfg(s2.dataCfg); s2.render(); + const cell = s2.interaction .getAllCells() .filter(({ cellType }) => cellType === CellTypes.ROW_CELL) @@ -386,8 +539,6 @@ describe('Pivot Table Core Data Process', () => { }); it('should copy correct data with \n data in grid mode', () => { - const newLineText = `1 - 2`; const sss = new PivotSheet( getContainer(), assembleDataCfg({ diff --git a/packages/s2-core/src/common/interface/interaction.ts b/packages/s2-core/src/common/interface/interaction.ts index 976eb53a61..a7af0330a3 100644 --- a/packages/s2-core/src/common/interface/interaction.ts +++ b/packages/s2-core/src/common/interface/interaction.ts @@ -121,6 +121,8 @@ export interface InteractionOptions { enableCopy?: boolean; // copy with filed format copyWithFormat?: boolean; + // copy with header info + copyWithHeader?: boolean; // auto reset sheet style when click outside or press ecs key, default true autoResetSheetStyle?: boolean; hiddenColumnFields?: string[]; diff --git a/packages/s2-core/src/utils/export/copy.ts b/packages/s2-core/src/utils/export/copy.ts index 52266e12cb..a25819dad5 100644 --- a/packages/s2-core/src/utils/export/copy.ts +++ b/packages/s2-core/src/utils/export/copy.ts @@ -1,7 +1,10 @@ +import { fill, forEach, map, zip } from 'lodash'; import { type CellMeta, CellTypes, CopyType, + EMPTY_PLACEHOLDER, + ID_SEPARATOR, InteractionStateName, VALUE_FIELD, } from '../../common'; @@ -96,6 +99,64 @@ export const convertString = (v: string) => { return v; }; +/** + * 根据 id 计算出行头或者列头展示的文本数组 + * 将 id : root[&]家具[&]桌子[&]price" + * 转换为 List: ['四川省', '成都市'] + * @param headerId + */ +const getHeaderList = (headerId: string) => { + const headerList = headerId.split(ID_SEPARATOR); + headerList.shift(); // 去除 root + return headerList; +}; + +// 把 string[][] 矩阵转换成字符串格式 +const transformDataMatrixToStr = (dataMatrix: string[][]) => { + return map(dataMatrix, (line) => line.join(newTab)).join(newLine); +}; + +// 生成矩阵:https://gw.alipayobjects.com/zos/antfincdn/bxBVt0nXx/a182c1d4-81bf-469f-b868-8b2e29acfc5f.png +const assembleMatrix = ( + rowMatrix: string[][], + colMatrix: string[][], + dataMatrix: string[][], +) => { + const rowWidth = rowMatrix[0]?.length ?? 0; + const colHeight = colMatrix?.length ?? 0; + const dataWidth = dataMatrix[0]?.length ?? 0; + const dataHeight = dataMatrix.length ?? 0; + const matrixWidth = rowWidth + dataWidth; + const matrixHeight = colHeight + dataHeight; + + let matrix = Array.from(Array(matrixHeight), () => new Array(matrixWidth)); + + matrix = map(matrix, (heightArr, y) => { + return map(heightArr, (w, x) => { + if (x >= 0 && x < rowWidth && y >= 0 && y < colHeight) { + return ''; + } + if (x >= rowWidth && x <= matrixWidth && y >= 0 && y < colHeight) { + return colMatrix[y][x - rowWidth]; + } + if (x >= 0 && x < rowWidth && y >= colHeight && y < matrixHeight) { + return rowMatrix[y - colHeight][x]; + } + if ( + x >= rowWidth && + x <= matrixWidth && + y >= colHeight && + y < matrixHeight + ) { + return dataMatrix[y - colHeight][x - rowWidth]; + } + return undefined; + }); + }) as string[][]; + + return transformDataMatrixToStr(matrix); +}; + export const processCopyData = ( displayData: DataType[], cells: CellMeta[][], @@ -110,7 +171,12 @@ export const processCopyData = ( return cells.reduce(getColString, '').slice(0, -2); }; -export const getTwoDimData = (cells: CellMeta[]) => { +/** + * 返回选中数据单元格生成的二维数组( CellMeta[][]) + * @param { CellMeta[] } cells + * @return { CellMeta[][] } + */ +export const getSelectedCellsMeta = (cells: CellMeta[]) => { if (!cells?.length) return []; const [minCell, maxCell] = [ { row: Infinity, col: Infinity }, @@ -157,37 +223,62 @@ const processTableColSelected = ( .join(newLine); }; -const getPivotCopyData = ( +const getDataMatrix = ( + leafRowNodes: Node[], + leafColNodes: Node[], + spreadsheet: SpreadSheet, +) => { + return map(leafRowNodes, (rowNode) => { + return leafColNodes.map((colNode) => { + const cellData = spreadsheet.dataSet.getCellData({ + query: { + ...rowNode.query, + ...colNode.query, + }, + rowNode, + isTotals: + rowNode.isTotals || + rowNode.isTotalMeasure || + colNode.isTotals || + colNode.isTotalMeasure, + }); + return getFormat(colNode.colIndex, spreadsheet)(cellData[VALUE_FIELD]); + }); + }); +}; + +const getPivotWithoutHeaderCopyData = ( spreadsheet: SpreadSheet, leafRows: Node[], leafCols: Node[], ) => { - return leafRows - .map((rowNode) => - leafCols - .map((colNode) => { - const cellData = spreadsheet.dataSet.getCellData({ - query: { - ...rowNode.query, - ...colNode.query, - }, - rowNode, - isTotals: - rowNode.isTotals || - rowNode.isTotalMeasure || - colNode.isTotals || - colNode.isTotalMeasure, - }); - return getFormat( - colNode.colIndex, - spreadsheet, - )(cellData[VALUE_FIELD]); - }) - .join(newTab), - ) - .join(newLine); + const dataMatrix = getDataMatrix(leafRows, leafCols, spreadsheet); + return transformDataMatrixToStr(dataMatrix); +}; + +const getPivotWithHeaderCopyData = ( + spreadsheet: SpreadSheet, + leafRowNodes: Node[], + leafColNodes: Node[], +) => { + const rowMatrix = map(leafRowNodes, (n) => getHeaderList(n.id)); + const colMatrix = zip(...map(leafColNodes, (n) => getHeaderList(n.id))); + const dataMatrix = getDataMatrix(leafRowNodes, leafColNodes, spreadsheet); + return assembleMatrix(rowMatrix, colMatrix, dataMatrix); }; +function getPivotCopyData( + spreadsheet: SpreadSheet, + allRowLeafNodes: Node[], + colNodes: Node[], +) { + const { copyWithHeader } = spreadsheet.options.interaction; + + return copyWithHeader + ? getPivotWithHeaderCopyData(spreadsheet, allRowLeafNodes, colNodes) + : getPivotWithoutHeaderCopyData(spreadsheet, allRowLeafNodes, colNodes); +} + const processPivotColSelected = ( spreadsheet: SpreadSheet, selectedCols: CellMeta[], @@ -205,6 +296,7 @@ const processPivotColSelected = ( return arr; }, []) : allColLeafNodes; + return getPivotCopyData(spreadsheet, allRowLeafNodes, colNodes); }; const processColSelected = ( @@ -303,8 +395,41 @@ export const getCopyData = (spreadsheet: SpreadSheet, copyType: CopyType) => { } }; +/** + * 生成包含行列头的导出数据。查看👇🏻图效果展示,更容易理解代码: + * https://gw.alipayobjects.com/zos/antfincdn/bxBVt0nXx/a182c1d4-81bf-469f-b868-8b2e29acfc5f.png + * @param cellMetaMatrix + * @param displayData + * @param spreadsheet + */ +const getDataWithHeaderMatrix = ( + cellMetaMatrix: CellMeta[][], + displayData: DataType[], + spreadsheet: SpreadSheet, +) => { + const colMatrix = zip( + ...map(cellMetaMatrix[0], (cellMeta) => { + const colId = cellMeta.id.split(EMPTY_PLACEHOLDER)?.[1] ?? ''; + return getHeaderList(colId); + }), + ); + + const rowMatrix = map(cellMetaMatrix, (cellsMeta) => { + const rowId = cellsMeta[0].id.split(EMPTY_PLACEHOLDER)?.[0] ?? ''; + return getHeaderList(rowId); + }); + + const dataMatrix = map(cellMetaMatrix, (cellsMeta) => { + return map(cellsMeta, (it) => format(it, displayData, spreadsheet)); + }); + + return assembleMatrix(rowMatrix, colMatrix, dataMatrix); +}; + export const getSelectedData = (spreadsheet: SpreadSheet) => { const interaction = spreadsheet.interaction; + const { copyWithHeader } = spreadsheet.options.interaction; + const cells = interaction.getState().cells || []; let data: string; const selectedCols = cells.filter(({ type }) => type === CellTypes.COL_CELL); @@ -327,7 +452,17 @@ export const getSelectedData = (spreadsheet: SpreadSheet) => { return; } // normal selected - data = processCopyData(displayData, getTwoDimData(cells), spreadsheet); + const selectedCellsMeta = getSelectedCellsMeta(cells); + + if (copyWithHeader) { + data = getDataWithHeaderMatrix( + selectedCellsMeta, + displayData, + spreadsheet, + ); + } else { + data = processCopyData(displayData, selectedCellsMeta, spreadsheet); + } } if (data) { diff --git a/s2-site/docs/common/interaction.zh.md b/s2-site/docs/common/interaction.zh.md index 0f667532aa..54e8b0b68d 100644 --- a/s2-site/docs/common/interaction.zh.md +++ b/s2-site/docs/common/interaction.zh.md @@ -5,26 +5,27 @@ order: 5 ## Interaction -| 参数 | 说明 | 类型 | 默认值 | 必选 | -| :--------------------- | ----------------------------------------------------- | :--------------------------------------------------------------------------------------- | :------ | :---: | -| linkFields | 标记字段为链接样式,用于外链跳转 | `string[]` | | | -| selectedCellsSpotlight | 是否开启选中高亮聚光灯效果 | `boolean` | `false` | | -| hoverHighlight | 鼠标悬停时高亮当前单元格,以及所对应的行头,列头 | `boolean` | `true` | | -| hoverFocus | 鼠标悬停在当前单元格超过默认 800ms 后,保持当前高亮,显示 tooltip,悬停时间通过设置 `duration` 来控制 | `boolean | {duration: number}` | `true` | | -| hiddenColumnFields | 用于配置默认隐藏的列,透视表需要配置列头唯一 id, 明细表配置列头 field 字段即可 | `string[]` | | | -| enableCopy | 是否允许复制 | `boolean` | `false` | | -| copyWithFormat | 是否使用 field format 格式复制 | `boolean` | `false` | | -| customInteractions | 自定义交互 [详情](/zh/docs/manual/advanced/interaction/custom) | [CustomInteraction[]](#custominteraction) | | | -| scrollSpeedRatio | 用于控制滚动速率,分水平和垂直两个方向,默认为 1 | [ScrollSpeedRatio](#scrollspeedratio) | | | -| autoResetSheetStyle | 用于控制点击表格外区域和按下 esc 键时是否重置交互状态 | `boolean` | `true` | | -| resize | 用于控制 resize 热区是否显示 | `boolean` \| [ResizeInteractionOptions](#resizeinteractionoptions) | `true` | | -| brushSelection | 是否允许刷选 | `boolean` | `true` | | -| multiSelection | 是否允许多选 (包含行头,列头,数值单元格) | `boolean` | `true` | | -| rangeSelection | 是否允许区间快捷多选 | `boolean` | `true` | | -| scrollbarPosition | 用于控制滚动条展示在内容区边缘还是画布边缘 | `content`\| `canvas` | `content` | | -| eventListenerOptions | 事件监听函数 `addEventListener` 的 [可选项配置](https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener), 可控制事件从冒泡阶段还是捕获阶段触发 | `false` | | -| selectedCellHighlight | 是否高亮选中格子所在的行列头 | `boolean` | `false` | | -| overscrollBehavior | 控制滚动至边界的行为,可禁用浏览器的默认滚动行为。[详情](/zh/docs/manual/advanced/interaction/basic/#修改滚动至边界行为) | `auto` \| `contain` \| `none` \| null | `auto` | +| 参数 | 说明 | 类型 | 默认值 | 必选 | +|:-------------------------------------| ----------------------------------------- | :--------------------------------------------------------------------------------------- | :------ | :---: | +| linkFields | 标记字段为链接样式,用于外链跳转 | `string[]` | | | +| selectedCellsSpotlight | 是否开启选中高亮聚光灯效果 | `boolean` | `false` | | +| hoverHighlight | 鼠标悬停时高亮当前单元格,以及所对应的行头,列头 | `boolean` | `true` | | +| hoverFocus | 鼠标悬停在当前单元格超过默认 800ms 后,保持当前高亮,显示 tooltip,悬停时间通过设置 `duration` 来控制 | `boolean | {duration: number}` | `true` | | +| hiddenColumnFields | 用于配置默认隐藏的列,透视表需要配置列头唯一 id, 明细表配置列头 field 字段即可 | `string[]` | | | +| enableCopy | 是否允许复制 | `boolean` | `false` | | +| copyWithHeader | 复制数据是否带表头信息 | `boolean` | `false` | | +| copyWithFormat | 是否使用 field format 格式复制 | `boolean` | `false` | | +| customInteractions | 自定义交互 [详情](/zh/docs/manual/advanced/interaction/custom) | [CustomInteraction[]](#custominteraction) | | | +| scrollSpeedRatio | 用于控制滚动速率,分水平和垂直两个方向,默认为 1 | [ScrollSpeedRatio](#scrollspeedratio) | | | +| autoResetSheetStyle | 用于控制点击表格外区域和按下 esc 键时是否重置交互状态 | `boolean` | `true` | | +| resize | 用于控制 resize 热区是否显示 | `boolean` \| [ResizeInteractionOptions](#resizeinteractionoptions) | `true` | | +| brushSelection | 是否允许刷选 | `boolean` | `true` | | +| multiSelection | 是否允许多选 (包含行头,列头,数值单元格) | `boolean` | `true` | | +| rangeSelection | 是否允许区间快捷多选 | `boolean` | `true` | | +| scrollbarPosition | 用于控制滚动条展示在内容区边缘还是画布边缘 | `content`\| `canvas` | `content` | | +| eventListenerOptions | 事件监听函数 `addEventListener` 的 [可选项配置](https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener), 可控制事件从冒泡阶段还是捕获阶段触发 | `false` | | +| selectedCellHighlight | 是否高亮选中格子所在的行列头 | `boolean` | `false` | | +| overscrollBehavior | 控制滚动至边界的行为,可禁用浏览器的默认滚动行为。[详情](/zh/docs/manual/advanced/interaction/basic/#修改滚动至边界行为) | `auto` \| `contain` \| `none` \| null | `auto` | ### CustomInteraction diff --git a/s2-site/docs/manual/basic/analysis/export.zh.md b/s2-site/docs/manual/basic/analysis/export.zh.md index f50a7f3e6b..a8bc2cde78 100644 --- a/s2-site/docs/manual/basic/analysis/export.zh.md +++ b/s2-site/docs/manual/basic/analysis/export.zh.md @@ -23,6 +23,19 @@ const s2Options = { ![复制](https://gw.alipayobjects.com/mdn/rms_56cbb2/afts/img/A*oL8_S5zBKSYAAAAAAAAAAAAAARQnAQ) +**copyWithHeader**: 复制数据是否带表头信息,默认为 `false` + +```ts +const s2Options = { + interaction: { + enableCopy: true, + copyWithHeader: true, + } +}; +``` + +![带表头复制](https://gw.alipayobjects.com/zos/antfincdn/wSBjSYKSM/3eee7bc2-7f8e-4dd9-8836-52a978d9718a.png) + ### 导出 `@antv/s2-react` 组件层提供了导出功能