diff --git a/packages/s2-core/__tests__/spreadsheet/theme-spec.ts b/packages/s2-core/__tests__/spreadsheet/theme-spec.ts index baae81115f..66edb2c81e 100644 --- a/packages/s2-core/__tests__/spreadsheet/theme-spec.ts +++ b/packages/s2-core/__tests__/spreadsheet/theme-spec.ts @@ -312,7 +312,7 @@ describe('SpreadSheet Theme Tests', () => { expectTextAlign({ textAlign, - fontWight: 520, + fontWight: 500, customNodes: isRowCell ? rowTotalNodes : colTotalNodes, }); }, diff --git a/packages/s2-core/__tests__/unit/cell/col-cell-spec.ts b/packages/s2-core/__tests__/unit/cell/col-cell-spec.ts new file mode 100644 index 0000000000..c70e2f138a --- /dev/null +++ b/packages/s2-core/__tests__/unit/cell/col-cell-spec.ts @@ -0,0 +1,108 @@ +import _ from 'lodash'; +import type { Node } from '@/facet/layout/node'; +import { PivotDataSet } from '@/data-set'; +import { SpreadSheet, PivotSheet } from '@/sheet-type'; +import { ColCell } from '@/cell'; +import type { TextAlign } from '@/common'; + +const MockPivotSheet = PivotSheet as unknown as jest.Mock; +const MockPivotDataSet = PivotDataSet as unknown as jest.Mock; + +describe('Col Cell Tests', () => { + let s2: SpreadSheet; + + beforeEach(() => { + const container = document.createElement('div'); + + s2 = new MockPivotSheet(container); + const dataSet: PivotDataSet = new MockPivotDataSet(s2); + s2.dataSet = dataSet; + }); + + describe('None-leaf Nodes Tests', () => { + const node = { + isLeaf: false, + x: 0, + y: 0, + height: 30, + width: 200, + } as unknown as Node; + + const headerConfig = { + width: 500, // col header width + scrollContainsRowHeader: true, + cornerWidth: 100, + scrollX: 30, // 模拟滚动了 30px + }; + + const actualTextWidth = 40; // 文字长度 + + test.each([ + ['left', 8], // col.padding.left + ['center', 100], // col.width / 2 + ['right', 192], // col.width - col.padding.right + ])( + 'should calc node text position in %s align mode', + (textAlign: TextAlign, textX: number) => { + s2.setThemeCfg({ + theme: { + colCell: { + bolderText: { + textAlign, + }, + }, + }, + }); + + const colCell = new ColCell(node, s2, { ...headerConfig }); + _.set(colCell, 'actualTextWidth', actualTextWidth); // 文字总长度 + + const getTextPosition = _.get(colCell, 'getTextPosition').bind(colCell); + expect(getTextPosition()).toEqual({ + x: textX, + y: 15, + }); + }, + ); + + test.each([ + ['left', 52], // col.padding.left + actualTextWidth + icon.margin.left + ['center', 115], // col.width / 2 + (actualTextWidth + icon.margin.left + icon.width + icon.margin.right) / 2 - (icon.width + icon.margin.right) + ['right', 178], // col.width - col.padding.right - icon.margin.right - icon.width + ])( + 'should calc icon position in %s align mode', + (textAlign: TextAlign, iconX: number) => { + s2.setThemeCfg({ + theme: { + colCell: { + bolderText: { + textAlign, + }, + }, + }, + }); + s2.setOptions({ + headerActionIcons: [ + { + iconNames: ['SortUp'], + belongsCell: 'colCell', + displayCondition: (meta) => { + return !meta.isLeaf; + }, + action: () => true, + }, + ], + }); + + const colCell = new ColCell(node, s2, { ...headerConfig }); + _.set(colCell, 'actualTextWidth', actualTextWidth); // 文字总长度 + + const getIconPosition = _.get(colCell, 'getIconPosition').bind(colCell); + expect(getIconPosition()).toEqual({ + x: iconX, + y: 10, + }); + }, + ); + }); +}); diff --git a/packages/s2-core/__tests__/unit/utils/text-spec.ts b/packages/s2-core/__tests__/unit/utils/text-spec.ts index ae0b2fe2bc..4417fc9f14 100644 --- a/packages/s2-core/__tests__/unit/utils/text-spec.ts +++ b/packages/s2-core/__tests__/unit/utils/text-spec.ts @@ -38,7 +38,8 @@ describe('Text Utils Tests', () => { placeholder: '--', }); - expect(text).toEqual('12...'); + expect(text).toEndWith('...'); + expect(text.length).toBeLessThanOrEqual(5); }); test('should get correct placeholder text with ""', () => { @@ -73,10 +74,11 @@ describe('Text Utils Tests', () => { test('should get correct ellipsis text', () => { const text = getEllipsisText({ text: '长度测试', - maxWidth: 20, + maxWidth: 24, }); - expect(text).toEqual('长...'); + expect(text).toEndWith('...'); + expect(text.length).toBeLessThanOrEqual(4); }); test('should get correct text width', () => { diff --git a/packages/s2-core/src/cell/col-cell.ts b/packages/s2-core/src/cell/col-cell.ts index 7e2c76f8f4..4899627012 100644 --- a/packages/s2-core/src/cell/col-cell.ts +++ b/packages/s2-core/src/cell/col-cell.ts @@ -36,8 +36,8 @@ import { HeaderCell } from './header-cell'; export class ColCell extends HeaderCell { protected declare headerConfig: ColHeaderConfig; - /** 文字区域(含icon)绘制起始坐标 */ - protected textAreaPosition: Point; + /** 文字绘制起始坐标 */ + protected textPosition: Point; public get cellType() { return CellTypes.COL_CELL; @@ -107,24 +107,60 @@ export class ColCell extends HeaderCell { } protected getIconPosition(): Point { - const { isLeaf } = this.meta; - const iconStyle = this.getIconStyle(); - - if (isLeaf) { + if (this.meta.isLeaf) { return super.getIconPosition(this.getActionIconsCount()); } - const position = this.textAreaPosition; + // 非叶子节点,因 label 滚动展示,需要适配不同 align情况 + const iconStyle = this.getIconStyle(); + const iconMarginLeft = iconStyle.margin.left; - const totalSpace = - this.actualTextWidth + - this.getActionIconsWidth() - - iconStyle.margin.right; - const startX = position.x - totalSpace / 2; + const textStyle = this.getTextStyle(); + const position = this.textPosition; + const textX = position.x; + + const y = position.y - iconStyle.size / 2; + + if (textStyle.textAlign === 'left') { + /** + * textX x + * | | + * v v + * +---------+ +----+ + * | text |--|icon| + * +---------+ +----+ + */ + return { + x: textX + this.actualTextWidth + iconMarginLeft, + y, + }; + } + if (textStyle.textAlign === 'right') { + /** + * textX x + * | | + * v v + * +---------+ +----+ + * | text |--|icon| + * +---------+ +----+ + */ + return { + x: textX + iconMarginLeft, + y, + }; + } + /** + * textX x + * | | + * v v + * +---------+ +----+ + * | text |--|icon| + * +---------+ +----+ + */ return { - x: startX + this.actualTextWidth + iconStyle.margin.left, - y: position.y - iconStyle.size / 2, + x: textX + this.actualTextWidth / 2 + iconMarginLeft, + y, }; } @@ -173,11 +209,8 @@ export class ColCell extends HeaderCell { this.getStyle().cell?.padding, ); - const iconCount = this.getActionIconsCount(); - const textAndIconSpace = - this.actualTextWidth + - this.getActionIconsWidth() - - (iconCount ? iconStyle.margin.right : 0); + const actionIconSpace = this.getActionIconsWidth(); + const textAndIconSpace = this.actualTextWidth + actionIconSpace; const textAreaRange = getTextAreaRange( adjustedViewport, @@ -185,26 +218,26 @@ export class ColCell extends HeaderCell { textAndIconSpace, // icon position 默认为 right ); - // textAreaRange.start 是以文字样式为 center 计算出的文字绘制点 - // 此处按实际样式(left or right)调整 - const startX = adjustColHeaderScrollingTextPosition( - textAreaRange.start, - textAreaRange.width - textAndIconSpace, + // textAreaRange.start 是 text&icon 整个区域的 center + // 此处按实际样式(left or right)调整计算出的文字绘制点 + const textX = adjustColHeaderScrollingTextPosition( + textAreaRange, + this.actualTextWidth, + actionIconSpace, textAlign, ); - const textY = contentBox.y + contentBox.height / 2; - this.textAreaPosition = { x: startX, y: textY }; - return { - x: startX - textAndIconSpace / 2 + this.actualTextWidth / 2, - y: textY, - }; + + this.textPosition = { x: textX, y: textY }; + return this.textPosition; } protected getActionIconsWidth() { const { size, margin } = this.getStyle().icon; const iconCount = this.getActionIconsCount(); - return (size + margin.left) * iconCount + iconCount > 0 ? margin.right : 0; + return ( + (size + margin.left) * iconCount + (iconCount > 0 ? margin.right : 0) + ); } protected getColResizeAreaKey() { diff --git a/packages/s2-core/src/sheet-type/spread-sheet.ts b/packages/s2-core/src/sheet-type/spread-sheet.ts index c5969e5c88..50c4a29897 100644 --- a/packages/s2-core/src/sheet-type/spread-sheet.ts +++ b/packages/s2-core/src/sheet-type/spread-sheet.ts @@ -65,6 +65,7 @@ import { getSafetyOptions, } from '../utils/merge'; import { getTooltipData, getTooltipOptions } from '../utils/tooltip'; +import { removeOffscreenCanvas } from '../utils/canvas'; export abstract class SpreadSheet extends EE { // theme config @@ -402,6 +403,8 @@ export abstract class SpreadSheet extends EE { this.destroyTooltip(); this.clearCanvasEvent(); this.container?.destroy(); + + removeOffscreenCanvas(); } /** diff --git a/packages/s2-core/src/theme/index.ts b/packages/s2-core/src/theme/index.ts index 5363b04595..282ca176f4 100644 --- a/packages/s2-core/src/theme/index.ts +++ b/packages/s2-core/src/theme/index.ts @@ -93,7 +93,7 @@ export const getTheme = ( bolderText: { fontFamily: FONT_FAMILY, fontSize: 12, - fontWeight: isWindows() ? 'bold' : 520, + fontWeight: isWindows() ? 'bold' : 500, fill: basicColors[14], linkTextFill: basicColors[6], opacity: 1, @@ -183,7 +183,7 @@ export const getTheme = ( bolderText: { fontFamily: FONT_FAMILY, fontSize: 12, - fontWeight: isWindows() ? 'bold' : 520, + fontWeight: isWindows() ? 'bold' : 500, fill: basicColors[0], opacity: 1, textAlign: 'center', @@ -263,7 +263,7 @@ export const getTheme = ( bolderText: { fontFamily: FONT_FAMILY, fontSize: 12, - fontWeight: isWindows() ? 'bold' : 520, + fontWeight: isWindows() ? 'bold' : 500, fill: basicColors[13], opacity: 1, textAlign: 'right', diff --git a/packages/s2-core/src/utils/canvas.ts b/packages/s2-core/src/utils/canvas.ts new file mode 100644 index 0000000000..db879a0090 --- /dev/null +++ b/packages/s2-core/src/utils/canvas.ts @@ -0,0 +1,30 @@ +const OFFSCREEN_CANVAS_DOM_ID = 's2-offscreen-canvas'; + +/** + * 获取工具 canvas + * 需要把 canvas 插入到 body 下,继承全局的 css 样式(如 letter-spacing) + * 否则后续的 measureText 与实际渲染会有较大差异 + */ +export const getOffscreenCanvas = () => { + let canvas = document.getElementById( + OFFSCREEN_CANVAS_DOM_ID, + ) as HTMLCanvasElement; + if (canvas) { + return canvas; + } + + canvas = document.createElement('canvas'); + canvas.id = OFFSCREEN_CANVAS_DOM_ID; + canvas.style.display = 'none'; + + document.body.appendChild(canvas); + + return canvas; +}; + +/** + * 移除工具 canvas + */ +export const removeOffscreenCanvas = () => { + document.getElementById(OFFSCREEN_CANVAS_DOM_ID)?.remove(); +}; diff --git a/packages/s2-core/src/utils/cell/cell.ts b/packages/s2-core/src/utils/cell/cell.ts index 93e88a9700..4e8fe2d0e1 100644 --- a/packages/s2-core/src/utils/cell/cell.ts +++ b/packages/s2-core/src/utils/cell/cell.ts @@ -179,14 +179,14 @@ export const getTextPosition = ( ) => getTextAndFollowingIconPosition(contentBox, textCfg).text; /** - * 在给定视窗和单元格的情况下,计算单元格文字实际绘制位置 + * 在给定视窗和单元格的情况下,计算单元格文字区域的坐标信息 * 计算遵循原则: * 1. 若可视范围小,尽可能多展示文字 * 2. 若可视范围大,居中展示文字 * @param viewport 视窗坐标信息 * @param content content 列头单元格 content 区域坐标信息 * @param textWidth 文字实际绘制区域宽度(含icon) - * @returns 文字绘制位置 + * @returns 文字绘制位置(start 为文字区域的中点坐标值) */ export const getTextAreaRange = ( viewport: AreaRange, @@ -333,7 +333,7 @@ export const getBorderPositionAndStyle = ( * * 以 textAlign=left 情况为例,由大到小的矩形分别是 viewport、cellContent、cellText * 左图是未调整前,滚动相交判定在 viewport 最左侧,即 colCell 滚动到 viewport 左侧后,文字会贴左边绘制 - * 右图是调整后,range.start 提前了 padding.left 个元素,文字与 viewport 有一定间隙更加美观 + * 右图是调整后,range.start 提前了 padding.left 个像素,文字与 viewport 有一定间隙更加美观 * * range.start range.start * | | @@ -375,29 +375,51 @@ export const adjustColHeaderScrollingViewport = ( }; /** - * 根据文字样式调整绘制的起始点(底层g始终使用 center 样式绘制) - * @param startX - * @param restWidth - * @param textAlign - * @returns + * 根据文字样式计算文字实际绘制起始 + * + * 以 textAlign=left 为例,g 绘制时取 text 最左侧的坐标值作为基准坐标 + * + * 计算前: 计算后: + * startX = textAreaRange.start = 中心点 startX = 最左侧坐标 + * + * textAreaRange.start startX + * | | + * v v + * +----------------------------+ +----------------------------+ + * | +------------------+ | |+------------------+ | + * | | text | icon | | || text | icon | | + * | +------------------+ | |+------------------+ | + * +----------------------------+ +----------------------------+ + * <------------------> + * textAndIconSpace + * <----------------------------> + * textAreaRange.width + * + * @param textAreaRange 文本&icon 绘制坐标 + * @param actualTextWidth 文本实际宽度 + * @param actionIconSpace icon 区域实际宽度 + * @param textAlign 对齐样式 + * @returns 文字绘制起点坐标 */ export const adjustColHeaderScrollingTextPosition = ( - startX: number, - restWidth: number, + textAreaRange: AreaRange, + actualTextWidth: number, + actionIconSpace: number, textAlign: TextAlign, ) => { - if (restWidth <= 0) { - // 没有足够的空间用于调整 - return startX; - } + const textAndIconSpace = actualTextWidth + actionIconSpace; + const startX = textAreaRange.start; // 文本&icon 区域中心点坐标 x - switch (textAlign) { - case 'left': - return startX - restWidth / 2; - case 'right': - return startX + restWidth / 2; - case 'center': - default: - return startX; + if (textAlign === 'center') { + return startX - actionIconSpace / 2; } + + const hasEnoughWidth = textAreaRange.width - textAndIconSpace > 0; + const offset = hasEnoughWidth + ? textAreaRange.width / 2 + : textAndIconSpace / 2; + + return textAlign === 'left' + ? startX - offset + : startX + offset - actionIconSpace; }; diff --git a/packages/s2-core/src/utils/text.ts b/packages/s2-core/src/utils/text.ts index 465204dadb..e8ffda9c61 100644 --- a/packages/s2-core/src/utils/text.ts +++ b/packages/s2-core/src/utils/text.ts @@ -23,11 +23,9 @@ import type { } from '../common/interface'; import type { TextTheme } from '../common/interface/theme'; import { renderText } from '../utils/g-renders'; +import { getOffscreenCanvas } from './canvas'; import { renderChart } from './g-mini-charts'; -const canvas = document.createElement('canvas'); -const ctx = canvas.getContext('2d'); - /** * 计算文本在画布中的宽度 */ @@ -36,6 +34,8 @@ export const measureTextWidth = memoize( if (!font) { return 0; } + const ctx = getOffscreenCanvas().getContext('2d'); + const { fontSize, fontFamily, fontWeight, fontStyle, fontVariant } = font as CSSStyleDeclaration; // copy G 里面的处理逻辑