diff --git a/e2e/visual-comparison/sheets/sheets-scroll.spec.ts b/e2e/visual-comparison/sheets/sheets-scroll.spec.ts index c606d2b5ac..54e855210b 100644 --- a/e2e/visual-comparison/sheets/sheets-scroll.spec.ts +++ b/e2e/visual-comparison/sheets/sheets-scroll.spec.ts @@ -21,7 +21,6 @@ test('cells rendering after scrolling', async () => { await page.waitForTimeout(1000); const canvas = page.locator(SHEET_MAIN_CANVAS_ID); - // TODO(@ai-review): Verify fixed-count wheel input and stable scroll frames eliminate CI flakiness without hiding merged-cell repaint regressions. await canvas.evaluate(async (element: HTMLCanvasElement) => { const scroll = async (deltaY: number) => { for (let elapsed = 0; elapsed < 1000; elapsed += 30) { diff --git a/packages/core/src/shared/__tests__/rectangle.spec.ts b/packages/core/src/shared/__tests__/rectangle.spec.ts index c2e05c4b11..8d4eda76dc 100644 --- a/packages/core/src/shared/__tests__/rectangle.spec.ts +++ b/packages/core/src/shared/__tests__/rectangle.spec.ts @@ -19,7 +19,6 @@ import { describe, expect, it } from 'vitest'; import { Range } from '../../sheets/range'; import { AbsoluteRefType } from '../../sheets/typedef'; import { ObjectMatrix } from '../object-matrix'; -import { multiSubtractMultiRanges } from '../object-matrix-query'; import { Rectangle } from '../rectangle'; function rangesToMatrix(ranges: IRange[]) { @@ -34,6 +33,13 @@ function rangesToMatrix(ranges: IRange[]) { return matrix.getMatrix(); } +function subtractedRangesToMatrix(ranges: IRange[], rangesToSubtract: IRange[]) { + const matrix = new ObjectMatrix(); + ranges.forEach((range) => Range.foreach(range, (row, col) => matrix.setValue(row, col, 1))); + rangesToSubtract.forEach((range) => Range.foreach(range, (row, col) => matrix.realDeleteValue(row, col))); + return matrix.getMatrix(); +} + const cellToRange = (row: number, col: number) => ({ startRow: row, endRow: row, startColumn: col, endColumn: col } as IRange); describe('test "Rectangle"', () => { it('test "subtract"', () => { @@ -134,8 +140,7 @@ describe('multiSubtractMulti', () => { const ranges2: IRange[] = [ { startColumn: 2, endColumn: 4, startRow: 2, endRow: 4 }, ]; - const expected: IRange[] = multiSubtractMultiRanges(ranges1, ranges2); - expect(rangesToMatrix(Rectangle.subtractMulti(ranges1, ranges2))).toEqual(rangesToMatrix(expected)); + expect(rangesToMatrix(Rectangle.subtractMulti(ranges1, ranges2))).toEqual(subtractedRangesToMatrix(ranges1, ranges2)); }); it('should handle subtracting multiple ranges from a single range', () => { @@ -146,8 +151,7 @@ describe('multiSubtractMulti', () => { { startColumn: 2, endColumn: 5, startRow: 2, endRow: 5 }, { startColumn: 6, endColumn: 8, startRow: 6, endRow: 8 }, ]; - const expected: IRange[] = multiSubtractMultiRanges(ranges1, ranges2); - expect(rangesToMatrix(Rectangle.subtractMulti(ranges1, ranges2))).toEqual(rangesToMatrix(expected)); + expect(rangesToMatrix(Rectangle.subtractMulti(ranges1, ranges2))).toEqual(subtractedRangesToMatrix(ranges1, ranges2)); }); it('should handle non-overlapping subtraction ranges', () => { @@ -157,8 +161,7 @@ describe('multiSubtractMulti', () => { const ranges2: IRange[] = [ { startColumn: 6, endColumn: 8, startRow: 6, endRow: 8 }, ]; - const expected: IRange[] = multiSubtractMultiRanges(ranges1, ranges2); - expect(rangesToMatrix(Rectangle.subtractMulti(ranges1, ranges2))).toEqual(rangesToMatrix(expected)); + expect(rangesToMatrix(Rectangle.subtractMulti(ranges1, ranges2))).toEqual(subtractedRangesToMatrix(ranges1, ranges2)); }); it('should handle subtraction ranges that completely overlap', () => { @@ -168,8 +171,7 @@ describe('multiSubtractMulti', () => { const ranges2: IRange[] = [ { startColumn: 1, endColumn: 5, startRow: 1, endRow: 5 }, ]; - const expected: IRange[] = multiSubtractMultiRanges(ranges1, ranges2); - expect(rangesToMatrix(Rectangle.subtractMulti(ranges1, ranges2))).toEqual(rangesToMatrix(expected)); + expect(rangesToMatrix(Rectangle.subtractMulti(ranges1, ranges2))).toEqual(subtractedRangesToMatrix(ranges1, ranges2)); }); it('should handle empty ranges', () => { @@ -177,8 +179,7 @@ describe('multiSubtractMulti', () => { const ranges2: IRange[] = [ { startColumn: 2, endColumn: 4, startRow: 2, endRow: 4 }, ]; - const expected: IRange[] = multiSubtractMultiRanges(ranges1, ranges2); - expect(rangesToMatrix(Rectangle.subtractMulti(ranges1, ranges2))).toEqual(rangesToMatrix(expected)); + expect(rangesToMatrix(Rectangle.subtractMulti(ranges1, ranges2))).toEqual(subtractedRangesToMatrix(ranges1, ranges2)); }); it('should handle empty subtraction ranges', () => { @@ -186,7 +187,6 @@ describe('multiSubtractMulti', () => { { startColumn: 1, endColumn: 5, startRow: 1, endRow: 5 }, ]; const ranges2: IRange[] = []; - const expected: IRange[] = multiSubtractMultiRanges(ranges1, ranges2); - expect(rangesToMatrix(Rectangle.subtractMulti(ranges1, ranges2))).toEqual(rangesToMatrix(expected)); + expect(rangesToMatrix(Rectangle.subtractMulti(ranges1, ranges2))).toEqual(subtractedRangesToMatrix(ranges1, ranges2)); }); }); diff --git a/packages/core/src/shared/index.ts b/packages/core/src/shared/index.ts index a65e677f43..cea55c6d92 100644 --- a/packages/core/src/shared/index.ts +++ b/packages/core/src/shared/index.ts @@ -45,7 +45,6 @@ export { numfmt, } from './numfmt'; export * from './object-matrix'; -export { queryObjectMatrix } from './object-matrix-query'; export * from './random-id'; export { moveRangeByOffset, splitIntoGrid } from './range'; export * from './rectangle'; diff --git a/packages/core/src/shared/object-matrix-query.ts b/packages/core/src/shared/object-matrix-query.ts deleted file mode 100644 index 7d58d61621..0000000000 --- a/packages/core/src/shared/object-matrix-query.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Copyright 2023-present DreamNum Co., Ltd. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { IRange } from '../sheets/typedef'; -import type { Nullable } from './types'; -import { Range } from '../sheets/range'; -import { ObjectMatrix } from './object-matrix'; -import { Rectangle } from './rectangle'; - -function maximalRectangle(matrix: T[][], match: (val: T) => boolean) { - if (matrix.length === 0 || matrix[0].length === 0) return null; - - const heights = new Array(matrix[0].length).fill(0); - let maxArea = 0; - let maxRect = null; - - for (let row = 0; row < matrix.length; row++) { - for (let col = 0; col < matrix[0].length; col++) { - heights[col] = match(matrix[row][col]) ? heights[col] + 1 : 0; - } - - const areaWithRect = largestRectangleArea(heights); - if (areaWithRect.area > maxArea) { - maxArea = areaWithRect.area; - // Adjust the rectangle's top row to the current row minus the height plus one - maxRect = { - startColumn: areaWithRect.start, - startRow: row - areaWithRect.height + 1, - endColumn: areaWithRect.end, - endRow: row, - }; - } - } - - return maxRect; -} - -function largestRectangleArea(heights: number[]) { - const stack: number[] = []; - let maxArea = 0; - let maxRect = { area: 0, height: 0, start: 0, end: 0 }; - let index = 0; - - while (index < heights.length) { - if (stack.length === 0 || heights[index] >= heights[stack[stack.length - 1]]) { - stack.push(index++); - } else { - const height = heights[stack.pop()!]; - const width = stack.length === 0 ? index : index - stack[stack.length - 1] - 1; - if (height * width > maxArea) { - maxArea = height * width; - maxRect = { area: maxArea, height, start: stack.length === 0 ? 0 : stack[stack.length - 1] + 1, end: index - 1 }; - } - } - } - - while (stack.length > 0) { - const height = heights[stack.pop()!]; - const width = stack.length === 0 ? index : index - stack[stack.length - 1] - 1; - if (height * width > maxArea) { - maxArea = height * width; - maxRect = { area: maxArea, height, start: stack.length === 0 ? 0 : stack[stack.length - 1] + 1, end: index - 1 }; - } - } - - return maxRect; -} - -function resetMatrix(matrix: Nullable[][], range: IRange) { - Range.foreach(range, (row, col) => { - matrix[row][col] = undefined; - }); -} - -/** - * @deprecated this function could cause memory out of use in large range. - */ -export function queryObjectMatrix(matrix: ObjectMatrix, match: (value: T) => boolean) { - const arrayMatrix = matrix.toFullArray(); - const results: IRange[] = []; - while (true) { - const rectangle = maximalRectangle(arrayMatrix, match); - if (!rectangle) { - break; - } - - results.push(rectangle); - resetMatrix(arrayMatrix, rectangle); - } - - return results; -} - -export function multiSubtractMultiRanges(ranges1: IRange[], ranges2: IRange[]): IRange[] { - const matrix = new ObjectMatrix(); - - ranges1.forEach((range) => { - Range.foreach(range, (row, col) => { - matrix.setValue(row, col, 1); - }); - }); - - ranges2.forEach((range) => { - Range.foreach(range, (row, col) => { - matrix.setValue(row, col, 0); - }); - }); - - return Rectangle.mergeRanges(queryObjectMatrix(matrix, (value) => value === 1)); -} diff --git a/packages/sheets-conditional-formatting-ui/src/controllers/__tests__/cf.copy-paste.controller.spec.ts b/packages/sheets-conditional-formatting-ui/src/controllers/__tests__/cf.copy-paste.controller.spec.ts index f3c2088012..00333011cb 100644 --- a/packages/sheets-conditional-formatting-ui/src/controllers/__tests__/cf.copy-paste.controller.spec.ts +++ b/packages/sheets-conditional-formatting-ui/src/controllers/__tests__/cf.copy-paste.controller.spec.ts @@ -14,7 +14,9 @@ * limitations under the License. */ +import type { Workbook } from '@univerjs/core'; import type { IConditionFormattingRule } from '@univerjs/sheets-conditional-formatting'; +import { IUniverInstanceService, Range, UniverInstanceType } from '@univerjs/core'; import { AddConditionalRuleMutation, CFRuleType, @@ -24,16 +26,19 @@ import { SetConditionalRuleMutation, } from '@univerjs/sheets-conditional-formatting'; import { COPY_TYPE, PREDEFINED_HOOK_NAME_PASTE } from '@univerjs/sheets-ui'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createCfUiTestBed } from '../../__tests__/create-cf-ui-test-bed'; import { ConditionalFormattingCopyPasteController } from '../cf.copy-paste.controller'; describe('ConditionalFormattingCopyPasteController', () => { afterEach(() => { - // each test disposes its own univer instance + vi.restoreAllMocks(); }); it('creates copy-paste mutations from the real rule model and view model', async () => { + vi.spyOn(Range, 'foreach').mockImplementation(() => { + throw new Error('must not enumerate cells'); + }); const testBed = createCfUiTestBed(); testBed.injector.add([ConditionalFormattingCopyPasteController]); testBed.injector.get(ConditionalFormattingCopyPasteController); @@ -145,4 +150,234 @@ describe('ConditionalFormattingCopyPasteController', () => { testBed.univer.dispose(); }); + + it('keeps identical overlapping source rules distinct when copying', async () => { + const testBed = createCfUiTestBed(); + testBed.injector.add([ConditionalFormattingCopyPasteController]); + testBed.injector.get(ConditionalFormattingCopyPasteController); + const config: IConditionFormattingRule['rule'] = { + type: CFRuleType.highlightCell, + subType: CFSubRuleType.text, + operator: CFTextOperator.notContainsText, + value: 'A1', + style: { bg: { rgb: '#0f0' } }, + }; + + for (const cfId of ['cf-source-1', 'cf-source-2']) { + await testBed.commandService.executeCommand(AddConditionalRuleMutation.id, { + unitId: testBed.unitId, + subUnitId: testBed.subUnitId, + rule: { + cfId, + ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }], + stopIfTrue: false, + rule: config, + }, + }); + } + + const hook = testBed.getClipboardHook()!; + hook.onBeforeCopy(testBed.unitId, testBed.subUnitId, { + startRow: 0, + endRow: 0, + startColumn: 0, + endColumn: 0, + }); + const result = hook.onPasteCells( + { unitId: testBed.unitId, subUnitId: testBed.subUnitId, range: { rows: [0], cols: [0] } }, + { unitId: testBed.unitId, subUnitId: testBed.subUnitId, range: { rows: [1], cols: [1] } }, + null, + { copyType: COPY_TYPE.COPY, pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE } + ); + + const copiedRuleIds = result.redos.flatMap((mutation) => { + if (mutation.id !== AddConditionalRuleMutation.id && mutation.id !== SetConditionalRuleMutation.id) { + return []; + } + const rule = (mutation.params as { rule: IConditionFormattingRule }).rule; + return rule.ranges.some((range) => range.startRow <= 1 && range.endRow >= 1 && range.startColumn <= 1 && range.endColumn >= 1) + ? [rule.cfId] + : []; + }); + expect(new Set(copiedRuleIds)).toEqual(new Set(['cf-source-1', 'cf-source-2'])); + testBed.univer.dispose(); + }); + + it('does not reuse a target rule with different stop-if-true semantics', async () => { + const testBed = createCfUiTestBed(); + const workbook = testBed.get(IUniverInstanceService).getUnit(testBed.unitId, UniverInstanceType.UNIVER_SHEET)!; + workbook.addWorksheet('sheet2', 1, { id: 'sheet2', name: 'sheet2' }); + testBed.injector.add([ConditionalFormattingCopyPasteController]); + testBed.injector.get(ConditionalFormattingCopyPasteController); + const config: IConditionFormattingRule['rule'] = { + type: CFRuleType.highlightCell, + subType: CFSubRuleType.text, + operator: CFTextOperator.notContainsText, + value: 'A1', + style: { bg: { rgb: '#0f0' } }, + }; + await testBed.commandService.executeCommand(AddConditionalRuleMutation.id, { + unitId: testBed.unitId, + subUnitId: testBed.subUnitId, + rule: { + cfId: 'cf-source', + ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }], + stopIfTrue: true, + rule: config, + }, + }); + await testBed.commandService.executeCommand(AddConditionalRuleMutation.id, { + unitId: testBed.unitId, + subUnitId: 'sheet2', + rule: { + cfId: 'cf-target', + ranges: [{ startRow: 2, endRow: 2, startColumn: 2, endColumn: 2 }], + stopIfTrue: false, + rule: config, + }, + }); + + const hook = testBed.getClipboardHook()!; + hook.onBeforeCopy(testBed.unitId, testBed.subUnitId, { + startRow: 0, + endRow: 0, + startColumn: 0, + endColumn: 0, + }); + const result = hook.onPasteCells( + { unitId: testBed.unitId, subUnitId: testBed.subUnitId, range: { rows: [0], cols: [0] } }, + { unitId: testBed.unitId, subUnitId: 'sheet2', range: { rows: [1], cols: [1] } }, + null, + { copyType: COPY_TYPE.COPY, pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE } + ); + + const addedRule = result.redos.find((mutation) => mutation.id === AddConditionalRuleMutation.id)?.params as { rule: IConditionFormattingRule } | undefined; + expect(addedRule?.rule.stopIfTrue).toBe(true); + testBed.univer.dispose(); + }); + + it.each([ + { name: 'matching semantics', targetValue: 'source' }, + { name: 'different semantics', targetValue: 'target' }, + ])('isolates source and target rules with colliding IDs during cross-sheet cut: $name', async ({ targetValue }) => { + const testBed = createCfUiTestBed(); + const workbook = testBed.get(IUniverInstanceService).getUnit(testBed.unitId, UniverInstanceType.UNIVER_SHEET)!; + workbook.addWorksheet('sheet2', 1, { id: 'sheet2', name: 'sheet2' }); + testBed.injector.add([ConditionalFormattingCopyPasteController]); + testBed.injector.get(ConditionalFormattingCopyPasteController); + const createRule = (value: string, ranges: IConditionFormattingRule['ranges']): IConditionFormattingRule => ({ + cfId: 'cf-collision', + ranges, + stopIfTrue: false, + rule: { + type: CFRuleType.highlightCell, + subType: CFSubRuleType.text, + operator: CFTextOperator.notContainsText, + value, + style: { bg: { rgb: value === 'source' ? '#0f0' : '#f00' } }, + }, + }); + const sourceRule = createRule('source', [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }]); + const targetRule = createRule(targetValue, [ + { startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 }, + { startRow: 2, endRow: 2, startColumn: 2, endColumn: 2 }, + ]); + await testBed.commandService.executeCommand(AddConditionalRuleMutation.id, { + unitId: testBed.unitId, + subUnitId: testBed.subUnitId, + rule: sourceRule, + }); + await testBed.commandService.executeCommand(AddConditionalRuleMutation.id, { + unitId: testBed.unitId, + subUnitId: 'sheet2', + rule: targetRule, + }); + + const hook = testBed.getClipboardHook()!; + hook.onBeforeCopy(testBed.unitId, testBed.subUnitId, { + startRow: 0, + endRow: 0, + startColumn: 0, + endColumn: 0, + }); + const result = hook.onPasteCells( + { unitId: testBed.unitId, subUnitId: testBed.subUnitId, range: { rows: [0], cols: [0] } }, + { unitId: testBed.unitId, subUnitId: 'sheet2', range: { rows: [1], cols: [1] } }, + null, + { copyType: COPY_TYPE.CUT, pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE } + ) as { redos: Array<{ id: string; params: object }>; undos: Array<{ id: string; params: object }> }; + + for (const mutation of result.redos) { + await testBed.commandService.executeCommand(mutation.id, mutation.params); + } + expect(testBed.ruleModel.getRule(testBed.unitId, testBed.subUnitId, 'cf-collision')).toBeFalsy(); + expect(testBed.ruleModel.getRule(testBed.unitId, 'sheet2', 'cf-collision')?.ranges).toEqual([ + { startRow: 2, endRow: 2, startColumn: 2, endColumn: 2 }, + ]); + const copiedRules = testBed.ruleModel.getSubunitRules(testBed.unitId, 'sheet2')?.filter((rule) => ( + rule.cfId !== 'cf-collision' && rule.rule.type === CFRuleType.highlightCell && 'value' in rule.rule && rule.rule.value === 'source' + )); + expect(copiedRules).toHaveLength(1); + expect(copiedRules?.[0].ranges).toEqual([{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 }]); + + for (const mutation of result.undos) { + await testBed.commandService.executeCommand(mutation.id, mutation.params); + } + expect(testBed.ruleModel.getRule(testBed.unitId, testBed.subUnitId, 'cf-collision')).toEqual(sourceRule); + expect(testBed.ruleModel.getSubunitRules(testBed.unitId, 'sheet2')).toEqual([targetRule]); + testBed.univer.dispose(); + }); + + it('preserves source rule priority when copying across worksheets', async () => { + const testBed = createCfUiTestBed(); + const workbook = testBed.get(IUniverInstanceService).getUnit(testBed.unitId, UniverInstanceType.UNIVER_SHEET)!; + workbook.addWorksheet('sheet2', 1, { id: 'sheet2', name: 'sheet2' }); + testBed.injector.add([ConditionalFormattingCopyPasteController]); + testBed.injector.get(ConditionalFormattingCopyPasteController); + for (const value of ['low', 'high']) { + await testBed.commandService.executeCommand(AddConditionalRuleMutation.id, { + unitId: testBed.unitId, + subUnitId: testBed.subUnitId, + rule: { + cfId: `cf-${value}`, + ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }], + stopIfTrue: false, + rule: { + type: CFRuleType.highlightCell, + subType: CFSubRuleType.text, + operator: CFTextOperator.notContainsText, + value, + style: { bg: { rgb: value === 'high' ? '#0f0' : '#f00' } }, + }, + }, + }); + } + + const hook = testBed.getClipboardHook()!; + hook.onBeforeCopy(testBed.unitId, testBed.subUnitId, { + startRow: 0, + endRow: 0, + startColumn: 0, + endColumn: 0, + }); + const result = hook.onPasteCells( + { unitId: testBed.unitId, subUnitId: testBed.subUnitId, range: { rows: [0], cols: [0] } }, + { unitId: testBed.unitId, subUnitId: 'sheet2', range: { rows: [1], cols: [1] } }, + null, + { copyType: COPY_TYPE.COPY, pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE } + ) as { redos: Array<{ id: string; params: object }>; undos: Array<{ id: string; params: object }> }; + for (const mutation of result.redos) { + await testBed.commandService.executeCommand(mutation.id, mutation.params); + } + + const copiedValues = testBed.ruleModel.getSubunitRules(testBed.unitId, 'sheet2')?.map((rule) => ( + rule.rule.type === CFRuleType.highlightCell && 'value' in rule.rule ? rule.rule.value : undefined + )); + expect(copiedValues).toEqual(['high', 'low']); + for (const mutation of result.undos) { + await testBed.commandService.executeCommand(mutation.id, mutation.params); + } + expect(testBed.ruleModel.getSubunitRules(testBed.unitId, 'sheet2')).toEqual([]); + testBed.univer.dispose(); + }); }); diff --git a/packages/sheets-conditional-formatting-ui/src/controllers/cf.auto-fill.controller.ts b/packages/sheets-conditional-formatting-ui/src/controllers/cf.auto-fill.controller.ts index 270266cb54..6e2f8f8cec 100644 --- a/packages/sheets-conditional-formatting-ui/src/controllers/cf.auto-fill.controller.ts +++ b/packages/sheets-conditional-formatting-ui/src/controllers/cf.auto-fill.controller.ts @@ -17,12 +17,11 @@ import type { IMutationInfo, IRange, Workbook } from '@univerjs/core'; import type { IDiscreteRange, ISheetAutoFillHook } from '@univerjs/sheets'; import type { IDeleteConditionalRuleMutationParams, ISetConditionalRuleMutationParams } from '@univerjs/sheets-conditional-formatting'; -import { Disposable, Inject, Injector, IUniverInstanceService, Range, Rectangle, UniverInstanceType } from '@univerjs/core'; +import { Disposable, getIntersectRange, Inject, Injector, IUniverInstanceService, Rectangle, UniverInstanceType } from '@univerjs/core'; import { AUTO_FILL_APPLY_TYPE, AutoFillTools, IAutoFillService } from '@univerjs/sheets'; import { ConditionalFormattingRangeTransformService, ConditionalFormattingRuleModel, - ConditionalFormattingViewModel, DeleteConditionalRuleMutation, DeleteConditionalRuleMutationUndoFactory, SetConditionalRuleMutation, @@ -42,7 +41,6 @@ export class ConditionalFormattingAutoFillController extends Disposable { @Inject(IUniverInstanceService) private _univerInstanceService: IUniverInstanceService, @Inject(IAutoFillService) private _autoFillService: IAutoFillService, @Inject(ConditionalFormattingRuleModel) private _conditionalFormattingRuleModel: ConditionalFormattingRuleModel, - @Inject(ConditionalFormattingViewModel) private _conditionalFormattingViewModel: ConditionalFormattingViewModel, @Inject(ConditionalFormattingRangeTransformService) private _conditionalFormattingRangeTransformService: ConditionalFormattingRangeTransformService ) { super(); @@ -54,117 +52,6 @@ export class ConditionalFormattingAutoFillController extends Disposable { private _initAutoFill() { const noopReturnFunc = () => ({ redos: [], undos: [] }); - const loopFunc = ( - sourceStartCell: { row: number; col: number }, - targetStartCell: { row: number; col: number }, - relativeRange: IRange, - rangeMap: Map, - rangeDeltaMap: Map, - mapFunc: (row: number, col: number) => ({ row: number; col: number }) - ) => { - const unitId = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET)!.getUnitId(); - const subUnitId = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET)!.getActiveSheet()?.getSheetId(); - if (!unitId || !subUnitId) { - return; - } - - const getRangeDelta = (cfId: string) => { - let rangeDelta = rangeDeltaMap.get(cfId); - if (!rangeDelta) { - rangeDelta = { add: [], remove: [] }; - rangeDeltaMap.set(cfId, rangeDelta); - } - return rangeDelta; - }; - const ensureRuleRanges = (cfId: string) => { - if (rangeMap.has(cfId)) { - return true; - } - - const rule = this._conditionalFormattingRuleModel.getRule(unitId, subUnitId, cfId); - if (!rule) { - return false; - } - - rangeMap.set(cfId, rule.ranges); - return true; - }; - const sourceRange = { - startRow: sourceStartCell.row, - startColumn: sourceStartCell.col, - endColumn: sourceStartCell.col, - endRow: sourceStartCell.row, - }; - const targetRange = { - startRow: targetStartCell.row, - startColumn: targetStartCell.col, - endColumn: targetStartCell.col, - endRow: targetStartCell.row, - }; - - Range.foreach(relativeRange, (row, col) => { - const sourcePositionRange = Rectangle.getPositionRange( - { - startRow: row, - startColumn: col, - endColumn: col, - endRow: row, - }, - sourceRange - ); - const targetPositionRange = Rectangle.getPositionRange( - { - startRow: row, - startColumn: col, - endColumn: col, - endRow: row, - }, - targetRange - ); - const { row: sourceRow, col: sourceCol } = mapFunc(sourcePositionRange.startRow, sourcePositionRange.startColumn); - const sourceCellCf = this._conditionalFormattingViewModel.getCellCfs( - unitId, - subUnitId, - sourceRow, - sourceCol - ); - const { row: targetRow, col: targetCol } = mapFunc(targetPositionRange.startRow, targetPositionRange.startColumn); - const targetCellCf = this._conditionalFormattingViewModel.getCellCfs( - unitId, - subUnitId, - targetRow, - targetCol - ); - if (targetCellCf) { - targetCellCf.forEach((cf) => { - if (!ensureRuleRanges(cf.cfId)) { - return; - } - getRangeDelta(cf.cfId).remove.push({ - startRow: targetRow, - endRow: targetRow, - startColumn: targetCol, - endColumn: targetCol, - }); - }); - } - - if (sourceCellCf) { - sourceCellCf.forEach((cf) => { - if (!ensureRuleRanges(cf.cfId)) { - return; - } - getRangeDelta(cf.cfId).add.push({ - startRow: targetRow, - endRow: targetRow, - startColumn: targetCol, - endColumn: targetCol, - }); - }); - } - }); - }; - const generalApplyFunc = (sourceRange: IDiscreteRange, targetRange: IDiscreteRange) => { const unitId = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET)?.getUnitId(); const subUnitId = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET)?.getActiveSheet()?.getSheetId(); @@ -177,18 +64,51 @@ export class ConditionalFormattingAutoFillController extends Disposable { return noopReturnFunc(); } - const virtualRange = virtualizeDiscreteRanges([sourceRange, targetRange]); - const [vSourceRange, vTargetRange] = virtualRange.ranges; - const { mapFunc } = virtualRange; - - const sourceStartCell = { - row: vSourceRange.startRow, - col: vSourceRange.startColumn, - }; - + const virtualization = virtualizeDiscreteRanges([sourceRange, targetRange]); + const [vSourceRange, vTargetRange] = virtualization.ranges; const repeats = AutoFillTools.getAutoFillRepeatRange(vSourceRange, vTargetRange); - repeats.forEach((repeat) => { - loopFunc(sourceStartCell, repeat.repeatStartCell, repeat.relativeRange, rangeMap, rangeDeltaMap, mapFunc); + const targetRanges = repeats.flatMap((repeat) => virtualization.mapRange(Rectangle.getPositionRange(repeat.relativeRange, { + startRow: repeat.repeatStartCell.row, + endRow: repeat.repeatStartCell.row, + startColumn: repeat.repeatStartCell.col, + endColumn: repeat.repeatStartCell.col, + }))); + const getRangeDelta = (cfId: string) => { + let rangeDelta = rangeDeltaMap.get(cfId); + if (!rangeDelta) { + rangeDelta = { add: [], remove: [] }; + rangeDeltaMap.set(cfId, rangeDelta); + } + return rangeDelta; + }; + const rules = this._conditionalFormattingRuleModel.getSubunitRules(unitId, subUnitId) ?? []; + + rules.forEach((rule) => { + if (Rectangle.doAnyRangesIntersect(rule.ranges, targetRanges)) { + rangeMap.set(rule.cfId, rule.ranges); + getRangeDelta(rule.cfId).remove.push(...targetRanges); + } + + const sourceRanges = rule.ranges.flatMap((range) => { + const projected = virtualization.projectRange(range); + const intersected = projected && getIntersectRange(projected, vSourceRange); + return intersected ? [Rectangle.getRelativeRange(intersected, vSourceRange)] : []; + }); + const additions = repeats.flatMap((repeat) => sourceRanges.flatMap((range) => { + const copiedRange = getIntersectRange(range, repeat.relativeRange); + return copiedRange + ? virtualization.mapRange(Rectangle.getPositionRange(copiedRange, { + startRow: repeat.repeatStartCell.row, + endRow: repeat.repeatStartCell.row, + startColumn: repeat.repeatStartCell.col, + endColumn: repeat.repeatStartCell.col, + })) + : []; + })); + if (additions.length) { + rangeMap.set(rule.cfId, rule.ranges); + getRangeDelta(rule.cfId).add.push(...additions); + } }); rangeDeltaMap.forEach((rangeDelta, cfId) => { const ranges = rangeMap.get(cfId); diff --git a/packages/sheets-conditional-formatting-ui/src/controllers/cf.copy-paste.controller.ts b/packages/sheets-conditional-formatting-ui/src/controllers/cf.copy-paste.controller.ts index f396be8cc9..7d479a675d 100644 --- a/packages/sheets-conditional-formatting-ui/src/controllers/cf.copy-paste.controller.ts +++ b/packages/sheets-conditional-formatting-ui/src/controllers/cf.copy-paste.controller.ts @@ -17,7 +17,6 @@ import type { IRange, Nullable } from '@univerjs/core'; import type { IAddConditionalRuleMutationParams, - IConditionalFormattingRuleConfig, IConditionFormattingRule, IDeleteConditionalRuleMutationParams, ISetConditionalRuleMutationParams, @@ -28,10 +27,7 @@ import { Inject, Injector, IUniverInstanceService, - ObjectMatrix, - Range, Rectangle, - Tools, } from '@univerjs/core'; import { getSheetCommandTarget, @@ -42,7 +38,6 @@ import { AddConditionalRuleMutationUndoFactory, ConditionalFormattingRangeTransformService, ConditionalFormattingRuleModel, - ConditionalFormattingViewModel, DeleteConditionalRuleMutation, DeleteConditionalRuleMutationUndoFactory, SetConditionalRuleMutation, @@ -52,11 +47,11 @@ import { import { COPY_TYPE, getRepeatRange, ISheetClipboardService, PREDEFINED_HOOK_NAME_PASTE, virtualizeDiscreteRanges } from '@univerjs/sheets-ui'; interface ICopyInfoType { - matrix: ObjectMatrix; + rules: Map; info: { unitId: string; subUnitId: string; - cfMap: Record; + cfMap: Record>; }; } @@ -73,7 +68,6 @@ export class ConditionalFormattingCopyPasteController extends Disposable { @Inject(ISheetClipboardService) private _sheetClipboardService: ISheetClipboardService, @Inject(ConditionalFormattingRuleModel) private _conditionalFormattingRuleModel: ConditionalFormattingRuleModel, @Inject(Injector) private _injector: Injector, - @Inject(ConditionalFormattingViewModel) private _conditionalFormattingViewModel: ConditionalFormattingViewModel, @Inject(IUniverInstanceService) private _univerInstanceService: IUniverInstanceService, @Inject(ConditionalFormattingRangeTransformService) private _conditionalFormattingRangeTransformService: ConditionalFormattingRangeTransformService ) { @@ -98,10 +92,10 @@ export class ConditionalFormattingCopyPasteController extends Disposable { } private _collectConditionalRule(unitId: string, subUnitId: string, range: IRange) { - const matrix = new ObjectMatrix(); - const cfMap: Record = {}; + const rules = new Map(); + const cfMap: Record> = {}; this._copyInfo = { - matrix, + rules, info: { unitId, subUnitId, @@ -115,28 +109,25 @@ export class ConditionalFormattingCopyPasteController extends Disposable { if (!discreteRange) { return; } - const { rows, cols } = discreteRange; - const cfIdSet: Set = new Set(); - rows.forEach((row, rowIndex) => { - cols.forEach((col, colIndex) => { - const cellCfList = this._conditionalFormattingViewModel.getCellCfs(unitId, subUnitId, row, col); - if (!cellCfList) { - return; - } - cellCfList.forEach((item) => cfIdSet.add(item.cfId)); - matrix.setValue(rowIndex, colIndex, cellCfList.map((item) => item.cfId)); + const { projectRange } = virtualizeDiscreteRanges([discreteRange]); + this._conditionalFormattingRuleModel.getSubunitRules(unitId, subUnitId)?.forEach((rule) => { + const projectedRanges = rule.ranges.flatMap((ruleRange) => { + const projected = projectRange(ruleRange); + return projected ? [projected] : []; }); - }); - cfIdSet.forEach((cfId) => { - const rule = this._conditionalFormattingRuleModel.getRule(unitId, subUnitId, cfId); - if (rule) { - cfMap[cfId] = rule.rule; + if (projectedRanges.length) { + rules.set(rule.cfId, projectedRanges.length > 1 ? Rectangle.mergeRanges(projectedRanges) : projectedRanges); + cfMap[rule.cfId] = { rule: rule.rule, stopIfTrue: rule.stopIfTrue }; } }); } // eslint-disable-next-line max-lines-per-function private _generateConditionalFormattingMutations(pasteFrom: ISheetDiscreteRangeLocation, pasteTo: ISheetDiscreteRangeLocation, payload: ICopyPastePayload) { + const copyInfo = this._copyInfo; + if (!copyInfo) { + return { redos: [], undos: [] }; + } const { unitId: copyUnitId, subUnitId: copySubUnitId, range: copyRange } = pasteFrom; const { unitId: pastedUnitId, subUnitId: pastedSubUnitId, range: pastedRange } = pasteTo; const { copyType = COPY_TYPE.COPY } = payload; @@ -152,147 +143,112 @@ export class ConditionalFormattingCopyPasteController extends Disposable { return { redos: [], undos: [] }; } - const { ranges: [vCopyRange, vPastedRange], mapFunc } = virtualizeDiscreteRanges([copyRange, pastedRange]); - const repeatRange = getRepeatRange(vCopyRange, vPastedRange, true); - const effectedConditionalFormattingRuleRanges: Record = {}; + }>(); + const getEffectKey = (unitId: string, subUnitId: string, cfId: string) => JSON.stringify([unitId, subUnitId, cfId]); // 1. delete the conditional formatting rules in the pasted range. - Range.foreach(vPastedRange, (row, col) => { - const { row: realRow, col: realCol } = mapFunc(row, col); - const cellCfList = this._conditionalFormattingViewModel.getCellCfs(pastedUnitId, pastedSubUnitId, realRow, realCol); - if (cellCfList) { - cellCfList.forEach((item) => { - if (!effectedConditionalFormattingRuleRanges[item.cfId]) { - const rule = this._conditionalFormattingRuleModel.getRule(pastedUnitId, pastedSubUnitId, item.cfId); - if (!rule) { - return; - } - effectedConditionalFormattingRuleRanges[item.cfId] = { - unitId: pastedUnitId, - subUnitId: pastedSubUnitId, - ranges: rule.ranges, - add: [], - remove: [], - }; - } - const current = effectedConditionalFormattingRuleRanges[item.cfId]; - current.remove.push({ - startRow: realRow, - endRow: realRow, - startColumn: realCol, - endColumn: realCol, - }); - }); + this._conditionalFormattingRuleModel.getSubunitRules(pastedUnitId, pastedSubUnitId)?.forEach((rule) => { + if (!Rectangle.doAnyRangesIntersect(rule.ranges, targetRanges)) { + return; } + effectedConditionalFormattingRuleRanges.set(getEffectKey(pastedUnitId, pastedSubUnitId, rule.cfId), { + cfId: rule.cfId, + unitId: pastedUnitId, + subUnitId: pastedSubUnitId, + ranges: rule.ranges, + add: [], + remove: targetRanges, + }); }); // 2. if it is cut from another worksheet, need to delete the conditional formatting rules in the copy range. if (copyType === COPY_TYPE.CUT && (pastedUnitId !== copyUnitId || pastedSubUnitId !== copySubUnitId)) { - Range.foreach(vCopyRange, (row, col) => { - const { row: realRow, col: realCol } = mapFunc(row, col); - const cellCfList = this._conditionalFormattingViewModel.getCellCfs(copyUnitId, copySubUnitId, realRow, realCol); - if (cellCfList) { - cellCfList.forEach((item) => { - if (!effectedConditionalFormattingRuleRanges[item.cfId]) { - const rule = this._conditionalFormattingRuleModel.getRule(copyUnitId, copySubUnitId, item.cfId); - if (!rule) { - return; - } - effectedConditionalFormattingRuleRanges[item.cfId] = { - unitId: copyUnitId, - subUnitId: copySubUnitId, - ranges: rule.ranges, - add: [], - remove: [], - }; - } - const current = effectedConditionalFormattingRuleRanges[item.cfId]; - current.remove.push({ - startRow: realRow, - endRow: realRow, - startColumn: realCol, - endColumn: realCol, - }); - }); + const sourceRanges = sourceVirtualization.mapRange(sourceVirtualRange); + copyInfo.rules.forEach((_ranges, cfId) => { + const rule = this._conditionalFormattingRuleModel.getRule(copyUnitId, copySubUnitId, cfId); + if (!rule) { + return; } + effectedConditionalFormattingRuleRanges.set(getEffectKey(copyUnitId, copySubUnitId, cfId), { + cfId, + unitId: copyUnitId, + subUnitId: copySubUnitId, + ranges: rule.ranges, + add: [], + remove: sourceRanges, + }); }); } - const { matrix, info } = this._copyInfo as ICopyInfoType; - const waitAddRule: IConditionFormattingRule[] = []; + const { rules, info } = copyInfo; + const waitAddRule = new Map(); const cacheCfIdMap: Record = {}; // 3. generate the new conditional formatting rules based on the copy range's conditional formatting rules and the paste position. const getCurrentSheetCfRule = (copyRangeCfId: string) => { const oldRule = info?.cfMap[copyRangeCfId]; - const targetRule = [...(this._conditionalFormattingRuleModel.getSubunitRules(pastedUnitId, pastedSubUnitId) || []), ...waitAddRule].find((rule) => { - return Tools.diffValue(rule.rule, oldRule); - }); - - if (targetRule) { - cacheCfIdMap[copyRangeCfId] = targetRule; - return targetRule; - } else { - const rule: IConditionFormattingRule = { - rule: oldRule, - cfId: this._conditionalFormattingRuleModel.createCfId(pastedUnitId, pastedSubUnitId), - ranges: [], - stopIfTrue: false, - }; - cacheCfIdMap[copyRangeCfId] = rule; - waitAddRule.push(rule); - return rule; + if (isSameSheet) { + const rule = this._conditionalFormattingRuleModel.getRule(pastedUnitId, pastedSubUnitId, copyRangeCfId); + if (rule) { + cacheCfIdMap[copyRangeCfId] = rule; + return rule; + } } + + const rule: IConditionFormattingRule = { + rule: oldRule.rule, + cfId: this._conditionalFormattingRuleModel.createCfId(pastedUnitId, pastedSubUnitId), + ranges: [], + stopIfTrue: oldRule.stopIfTrue, + }; + cacheCfIdMap[copyRangeCfId] = rule; + waitAddRule.set(rule.cfId, rule); + return rule; }; - repeatRange.forEach((item) => { - matrix && - matrix.forValue((row, col, copyRangeCfIdList) => { - const range = Rectangle.getPositionRange( - { - startRow: row, - endRow: row, - startColumn: col, - endColumn: col, - }, - item.startRange - ); - - const { row: _row, col: _col } = mapFunc(range.startRow, range.startColumn); - - copyRangeCfIdList.forEach((cfId) => { - const rule = cacheCfIdMap[cfId] || getCurrentSheetCfRule(cfId); - if (!effectedConditionalFormattingRuleRanges[rule.cfId]) { - effectedConditionalFormattingRuleRanges[rule.cfId] = { - unitId: pastedUnitId, - subUnitId: pastedSubUnitId, - ranges: rule.ranges, - add: [], - remove: [], - }; - } - const current = effectedConditionalFormattingRuleRanges[rule.cfId]; - current.add.push({ - startRow: _row, - endRow: _row, - startColumn: _col, - endColumn: _col, - }); - }); + const sourceRuleEntries = Array.from(rules.entries()); + if (!isSameSheet) { + // AddRule prepends, so emit lower-priority cross-sheet clones first. + sourceRuleEntries.reverse(); + } + sourceRuleEntries.forEach(([cfId, sourceRanges]) => { + const rule = cacheCfIdMap[cfId] || getCurrentSheetCfRule(cfId); + const effectKey = getEffectKey(pastedUnitId, pastedSubUnitId, rule.cfId); + if (!effectedConditionalFormattingRuleRanges.has(effectKey)) { + effectedConditionalFormattingRuleRanges.set(effectKey, { + cfId: rule.cfId, + unitId: pastedUnitId, + subUnitId: pastedSubUnitId, + ranges: rule.ranges, + add: [], + remove: [], }); + } + const current = effectedConditionalFormattingRuleRanges.get(effectKey)!; + current.add.push(...repeatRange.flatMap((item) => sourceRanges.flatMap((sourceRange) => ( + targetVirtualization.mapRange(Rectangle.getPositionRange(sourceRange, item.startRange)) + )))); }); const redos = []; const undos = []; - for (const cfId in effectedConditionalFormattingRuleRanges) { - const { unitId, subUnitId, ranges: sourceRanges, add, remove } = effectedConditionalFormattingRuleRanges[cfId]; + for (const effect of effectedConditionalFormattingRuleRanges.values()) { + const { cfId, unitId, subUnitId, ranges: sourceRanges, add, remove } = effect; const ranges = this._conditionalFormattingRangeTransformService.applyRangeDelta(sourceRanges, remove, add); if (!ranges.length) { @@ -303,14 +259,15 @@ export class ConditionalFormattingCopyPasteController extends Disposable { }; redos.push({ id: DeleteConditionalRuleMutation.id, params: deleteParams }); undos.push(...DeleteConditionalRuleMutationUndoFactory(this._injector, deleteParams)); + continue; } - if (waitAddRule.some((rule) => rule.cfId === cfId)) { - const rule = waitAddRule.find((rule) => rule.cfId === cfId) as IConditionFormattingRule; + const waitAdd = waitAddRule.get(cfId); + if (waitAdd) { const addParams: IAddConditionalRuleMutationParams = { unitId: pastedUnitId, subUnitId: pastedSubUnitId, - rule: { ...rule, ranges }, + rule: { ...waitAdd, ranges }, }; redos.push({ id: AddConditionalRuleMutation.id, params: addParams }); undos.push(AddConditionalRuleMutationUndoFactory(this._injector, addParams)); diff --git a/packages/sheets-conditional-formatting-ui/src/controllers/cf.painter.controller.ts b/packages/sheets-conditional-formatting-ui/src/controllers/cf.painter.controller.ts index 892edee45a..87c9e4ffd5 100644 --- a/packages/sheets-conditional-formatting-ui/src/controllers/cf.painter.controller.ts +++ b/packages/sheets-conditional-formatting-ui/src/controllers/cf.painter.controller.ts @@ -17,18 +17,18 @@ import type { IMutationInfo, IRange, Nullable, Workbook } from '@univerjs/core'; import type { IAddConditionalRuleMutationParams, + IConditionFormattingRule, IDeleteConditionalRuleMutationParams, ISetConditionalRuleMutationParams, } from '@univerjs/sheets-conditional-formatting'; import type { IFormatPainterHook } from '@univerjs/sheets-ui'; -import { Disposable, Inject, Injector, IUniverInstanceService, Range, Rectangle, Tools, UniverInstanceType } from '@univerjs/core'; +import { Disposable, getIntersectRange, Inject, Injector, IUniverInstanceService, Rectangle, Tools, UniverInstanceType } from '@univerjs/core'; import { SheetsSelectionsService } from '@univerjs/sheets'; import { AddConditionalRuleMutation, AddConditionalRuleMutationUndoFactory, ConditionalFormattingRangeTransformService, ConditionalFormattingRuleModel, - ConditionalFormattingViewModel, DeleteConditionalRuleMutation, DeleteConditionalRuleMutationUndoFactory, SetConditionalRuleMutation, @@ -101,7 +101,6 @@ export class ConditionalFormattingPainterController extends Disposable { @Inject(IFormatPainterService) private _formatPainterService: IFormatPainterService, @Inject(SheetsSelectionsService) private _sheetsSelectionsService: SheetsSelectionsService, @Inject(ConditionalFormattingRuleModel) private _conditionalFormattingRuleModel: ConditionalFormattingRuleModel, - @Inject(ConditionalFormattingViewModel) private _conditionalFormattingViewModel: ConditionalFormattingViewModel, @Inject(ConditionalFormattingRangeTransformService) private _conditionalFormattingRangeTransformService: ConditionalFormattingRangeTransformService ) { @@ -114,108 +113,6 @@ export class ConditionalFormattingPainterController extends Disposable { private _initFormattingPainter() { const noopReturnFunc = () => ({ redos: [], undos: [] }); - const loopFunc = ( - sourceStartCell: { row: number; col: number }, - targetStartCell: { row: number; col: number }, - relativeRange: IRange, - rangeMap: Map, - rangeDeltaMap: Map, - config: { - targetUnitId: string; - targetSubUnitId: string; - } - ) => { - const { unitId: sourceUnitId, subUnitId: sourceSubUnitId } = this._painterConfig!; - const { targetUnitId, targetSubUnitId } = config; - - const getRangeDelta = (cfId: string) => { - let rangeDelta = rangeDeltaMap.get(cfId); - if (!rangeDelta) { - rangeDelta = { add: [], remove: [] }; - rangeDeltaMap.set(cfId, rangeDelta); - } - return rangeDelta; - }; - const sourceRange = { - startRow: sourceStartCell.row, - startColumn: sourceStartCell.col, - endColumn: sourceStartCell.col, - endRow: sourceStartCell.row, - }; - const targetRange = { - startRow: targetStartCell.row, - startColumn: targetStartCell.col, - endColumn: targetStartCell.col, - endRow: targetStartCell.row, - }; - - Range.foreach(relativeRange, (row, col) => { - const sourcePositionRange = Rectangle.getPositionRange( - { - startRow: row, - startColumn: col, - endColumn: col, - endRow: row, - }, - sourceRange - ); - const targetPositionRange = Rectangle.getPositionRange( - { - startRow: row, - startColumn: col, - endColumn: col, - endRow: row, - }, - targetRange - ); - - const sourceCellCf = this._conditionalFormattingViewModel.getCellCfs( - sourceUnitId, - sourceSubUnitId, - sourcePositionRange.startRow, - sourcePositionRange.startColumn - ); - - const targetCellCf = this._conditionalFormattingViewModel.getCellCfs( - targetUnitId, - targetSubUnitId, - targetPositionRange.startRow, - targetPositionRange.startColumn - ); - - if (targetCellCf) { - targetCellCf.forEach((cf) => { - if (!rangeMap.has(cf.cfId)) { - const rule = this._conditionalFormattingRuleModel.getRule(targetUnitId, targetSubUnitId, cf.cfId); - if (!rule) { - return; - } - rangeMap.set(cf.cfId, rule.ranges); - } - getRangeDelta(cf.cfId).remove.push({ - startRow: targetPositionRange.startRow, - endRow: targetPositionRange.startRow, - startColumn: targetPositionRange.startColumn, - endColumn: targetPositionRange.startColumn, - }); - }); - } - - if (sourceCellCf) { - sourceCellCf.forEach((cf) => { - if (!rangeMap.has(cf.cfId)) { - return; - } - getRangeDelta(cf.cfId).add.push({ - startRow: targetPositionRange.startRow, - endRow: targetPositionRange.startRow, - startColumn: targetPositionRange.startColumn, - endColumn: targetPositionRange.startColumn, - }); - }); - } - }); - }; // eslint-disable-next-line max-lines-per-function const generalApplyFunc = (targetUnitId: string, targetSubUnitId: string, targetRange: IRange) => { const { range: sourceRange, unitId: sourceUnitId, subUnitId: sourceSubUnitId } = this._painterConfig!; @@ -228,23 +125,53 @@ export class ConditionalFormattingPainterController extends Disposable { if (!targetUnitId || !targetSubUnitId || !sourceUnitId || !sourceSubUnitId) { return noopReturnFunc(); } - const ruleList = this._conditionalFormattingRuleModel.getSubunitRules(sourceUnitId, sourceSubUnitId) ?? []; - ruleList?.forEach((rule) => { - const { ranges, cfId } = rule; - if (ranges.some((range) => Rectangle.intersects(sourceRange, range))) { - rangeMap.set(cfId, isSkipSheet ? [] : ranges); + const repeats = repeatByRange(sourceRange, targetRange); + const targetRanges = repeats.map((repeat) => Rectangle.getPositionRange(repeat.repeatRelativeRange, repeat.startRange)); + const getRangeDelta = (cfId: string) => { + let rangeDelta = rangeDeltaMap.get(cfId); + if (!rangeDelta) { + rangeDelta = { add: [], remove: [] }; + rangeDeltaMap.set(cfId, rangeDelta); + } + return rangeDelta; + }; + + const targetRuleList = this._conditionalFormattingRuleModel.getSubunitRules(targetUnitId, targetSubUnitId) ?? []; + const waitAddRule = new Map(); + targetRuleList.forEach((rule) => { + if (Rectangle.doAnyRangesIntersect(rule.ranges, targetRanges)) { + rangeMap.set(rule.cfId, rule.ranges); + getRangeDelta(rule.cfId).remove.push(...targetRanges); } }); - const sourceStartCell = { - row: sourceRange.startRow, - col: sourceRange.startColumn, - }; - - const repeats = repeatByRange(sourceRange, targetRange); - - repeats.forEach((repeat) => { - loopFunc(sourceStartCell, { row: repeat.startRange.startRow, col: repeat.startRange.startColumn }, repeat.repeatRelativeRange, rangeMap, rangeDeltaMap, { targetUnitId, targetSubUnitId }); + const sourceRuleList = this._conditionalFormattingRuleModel.getSubunitRules(sourceUnitId, sourceSubUnitId) ?? []; + const sourceRules = isSkipSheet ? [...sourceRuleList].reverse() : sourceRuleList; + sourceRules.forEach((rule) => { + const sourceRanges = rule.ranges.flatMap((range) => { + const intersected = getIntersectRange(range, sourceRange); + return intersected ? [Rectangle.getRelativeRange(intersected, sourceRange)] : []; + }); + const additions = repeats.flatMap((repeat) => sourceRanges.flatMap((range) => { + const copiedRange = getIntersectRange(range, repeat.repeatRelativeRange); + return copiedRange ? [Rectangle.getPositionRange(copiedRange, repeat.startRange)] : []; + })); + if (additions.length) { + let targetCfId = rule.cfId; + if (isSkipSheet) { + targetCfId = this._conditionalFormattingRuleModel.createCfId(targetUnitId, targetSubUnitId); + waitAddRule.set(targetCfId, { + ...Tools.deepClone(rule), + cfId: targetCfId, + ranges: [], + }); + rangeMap.set(targetCfId, []); + } + if (!rangeMap.has(targetCfId)) { + rangeMap.set(targetCfId, rule.ranges); + } + getRangeDelta(targetCfId).add.push(...additions); + } }); rangeDeltaMap.forEach((rangeDelta, cfId) => { const ranges = rangeMap.get(cfId); @@ -283,25 +210,21 @@ export class ConditionalFormattingPainterController extends Disposable { undos.push(...DeleteConditionalRuleMutationUndoFactory(this._injector, params)); } } else { - const rule = this._conditionalFormattingRuleModel.getRule(targetUnitId, targetSubUnitId, cfId); - if (!rule) { + const waitAdd = waitAddRule.get(cfId); + if (waitAdd) { if (ranges.length) { - const sourceRule = this._conditionalFormattingRuleModel.getRule(sourceUnitId, sourceSubUnitId, cfId); - if (sourceRule) { - const params: IAddConditionalRuleMutationParams = { - unitId: targetUnitId, - subUnitId: targetSubUnitId, - rule: { - ...Tools.deepClone(sourceRule), - cfId: this._conditionalFormattingRuleModel.createCfId(targetUnitId, targetSubUnitId), - ranges, - }, - }; - redos.push({ id: AddConditionalRuleMutation.id, params }); - undos.push(AddConditionalRuleMutationUndoFactory(this._injector, params)); - } + const params: IAddConditionalRuleMutationParams = { + unitId: targetUnitId, + subUnitId: targetSubUnitId, + rule: { ...waitAdd, ranges }, + }; + redos.push({ id: AddConditionalRuleMutation.id, params }); + undos.push(AddConditionalRuleMutationUndoFactory(this._injector, params)); } - } else { + return; + } + const rule = this._conditionalFormattingRuleModel.getRule(targetUnitId, targetSubUnitId, cfId); + if (rule) { if (ranges.length) { const params: ISetConditionalRuleMutationParams = { unitId: targetUnitId, diff --git a/packages/sheets-data-validation-ui/src/__tests__/create-dv-ui-test-bed.ts b/packages/sheets-data-validation-ui/src/__tests__/create-dv-ui-test-bed.ts new file mode 100644 index 0000000000..354f0ddd57 --- /dev/null +++ b/packages/sheets-data-validation-ui/src/__tests__/create-dv-ui-test-bed.ts @@ -0,0 +1,107 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { IWorkbookData, Workbook } from '@univerjs/core'; +import type { ISheetClipboardHook } from '@univerjs/sheets-ui'; +import { + Disposable, + ICommandService, + ILogService, + IUniverInstanceService, + LocaleService, + LocaleType, + LogLevel, + toDisposable, + Tools, + Univer, + UniverInstanceType, +} from '@univerjs/core'; +import { UniverDataValidationPlugin } from '@univerjs/data-validation'; +import { IAutoFillService, UniverSheetsPlugin } from '@univerjs/sheets'; +import { SheetDataValidationModel, UniverSheetsDataValidationPlugin } from '@univerjs/sheets-data-validation'; +import { ISheetClipboardService } from '@univerjs/sheets-ui'; +import enUS from '@univerjs/sheets/locale/en-US'; + +const TEST_WORKBOOK_DATA: IWorkbookData = { + id: 'test', + appVersion: '3.0.0-alpha', + locale: LocaleType.EN_US, + name: '', + sheetOrder: ['sheet1', 'sheet2'], + styles: {}, + sheets: { + sheet1: { + id: 'sheet1', + name: 'Sheet1', + rowCount: 200_000, + columnCount: 2_000, + cellData: {}, + }, + sheet2: { + id: 'sheet2', + name: 'Sheet2', + rowCount: 100, + columnCount: 100, + cellData: {}, + }, + }, +}; + +class TestSheetClipboardService extends Disposable { + private _hooks: ISheetClipboardHook[] = []; + + addClipboardHook(hook: ISheetClipboardHook) { + this._hooks.push(hook); + return toDisposable(() => { + this._hooks = this._hooks.filter((item) => item !== hook); + }); + } + + getHooks() { + return this._hooks; + } +} + +export function createDvUiTestBed() { + const univer = new Univer(); + const injector = univer.__getInjector(); + const get = injector.get.bind(injector); + + univer.registerPlugin(UniverDataValidationPlugin); + univer.registerPlugin(UniverSheetsPlugin, { notExecuteFormula: true }); + univer.registerPlugin(UniverSheetsDataValidationPlugin); + + const workbook = univer.createUnit(UniverInstanceType.UNIVER_SHEET, Tools.deepClone(TEST_WORKBOOK_DATA)); + get(IUniverInstanceService).focusUnit(workbook.getUnitId()); + get(ILogService).setLogLevel(LogLevel.SILENT); + get(LocaleService).load({ enUS }); + get(LocaleService).setLocale(LocaleType.EN_US); + + injector.add([ISheetClipboardService, { useClass: TestSheetClipboardService as never }]); + const clipboardService = get(ISheetClipboardService) as unknown as TestSheetClipboardService; + + return { + univer, + injector, + workbook, + commandService: get(ICommandService), + dataValidationModel: get(SheetDataValidationModel), + autoFillService: get(IAutoFillService), + getClipboardHook: () => clipboardService.getHooks()[0], + unitId: 'test', + subUnitId: 'sheet1', + }; +} diff --git a/packages/sheets-data-validation-ui/src/controllers/__tests__/dv-auto-fill.controller.spec.ts b/packages/sheets-data-validation-ui/src/controllers/__tests__/dv-auto-fill.controller.spec.ts index 38d9137ecd..200f61a28b 100644 --- a/packages/sheets-data-validation-ui/src/controllers/__tests__/dv-auto-fill.controller.spec.ts +++ b/packages/sheets-data-validation-ui/src/controllers/__tests__/dv-auto-fill.controller.spec.ts @@ -14,120 +14,122 @@ * limitations under the License. */ -import { DataValidationType } from '@univerjs/core'; -import { AUTO_FILL_APPLY_TYPE, AutoFillTools } from '@univerjs/sheets'; -import { DATA_VALIDATION_PLUGIN_NAME, getDataValidationDiffMutations } from '@univerjs/sheets-data-validation'; -import { virtualizeDiscreteRanges } from '@univerjs/sheets-ui'; -import { describe, expect, it, vi } from 'vitest'; +import type { ISheetAutoFillHook } from '@univerjs/sheets'; +import { DataValidationType, Direction, Range } from '@univerjs/core'; +import { AddDataValidationMutation } from '@univerjs/data-validation'; +import { AUTO_FILL_APPLY_TYPE } from '@univerjs/sheets'; +import { DATA_VALIDATION_PLUGIN_NAME } from '@univerjs/sheets-data-validation'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createDvUiTestBed } from '../../__tests__/create-dv-ui-test-bed'; import { DataValidationAutoFillController } from '../dv-auto-fill.controller'; -vi.mock('@univerjs/sheets-ui', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - virtualizeDiscreteRanges: vi.fn(() => ({ - ranges: [ - { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, - { startRow: 1, startColumn: 1, endRow: 1, endColumn: 1 }, - ], - mapFunc: (row: number, col: number) => ({ row, col }), - })), - }; -}); - -vi.mock('@univerjs/sheets-data-validation', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - getDataValidationDiffMutations: vi.fn(() => ({ redoMutations: ['redo-dv'], undoMutations: ['undo-dv'] })), - }; -}); - -vi.mock('@univerjs/sheets', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - AutoFillTools: { - ...actual.AutoFillTools, - getAutoFillRepeatRange: vi.fn(() => [{ - repeatStartCell: { row: 1, col: 1 }, - relativeRange: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }, - }]), - }, - }; -}); - describe('DataValidationAutoFillController', () => { - it('registers a hook and disables series fill when checkbox validation exists in source cells', () => { - let hook: { id: string; onBeforeFillData: (location: { unitId: string; subUnitId: string; source: { rows: number[]; cols: number[] } }) => void } | undefined; - const autoFillService = { - addHook: vi.fn((registeredHook) => { - hook = registeredHook; - return { dispose: vi.fn() }; - }), - setDisableApplyType: vi.fn(), - }; + let testBed: ReturnType; + let hook: ISheetAutoFillHook; - const controller = new DataValidationAutoFillController( - autoFillService as never, - { - getRuleByLocation: vi.fn(() => ({ type: DataValidationType.CHECKBOX })), - } as never, - {} as never - ); + beforeEach(() => { + testBed = createDvUiTestBed(); + testBed.injector.add([DataValidationAutoFillController]); + testBed.injector.get(DataValidationAutoFillController); + const registeredHook = testBed.autoFillService.getAllHooks().find((item) => item.id === DATA_VALIDATION_PLUGIN_NAME); + if (!registeredHook) { + throw new Error('Data validation autofill hook was not registered'); + } + hook = registeredHook; + }); - expect(controller).toBeTruthy(); - expect(hook?.id).toBe(DATA_VALIDATION_PLUGIN_NAME); + afterEach(() => { + vi.restoreAllMocks(); + testBed.univer.dispose(); + }); - hook!.onBeforeFillData({ - unitId: 'book-1', - subUnitId: 'sheet-1', - source: { rows: [0], cols: [0] }, + it('disables series fill for checkbox rules without querying each source cell', async () => { + await testBed.commandService.executeCommand(AddDataValidationMutation.id, { + unitId: testBed.unitId, + subUnitId: testBed.subUnitId, + rule: { + uid: 'checkbox-rule', + type: DataValidationType.CHECKBOX, + ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }], + }, + }); + vi.spyOn(testBed.dataValidationModel, 'getRuleByLocation').mockImplementation(() => { + throw new Error('must not query validation by cell'); }); - expect(autoFillService.setDisableApplyType).toHaveBeenCalledWith(AUTO_FILL_APPLY_TYPE.SERIES, true); + hook.onBeforeFillData?.({ + unitId: testBed.unitId, + subUnitId: testBed.subUnitId, + source: { + rows: Array.from({ length: 100_000 }, (_, index) => index), + cols: Array.from({ length: 1_000 }, (_, index) => index), + }, + target: { rows: [100_000], cols: [0] }, + }, Direction.DOWN); + + expect(testBed.autoFillService.menu.find((item) => item.value === AUTO_FILL_APPLY_TYPE.SERIES)?.disable).toBe(true); }); - it('builds diff mutations for copy and format autofill, and skips unsupported apply types', () => { - let hook: { onFillData: (location: { unitId: string; subUnitId: string; source: { rows: number[]; cols: number[]; startRow: number; endRow: number; startColumn: number; endColumn: number }; target: { rows: number[]; cols: number[]; startRow: number; endRow: number; startColumn: number; endColumn: number } }, direction: unknown, applyType: AUTO_FILL_APPLY_TYPE) => { redos: unknown[]; undos: unknown[] } } | undefined; - const ruleMatrixCopy = { - addRangeRules: vi.fn(), - diff: vi.fn(() => 'diffs'), - }; + it('ignores checkbox rules that only cover filtered-out source rows', async () => { + await testBed.commandService.executeCommand(AddDataValidationMutation.id, { + unitId: testBed.unitId, + subUnitId: testBed.subUnitId, + rule: { + uid: 'filtered-checkbox-rule', + type: DataValidationType.CHECKBOX, + ranges: [{ startRow: 1, endRow: 1, startColumn: 0, endColumn: 0 }], + }, + }); + vi.spyOn(testBed.dataValidationModel, 'getRuleByLocation').mockImplementation(() => { + throw new Error('must not query validation by cell'); + }); - const controller = new DataValidationAutoFillController( - { - addHook: vi.fn((registeredHook) => { - hook = registeredHook; - return { dispose: vi.fn() }; - }), - setDisableApplyType: vi.fn(), - } as never, - { - getRuleByLocation: vi.fn(() => null), - getRuleObjectMatrix: vi.fn(() => ({ clone: vi.fn(() => ruleMatrixCopy) })), - getRuleIdByLocation: vi.fn(() => 'rule-1'), - getRules: vi.fn(() => ['existing-rule']), - } as never, - {} as never - ); + hook.onBeforeFillData?.({ + unitId: testBed.unitId, + subUnitId: testBed.subUnitId, + source: { rows: [0, 2], cols: [0] }, + target: { rows: [3], cols: [0] }, + }, Direction.DOWN); - expect(controller).toBeTruthy(); + expect(testBed.autoFillService.menu.find((item) => item.value === AUTO_FILL_APPLY_TYPE.SERIES)?.disable).toBe(false); + }); + it('executes and undoes real validation mutations without enumerating autofill cells', async () => { + await testBed.commandService.executeCommand(AddDataValidationMutation.id, { + unitId: testBed.unitId, + subUnitId: testBed.subUnitId, + rule: { + uid: 'decimal-rule', + type: DataValidationType.DECIMAL, + formula1: '1', + ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }], + }, + }); + const foreach = vi.spyOn(Range, 'foreach').mockImplementation(() => { + throw new Error('must not enumerate cells'); + }); const location = { - unitId: 'book-1', - subUnitId: 'sheet-1', - source: { rows: [0], cols: [0], startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }, - target: { rows: [1], cols: [1], startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 }, + unitId: testBed.unitId, + subUnitId: testBed.subUnitId, + source: { rows: [0], cols: [0] }, + target: { rows: [1], cols: [0] }, }; - const result = hook!.onFillData(location, 'down', AUTO_FILL_APPLY_TYPE.ONLY_FORMAT); - expect(vi.mocked(virtualizeDiscreteRanges)).toHaveBeenCalledWith([location.source, location.target]); - expect(vi.mocked(AutoFillTools.getAutoFillRepeatRange)).toHaveBeenCalled(); - expect(ruleMatrixCopy.addRangeRules).toHaveBeenCalledWith([{ id: 'rule-1', ranges: [{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 }] }]); - expect(vi.mocked(getDataValidationDiffMutations)).toHaveBeenCalledWith('book-1', 'sheet-1', 'diffs', {}, 'patched', true); - expect(result).toEqual({ redos: ['redo-dv'], undos: ['undo-dv'] }); + const result = hook.onFillData?.(location, Direction.DOWN, AUTO_FILL_APPLY_TYPE.COPY); + if (!result) { + throw new Error('Data validation autofill did not return mutations'); + } + foreach.mockRestore(); + for (const mutation of result.redos) { + await testBed.commandService.executeCommand(mutation.id, mutation.params); + } + expect(testBed.dataValidationModel.getRuleByLocation(testBed.unitId, testBed.subUnitId, 0, 0)?.uid).toBe('decimal-rule'); + expect(testBed.dataValidationModel.getRuleByLocation(testBed.unitId, testBed.subUnitId, 1, 0)?.uid).toBe('decimal-rule'); - expect(hook!.onFillData(location, 'down', AUTO_FILL_APPLY_TYPE.NO_FORMAT)).toEqual({ redos: [], undos: [] }); + for (const mutation of result.undos) { + await testBed.commandService.executeCommand(mutation.id, mutation.params); + } + expect(testBed.dataValidationModel.getRuleByLocation(testBed.unitId, testBed.subUnitId, 0, 0)?.uid).toBe('decimal-rule'); + expect(testBed.dataValidationModel.getRuleByLocation(testBed.unitId, testBed.subUnitId, 1, 0)).toBeUndefined(); }); }); diff --git a/packages/sheets-data-validation-ui/src/controllers/dv-auto-fill.controller.ts b/packages/sheets-data-validation-ui/src/controllers/dv-auto-fill.controller.ts index 849c16bfdf..fc5fc8457b 100644 --- a/packages/sheets-data-validation-ui/src/controllers/dv-auto-fill.controller.ts +++ b/packages/sheets-data-validation-ui/src/controllers/dv-auto-fill.controller.ts @@ -14,8 +14,9 @@ * limitations under the License. */ +import type { IRange } from '@univerjs/core'; import type { IAutoFillLocation, ISheetAutoFillHook } from '@univerjs/sheets'; -import { DataValidationType, Disposable, Inject, Injector, ObjectMatrix, queryObjectMatrix, Range, Rectangle } from '@univerjs/core'; +import { DataValidationType, Disposable, getIntersectRange, Inject, Injector, Rectangle } from '@univerjs/core'; import { AUTO_FILL_APPLY_TYPE, AutoFillTools, IAutoFillService } from '@univerjs/sheets'; import { DATA_VALIDATION_PLUGIN_NAME, getDataValidationDiffMutations, SheetDataValidationModel } from '@univerjs/sheets-data-validation'; import { virtualizeDiscreteRanges } from '@univerjs/sheets-ui'; @@ -42,58 +43,44 @@ export class DataValidationAutoFillController extends Disposable { const virtualRange = virtualizeDiscreteRanges([sourceRange, targetRange]); const [vSourceRange, vTargetRange] = virtualRange.ranges; - const { mapFunc } = virtualRange; - const sourceStartCell = { - row: vSourceRange.startRow, - col: vSourceRange.startColumn, - }; + const { mapRange, projectRange } = virtualRange; const repeats = AutoFillTools.getAutoFillRepeatRange(vSourceRange, vTargetRange); - const additionMatrix = new ObjectMatrix(); - const additionRules = new Set(); - repeats.forEach((repeat) => { - const targetStartCell = repeat.repeatStartCell; - const relativeRange = repeat.relativeRange; - const sourceRange = { - startRow: sourceStartCell.row, - startColumn: sourceStartCell.col, - endColumn: sourceStartCell.col, - endRow: sourceStartCell.row, - }; - const targetRange = { - startRow: targetStartCell.row, - startColumn: targetStartCell.col, - endColumn: targetStartCell.col, - endRow: targetStartCell.row, - }; - Range.foreach(relativeRange, (row, col) => { - const sourcePositionRange = Rectangle.getPositionRange( - { - startRow: row, - startColumn: col, - endColumn: col, - endRow: row, - }, - sourceRange - ); - const { row: sourceRow, col: sourceCol } = mapFunc(sourcePositionRange.startRow, sourcePositionRange.startColumn); - // if ruleId exists, set more dv rules, if not, clear dv rules. - const ruleId = this._sheetDataValidationModel.getRuleIdByLocation(unitId, subUnitId, sourceRow, sourceCol) || ''; - const targetPositionRange = Rectangle.getPositionRange( - { - startRow: row, - startColumn: col, - endColumn: col, - endRow: row, - }, - targetRange - ); - const { row: targetRow, col: targetCol } = mapFunc(targetPositionRange.startRow, targetPositionRange.startColumn); + const additionsByRuleId = new Map(); + additionsByRuleId.set('', repeats.flatMap((repeat) => mapRange(Rectangle.getPositionRange(repeat.relativeRange, { + startRow: repeat.repeatStartCell.row, + endRow: repeat.repeatStartCell.row, + startColumn: repeat.repeatStartCell.col, + endColumn: repeat.repeatStartCell.col, + })))); - additionMatrix.setValue(targetRow, targetCol, ruleId); - additionRules.add(ruleId); + this._sheetDataValidationModel.getRules(unitId, subUnitId).forEach((rule) => { + const relativeSourceRanges = rule.ranges.flatMap((range) => { + const projected = projectRange(range); + const intersected = projected && getIntersectRange(projected, vSourceRange); + return intersected + ? [Rectangle.getRelativeRange(intersected, vSourceRange)] + : []; }); + const targetRanges = repeats.flatMap((repeat) => relativeSourceRanges.flatMap((sourceRange) => { + const copiedRange = getIntersectRange(sourceRange, repeat.relativeRange); + if (!copiedRange) { + return []; + } + return mapRange(Rectangle.getPositionRange(copiedRange, { + startRow: repeat.repeatStartCell.row, + endRow: repeat.repeatStartCell.row, + startColumn: repeat.repeatStartCell.col, + endColumn: repeat.repeatStartCell.col, + })); + })); + if (targetRanges.length) { + additionsByRuleId.set(rule.uid, targetRanges); + } }); - const additions = Array.from(additionRules).map((id) => ({ id, ranges: queryObjectMatrix(additionMatrix, (value) => value === id) })); + const additions = Array.from(additionsByRuleId, ([id, ranges]) => ({ + id, + ranges: ranges.length > 1 ? Rectangle.mergeRanges(ranges) : ranges, + })); ruleMatrixCopy.addRangeRules(additions); const diffs = ruleMatrixCopy.diff(this._sheetDataValidationModel.getRules(unitId, subUnitId)); const { redoMutations, undoMutations } = getDataValidationDiffMutations(unitId, subUnitId, diffs, this._injector, 'patched', applyType === AUTO_FILL_APPLY_TYPE.ONLY_FORMAT); @@ -106,14 +93,12 @@ export class DataValidationAutoFillController extends Disposable { id: DATA_VALIDATION_PLUGIN_NAME, onBeforeFillData: (location) => { const { source: sourceRange, unitId, subUnitId } = location; - for (const row of sourceRange.rows) { - for (const col of sourceRange.cols) { - const dv = this._sheetDataValidationModel.getRuleByLocation(unitId, subUnitId, row, col); - if (dv && dv.type === DataValidationType.CHECKBOX) { - this._autoFillService.setDisableApplyType(AUTO_FILL_APPLY_TYPE.SERIES, true); - return; - } - } + const { projectRange } = virtualizeDiscreteRanges([sourceRange]); + const hasCheckbox = this._sheetDataValidationModel.getRules(unitId, subUnitId).some((rule) => ( + rule.type === DataValidationType.CHECKBOX && rule.ranges.some((range) => projectRange(range) !== null) + )); + if (hasCheckbox) { + this._autoFillService.setDisableApplyType(AUTO_FILL_APPLY_TYPE.SERIES, true); } }, onFillData: (location, direction, applyType) => { diff --git a/packages/sheets-data-validation-ui/src/controllers/dv-copy-paste.controller.ts b/packages/sheets-data-validation-ui/src/controllers/dv-copy-paste.controller.ts index 542cd8ed52..0823e67834 100644 --- a/packages/sheets-data-validation-ui/src/controllers/dv-copy-paste.controller.ts +++ b/packages/sheets-data-validation-ui/src/controllers/dv-copy-paste.controller.ts @@ -16,13 +16,13 @@ import type { IRange, ISheetDataValidationRule, Nullable } from '@univerjs/core'; import type { ICopyPastePayload, IPasteHookValueType, ISheetDiscreteRangeLocation } from '@univerjs/sheets-ui'; -import { Disposable, Inject, Injector, IUniverInstanceService, ObjectMatrix, queryObjectMatrix, Rectangle } from '@univerjs/core'; +import { Disposable, Inject, Injector, IUniverInstanceService, Rectangle } from '@univerjs/core'; import { getSheetCommandTarget, rangeToDiscreteRange } from '@univerjs/sheets'; import { DATA_VALIDATION_PLUGIN_NAME, getDataValidationDiffMutations, SheetDataValidationModel } from '@univerjs/sheets-data-validation'; import { COPY_TYPE, getRepeatRange, ISheetClipboardService, PREDEFINED_HOOK_NAME_PASTE, virtualizeDiscreteRanges } from '@univerjs/sheets-ui'; interface ICopyInfoType { - matrix: ObjectMatrix; + rules: Map; unitId: string; subUnitId: string; } @@ -61,11 +61,11 @@ export class DataValidationCopyPasteController extends Disposable { } private _collect(unitId: string, subUnitId: string, range: IRange) { - const matrix = new ObjectMatrix(); + const rules = new Map(); this._copyInfo = { unitId, subUnitId, - matrix, + rules, }; const discreteRange = this._injector.invoke((accessor) => { @@ -74,17 +74,24 @@ export class DataValidationCopyPasteController extends Disposable { if (!discreteRange) { return; } - const { rows, cols } = discreteRange; - rows.forEach((row, rowIndex) => { - cols.forEach((col, colIndex) => { - const ruleId = this._sheetDataValidationModel.getRuleIdByLocation(unitId, subUnitId, row, col); - matrix.setValue(rowIndex, colIndex, ruleId ?? ''); + const { projectRange } = virtualizeDiscreteRanges([discreteRange]); + this._sheetDataValidationModel.getRules(unitId, subUnitId).forEach((rule) => { + const projectedRanges = rule.ranges.flatMap((ruleRange) => { + const projected = projectRange(ruleRange); + return projected ? [projected] : []; }); + if (projectedRanges.length) { + rules.set(rule.uid, projectedRanges.length > 1 ? Rectangle.mergeRanges(projectedRanges) : projectedRanges); + } }); } // eslint-disable-next-line max-lines-per-function private _generateMutations(pasteFrom: ISheetDiscreteRangeLocation, pasteTo: ISheetDiscreteRangeLocation, payload: ICopyPastePayload) { + const copyInfo = this._copyInfo; + if (!copyInfo) { + return { redos: [], undos: [] }; + } const { unitId: copyUnitId, subUnitId: copySubUnitId, range: copyRange } = pasteFrom; const { unitId: pastedUnitId, subUnitId: pastedSubUnitId, range: pastedRange } = pasteTo; const { copyType = COPY_TYPE.COPY } = payload; @@ -100,41 +107,29 @@ export class DataValidationCopyPasteController extends Disposable { return { redos: [], undos: [] }; } + const sourceVirtualRange = virtualizeDiscreteRanges([copyRange]).ranges[0]; + const targetVirtualization = virtualizeDiscreteRanges([pastedRange]); + const targetVirtualRange = targetVirtualization.ranges[0]; + const repeatRange = getRepeatRange(sourceVirtualRange, targetVirtualRange, true); + const clearTargetRanges = targetVirtualization.mapRange(targetVirtualRange); + const getTargetRanges = (sourceRanges: IRange[]) => repeatRange.flatMap(({ startRange }) => sourceRanges.flatMap((sourceRange) => ( + targetVirtualization.mapRange(Rectangle.getPositionRange(sourceRange, startRange)) + ))); + if (pastedUnitId !== copyUnitId || pastedSubUnitId !== copySubUnitId) { const ruleMatrix = this._sheetDataValidationModel.getRuleObjectMatrix(pastedUnitId, pastedSubUnitId).clone(); - const additionMatrix = new ObjectMatrix(); - const addRules = new Set(); - - const { ranges: [vCopyRange, vPastedRange], mapFunc } = virtualizeDiscreteRanges([copyRange, pastedRange]); - - const repeatRange = getRepeatRange(vCopyRange, vPastedRange, true); const additionRules: Map = new Map(); + const additions = [{ id: '', ranges: clearTargetRanges }]; - repeatRange.forEach(({ startRange }) => { - this._copyInfo?.matrix.forValue((row, col, ruleId) => { - const range = Rectangle.getPositionRange( - { - startRow: row, - endRow: row, - startColumn: col, - endColumn: col, - }, - startRange - ); - const transformedRuleId = `${copySubUnitId}-${ruleId}`; - const oldRule = this._sheetDataValidationModel.getRuleById(copyUnitId, copySubUnitId, ruleId); - - if (!this._sheetDataValidationModel.getRuleById(pastedUnitId, pastedSubUnitId, transformedRuleId) && oldRule) { - additionRules.set(transformedRuleId, { ...oldRule, uid: transformedRuleId }); - } - - const { row: startRow, col: startColumn } = mapFunc(range.startRow, range.startColumn); - addRules.add(transformedRuleId); - additionMatrix.setValue(startRow, startColumn, transformedRuleId); - }); + copyInfo.rules.forEach((ranges, ruleId) => { + const transformedRuleId = `${copySubUnitId}-${ruleId}`; + const oldRule = this._sheetDataValidationModel.getRuleById(copyUnitId, copySubUnitId, ruleId); + if (!this._sheetDataValidationModel.getRuleById(pastedUnitId, pastedSubUnitId, transformedRuleId) && oldRule) { + additionRules.set(transformedRuleId, { ...oldRule, uid: transformedRuleId }); + } + additions.push({ id: transformedRuleId, ranges: getTargetRanges(ranges) }); }); - const additions = Array.from(addRules).map((id) => ({ id, ranges: queryObjectMatrix(additionMatrix, (value) => value === id) })); ruleMatrix.addRangeRules(additions); const { redoMutations, undoMutations } = getDataValidationDiffMutations( @@ -149,17 +144,10 @@ export class DataValidationCopyPasteController extends Disposable { if (copyType === COPY_TYPE.CUT) { // Delete rules in copy range const copySheetRuleMatrix = this._sheetDataValidationModel.getRuleObjectMatrix(copyUnitId, copySubUnitId).clone(); - const deleteRangeStartCell = mapFunc(vCopyRange.startRow, vCopyRange.startColumn); - const deleteRangeEndCell = mapFunc(vCopyRange.endRow, vCopyRange.endColumn); copySheetRuleMatrix.addRangeRules([ { id: '', - ranges: [{ - startRow: deleteRangeStartCell.row, - endRow: deleteRangeEndCell.row, - startColumn: deleteRangeStartCell.col, - endColumn: deleteRangeEndCell.col, - }], + ranges: virtualizeDiscreteRanges([copyRange]).mapRange(sourceVirtualRange), }, ]); const { redoMutations: cutRedos, undoMutations: cutUndos } = getDataValidationDiffMutations( @@ -180,31 +168,10 @@ export class DataValidationCopyPasteController extends Disposable { }; } else { const ruleMatrix = this._sheetDataValidationModel.getRuleObjectMatrix(copyUnitId, copySubUnitId).clone(); - const additionMatrix = new ObjectMatrix(); - const additionRules = new Set(); - - const { ranges: [vCopyRange, vPastedRange], mapFunc } = virtualizeDiscreteRanges([copyRange, pastedRange]); - - const repeatRange = getRepeatRange(vCopyRange, vPastedRange, true); - - repeatRange.forEach(({ startRange }) => { - this._copyInfo?.matrix.forValue((row, col, ruleId) => { - const range = Rectangle.getPositionRange( - { - startRow: row, - endRow: row, - startColumn: col, - endColumn: col, - }, - startRange - ); - const { row: startRow, col: startColumn } = mapFunc(range.startRow, range.startColumn); - additionMatrix.setValue(startRow, startColumn, ruleId); - additionRules.add(ruleId); - }); - }); - - const additions = Array.from(additionRules).map((id) => ({ id, ranges: queryObjectMatrix(additionMatrix, (value) => value === id) })); + const additions = [ + { id: '', ranges: clearTargetRanges }, + ...Array.from(copyInfo.rules, ([id, ranges]) => ({ id, ranges: getTargetRanges(ranges) })), + ]; ruleMatrix.addRangeRules(additions); const { redoMutations, undoMutations } = getDataValidationDiffMutations( pastedUnitId, diff --git a/packages/sheets-ui/src/controllers/utils/__tests__/range-tools.spec.ts b/packages/sheets-ui/src/controllers/utils/__tests__/range-tools.spec.ts index be01327aab..d4ca491a18 100644 --- a/packages/sheets-ui/src/controllers/utils/__tests__/range-tools.spec.ts +++ b/packages/sheets-ui/src/controllers/utils/__tests__/range-tools.spec.ts @@ -38,4 +38,25 @@ describe('range-tools', () => { expect(mapFunc(0, 0)).toEqual({ row: 5, col: 10 }); expect(mapFunc(2, 2)).toEqual({ row: 9, col: 30 }); }); + + it('maps rectangles between virtual and discrete coordinates', () => { + const result = virtualizeDiscreteRanges([{ + rows: [5, 6, 9], + cols: [10, 11, 20], + }]); + + expect(result).toHaveProperty('mapRange'); + expect(result.mapRange({ startRow: 0, endRow: 2, startColumn: 0, endColumn: 2 })).toEqual([ + { startRow: 5, endRow: 6, startColumn: 10, endColumn: 11 }, + { startRow: 5, endRow: 6, startColumn: 20, endColumn: 20 }, + { startRow: 9, endRow: 9, startColumn: 10, endColumn: 11 }, + { startRow: 9, endRow: 9, startColumn: 20, endColumn: 20 }, + ]); + expect(result.projectRange({ startRow: 6, endRow: 9, startColumn: 11, endColumn: 20 })).toEqual({ + startRow: 1, + endRow: 2, + startColumn: 1, + endColumn: 2, + }); + }); }); diff --git a/packages/sheets-ui/src/controllers/utils/range-tools.ts b/packages/sheets-ui/src/controllers/utils/range-tools.ts index d461eda3f1..4330c7bca2 100644 --- a/packages/sheets-ui/src/controllers/utils/range-tools.ts +++ b/packages/sheets-ui/src/controllers/utils/range-tools.ts @@ -17,9 +17,58 @@ import type { IRange } from '@univerjs/core'; import type { IDiscreteRange } from '@univerjs/sheets'; +interface ILine { + start: number; + end: number; +} + +function groupConsecutive(values: number[], start: number, end: number): ILine[] { + const groups: ILine[] = []; + for (let index = start; index <= end; index++) { + const value = values[index]; + const previous = groups[groups.length - 1]; + if (previous && value === previous.end + 1) { + previous.end = value; + } else { + groups.push({ start: value, end: value }); + } + } + return groups; +} + +function projectLine(values: number[], start: number, end: number): ILine | null { + let low = 0; + let high = values.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (values[middle] < start) { + low = middle + 1; + } else { + high = middle; + } + } + const first = low; + if (first === values.length || values[first] > end) { + return null; + } + + high = values.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (values[middle] <= end) { + low = middle + 1; + } else { + high = middle; + } + } + return { start: first, end: low - 1 }; +} + export function virtualizeDiscreteRanges(ranges: IDiscreteRange[]): { ranges: IRange[]; mapFunc: (row: number, col: number) => { row: number; col: number }; + mapRange: (range: IRange) => IRange[]; + projectRange: (range: IRange) => IRange | null; } { let totalRows: number[] = []; let totalCols: number[] = []; @@ -51,5 +100,22 @@ export function virtualizeDiscreteRanges(ranges: IDiscreteRange[]): { col: totalCols[col], } ), + mapRange: (range) => { + const rowGroups = groupConsecutive(totalRows, range.startRow, range.endRow); + const columnGroups = groupConsecutive(totalCols, range.startColumn, range.endColumn); + return rowGroups.flatMap((row) => columnGroups.map((column) => ({ + startRow: row.start, + endRow: row.end, + startColumn: column.start, + endColumn: column.end, + }))); + }, + projectRange: (range) => { + const row = projectLine(totalRows, range.startRow, range.endRow); + const column = projectLine(totalCols, range.startColumn, range.endColumn); + return row && column + ? { startRow: row.start, endRow: row.end, startColumn: column.start, endColumn: column.end } + : null; + }, }; } diff --git a/packages/sheets/src/services/ref-range/__tests__/util-behavior.spec.ts b/packages/sheets/src/services/ref-range/__tests__/util-behavior.spec.ts index 5473dd7234..f437c07261 100644 --- a/packages/sheets/src/services/ref-range/__tests__/util-behavior.spec.ts +++ b/packages/sheets/src/services/ref-range/__tests__/util-behavior.spec.ts @@ -23,8 +23,8 @@ import type { } from '../../../basics'; import type { IMoveRangeMutationParams } from '../../../commands/mutations/move-range.mutation'; import type { IMoveColumnsMutationParams } from '../../../commands/mutations/move-rows-cols.mutation'; -import { Direction, IUniverInstanceService, MAX_COLUMN_COUNT, MAX_ROW_COUNT, RANGE_TYPE } from '@univerjs/core'; -import { describe, expect, it } from 'vitest'; +import { Direction, IUniverInstanceService, MAX_COLUMN_COUNT, MAX_ROW_COUNT, Range, RANGE_TYPE } from '@univerjs/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { InsertColMutation, InsertRowMutation } from '../../../commands/mutations/insert-row-col.mutation'; import { MoveRangeMutation } from '../../../commands/mutations/move-range.mutation'; import { MoveColsMutation } from '../../../commands/mutations/move-rows-cols.mutation'; @@ -71,6 +71,18 @@ const formatRanges = (ranges: IRange[]) => .map((range) => [range.startRow, range.endRow, range.startColumn, range.endColumn] as const) .sort((prev, aft) => prev[0] - aft[0] || prev[2] - aft[2] || prev[1] - aft[1] || prev[3] - aft[3]); +const formatCoveredCells = (ranges: IRange[]) => { + const cells = new Set(); + ranges.forEach((range) => { + for (let row = range.startRow; row <= range.endRow; row++) { + for (let column = range.startColumn; column <= range.endColumn; column++) { + cells.add(`${row}:${column}`); + } + } + }); + return Array.from(cells).sort(); +}; + const selectionDeps = (range?: IRange) => ({ selectionManagerService: { getCurrentSelections: () => range ? [{ range }] : [], @@ -78,6 +90,50 @@ const selectionDeps = (range?: IRange) => ({ } as any); describe('ref range util behavior coverage', () => { + afterEach(() => vi.restoreAllMocks()); + + it('transforms large ranges without enumerating individual cells', () => { + const foreach = vi.spyOn(Range, 'foreach').mockImplementation(() => { + throw new Error('must not enumerate cells'); + }); + const targetRange = r(0, MAX_ROW_COUNT - 1, 0, MAX_COLUMN_COUNT - 1); + + expect(() => handleMoveRowsCommon({ + id: EffectRefRangId.MoveRowsCommandId, + params: { fromRange: r(2, 3, 0, MAX_COLUMN_COUNT - 1), toRange: r(8, 9, 0, MAX_COLUMN_COUNT - 1) }, + }, targetRange)).not.toThrow(); + expect(() => handleMoveColsCommon({ + id: EffectRefRangId.MoveColsCommandId, + params: { fromRange: r(0, MAX_ROW_COUNT - 1, 2, 3), toRange: r(0, MAX_ROW_COUNT - 1, 8, 9) }, + }, targetRange)).not.toThrow(); + expect(() => handleReorderRangeCommon({ + id: EffectRefRangId.ReorderRangeCommandId, + params: { unitId: 'unit', subUnitId: 'sheet', range: r(2, 3, 0, MAX_COLUMN_COUNT - 1), order: { 2: 3, 3: 2 } }, + }, targetRange)).not.toThrow(); + expect(() => handleMoveRangeCommon({ + id: EffectRefRangId.MoveRangeCommandId, + params: { fromRange: r(2, 3, 2, 3), toRange: r(8, 9, 8, 9) }, + }, targetRange)).not.toThrow(); + expect(() => handleInsertRangeMoveDownCommon({ + id: EffectRefRangId.InsertRangeMoveDownCommandId, + params: { range: r(2, 3, 2, 3) }, + }, targetRange)).not.toThrow(); + expect(() => handleInsertRangeMoveRightCommon({ + id: EffectRefRangId.InsertRangeMoveRightCommandId, + params: { range: r(2, 3, 2, 3) }, + }, targetRange)).not.toThrow(); + expect(() => handleDeleteRangeMoveLeftCommon({ + id: EffectRefRangId.DeleteRangeMoveLeftCommandId, + params: { range: r(2, 3, 2, 3) }, + }, targetRange)).not.toThrow(); + expect(() => handleDeleteRangeMoveUpCommon({ + id: EffectRefRangId.DeleteRangeMoveUpCommandId, + params: { range: r(2, 3, 2, 3) }, + }, targetRange)).not.toThrow(); + + foreach.mockRestore(); + }); + describe('range type normalization', () => { it('expands typed and inferred column/row/all ranges for internal calculation', () => { expect(handleRangeTypeInput(r(Number.NaN, Number.NaN, 2, 4))).toEqual(r(0, MAX_ROW_COUNT - 1, 2, 4)); @@ -182,19 +238,19 @@ describe('ref range util behavior coverage', () => { }); it('removes deleted cells and moves the trailing cells into the gap', () => { - expect(formatRanges(handleDeleteRangeMoveLeftCommon({ + expect(formatCoveredCells(handleDeleteRangeMoveLeftCommon({ id: EffectRefRangId.DeleteRangeMoveLeftCommandId, params: { range: r(1, 2, 2, 3) }, - }, r(0, 3, 1, 5)))).toEqual(formatRanges([ + }, r(0, 3, 1, 5)))).toEqual(formatCoveredCells([ r(0, 3, 1, 3), r(0, 0, 4, 5), r(3, 3, 4, 5), ])); - expect(formatRanges(handleDeleteRangeMoveUpCommon({ + expect(formatCoveredCells(handleDeleteRangeMoveUpCommon({ id: EffectRefRangId.DeleteRangeMoveUpCommandId, params: { range: r(2, 3, 1, 2) }, - }, r(1, 5, 0, 3)))).toEqual(formatRanges([ + }, r(1, 5, 0, 3)))).toEqual(formatCoveredCells([ r(1, 3, 0, 3), r(4, 5, 0, 0), r(4, 5, 3, 3), @@ -293,10 +349,10 @@ describe('ref range util behavior coverage', () => { }); it('uses common range transformations for split range operations', () => { - expect(formatRanges(handleCommonDefaultRangeChangeWithEffectRefCommands(r(0, 3, 1, 5), { + expect(formatCoveredCells(handleCommonDefaultRangeChangeWithEffectRefCommands(r(0, 3, 1, 5), { id: EffectRefRangId.DeleteRangeMoveLeftCommandId, params: { range: r(1, 2, 2, 3) }, - }))).toEqual(formatRanges([ + }))).toEqual(formatCoveredCells([ r(0, 3, 1, 3), r(0, 0, 4, 5), r(3, 3, 4, 5), diff --git a/packages/sheets/src/services/ref-range/__tests__/utils.spec.ts b/packages/sheets/src/services/ref-range/__tests__/utils.spec.ts index 62e27465fa..c066e31434 100644 --- a/packages/sheets/src/services/ref-range/__tests__/utils.spec.ts +++ b/packages/sheets/src/services/ref-range/__tests__/utils.spec.ts @@ -66,6 +66,18 @@ const countRange = ([a, b, c, d]: readonly [number, number, number, number]) => const formatRanges = (ranges: IRange[]) => ranges.map((range) => [range.startRow, range.endRow, range.startColumn, range.endColumn] as const).sort((prev, aft) => countRange(prev) - countRange(aft)); +const formatCoveredCells = (ranges: IRange[]) => { + const cells = new Set(); + ranges.forEach((range) => { + for (let row = range.startRow; row <= range.endRow; row++) { + for (let column = range.startColumn; column <= range.endColumn; column++) { + cells.add(`${row}:${column}`); + } + } + }); + return Array.from(cells).sort(); +}; + describe('test ref-range move', () => { describe('range type and effect-range helpers', () => { it('normalizes row, column, all, and NaN range coordinates', () => { @@ -535,7 +547,7 @@ describe('test ref-range move', () => { targetRange1_2 ); - expect(resRange1_1).toEqual( + expect(formatRanges(resRange1_1)).toEqual(formatRanges( [ { endColumn: 10, @@ -550,8 +562,8 @@ describe('test ref-range move', () => { startRow: 2, }, ] - ); - expect(resRange1_2).toEqual([ + )); + expect(formatRanges(resRange1_2)).toEqual(formatRanges([ { endColumn: 10, endRow: 22, @@ -564,7 +576,7 @@ describe('test ref-range move', () => { startColumn: 0, startRow: 6, }, - ]); + ])); }); }); }); @@ -1621,8 +1633,8 @@ describe('test ref-range move', () => { }] ) ); - expect(formatRanges(res2)).toEqual( - formatRanges( + expect(formatCoveredCells(res2)).toEqual( + formatCoveredCells( [{ endColumn: 10, endRow: 10, diff --git a/packages/sheets/src/services/ref-range/util.ts b/packages/sheets/src/services/ref-range/util.ts index 1bd6b77996..1bee97aa68 100644 --- a/packages/sheets/src/services/ref-range/util.ts +++ b/packages/sheets/src/services/ref-range/util.ts @@ -44,7 +44,7 @@ import type { IRemoveRowColCommand, IReorderRangeCommand, } from './type'; -import { Direction, getIntersectRange, IUniverInstanceService, MAX_COLUMN_COUNT, MAX_ROW_COUNT, mergeIntervals, ObjectMatrix, queryObjectMatrix, Range, RANGE_TYPE, Rectangle } from '@univerjs/core'; +import { Direction, getIntersectRange, IUniverInstanceService, MAX_COLUMN_COUNT, MAX_ROW_COUNT, mergeIntervals, ObjectMatrix, RANGE_TYPE, Rectangle } from '@univerjs/core'; import { DeleteRangeMoveLeftCommand } from '../../commands/commands/delete-range-move-left.command'; import { DeleteRangeMoveUpCommand } from '../../commands/commands/delete-range-move-up.command'; import { InsertRangeMoveDownCommand } from '../../commands/commands/insert-range-move-down.command'; @@ -120,6 +120,67 @@ interface ILine { start: number; end: number; } + +interface ILineTransform extends ILine { + offset: number; +} + +function mergeAndSortRanges(ranges: IRange[]): IRange[] { + const merged = ranges.length > 1 ? Rectangle.mergeRanges(ranges) : ranges; + return sortRanges(merged); +} + +function sortRanges(ranges: IRange[]): IRange[] { + return ranges.sort((a, b) => a.startRow - b.startRow || a.startColumn - b.startColumn || a.endRow - b.endRow || a.endColumn - b.endColumn); +} + +function translateRange(range: IRange, rowOffset: number, columnOffset: number): IRange { + return { + startRow: range.startRow + rowOffset, + endRow: range.endRow + rowOffset, + startColumn: range.startColumn + columnOffset, + endColumn: range.endColumn + columnOffset, + }; +} + +function moveRangeAlongAxis(targetRange: IRange, from: number, count: number, to: number, isRow: boolean): IRange[] { + let transforms: ILineTransform[]; + if (from > to) { + transforms = [ + { start: Number.NEGATIVE_INFINITY, end: to - 1, offset: 0 }, + { start: to, end: from - 1, offset: count }, + { start: from, end: from + count - 1, offset: to - from }, + { start: from + count, end: Number.POSITIVE_INFINITY, offset: 0 }, + ]; + } else { + if (from + count > to) { + throw new Error('Invalid move operation'); + } + transforms = [ + { start: Number.NEGATIVE_INFINITY, end: from - 1, offset: 0 }, + { start: from, end: from + count - 1, offset: to - from - count }, + { start: from + count, end: to - 1, offset: -count }, + { start: to, end: Number.POSITIVE_INFINITY, offset: 0 }, + ]; + } + + const rangeStart = isRow ? targetRange.startRow : targetRange.startColumn; + const rangeEnd = isRow ? targetRange.endRow : targetRange.endColumn; + const ranges = transforms.flatMap(({ start, end, offset }) => { + const intersectStart = Math.max(rangeStart, start); + const intersectEnd = Math.min(rangeEnd, end); + if (intersectStart > intersectEnd) { + return []; + } + + const range = isRow + ? { startRow: intersectStart, endRow: intersectEnd, startColumn: targetRange.startColumn, endColumn: targetRange.endColumn } + : { startRow: targetRange.startRow, endRow: targetRange.endRow, startColumn: intersectStart, endColumn: intersectEnd }; + return [translateRange(range, isRow ? offset : 0, isRow ? 0 : offset)]; + }); + + return mergeAndSortRanges(ranges); +} /** * see docs/tldr/ref-range/move-rows-cols.tldr */ @@ -276,17 +337,7 @@ export const handleMoveRowsCommon = (params: IMoveRowsCommand, targetRange: IRan const count = fromRange.endRow - fromRange.startRow + 1; const toRow = toRange.startRow; - const matrix = new ObjectMatrix(); - - Range.foreach(targetRange, (row, col) => { - matrix.setValue(row, col, 1); - }); - - matrix.moveRows(fromRow, count, toRow); - - // TODO@zhangw try to remove queryObjectMatrix, this could case memory out of use in large range. - const res = queryObjectMatrix(matrix, (value) => value === 1); - return res; + return moveRangeAlongAxis(targetRange, fromRow, count, toRow, true); }; export const handleReorderRangeCommon = (param: IReorderRangeCommand, targetRange: IRange) => { @@ -294,26 +345,25 @@ export const handleReorderRangeCommon = (param: IReorderRangeCommand, targetRang if (!range || !order) { return [targetRange]; } - const matrix = new ObjectMatrix(); - Range.foreach(targetRange, (row, col) => { - matrix.setValue(row, col, 1); - }); + const overwrittenRows: IRange[] = []; + const additions: IRange[] = []; + const startColumn = Math.max(range.startColumn, targetRange.startColumn); + const endColumn = Math.min(range.endColumn, targetRange.endColumn); - const cacheMatrix = new ObjectMatrix(); - Range.foreach(range, (row, col) => { - if (Object.prototype.hasOwnProperty.call(order, row)) { - const targetRow = order[row]; - const cloneCell = matrix.getValue(targetRow, col) ?? 0; - cacheMatrix.setValue(row, col, cloneCell); + Object.keys(order).forEach((key) => { + const row = Number(key); + if (row < range.startRow || row > range.endRow) { + return; + } + + overwrittenRows.push({ startRow: row, endRow: row, startColumn: range.startColumn, endColumn: range.endColumn }); + const sourceRow = order[row]; + if (sourceRow >= targetRange.startRow && sourceRow <= targetRange.endRow && startColumn <= endColumn) { + additions.push({ startRow: row, endRow: row, startColumn, endColumn }); } }); - cacheMatrix.forValue((row, col, cellData) => { - matrix.setValue(row, col, cellData); - }); - // TODO@zhangw try to remove queryObjectMatrix, this could case memory out of use in large range. - const res = queryObjectMatrix(matrix, (value) => value === 1); - return res; + return mergeAndSortRanges([...Rectangle.subtractMulti([targetRange], overwrittenRows), ...additions]); }; export const handleMoveCols = (params: IMoveColsCommand, targetRange: IRange): IOperator[] => { @@ -355,15 +405,7 @@ export const handleMoveColsCommon = (params: IMoveColsCommand, targetRange: IRan const count = fromRange.endColumn - fromRange.startColumn + 1; const toCol = toRange.startColumn; - const matrix = new ObjectMatrix(); - - Range.foreach(targetRange, (row, col) => { - matrix.setValue(row, col, 1); - }); - - matrix.moveColumns(fromCol, count, toCol); - // TODO@zhangw try to remove queryObjectMatrix, this could case memory out of use in large range. - return queryObjectMatrix(matrix, (value) => value === 1); + return moveRangeAlongAxis(targetRange, fromCol, count, toCol, false); }; export const handleMoveRange = (param: IMoveRangeCommand, targetRange: IRange) => { @@ -414,41 +456,13 @@ export const handleMoveRangeCommon = (param: IMoveRangeCommand, targetRange: IRa return [positionRange]; } - const matrix = new ObjectMatrix(); - - Range.foreach(targetRange, (row, col) => { - matrix.setValue(row, col, 1); - }); - - const fromMatrix = new ObjectMatrix(); - const loopFromRange = getIntersectRange(fromRange, targetRange); - - loopFromRange && Range.foreach(loopFromRange, (row, col) => { - if (matrix.getValue(row, col)) { - matrix.setValue(row, col, undefined); - fromMatrix.setValue(row, col, 1); - } - }); - const columnOffset = toRange.startColumn - fromRange.startColumn; const rowOffset = toRange.startRow - fromRange.startRow; + const movedRange = getIntersectRange(fromRange, targetRange); + const remainingRanges = Rectangle.subtractMulti([targetRange], [fromRange, toRange]); + const translatedRanges = movedRange ? [translateRange(movedRange, rowOffset, columnOffset)] : []; - const loopToRange = { - startColumn: toRange.startColumn - columnOffset, - endColumn: toRange.endColumn - columnOffset, - startRow: toRange.startRow - rowOffset, - endRow: toRange.endRow - rowOffset, - }; - - loopToRange && Range.foreach(loopToRange, (row, col) => { - const targetRow = row + rowOffset; - const targetCol = col + columnOffset; - matrix.setValue(targetRow, targetCol, fromMatrix.getValue(row, col) ?? 0); - }); - - // TODO@zhangw try to remove queryObjectMatrix, this could case memory out of use in large range. - const res = queryObjectMatrix(matrix, (value) => value === 1); - return res; + return mergeAndSortRanges([...remainingRanges, ...translatedRanges]); }; // see docs/tldr/ref-range/remove-rows-cols.tldr @@ -769,19 +783,7 @@ export const handleInsertRangeMoveDownCommon = (param: IInsertRangeMoveDownComma return [targetRange]; } - const matrix = new ObjectMatrix(); - noMoveRanges.forEach((noMoveRange) => { - Range.foreach(noMoveRange, (row, col) => { - matrix.setValue(row, col, 1); - }); - }); - - targetMoveRange && Range.foreach(targetMoveRange, (row, col) => { - matrix.setValue(row + moveCount, col, 1); - }); - - // TODO@zhangw try to remove queryObjectMatrix, this could case memory out of use in large range. - return queryObjectMatrix(matrix, (v) => v === 1); + return sortRanges([...noMoveRanges, translateRange(targetMoveRange, moveCount, 0)]); }; export const handleInsertRangeMoveRight = (param: IInsertRangeMoveRightCommand, targetRange: IRange) => { @@ -820,19 +822,7 @@ export const handleInsertRangeMoveRightCommon = (param: IInsertRangeMoveRightCom return [targetRange]; } - const matrix = new ObjectMatrix(); - noMoveRanges.forEach((noMoveRange) => { - Range.foreach(noMoveRange, (row, col) => { - matrix.setValue(row, col, 1); - }); - }); - - targetMoveRange && Range.foreach(targetMoveRange, (row, col) => { - matrix.setValue(row, col + moveCount, 1); - }); - - // TODO@zhangw try to remove queryObjectMatrix, this could case memory out of use in large range. - return queryObjectMatrix(matrix, (v) => v === 1); + return sortRanges([...noMoveRanges, translateRange(targetMoveRange, 0, moveCount)]); }; export const handleDeleteRangeMoveLeft = (param: IDeleteRangeMoveLeftCommand, targetRange: IRange) => { @@ -872,34 +862,18 @@ export const handleDeleteRangeMoveLeftCommon = (param: IDeleteRangeMoveLeftComma }; const moveCount = range.endColumn - range.startColumn + 1; - // this range need delete - const targetDeleteRange = getIntersectRange(range, targetRange); - const noMoveRanges = Rectangle.subtract(targetRange, rightRange); const targetMoveRange = getIntersectRange(rightRange, targetRange); - if (!targetDeleteRange && !targetMoveRange) { + if (!targetMoveRange) { return [targetRange]; } - const matrix = new ObjectMatrix(); + const movedRanges = targetMoveRange + ? Rectangle.subtract(targetMoveRange, range).map((target) => translateRange(target, 0, -moveCount)) + : []; - targetMoveRange && Range.foreach(targetMoveRange, (row, col) => { - matrix.setValue(row, col - moveCount, 1); - }); - - targetDeleteRange && Range.foreach(targetDeleteRange, (row, col) => { - matrix.setValue(row, col - moveCount, 0); - }); - - noMoveRanges.forEach((noMoveRange) => { - Range.foreach(noMoveRange, (row, col) => { - matrix.setValue(row, col, 1); - }); - }); - - // TODO@zhangw try to remove queryObjectMatrix, this could case memory out of use in large range. - return queryObjectMatrix(matrix, (v) => v === 1); + return mergeAndSortRanges([...noMoveRanges, ...movedRanges]); }; export const handleDeleteRangeMoveUp = (param: IDeleteRangeMoveUpCommand, targetRange: IRange) => { @@ -935,34 +909,18 @@ export const handleDeleteRangeMoveUpCommon = (param: IDeleteRangeMoveUpCommand, }; const moveCount = range.endRow - range.startRow + 1; - // this range need delete - const targetDeleteRange = getIntersectRange(range, targetRange); - const noMoveRanges = Rectangle.subtract(targetRange, bottomRange); const targetMoveRange = getIntersectRange(bottomRange, targetRange); - if (!targetDeleteRange && !targetMoveRange) { + if (!targetMoveRange) { return [targetRange]; } - const matrix = new ObjectMatrix(); + const movedRanges = targetMoveRange + ? Rectangle.subtract(targetMoveRange, range).map((target) => translateRange(target, -moveCount, 0)) + : []; - targetMoveRange && Range.foreach(targetMoveRange, (row, col) => { - matrix.setValue(row - moveCount, col, 1); - }); - - targetDeleteRange && Range.foreach(targetDeleteRange, (row, col) => { - matrix.setValue(row - moveCount, col, 0); - }); - - noMoveRanges.forEach((noMoveRange) => { - Range.foreach(noMoveRange, (row, col) => { - matrix.setValue(row, col, 1); - }); - }); - - // TODO@zhangw try to remove queryObjectMatrix, this could case memory out of use in large range. - return queryObjectMatrix(matrix, (v) => v === 1); + return mergeAndSortRanges([...noMoveRanges, ...movedRanges]); }; export const handleRemoveRowCommon = (param: IRemoveRowColCommandInterceptParams, targetRange: IRange) => {