fix(sheets): stabilize embedded clipboard and autofill (#7307)

This commit is contained in:
Univer
2026-07-22 22:59:10 +08:00
committed by GitHub
parent 23029b5829
commit 73a3de7232
11 changed files with 412 additions and 284 deletions
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { ICellData, Injector, IStyleData, Nullable, Univer, Workbook } from '@univerjs/core';
import type { ICellData, Injector, IRange, IStyleData, Nullable, Workbook } from '@univerjs/core';
import {
CellValueType,
ICommandService,
@@ -45,8 +45,10 @@ import {
SheetsSelectionsService,
} from '@univerjs/sheets';
import { IPlatformService, IShortcutService, PlatformService, ShortcutService } from '@univerjs/ui';
import { beforeEach, describe, expect, it } from 'vitest';
import { AutoFillUIController } from '../../../controllers/auto-fill-ui.controller';
import { Subject } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AutoFillRenderController, AutoFillUIController, detectAutoFillRange } from '../../../controllers/auto-fill-ui.controller';
import { createRenderTestBed } from '../../../controllers/render-controllers/__tests__/render-test-bed';
import { EditorBridgeService, IEditorBridgeService } from '../../../services/editor-bridge.service';
import { ISheetSelectionRenderService } from '../../../services/selection/base-selection-render.service';
import { SheetSelectionRenderService } from '../../../services/selection/selection-render.service';
@@ -55,10 +57,72 @@ import { SheetsRenderService } from '../../../services/sheets-render.service';
import { createCommandTestBed } from './create-command-test-bed';
class mockSheetsRenderService {
registerSkeletonChangingMutations(id: string) {
registerSkeletonChangingMutations(_id: string) {
}
}
describe('AutoFillRenderController', () => {
it('routes fill events to the workbook owned by the render context', () => {
const selectionFilled$ = new Subject<IRange | null>();
const getSelectionControls = vi.fn(() => [{
model: {
startColumn: 1,
endColumn: 1,
startRow: 2,
endRow: 2,
},
selectionFilled$,
fillControl: {
onDblclick$: {
subscribeEvent: vi.fn(() => ({ dispose: vi.fn() })),
},
onPointerDown$: {
subscribeEvent: vi.fn(() => ({ dispose: vi.fn() })),
},
},
}]);
const testBed = createRenderTestBed({
dependencies: [
[ISheetSelectionRenderService, { useValue: { getSelectionControls } }],
[IEditorBridgeService, {
useValue: {
isVisible: () => ({ visible: false }),
},
}],
],
});
const executeCommand = vi.spyOn(testBed.commandService, 'executeCommand').mockResolvedValue(true);
const controller = testBed.injector.createInstance(AutoFillRenderController, testBed.context);
selectionFilled$.next({
startColumn: 1,
endColumn: 1,
startRow: 2,
endRow: 5,
});
expect(executeCommand).toHaveBeenCalledWith(AutoFillCommand.id, {
sourceRange: {
startColumn: 1,
endColumn: 1,
startRow: 2,
endRow: 2,
},
targetRange: {
startColumn: 1,
endColumn: 1,
startRow: 2,
endRow: 5,
},
unitId: testBed.context.unitId,
subUnitId: testBed.sheet.getActiveSheet().getSheetId(),
});
controller.dispose();
testBed.univer.dispose();
});
});
const TEST_WORKBOOK_DATA = {
id: 'test',
appVersion: '3.0.0-alpha',
@@ -263,10 +327,8 @@ const TEST_WORKBOOK_DATA = {
};
describe('Test auto fill rules in controller', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
let autoFillController: AutoFillUIController;
let themeService: ThemeService;
let getValues: (
@@ -282,8 +344,6 @@ describe('Test auto fill rules in controller', () => {
endRow: number,
endColumn: number
) => Array<Array<Nullable<IStyleData>>> | undefined;
let selectionManagerService: SheetsSelectionsService;
beforeEach(() => {
const testBed = createCommandTestBed(TEST_WORKBOOK_DATA, [
[DocSelectionManagerService],
@@ -300,7 +360,6 @@ describe('Test auto fill rules in controller', () => {
[AutoFillController],
[AutoFillUIController],
]);
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
@@ -309,8 +368,7 @@ describe('Test auto fill rules in controller', () => {
const newTheme = set(theme, 'black', '#35322b');
themeService.setTheme(newTheme);
autoFillController = get(AutoFillUIController);
selectionManagerService = get(SheetsSelectionsService);
get(AutoFillUIController);
commandService.registerCommand(SetRangeValuesMutation);
commandService.registerCommand(SetSelectionsOperation);
commandService.registerCommand(RemoveWorksheetMergeMutation);
@@ -655,11 +713,19 @@ describe('Test auto fill rules in controller', () => {
const workbook = get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
if (!workbook) throw new Error('This is an error');
// test other string
(autoFillController as any)._handleDbClickFill({
const sourceRange: IRange = {
startRow: 10,
startColumn: 1,
endRow: 10,
endColumn: 1,
};
const worksheet = workbook.getSheetBySheetId('sheet1');
if (!worksheet) throw new Error('Worksheet sheet1 does not exist');
await commandService.executeCommand(AutoFillCommand.id, {
sourceRange,
targetRange: detectAutoFillRange(sourceRange, worksheet),
unitId: workbook.getUnitId(),
subUnitId: worksheet.getSheetId(),
});
expect(workbook.getSheetBySheetId('sheet1')?.getCell(11, 1)?.v).toBe(2);
expect(workbook.getSheetBySheetId('sheet1')?.getCell(12, 1)?.v).toBe(2);
@@ -18,6 +18,7 @@ import type { IRange } from '@univerjs/core';
import {
ICommandService,
IConfirmService,
IPermissionService,
IUniverInstanceService,
LocaleService,
} from '@univerjs/core';
@@ -26,6 +27,7 @@ import {
DeleteRangeMoveUpCommand,
InsertRangeMoveDownCommand,
InsertRangeMoveRightCommand,
SheetPermissionCheckController,
SheetsSelectionsService,
} from '@univerjs/sheets';
import * as sheets from '@univerjs/sheets';
@@ -257,6 +259,13 @@ describe('clipboard command branches', () => {
const accessor = createAccessor([
[ISheetClipboardService, clipboardService],
[IClipboardInterfaceService, { supportClipboard: true, read }],
[IUniverInstanceService, { getCurrentUnitOfType: vi.fn(() => null) }],
[IPermissionService, { getPermissionPoint: vi.fn(() => undefined) }],
[LocaleService, { t: vi.fn((key: string) => key) }],
[SheetPermissionCheckController, {
permissionCheckWithRanges: vi.fn(() => true),
blockExecuteWithoutPermission: vi.fn(),
}],
]);
expect(await SheetCopyCommand.handler(accessor)).toBe(true);
@@ -267,6 +276,13 @@ describe('clipboard command branches', () => {
const accessorWithoutClipboardAPI = createAccessor([
[ISheetClipboardService, clipboardService],
[IClipboardInterfaceService, { supportClipboard: false, read: vi.fn(async () => []) }],
[IUniverInstanceService, { getCurrentUnitOfType: vi.fn(() => null) }],
[IPermissionService, { getPermissionPoint: vi.fn(() => undefined) }],
[LocaleService, { t: vi.fn((key: string) => key) }],
[SheetPermissionCheckController, {
permissionCheckWithRanges: vi.fn(() => true),
blockExecuteWithoutPermission: vi.fn(),
}],
]);
expect(await SheetPasteCommand.handler(accessorWithoutClipboardAPI, { value: 'value-only' })).toBe(true);
expect(pasteByCopyId).toHaveBeenCalledWith('copy-1', 'value-only');
@@ -277,6 +293,13 @@ describe('clipboard command branches', () => {
copyContentCache: () => ({ getLastCopyId: () => '' }),
}],
[IClipboardInterfaceService, { supportClipboard: true, read: vi.fn(async () => []) }],
[IUniverInstanceService, { getCurrentUnitOfType: vi.fn(() => null) }],
[IPermissionService, { getPermissionPoint: vi.fn(() => undefined) }],
[LocaleService, { t: vi.fn((key: string) => key) }],
[SheetPermissionCheckController, {
permissionCheckWithRanges: vi.fn(() => true),
blockExecuteWithoutPermission: vi.fn(),
}],
]);
expect(await SheetPasteCommand.handler(accessorNoData, { value: 'none' })).toBe(false);
@@ -292,6 +315,40 @@ describe('clipboard command branches', () => {
expect(rePasteWithPasteType).toHaveBeenCalledWith(PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_VALUE);
});
it('checks clipboard permissions only after the sheet implementation is selected', async () => {
const copy = vi.fn(async () => true);
const cut = vi.fn(async () => true);
const legacyPaste = vi.fn();
const permissionCheckWithRanges = vi.fn(() => false);
const blockExecuteWithoutPermission = vi.fn((message: string) => {
throw new Error(message);
});
const accessor = createAccessor([
[ISheetClipboardService, {
copy,
cut,
legacyPaste,
}],
[IUniverInstanceService, { getCurrentUnitOfType: vi.fn(() => null) }],
[IPermissionService, { getPermissionPoint: vi.fn(() => undefined) }],
[LocaleService, { t: vi.fn((key: string) => `translated:${key}`) }],
[SheetPermissionCheckController, {
permissionCheckWithRanges,
blockExecuteWithoutPermission,
}],
]);
await expect(SheetCopyCommand.handler(accessor)).rejects.toThrow('translated:sheets-ui.permission.dialog.copyErr');
await expect(SheetCutCommand.handler(accessor)).rejects.toThrow('translated:sheets-ui.permission.dialog.copyErr');
await expect(SheetPasteCommand.handler(accessor, { value: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE })).rejects.toThrow('translated:sheets-ui.permission.dialog.pasteErr');
await expect(SheetPasteShortKeyCommand.handler(accessor, {})).rejects.toThrow('translated:sheets-ui.permission.dialog.pasteErr');
expect(permissionCheckWithRanges).toHaveBeenCalledTimes(4);
expect(copy).not.toHaveBeenCalled();
expect(cut).not.toHaveBeenCalled();
expect(legacyPaste).not.toHaveBeenCalled();
});
it('routes special paste commands to SheetPasteCommand', async () => {
const executeCommand = vi.fn(async () => true);
const accessor = createAccessor([
@@ -15,8 +15,22 @@
*/
import type { IAccessor, ICommand, IMultiCommand } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import type { IPasteHookKeyType } from '../../services/clipboard/type';
import { CommandType, ICommandService } from '@univerjs/core';
import { CommandType, ICommandService, IPermissionService, IUniverInstanceService, LocaleService } from '@univerjs/core';
import {
getSheetCommandTarget,
RangeProtectionPermissionEditPoint,
RangeProtectionPermissionViewPoint,
SheetPermissionCheckController,
WorkbookCopyPermission,
WorkbookEditablePermission,
WorksheetCopyPermission,
WorksheetEditPermission,
WorksheetSetCellStylePermission,
WorksheetSetCellValuePermission,
WorksheetSetColumnStylePermission,
} from '@univerjs/sheets';
import { CopyCommand, CutCommand, IClipboardInterfaceService, PasteCommand, SheetPasteShortKeyCommandName } from '@univerjs/ui';
import { whenSheetFocused } from '../../controllers/shortcuts/utils';
import { ISheetClipboardService, PREDEFINED_HOOK_NAME_PASTE } from '../../services/clipboard/clipboard.service';
@@ -31,6 +45,7 @@ export const SheetCopyCommand: IMultiCommand = {
priority: SHEET_CLIPBOARD_PRIORITY,
preconditions: whenSheetFocused,
handler: async (accessor) => {
checkSheetClipboardPermission(accessor, CopyCommand.id);
const sheetClipboardService = accessor.get(ISheetClipboardService);
return sheetClipboardService.copy();
},
@@ -44,6 +59,7 @@ export const SheetCutCommand: IMultiCommand = {
priority: SHEET_CLIPBOARD_PRIORITY,
preconditions: whenSheetFocused,
handler: async (accessor) => {
checkSheetClipboardPermission(accessor, CutCommand.id);
const sheetClipboardService = accessor.get(ISheetClipboardService);
return sheetClipboardService.cut();
},
@@ -68,6 +84,7 @@ export const SheetPasteCommand: IMultiCommand = {
priority: SHEET_CLIPBOARD_PRIORITY,
preconditions: whenSheetFocused,
handler: async (accessor: IAccessor, params: ISheetPasteParams) => {
checkSheetClipboardPermission(accessor, PasteCommand.id, params);
// const messageService = accessor.get(IMessageService);
// TODO: @yuhongz: check if there is excel content in the clipboard, if so
@@ -97,6 +114,7 @@ export const SheetPasteShortKeyCommand: ICommand = {
id: SheetPasteShortKeyCommandName,
type: CommandType.COMMAND,
handler: async (accessor: IAccessor, params: ISheetPasteByShortKeyParams) => {
checkSheetClipboardPermission(accessor, PasteCommand.id);
const clipboardService = accessor.get(ISheetClipboardService);
const { htmlContent, textContent, files, formulaClipboardPayload } = params;
clipboardService.legacyPaste(htmlContent, textContent, files, formulaClipboardPayload);
@@ -105,6 +123,91 @@ export const SheetPasteShortKeyCommand: ICommand = {
},
};
function checkSheetClipboardPermission(accessor: IAccessor, commandId: string, params?: ISheetPasteParams): void {
const permissionCheckController = accessor.get(SheetPermissionCheckController);
let permission = true;
let errorKey: LocaleKey = 'sheets-ui.permission.dialog.commonErr';
switch (commandId) {
case CopyCommand.id:
permission = permissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookCopyPermission],
worksheetTypes: [WorksheetCopyPermission],
rangeTypes: [RangeProtectionPermissionViewPoint],
});
errorKey = 'sheets-ui.permission.dialog.copyErr';
break;
case CutCommand.id:
permission = permissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookCopyPermission, WorkbookEditablePermission],
worksheetTypes: [WorksheetCopyPermission, WorksheetEditPermission],
rangeTypes: [RangeProtectionPermissionViewPoint, RangeProtectionPermissionEditPoint],
});
errorKey = 'sheets-ui.permission.dialog.copyErr';
break;
case PasteCommand.id:
permission = checkSheetPastePermission(permissionCheckController, params);
errorKey = 'sheets-ui.permission.dialog.pasteErr';
break;
}
if (permission) {
return;
}
const localeService = accessor.get(LocaleService);
let errorMsg = localeService.t<LocaleKey>(errorKey);
if (commandId === CopyCommand.id || commandId === CutCommand.id) {
const instanceService = accessor.get(IUniverInstanceService);
const target = getSheetCommandTarget(instanceService);
const permissionService = accessor.get(IPermissionService);
if (
target &&
!permissionService.getPermissionPoint(new WorkbookCopyPermission(target.unitId).id)?.value
) {
errorMsg = localeService.t<LocaleKey>('sheets-ui.permission.dialog.workbookCopyErr');
}
}
permissionCheckController.blockExecuteWithoutPermission(errorMsg);
}
function checkSheetPastePermission(
permissionCheckController: SheetPermissionCheckController,
params?: ISheetPasteParams
): boolean {
if (
params?.value === PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_VALUE ||
params?.value === PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMULA ||
params?.value === PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMAT
) {
return permissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetSetCellStylePermission, WorksheetEditPermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
});
}
if (params?.value === PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_COL_WIDTH) {
return permissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [
WorksheetEditPermission,
WorksheetSetCellValuePermission,
WorksheetSetCellStylePermission,
WorksheetSetColumnStylePermission,
],
rangeTypes: [RangeProtectionPermissionEditPoint],
});
}
return permissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetSetCellValuePermission, WorksheetSetCellStylePermission, WorksheetEditPermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
});
}
export const SheetPasteValueCommand: ICommand = {
id: 'sheet.command.paste-value',
type: CommandType.COMMAND,
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import type { ICommandInfo, IExecutionOptions, IMutationCommonParams, IRange, Nullable, UnitModel, Workbook } from '@univerjs/core';
import type { ICommandInfo, IExecutionOptions, IMutationCommonParams, IRange, Nullable, UnitModel, Workbook, Worksheet } from '@univerjs/core';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { IAutoFillLocation, IRemoveSheetMutationParams } from '@univerjs/sheets';
import {
Disposable,
@@ -22,9 +23,8 @@ import {
ICommandService,
Inject,
IUniverInstanceService,
UniverInstanceType,
} from '@univerjs/core';
import { DeviceInputEventType, getCurrentTypeOfRenderer, IRenderManagerService } from '@univerjs/engine-render';
import { DeviceInputEventType } from '@univerjs/engine-render';
import {
AUTO_FILL_HOOK_TYPE,
AutoClearContentCommand,
@@ -42,10 +42,10 @@ import {
RemoveSheetMutation,
SetRangeValuesCommand,
SetRangeValuesMutation,
SetSelectionsOperation,
SetWorksheetActiveOperation,
SetWorksheetColWidthMutation,
SetWorksheetRowHeightMutation,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { SetCellEditVisibleOperation } from '../commands/operations/cell-edit.operation';
import { SetZoomRatioOperation } from '../commands/operations/set-zoom-ratio.operation';
@@ -61,8 +61,6 @@ export class AutoFillUIController extends Disposable {
@ICommandService private readonly _commandService: ICommandService,
@IAutoFillService private readonly _autoFillService: IAutoFillService,
@Inject(AutoFillController) private _autoFillController: AutoFillController,
@IEditorBridgeService private readonly _editorBridgeService: IEditorBridgeService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@Inject(SheetsRenderService) private _sheetsRenderService: SheetsRenderService
) {
super();
@@ -72,7 +70,6 @@ export class AutoFillUIController extends Disposable {
private _init() {
this._initDefaultHook();
this._initSelectionControlFillChanged();
this._initQuitListener();
this._initSkeletonChange();
}
@@ -144,182 +141,147 @@ export class AutoFillUIController extends Disposable {
this._autoFillController.quit();
this._autoFillService.setShowMenu(false);
}
}
private _initSelectionControlFillChanged() {
const disposableCollection = new DisposableCollection();
let pendingRetry = false;
let retryCount = 0;
export class AutoFillRenderController extends Disposable implements IRenderModule {
private readonly _selectionControlDisposables = new DisposableCollection();
const scheduleUpdateListener = (listener: () => void) => {
if (pendingRetry) {
return;
}
constructor(
private readonly _context: IRenderContext<Workbook>,
@Inject(ISheetSelectionRenderService) private readonly _selectionRenderService: ISheetSelectionRenderService,
@Inject(SheetsSelectionsService) private readonly _selectionManagerService: SheetsSelectionsService,
@ICommandService private readonly _commandService: ICommandService,
@IEditorBridgeService private readonly _editorBridgeService: IEditorBridgeService
) {
super();
pendingRetry = true;
setTimeout(() => {
pendingRetry = false;
listener();
}, 0);
};
this._initSelectionControlFillChanged();
}
const updateListener = () => {
// Each range change requires re-listening.
disposableCollection.dispose();
override dispose(): void {
this._selectionControlDisposables.dispose();
super.dispose();
}
const currentRenderer = getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_SHEET, this._univerInstanceService, this._renderManagerService);
if (!currentRenderer) return;
const selectionRenderService = getResolvedSelectionRenderService(currentRenderer);
if (!selectionRenderService) {
retryCount += 1;
if (retryCount <= 3) {
scheduleUpdateListener(updateListener);
}
return;
}
retryCount = 0;
const selectionControls = selectionRenderService.getSelectionControls();
selectionControls.forEach((controlSelection) => {
disposableCollection.add(controlSelection.selectionFilled$.subscribe((filled) => {
if (
filled == null ||
filled.startColumn === -1 ||
filled.startRow === -1 ||
filled.endColumn === -1 ||
filled.endRow === -1
) {
return;
}
const source: IRange = {
startColumn: controlSelection.model.startColumn,
endColumn: controlSelection.model.endColumn,
startRow: controlSelection.model.startRow,
endRow: controlSelection.model.endRow,
};
const selection: IRange = {
startColumn: filled.startColumn,
endColumn: filled.endColumn,
startRow: filled.startRow,
endRow: filled.endRow,
};
this._commandService.executeCommand(AutoFillCommand.id, { sourceRange: source, targetRange: selection });
}));
// double click to fill range, range length will align to left or right column.
// fill results will be as same as drag operation
disposableCollection.add(controlSelection.fillControl.onDblclick$.subscribeEvent(() => {
const source = {
startColumn: controlSelection.model.startColumn,
endColumn: controlSelection.model.endColumn,
startRow: controlSelection.model.startRow,
endRow: controlSelection.model.endRow,
};
this._handleDbClickFill(source);
}));
disposableCollection.add(controlSelection.fillControl.onPointerDown$.subscribeEvent(() => {
const visibleState = this._editorBridgeService.isVisible();
if (visibleState.visible) {
this._commandService.syncExecuteCommand(
SetCellEditVisibleOperation.id,
{
visible: false,
eventType: DeviceInputEventType.PointerDown,
unitId: currentRenderer.unitId,
}
);
}
}));
});
};
scheduleUpdateListener(updateListener);
// Should subscribe current current renderer change as well.
// TODO@yuhongz: this seems not ideal. This should be an `IRenderModule` for running with multiple renderers?
this.disposeWithMe(this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id === SetSelectionsOperation.id) {
scheduleUpdateListener(updateListener);
}
private _initSelectionControlFillChanged(): void {
this._updateSelectionControlListeners();
this.disposeWithMe(this._selectionManagerService.selectionChanged$.subscribe(() => {
this._updateSelectionControlListeners();
}));
}
this.disposeWithMe(this._univerInstanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET)
.subscribe(() => scheduleUpdateListener(updateListener)));
private _updateSelectionControlListeners(): void {
// Selection controls are recreated when the selected ranges change.
this._selectionControlDisposables.dispose();
this._selectionRenderService.getSelectionControls().forEach((controlSelection) => {
this._selectionControlDisposables.add(controlSelection.selectionFilled$.subscribe((filled) => {
if (
filled == null ||
filled.startColumn === -1 ||
filled.startRow === -1 ||
filled.endColumn === -1 ||
filled.endRow === -1
) {
return;
}
const sourceRange: IRange = {
startColumn: controlSelection.model.startColumn,
endColumn: controlSelection.model.endColumn,
startRow: controlSelection.model.startRow,
endRow: controlSelection.model.endRow,
};
const targetRange: IRange = {
startColumn: filled.startColumn,
endColumn: filled.endColumn,
startRow: filled.startRow,
endRow: filled.endRow,
};
this._executeAutoFill(sourceRange, targetRange);
}));
// Double click has the same effect as dragging the fill control, but its target range is detected automatically.
this._selectionControlDisposables.add(controlSelection.fillControl.onDblclick$.subscribeEvent(() => {
const sourceRange: IRange = {
startColumn: controlSelection.model.startColumn,
endColumn: controlSelection.model.endColumn,
startRow: controlSelection.model.startRow,
endRow: controlSelection.model.endRow,
};
this._handleDbClickFill(sourceRange);
}));
this._selectionControlDisposables.add(controlSelection.fillControl.onPointerDown$.subscribeEvent(() => {
const visibleState = this._editorBridgeService.isVisible();
if (visibleState.visible) {
this._commandService.syncExecuteCommand(SetCellEditVisibleOperation.id, {
visible: false,
eventType: DeviceInputEventType.PointerDown,
unitId: this._context.unitId,
});
}
}));
});
}
private _executeAutoFill(sourceRange: IRange, targetRange: IRange): void {
const subUnitId = this._context.unit.getActiveSheet().getSheetId();
this._commandService.executeCommand(AutoFillCommand.id, {
sourceRange,
targetRange,
unitId: this._context.unitId,
subUnitId,
});
}
private _handleDbClickFill(source: IRange) {
const selection = this._detectFillRange(source);
const worksheet = this._context.unit.getActiveSheet();
const selection = detectAutoFillRange(source, worksheet);
// double click only works when dest range is longer than source range
if (selection.endRow <= source.endRow) {
return;
}
// double click effect is the same as drag effect, but the apply area is automatically calculated (by method '_detectFillRange')
this._commandService.executeCommand(AutoFillCommand.id, { sourceRange: source, targetRange: selection });
}
private _detectFillRange(source: IRange) {
const { startRow, endRow, startColumn, endColumn } = source;
const worksheet = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)?.getActiveSheet();
if (!worksheet) {
return source;
}
const matrix = worksheet.getCellMatrix();
const maxRow = worksheet.getMaxRows();
const maxColumn = worksheet.getMaxColumns();
let detectEndRow = endRow + 1;
// left column first, or consider right column.
if (startColumn > 0 && matrix.getValue(detectEndRow, startColumn - 1)?.v != null) {
while (matrix.getValue(detectEndRow + 1, startColumn - 1)?.v != null && detectEndRow < maxRow) {
detectEndRow += 1;
}
} else if (endColumn < maxColumn - 1 && matrix.getValue(detectEndRow, endColumn + 1)?.v != null) {
while (matrix.getValue(detectEndRow + 1, endColumn + 1)?.v != null && detectEndRow < maxRow) {
detectEndRow += 1;
}
} else {
detectEndRow = endRow;
}
// If the fill range contains data, stop filling at the first row of data.
for (let i = endRow + 1; i <= detectEndRow; i++) {
for (let j = startColumn; j <= endColumn; j++) {
if (matrix.getValue(i, j)?.v != null) {
detectEndRow = i - 1;
break;
}
}
}
return {
startColumn,
endColumn,
startRow,
endRow: detectEndRow,
};
// Double click uses the same fill command as dragging, with a target range calculated from adjacent data.
this._executeAutoFill(source, selection);
}
}
function getResolvedSelectionRenderService(renderer: unknown): ISheetSelectionRenderService | undefined {
const injector = (renderer as { getInjector?: () => unknown }).getInjector?.();
const resolvedDependencies = (injector as {
resolvedDependencyCollection?: {
resolvedDependencies?: Map<unknown, unknown[]>;
};
} | undefined)?.resolvedDependencyCollection?.resolvedDependencies;
if (!resolvedDependencies) {
return undefined;
export function detectAutoFillRange(source: IRange, worksheet: Worksheet): IRange {
const { startRow, endRow, startColumn, endColumn } = source;
const matrix = worksheet.getCellMatrix();
const maxRow = worksheet.getMaxRows();
const maxColumn = worksheet.getMaxColumns();
let detectEndRow = endRow + 1;
// left column first, or consider right column.
if (startColumn > 0 && matrix.getValue(detectEndRow, startColumn - 1)?.v != null) {
while (matrix.getValue(detectEndRow + 1, startColumn - 1)?.v != null && detectEndRow < maxRow) {
detectEndRow += 1;
}
} else if (endColumn < maxColumn - 1 && matrix.getValue(detectEndRow, endColumn + 1)?.v != null) {
while (matrix.getValue(detectEndRow + 1, endColumn + 1)?.v != null && detectEndRow < maxRow) {
detectEndRow += 1;
}
} else {
detectEndRow = endRow;
}
for (const [identifier, values] of resolvedDependencies) {
if ((identifier as { decoratorName?: unknown }).decoratorName === (ISheetSelectionRenderService as unknown as { decoratorName?: unknown }).decoratorName) {
return values.length === 1 ? values[0] as ISheetSelectionRenderService : undefined;
// If the fill range contains data, stop filling at the first row of data.
for (let i = endRow + 1; i <= detectEndRow; i++) {
for (let j = startColumn; j <= endColumn; j++) {
if (matrix.getValue(i, j)?.v != null) {
detectEndRow = i - 1;
break;
}
}
}
return undefined;
return {
startColumn,
endColumn,
startRow,
endRow: detectEndRow,
};
}
@@ -24,6 +24,7 @@ import { InsertTextCommand } from '@univerjs/docs';
import { IMEInputCommand } from '@univerjs/docs-ui';
import { EMPTY } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { SheetCopyCommand, SheetCutCommand, SheetPasteCommand } from '../../../commands/commands/clipboard.command';
import { SheetPermissionCheckUIController } from '../sheet-permission-check-ui.controller';
type ControllerConstructorArgs = ConstructorParameters<typeof SheetPermissionCheckUIController>;
@@ -31,6 +32,7 @@ type ControllerConstructorArgs = ConstructorParameters<typeof SheetPermissionChe
function createController() {
let beforeCommandExecuted: ((commandInfo: ICommandInfo) => void) | undefined;
const permissionCheckWithoutRange = vi.fn(() => false);
const permissionCheckWithRanges = vi.fn(() => true);
const blockExecuteWithoutPermission = vi.fn();
const controller = new SheetPermissionCheckUIController(
{
@@ -39,23 +41,24 @@ function createController() {
return { dispose: vi.fn() };
}),
} as unknown as ControllerConstructorArgs[0],
{} as unknown as ControllerConstructorArgs[1],
{ getShowComponents: vi.fn(() => true) } as unknown as ControllerConstructorArgs[2],
{ open: vi.fn(), close: vi.fn() } as unknown as ControllerConstructorArgs[3],
{} as unknown as ControllerConstructorArgs[4],
{ t: vi.fn((key: string) => `translated:${key}`) } as unknown as ControllerConstructorArgs[5],
{ getContextValue: vi.fn(() => false) } as unknown as ControllerConstructorArgs[6],
{ getShowComponents: vi.fn(() => true) } as unknown as ControllerConstructorArgs[1],
{ open: vi.fn(), close: vi.fn() } as unknown as ControllerConstructorArgs[2],
{} as unknown as ControllerConstructorArgs[3],
{ t: vi.fn((key: string) => `translated:${key}`) } as unknown as ControllerConstructorArgs[4],
{ getContextValue: vi.fn(() => false) } as unknown as ControllerConstructorArgs[5],
{
triggerPermissionUIEvent$: EMPTY,
permissionCheckWithoutRange,
permissionCheckWithRanges,
blockExecuteWithoutPermission,
} as unknown as ControllerConstructorArgs[7]
} as unknown as ControllerConstructorArgs[6]
);
return {
controller,
executeBefore: (commandInfo: ICommandInfo) => beforeCommandExecuted?.(commandInfo),
permissionCheckWithoutRange,
permissionCheckWithRanges,
blockExecuteWithoutPermission,
};
}
@@ -96,4 +99,18 @@ describe('SheetPermissionCheckUIController', () => {
expect(blockExecuteWithoutPermission).toHaveBeenCalledWith('translated:sheets-ui.permission.dialog.editErr');
controller.dispose();
});
it.each([
[SheetCopyCommand.id, 'copy'],
[SheetCutCommand.id, 'cut'],
[SheetPasteCommand.id, 'paste'],
])('does not preflight the global %s command before its multi-command implementation is selected', (id) => {
const { controller, executeBefore, permissionCheckWithRanges, blockExecuteWithoutPermission } = createController();
executeBefore({ id });
expect(permissionCheckWithRanges).not.toHaveBeenCalled();
expect(blockExecuteWithoutPermission).not.toHaveBeenCalled();
controller.dispose();
});
});
@@ -17,7 +17,6 @@
import type { ICommandInfo } from '@univerjs/core';
import type { IInsertTextCommandParams } from '@univerjs/docs';
import type { IIMEInputCommandParams } from '@univerjs/docs-ui';
import type { ISheetPasteParams } from '../../commands/commands/clipboard.command';
import type { LocaleKey } from '../../locale/types';
import type { IEditorBridgeServiceVisibleParam } from '../../services/editor-bridge.service';
import {
@@ -29,37 +28,23 @@ import {
IContextService,
Inject,
IPermissionService,
IUniverInstanceService,
LocaleService,
SHEET_EDITOR_UNITS,
} from '@univerjs/core';
import { InsertTextCommand } from '@univerjs/docs';
import { IMEInputCommand } from '@univerjs/docs-ui';
import {
getSheetCommandTarget,
RangeProtectionPermissionEditPoint,
RangeProtectionPermissionViewPoint,
RangeProtectionRuleModel,
SheetPermissionCheckController,
WorkbookCopyPermission,
WorkbookEditablePermission,
WorksheetCopyPermission,
WorksheetEditPermission,
WorksheetSetCellStylePermission,
WorksheetSetCellValuePermission,
WorksheetSetColumnStylePermission,
} from '@univerjs/sheets';
import { IDialogService } from '@univerjs/ui';
import {
SheetCopyCommand,
SheetCutCommand,
SheetPasteColWidthCommand,
SheetPasteCommand,
SheetPasteShortKeyCommand,
} from '../../commands/commands/clipboard.command';
import { ApplyFormatPainterCommand } from '../../commands/commands/set-format-painter.command';
import { SetCellEditVisibleOperation } from '../../commands/operations/cell-edit.operation';
import { PREDEFINED_HOOK_NAME_PASTE } from '../../services/clipboard/clipboard.service';
import {
UNIVER_SHEET_PERMISSION_ALERT_DIALOG,
UNIVER_SHEET_PERMISSION_ALERT_DIALOG_ID,
@@ -70,7 +55,6 @@ export class SheetPermissionCheckUIController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IPermissionService private readonly _permissionService: IPermissionService,
@IDialogService private readonly _dialogService: IDialogService,
@Inject(RangeProtectionRuleModel) private _rangeProtectionRuleModel: RangeProtectionRuleModel,
@@ -117,14 +101,12 @@ export class SheetPermissionCheckUIController extends Disposable {
}
}
// eslint-disable-next-line max-lines-per-function,complexity
private _getPermissionCheck(commandInfo: ICommandInfo) {
const { id } = commandInfo;
let permission = true;
let errorMsg = '';
let params;
let target;
switch (id) {
case InsertTextCommand.id:
@@ -160,21 +142,6 @@ export class SheetPermissionCheckUIController extends Disposable {
});
errorMsg = this._localeService.t<LocaleKey>('sheets-ui.permission.dialog.editErr');
break;
case SheetPasteColWidthCommand.id:
permission = this._sheetPermissionCheckController.permissionCheckWithoutRange({
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetEditPermission, WorksheetSetColumnStylePermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
});
errorMsg = this._localeService.t<LocaleKey>('sheets-ui.permission.dialog.pasteErr');
break;
case SheetPasteShortKeyCommand.id:
case SheetPasteCommand.id:
params = commandInfo.params as ISheetPasteParams;
permission = this._permissionCheckByPaste(params);
errorMsg = this._localeService.t<LocaleKey>('sheets-ui.permission.dialog.pasteErr');
break;
case ApplyFormatPainterCommand.id:
permission = this._sheetPermissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookEditablePermission],
@@ -183,43 +150,6 @@ export class SheetPermissionCheckUIController extends Disposable {
});
errorMsg = this._localeService.t<LocaleKey>('sheets-ui.permission.dialog.commonErr');
break;
case SheetCopyCommand.id:
permission = this._sheetPermissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookCopyPermission],
worksheetTypes: [WorksheetCopyPermission],
rangeTypes: [RangeProtectionPermissionViewPoint],
});
errorMsg = this._localeService.t<LocaleKey>('sheets-ui.permission.dialog.copyErr');
target = getSheetCommandTarget(this._univerInstanceService);
if (
!permission &&
target &&
!this._permissionService.getPermissionPoint(new WorkbookCopyPermission(target.unitId).id)?.value
) {
errorMsg = this._localeService.t<LocaleKey>('sheets-ui.permission.dialog.workbookCopyErr');
}
break;
case SheetCutCommand.id:
permission = this._sheetPermissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookCopyPermission, WorkbookEditablePermission],
worksheetTypes: [WorksheetCopyPermission, WorksheetEditPermission],
rangeTypes: [RangeProtectionPermissionViewPoint, RangeProtectionPermissionEditPoint],
});
errorMsg = this._localeService.t<LocaleKey>('sheets-ui.permission.dialog.copyErr');
target = getSheetCommandTarget(this._univerInstanceService);
if (
!permission &&
target &&
!this._permissionService.getPermissionPoint(new WorkbookCopyPermission(target.unitId).id)?.value
) {
errorMsg = this._localeService.t<LocaleKey>('sheets-ui.permission.dialog.workbookCopyErr');
}
break;
default:
break;
}
@@ -240,26 +170,4 @@ export class SheetPermissionCheckUIController extends Disposable {
})
);
}
private _permissionCheckByPaste(params: ISheetPasteParams) {
if (params.value === PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_VALUE || params.value === PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMULA) {
return this._sheetPermissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetSetCellStylePermission, WorksheetEditPermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
});
} else if (params.value === PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMAT) {
return this._sheetPermissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetSetCellStylePermission, WorksheetEditPermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
});
} else {
return this._sheetPermissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetSetCellValuePermission, WorksheetSetCellStylePermission, WorksheetEditPermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
});
}
}
}
+2 -1
View File
@@ -36,7 +36,7 @@ import { UI_PLUGIN_CONFIG_KEY, UniverMobileUIPlugin } from '@univerjs/ui';
import { filter } from 'rxjs/operators';
import pkg from '../package.json';
import { defaultPluginConfig, SHEETS_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { AutoFillUIController } from './controllers/auto-fill-ui.controller';
import { AutoFillRenderController, AutoFillUIController } from './controllers/auto-fill-ui.controller';
import { AutoHeightController } from './controllers/auto-height.controller';
import { AutoWidthController } from './controllers/auto-width.controller';
import { CellCustomRenderController } from './controllers/cell-custom-render.controller';
@@ -286,6 +286,7 @@ export class UniverSheetsMobileUIPlugin extends Plugin {
[SheetContextMenuMobileRenderController],
[MobileHeaderResizeRenderController],
[MoveRangeRenderController],
[AutoFillRenderController],
// editor
[EditorBridgeRenderController],
+2 -1
View File
@@ -36,7 +36,7 @@ import { UI_PLUGIN_CONFIG_KEY } from '@univerjs/ui';
import { filter } from 'rxjs/operators';
import pkg from '../package.json';
import { defaultPluginConfig, SHEETS_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { AutoFillUIController } from './controllers/auto-fill-ui.controller';
import { AutoFillRenderController, AutoFillUIController } from './controllers/auto-fill-ui.controller';
import { AutoHeightController } from './controllers/auto-height.controller';
import { AutoWidthController } from './controllers/auto-width.controller';
import { CellCustomRenderController } from './controllers/cell-custom-render.controller';
@@ -289,6 +289,7 @@ export class UniverSheetsUIPlugin extends Plugin {
[CellCustomRenderController],
[SheetContextMenuRenderController],
[MoveRangeRenderController],
[AutoFillRenderController],
// editor
[EditorBridgeRenderController],
@@ -45,7 +45,14 @@ import {
LexerTreeBuilder,
} from '@univerjs/engine-formula';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { SheetInterceptorService, SheetSkeletonService, SheetsSelectionsService } from '@univerjs/sheets';
import {
RangeProtectionRuleModel,
SheetInterceptorService,
SheetPermissionCheckController,
SheetSkeletonService,
SheetsSelectionsService,
WorksheetProtectionRuleModel,
} from '@univerjs/sheets';
import {
BrowserClipboardService,
DesktopMessageService,
@@ -573,6 +580,9 @@ export function clipboardTestBed(workbookData?: IWorkbookData, dependencies?: De
const injector = this._injector;
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([SheetsSelectionsService]);
injector.add([WorksheetProtectionRuleModel]);
injector.add([RangeProtectionRuleModel]);
injector.add([SheetPermissionCheckController]);
injector.add([IClipboardInterfaceService, { useClass: BrowserClipboardService, lazy: true }]);
injector.add([ISheetClipboardService, { useClass: SheetClipboardService }]);
injector.add([IMessageService, { useClass: DesktopMessageService, lazy: true }]);
@@ -16,6 +16,7 @@
import type { IAccessor, ICommand, IRange } from '@univerjs/core';
import type { ISetRangeValuesMutationParams } from '../mutations/set-range-values.mutation';
import type { ISheetCommandSharedParams } from '../utils/interface';
import { CommandType, ICommandService, IUndoRedoService, IUniverInstanceService, sequenceExecute } from '@univerjs/core';
import { generateNullCellValue } from '../../basics/utils';
import { IAutoFillService } from '../../services/auto-fill/auto-fill.service';
@@ -26,11 +27,9 @@ import { SetRangeValuesMutation, SetRangeValuesUndoMutationFactory } from '../mu
import { SetSelectionsOperation } from '../operations/selection.operation';
import { getSheetCommandTarget } from './utils/target-util';
export interface IAutoFillCommandParams {
export interface IAutoFillCommandParams extends Partial<ISheetCommandSharedParams> {
sourceRange: IRange;
targetRange: IRange;
unitId?: string; // if not provided, use current unitId
subUnitId?: string; // if not provided, use current subUnitId
applyType?: AUTO_FILL_APPLY_TYPE; // manual apply type
}
@@ -135,7 +134,7 @@ export const SheetCopyRightCommand: ICommand = {
handler: async (accessor: IAccessor) => executeSheetCopyFill(accessor, 'right'),
};
export interface IAutoClearContentCommand {
export interface IAutoClearContentCommand extends ISheetCommandSharedParams {
clearRange: IRange;
selectionRange: IRange;
}
@@ -145,7 +144,7 @@ export const AutoClearContentCommand: ICommand = {
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler: async (accessor: IAccessor, params: IAutoClearContentCommand) => {
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService));
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService), params);
if (!target) return false;
const commandService = accessor.get(ICommandService);
@@ -176,6 +176,8 @@ export class AutoFillService extends Disposable implements IAutoFillService {
// situation 1: drag to smaller range, horizontally.
if (selection.endColumn < source.endColumn && selection.endColumn > source.startColumn) {
return this._commandService.executeCommand(AutoClearContentCommand.id, {
unitId,
subUnitId,
clearRange: {
startRow: selection.startRow,
endRow: selection.endRow,
@@ -188,6 +190,8 @@ export class AutoFillService extends Disposable implements IAutoFillService {
// situation 2: drag to smaller range, vertically.
if (selection.endRow < source.endRow && selection.endRow > source.startRow) {
return this._commandService.executeCommand(AutoClearContentCommand.id, {
unitId,
subUnitId,
clearRange: {
startRow: selection.endRow + 1,
endRow: source.endRow,
@@ -225,8 +229,8 @@ export class AutoFillService extends Disposable implements IAutoFillService {
this.direction = direction;
const autoFillSource = this._injector.invoke((accessor: IAccessor) => rangeToDiscreteRange(source, accessor));
const autoFillTarget = this._injector.invoke((accessor: IAccessor) => rangeToDiscreteRange(target, accessor));
const autoFillSource = this._injector.invoke((accessor: IAccessor) => rangeToDiscreteRange(source, accessor, unitId, subUnitId));
const autoFillTarget = this._injector.invoke((accessor: IAccessor) => rangeToDiscreteRange(target, accessor, unitId, subUnitId));
if (!autoFillSource || !autoFillTarget) {
return false;