diff --git a/packages/core/src/sheets/__tests__/sheet-skeleton.integration.spec.ts b/packages/core/src/sheets/__tests__/sheet-skeleton.integration.spec.ts index 366bfdaffa..88b16f4e97 100644 --- a/packages/core/src/sheets/__tests__/sheet-skeleton.integration.spec.ts +++ b/packages/core/src/sheets/__tests__/sheet-skeleton.integration.spec.ts @@ -25,7 +25,7 @@ import { ThemeService } from '../../services/theme/theme.service'; import { ColorKit } from '../../shared'; import { BooleanNumber, HorizontalAlign } from '../../types/enum'; import { LocaleType } from '../../types/enum/locale-type'; -import { SheetSkeleton } from '../sheet-skeleton'; +import { createSheetGapTestConfig, SheetSkeleton } from '../sheet-skeleton'; import { RANGE_TYPE } from '../typedef'; import { createCoreTestBed } from './create-core-test-bed'; @@ -350,6 +350,50 @@ describe('SheetSkeleton integration', () => { expect(skeleton.getColGapAtPosition(100)).toBe(-1); }); + it('should preserve viewport positions when decomposing offsets that land inside gaps', () => { + const testBed = createCoreTestBed(workbookDataFactory()); + const injector = testBed.univer.__getInjector(); + const worksheet = testBed.sheet.getActiveSheet()!; + const skeleton = new SheetSkeleton( + worksheet, + testBed.sheet.getStyles(), + injector.get(LocaleService), + injector.get(IContextService), + injector.get(IConfigService), + injector as Injector + ).calculate()!; + + disposables.push(() => testBed.univer.dispose()); + + skeleton.setGapConfig({ + rowGaps: { + 1: { size: 5 }, + }, + colGaps: { + 2: { size: 7 }, + }, + }); + + const viewportScrollX = 195; + const viewportScrollY = 30; + const { row, column, rowOffset, columnOffset } = skeleton.getOffsetRelativeToRowCol(viewportScrollX, viewportScrollY); + const { startX, startY } = skeleton.getCellWithCoordByIndex(row, column, false); + + expect({ row, column, rowOffset, columnOffset }).toEqual({ + row: 1, + column: 2, + rowOffset: -3, + columnOffset: -4, + }); + expect({ + viewportScrollX: startX + columnOffset, + viewportScrollY: startY + rowOffset, + }).toEqual({ + viewportScrollX, + viewportScrollY, + }); + }); + it('should keep provided gap default colors and return no-gap helpers when row/col gaps are absent', () => { const testBed = createCoreTestBed(workbookDataFactory()); const injector = testBed.univer.__getInjector(); @@ -377,6 +421,52 @@ describe('SheetSkeleton integration', () => { expect(skeleton.getColGapAtPosition(20)).toBe(-1); }); + it('should expose a reusable mixed gap test fixture', () => { + const testBed = createCoreTestBed(workbookDataFactory()); + const injector = testBed.univer.__getInjector(); + const worksheet = testBed.sheet.getActiveSheet()!; + const skeleton = new SheetSkeleton( + worksheet, + testBed.sheet.getStyles(), + injector.get(LocaleService), + injector.get(IContextService), + injector.get(IConfigService), + injector as Injector + ).calculate()!; + + disposables.push(() => testBed.univer.dispose()); + + skeleton.setGapConfig(createSheetGapTestConfig()); + + expect(skeleton.gapConfig.defaultBackgroundColor).toBe('rgba(24, 119, 242, 0.08)'); + expect(skeleton.gapConfig.defaultStripeColor).toBe('rgba(24, 119, 242, 0.25)'); + expect(skeleton.gapConfig.rowGaps?.[3]).toEqual({ + size: 10, + color: 'rgba(245, 158, 11, 0.14)', + }); + expect(skeleton.gapConfig.colGaps?.[4]).toEqual({ + size: 12, + color: 'rgba(244, 63, 94, 0.12)', + stripeColor: 'rgba(225, 29, 72, 0.30)', + }); + + expect(skeleton.rowHeightAccumulation).toEqual([28, 58, 82, 116, 116, 140, 178, 202]); + expect(skeleton.columnWidthAccumulation).toEqual([72, 197, 277, 349, 361, 433]); + + expect(skeleton.getRowGapAtPosition(30)).toBe(1); + expect(skeleton.getRowGapAtPosition(86)).toBe(3); + expect(skeleton.getRowGapAtPosition(145)).toBe(6); + expect(skeleton.getColGapAtPosition(74)).toBe(1); + expect(skeleton.getColGapAtPosition(200)).toBe(2); + expect(skeleton.getColGapAtPosition(355)).toBe(4); + expect(skeleton.getNoMergeCellWithCoordByIndex(3, 2, false)).toEqual({ + startY: 92, + endY: 116, + startX: 205, + endX: 277, + }); + }); + it('should preserve provided one-sided gap color and fill the missing side from theme', () => { const testBed = createCoreTestBed(workbookDataFactory()); const injector = testBed.univer.__getInjector(); diff --git a/packages/core/src/sheets/sheet-skeleton.ts b/packages/core/src/sheets/sheet-skeleton.ts index 6d6523dc5c..1dd953567c 100644 --- a/packages/core/src/sheets/sheet-skeleton.ts +++ b/packages/core/src/sheets/sheet-skeleton.ts @@ -76,6 +76,39 @@ export interface ISheetGapConfig { defaultBackgroundColor?: string; } +/** + * Reusable gap fixture for visual and integration testing. + */ +export function createSheetGapTestConfig(overrides: Partial = {}): ISheetGapConfig { + const baseConfig: ISheetGapConfig = { + defaultBackgroundColor: 'rgba(24, 119, 242, 0.08)', + defaultStripeColor: 'rgba(24, 119, 242, 0.25)', + rowGaps: { + 1: { size: 6 }, + 3: { size: 10, color: 'rgba(245, 158, 11, 0.14)' }, + 6: { size: 14, color: 'rgba(16, 185, 129, 0.12)', stripeColor: 'rgba(5, 150, 105, 0.35)' }, + }, + colGaps: { + 1: { size: 5 }, + 2: { size: 8, stripeColor: 'rgba(59, 130, 246, 0.35)' }, + 4: { size: 12, color: 'rgba(244, 63, 94, 0.12)', stripeColor: 'rgba(225, 29, 72, 0.30)' }, + }, + }; + + return { + ...baseConfig, + ...overrides, + rowGaps: { + ...baseConfig.rowGaps, + ...overrides.rowGaps, + }, + colGaps: { + ...baseConfig.colGaps, + ...overrides.colGaps, + }, + }; +} + /** * Optional gap size getter for coordinate calculation functions. */ @@ -1068,20 +1101,12 @@ export class SheetSkeleton extends Skeleton { rowOffset: number; } { const column = searchArray(this.columnWidthAccumulation, offsetX); - let columnOffset = 0; - if (column === 0) { - columnOffset = offsetX; - } else { - columnOffset = offsetX - this._columnWidthAccumulation[column - 1]; - } + const columnStart = (this._columnWidthAccumulation[column - 1] || 0) + this.getColGapSize(column); + const columnOffset = offsetX - columnStart; const row = searchArray(this.rowHeightAccumulation, offsetY); - let rowOffset = 0; - if (row === 0) { - rowOffset = offsetY; - } else { - rowOffset = offsetY - this._rowHeightAccumulation[row - 1]; - } + const rowStart = (this._rowHeightAccumulation[row - 1] || 0) + this.getRowGapSize(row); + const rowOffset = offsetY - rowStart; return { row, column, diff --git a/packages/engine-render/src/components/sheets/__tests__/spreadsheet.integration.spec.ts b/packages/engine-render/src/components/sheets/__tests__/spreadsheet.integration.spec.ts index d2deacf1f8..66e1e078d6 100644 --- a/packages/engine-render/src/components/sheets/__tests__/spreadsheet.integration.spec.ts +++ b/packages/engine-render/src/components/sheets/__tests__/spreadsheet.integration.spec.ts @@ -19,6 +19,7 @@ import type { IBoundRectNoAngle, IViewportInfo } from '../../../basics/vector2'; import { BooleanNumber, BorderStyleTypes, + createSheetGapTestConfig, ILogService, IUniverInstanceService, LocaleType, @@ -489,4 +490,54 @@ describe('spreadsheet integration', () => { ); }).not.toThrow(); }); + + it('draws the reusable gap fixture with mixed default and item colors', () => { + const { spreadsheet, skeleton, mainCanvas } = fixture; + const context = mainCanvas.getContext() as any; + + skeleton.setGapConfig(createSheetGapTestConfig()); + + const drawSingleGapRectSpy = vi.spyOn(spreadsheet as any, '_drawSingleGapRect'); + + (spreadsheet as any)._drawGapAreas( + context, + skeleton, + 0, + 7, + 0, + 5, + 0, + 500, + 0, + 320 + ); + + expect(drawSingleGapRectSpy).toHaveBeenCalledTimes(6); + expect(drawSingleGapRectSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + size: 10, + color: 'rgba(245, 158, 11, 0.14)', + }), + expect.any(Number), + expect.any(Number), + expect.any(Number), + 10, + 'rgba(24, 119, 242, 0.08)', + 'rgba(24, 119, 242, 0.25)' + ); + expect(drawSingleGapRectSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + size: 8, + stripeColor: 'rgba(59, 130, 246, 0.35)', + }), + expect.any(Number), + expect.any(Number), + 8, + expect.any(Number), + 'rgba(24, 119, 242, 0.08)', + 'rgba(24, 119, 242, 0.25)' + ); + }); }); diff --git a/packages/sheets-ui/src/services/__tests__/scroll-manager.service.spec.ts b/packages/sheets-ui/src/services/__tests__/scroll-manager.service.spec.ts index 1f13a67890..796bcadde8 100644 --- a/packages/sheets-ui/src/services/__tests__/scroll-manager.service.spec.ts +++ b/packages/sheets-ui/src/services/__tests__/scroll-manager.service.spec.ts @@ -131,4 +131,44 @@ describe('SheetScrollManagerService', () => { viewportScrollX: 337, }); }); + + it('calculates viewport scroll from the actual cell start when gaps shift the origin', () => { + const injector = new Injector(); + injector.add([ + SheetSkeletonManagerService, + { + useValue: { + getCurrentSkeleton: () => ({ + rowHeightAccumulation: [28, 57, 81], + columnWidthAccumulation: [72, 192, 271], + getCellWithCoordByIndex: (row: number, column: number, header?: boolean) => { + expect(header).toBe(false); + expect(row).toBe(1); + expect(column).toBe(2); + + return { + startY: 33, + startX: 199, + }; + }, + }), + } as unknown as SheetSkeletonManagerService, + }, + ]); + const service = injector.createInstance(SheetScrollManagerService, createRenderContext('u-1')); + + expect(service.calcViewportScrollFromRowColOffset({ + sheetViewStartRow: 1, + sheetViewStartColumn: 2, + offsetX: -4, + offsetY: -3, + scrollX: 0, + scrollY: 0, + viewportScrollX: 0, + viewportScrollY: 0, + })).toEqual({ + viewportScrollX: 195, + viewportScrollY: 30, + }); + }); }); diff --git a/packages/sheets-ui/src/services/scroll-manager.service.ts b/packages/sheets-ui/src/services/scroll-manager.service.ts index 113b71cd07..4002e726d8 100644 --- a/packages/sheets-ui/src/services/scroll-manager.service.ts +++ b/packages/sheets-ui/src/services/scroll-manager.service.ts @@ -169,10 +169,10 @@ export class SheetScrollManagerService implements IRenderModule { offsetY = offsetY || 0; const skeleton = this._sheetSkeletonManagerService.getCurrentSkeleton(); - const rowAcc = skeleton?.rowHeightAccumulation[sheetViewStartRow - 1] || 0; - const colAcc = skeleton?.columnWidthAccumulation[sheetViewStartColumn - 1] || 0; - const viewportScrollX = colAcc + offsetX; - const viewportScrollY = rowAcc + offsetY; + const { startX: colStart = skeleton?.columnWidthAccumulation[sheetViewStartColumn - 1] || 0, startY: rowStart = skeleton?.rowHeightAccumulation[sheetViewStartRow - 1] || 0 } = + skeleton?.getCellWithCoordByIndex?.(sheetViewStartRow, sheetViewStartColumn, false) ?? {}; + const viewportScrollX = colStart + offsetX; + const viewportScrollY = rowStart + offsetY; return { viewportScrollX,