fix(sheets-ui): improve percentage cell editing (#7557)

This commit is contained in:
wpxp123456
2026-08-19 18:12:01 +08:00
committed by GitHub
parent 49e11a67c3
commit 91c4c2912e
7 changed files with 315 additions and 26 deletions
+2
View File
@@ -319,6 +319,8 @@ export interface IFontRenderExtension {
export interface ICellDataForSheetInterceptor extends ICellData {
interceptorStyle?: Nullable<IStyleData>;
isInArrayFormulaRange?: Nullable<boolean>;
/** Marks intercepted cell data prepared for percentage editing. */
isPercentFormat?: boolean;
markers?: ICellMarks;
customRender?: Nullable<ICellCustomRender[]>;
interceptorAutoHeight?: () => number | undefined;
@@ -174,6 +174,46 @@ describe('test editor', () => {
// The currency format needs to be entered in the editor with real values, not with currency symbols
expect(result!.v).toEqual('100.1234567%');
expect(result!.t).toEqual(2);
expect(result!.isPercentFormat).toBe(true);
});
describe('after edit with percent', () => {
function setPercentFormat() {
const params: ISetNumfmtMutationParams = {
unitId,
subUnitId,
values: {
1: {
ranges: [{ startRow: 10, endRow: 10, startColumn: 0, endColumn: 0 }],
},
},
refMap: {
1: {
pattern: '0.00%',
},
},
};
commandService.syncExecuteCommand(SetNumfmtMutation.id, params);
}
it.each([
['35%', 0.35],
['0.35%', 0.0035],
])('stores edited percent text %s as %s', (content, expected) => {
setPercentFormat();
const sheetInterceptorService = testBed.get(SheetInterceptorService);
const result = sheetInterceptorService.writeCellInterceptor
.fetchThroughInterceptors(AFTER_CELL_EDIT)({ v: content, t: CellValueType.NUMBER }, {
workbook,
worksheet,
unitId,
subUnitId,
row: 10,
col: 0,
});
expect(result?.v).toBe(expected);
});
});
it('after edit with data', () => {
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { ICellData, IDocumentBody, IRange, Nullable, Workbook } from '@univerjs/core';
import type { ICellData, ICellDataForSheetInterceptor, IDocumentBody, IRange, Nullable, Workbook } from '@univerjs/core';
import type {
INumfmtItemWithCache,
IRemoveNumfmtMutationParams,
@@ -133,9 +133,10 @@ export class NumfmtEditorController extends Disposable {
* If the editor also display '100.12%', will lose precision when before edit.
*/
case 'percent': {
const cell: Nullable<ICellData> = { ...context.worksheet.getCellRaw(row, col) };
const cell: Nullable<ICellDataForSheetInterceptor> = { ...context.worksheet.getCellRaw(row, col) };
if (cell?.t === CellValueType.NUMBER && isRealNum(cell.v)) {
cell.v = `${stripErrorMargin(Number(cell.v) * 100)}%`;
cell.isPercentFormat = true;
}
return next && next(cell);
}
@@ -24,6 +24,7 @@ import {
LocaleType,
UniverInstanceType,
} from '@univerjs/core';
import { InsertTextCommand } from '@univerjs/docs';
import { MoveCursorOperation, MoveSelectionOperation, SetDocInputStyleCommand, VIEWPORT_KEY } from '@univerjs/docs-ui';
import { LexerTreeBuilder } from '@univerjs/engine-formula';
import { DeviceInputEventType } from '@univerjs/engine-render';
@@ -69,7 +70,7 @@ describe('emptyBody', () => {
});
});
function createController() {
function createController(initialDataStream = 'new value\r\n', isPercentFormat = false, isInArrayFormulaRange = false) {
const worksheet = {
getSheetId: vi.fn(() => 'sheet-1'),
getCellRaw: vi.fn(() => ({ v: 'old' })),
@@ -83,11 +84,11 @@ function createController() {
getStyles: vi.fn(() => styles),
};
let normalSnapshot = {
body: { dataStream: 'new value\r\n', paragraphs: [{ startIndex: 9, paragraphId: 'normal-para' }] },
body: { dataStream: initialDataStream, paragraphs: [{ startIndex: 9, paragraphId: 'normal-para' }] },
documentStyle: {},
};
let formulaSnapshot = {
body: { dataStream: 'new value\r\n', paragraphs: [{ startIndex: 9, paragraphId: 'formula-para' }] },
body: { dataStream: initialDataStream, paragraphs: [{ startIndex: 9, paragraphId: 'formula-para' }] },
documentStyle: {},
};
const docModel = {
@@ -144,6 +145,8 @@ function createController() {
row: 2,
column: 3,
documentLayoutObject: { documentModel },
isInArrayFormulaRange,
isPercentFormat,
})),
getEditLocation: vi.fn(() => ({
unitId: 'unit-1',
@@ -151,8 +154,12 @@ function createController() {
row: 2,
column: 3,
documentLayoutObject: { documentModel },
isInArrayFormulaRange,
isPercentFormat,
})),
getCurrentEditorId: vi.fn(() => DOCS_NORMAL_EDITOR_UNIT_ID_KEY),
isVisible: vi.fn(() => ({ visible: true, eventType: DeviceInputEventType.Keyboard, unitId: 'unit-1' })),
getEditorDirty: vi.fn(() => false),
isForceKeepVisible: vi.fn(() => false),
disableForceKeepVisible: vi.fn(),
refreshEditCellPosition: vi.fn(),
@@ -348,6 +355,138 @@ describe('EditingRenderController business methods', () => {
expect(controller._editorBridgeService.changeEditorDirty).not.toHaveBeenCalled();
});
it.each(['3', '35', '0.35', '35%'])('preserves the percent suffix when input starts with a digit: %s', (initialValue) => {
const { controller, getFormulaSnapshot, getNormalSnapshot } = createController('25%\r\n', true);
controller._handleEditorVisible({
visible: true,
eventType: DeviceInputEventType.Keyboard,
keycode: KeyCode.Digit3,
initialValue,
unitId: 'unit-1',
});
expect(getNormalSnapshot().body.dataStream).toBe('25%\r\n');
expect(getFormulaSnapshot().body.dataStream).toBe('25%\r\n');
expect(controller._textSelectionManagerService.replaceDocRanges).toHaveBeenCalledWith(
[{ startOffset: 0, endOffset: 2, collapsed: false }],
{
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
subUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
}
);
});
it.each(['$30', '=1', 'text', '+', '-', '.', ','])('replaces percent content when input does not start with a digit: %s', (initialValue) => {
const { controller, getNormalSnapshot } = createController('25%\r\n', true);
controller._handleEditorVisible({
visible: true,
eventType: DeviceInputEventType.Keyboard,
keycode: KeyCode.Digit3,
initialValue,
unitId: 'unit-1',
});
expect(getNormalSnapshot().body.dataStream).toBe('\r\n');
});
it.each([KeyCode.BACKSPACE, KeyCode.DELETE])('keeps full-clear behavior for keycode %s', (keycode) => {
const { controller, getNormalSnapshot } = createController('25%\r\n', true);
controller._handleEditorVisible({
visible: true,
eventType: DeviceInputEventType.Keyboard,
keycode,
unitId: 'unit-1',
});
expect(getNormalSnapshot().body.dataStream).toBe('\r\n');
});
it('keeps full-clear behavior for array formula cells', () => {
const { controller, getNormalSnapshot } = createController('25%\r\n', true, true);
controller._handleEditorVisible({
visible: true,
eventType: DeviceInputEventType.Keyboard,
keycode: KeyCode.Digit3,
initialValue: '3',
unitId: 'unit-1',
});
expect(getNormalSnapshot().body.dataStream).toBe('\r\n');
});
it.each([
['F2', { eventType: DeviceInputEventType.Keyboard, keycode: KeyCode.F2 }],
['double-click', { eventType: DeviceInputEventType.Dblclick }],
])('places the caret before the percent suffix for %s editing', (_name, input) => {
const { controller } = createController('25%\r\n', true);
const selection = { startOffset: 2, endOffset: 2, collapsed: true };
controller._handleEditorVisible({
visible: true,
unitId: 'unit-1',
...input,
});
expect(controller._textSelectionManagerService.replaceDocRanges).toHaveBeenLastCalledWith(
[selection],
{
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
subUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
}
);
});
it('overtypes a preserved percent suffix on subsequent percent input', () => {
const { controller } = createController('0.35%\r\n', true);
const beforeCommandListeners: Array<(command: { id: string; params: unknown }) => void> = [];
controller._commandService.beforeCommandExecuted = vi.fn((listener) => {
beforeCommandListeners.push(listener);
return { dispose: vi.fn() };
});
controller._commandService.onCommandExecuted = vi.fn(() => ({ dispose: vi.fn() }));
controller._commandExecutedListener({ add: vi.fn() });
const params = {
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
body: { dataStream: '%' },
range: { startOffset: 4, endOffset: 4, collapsed: true },
};
expect(beforeCommandListeners).toHaveLength(1);
beforeCommandListeners[0]({ id: InsertTextCommand.id, params });
expect(params.range).toEqual({ startOffset: 4, endOffset: 5, collapsed: false });
});
it('overtypes the original percent value on initial digit input', () => {
const { controller } = createController('25%\r\n', true);
const beforeCommandListeners: Array<(command: { id: string; params: unknown }) => void> = [];
controller._editorBridgeService.isVisible.mockReturnValue({
visible: true,
eventType: DeviceInputEventType.Keyboard,
initialValue: '3',
unitId: 'unit-1',
});
controller._commandService.beforeCommandExecuted = vi.fn((listener) => {
beforeCommandListeners.push(listener);
return { dispose: vi.fn() };
});
controller._commandService.onCommandExecuted = vi.fn(() => ({ dispose: vi.fn() }));
controller._commandExecutedListener({ add: vi.fn() });
const params = {
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
body: { dataStream: '3' },
range: { startOffset: 0, endOffset: 0, collapsed: true },
};
beforeCommandListeners[0]({ id: InsertTextCommand.id, params });
expect(params.range).toEqual({ startOffset: 0, endOffset: 2, collapsed: false });
});
it('syncs the active sheet editor selection instead of the host document selection on focus', () => {
const { controller, workbook } = createController();
const focus$ = new Subject<boolean>();
@@ -26,18 +26,19 @@ import type {
IDocumentStyle,
IMutationInfo,
IStyleData,
ITextRange,
Nullable,
Styles,
Workbook,
} from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IInsertTextCommandParams, IRichTextEditingMutationParams } from '@univerjs/docs';
import type {
ISetRangeValuesCommandParams,
ISetWorksheetActivateCommandParams,
MutationsAffectRange,
} from '@univerjs/sheets';
import type { IUniverSheetsUIConfig } from '../../config/config';
import type { IEditorBridgeServiceVisibleParam } from '../../services/editor-bridge.service';
import type { ICellEditorState, IEditorBridgeServiceVisibleParam } from '../../services/editor-bridge.service';
import {
CellValueType,
createParagraphId,
@@ -66,7 +67,7 @@ import {
UniverInstanceType,
WrapStrategy,
} from '@univerjs/core';
import { DocSelectionManagerService, DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { DocSelectionManagerService, DocSkeletonManagerService, InsertTextCommand, RichTextEditingMutation } from '@univerjs/docs';
import {
DocSelectionRenderService,
IEditorService,
@@ -118,6 +119,45 @@ import { isRangeSelector } from './utils/is-range-selector';
const HIDDEN_EDITOR_POSITION = -1000;
function getPercentOffset(editCellState: ICellEditorState, dataStream: string | undefined): Nullable<number> {
if (!editCellState.isPercentFormat || editCellState.isInArrayFormulaRange) {
return null;
}
const percentSuffix = `%${DEFAULT_EMPTY_DOCUMENT_VALUE}`;
return dataStream?.endsWith(percentSuffix) ? dataStream.length - percentSuffix.length : null;
}
function getPercentEditorSelection(
editCellState: ICellEditorState,
visibleParam: IEditorBridgeServiceVisibleParam,
dataStream: string | undefined
): Nullable<ITextRange> {
const percentOffset = getPercentOffset(editCellState, dataStream);
if (percentOffset == null) {
return null;
}
const { eventType, initialValue, keycode } = visibleParam;
if (
eventType === DeviceInputEventType.Dblclick ||
(eventType === DeviceInputEventType.Keyboard && keycode === KeyCode.F2)
) {
return { startOffset: percentOffset, endOffset: percentOffset, collapsed: true };
}
if (
eventType !== DeviceInputEventType.Keyboard ||
keycode === KeyCode.BACKSPACE ||
keycode === KeyCode.DELETE ||
!/^\d/.test(initialValue ?? '')
) {
return null;
}
return { startOffset: 0, endOffset: percentOffset, collapsed: false };
}
enum CursorChange {
InitialState,
StartEditor,
@@ -375,6 +415,48 @@ export class EditingRenderController extends Disposable {
* Listen to document edits to refresh the size of the sheet editor, not for normal editor.
*/
private _commandExecutedListener(d: DisposableCollection) {
d.add(this._commandService.beforeCommandExecuted((command: ICommandInfo) => {
if (command.id !== InsertTextCommand.id) {
return;
}
const params = command.params as IInsertTextCommandParams;
const visibleParam = this._editorBridgeService.isVisible();
const editCellState = this._editorBridgeService.getEditLocation();
if (params.unitId !== DOCS_NORMAL_EDITOR_UNIT_ID_KEY || !visibleParam.visible || editCellState == null) {
return;
}
const dataStream = this._getDocumentDataModel()?.getBody()?.dataStream;
if (dataStream == null) {
return;
}
const percentOffset = getPercentOffset(editCellState, dataStream);
// The input event captures its range before the editor opens, so the first digit still carries a stale range.
if (
percentOffset != null &&
!this._editorBridgeService.getEditorDirty() &&
visibleParam.eventType === DeviceInputEventType.Keyboard &&
/^\d/.test(visibleParam.initialValue ?? '') &&
/^\d/.test(params.body.dataStream)
) {
params.range = { startOffset: 0, endOffset: percentOffset, collapsed: false };
return;
}
// Overtype the preserved suffix so entering `35%` does not produce `35%%`.
if (
percentOffset != null &&
params.body.dataStream === '%' &&
params.range.collapsed &&
params.range.startOffset === percentOffset &&
params.range.endOffset === percentOffset
) {
params.range = { ...params.range, endOffset: percentOffset + 1, collapsed: false };
}
}));
d.add(this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id === RichTextEditingMutation.id) {
const params = command.params as IRichTextEditingMutationParams;
@@ -498,6 +580,16 @@ export class EditingRenderController extends Disposable {
}
);
};
const replaceSelection = (selection: ITextRange) => {
this._textSelectionManagerService.replaceDocRanges(
[selection],
{
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
subUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
}
);
};
const percentSelection = getPercentEditorSelection(editCellState, param, documentDataModel.getBody()?.dataStream);
const cellImage = isCellImage(documentDataModel.getSnapshot());
this._submitEmptyCellImageEdit = cellImage && eventType === DeviceInputEventType.Keyboard && keycode === KeyCode.BACKSPACE;
@@ -513,34 +605,46 @@ export class EditingRenderController extends Disposable {
},
]);
const endOffset = (documentDataModel.getBody()?.dataStream.length ?? 2) - 2;
this._textSelectionManagerService.replaceDocRanges(
[{
startOffset: endOffset,
endOffset,
}],
{
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
subUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
}
);
if (percentSelection) {
replaceSelection(percentSelection);
} else {
this._textSelectionManagerService.replaceDocRanges(
[{
startOffset: endOffset,
endOffset,
}],
{
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
subUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
}
);
}
} else if (
// clear and edit
eventType === DeviceInputEventType.Keyboard ||
(eventType === DeviceInputEventType.Dblclick && isInArrayFormulaRange)
) {
clearAndEdit();
if (percentSelection) {
replaceSelection(percentSelection);
} else {
clearAndEdit();
}
} else if (eventType === DeviceInputEventType.Dblclick) {
if (this._contextService.getContextValue(FOCUSING_EDITOR_INPUT_FORMULA)) {
return;
}
const cursor = documentDataModel.getBody()!.dataStream.length - 2 || 0;
this._textSelectionManagerService.replaceDocRanges([
{
startOffset: cursor,
endOffset: cursor,
},
]);
if (percentSelection) {
replaceSelection(percentSelection);
} else {
this._textSelectionManagerService.replaceDocRanges([
{
startOffset: cursor,
endOffset: cursor,
},
]);
}
}
this._renderManagerService.getRenderUnitById(unitId)?.scene.resetCursor();
@@ -282,7 +282,7 @@ describe('EditorBridgeService', () => {
getSheetId: () => 'sheet-1',
getFreeze: () => null,
getCellRaw: vi.fn(() => ({ v: '=SUM(A1:A2)' })),
getCell: vi.fn(() => ({ isInArrayFormulaRange: true })),
getCell: vi.fn(() => ({ isInArrayFormulaRange: true, isPercentFormat: true })),
getCellDocumentModelWithFormula: vi.fn(() => ({ documentModel })),
getBlankCellDocumentModel: vi.fn(() => ({ documentModel })),
};
@@ -310,6 +310,7 @@ describe('EditorBridgeService', () => {
row: 1,
column: 2,
editorUnitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
isPercentFormat: true,
}));
expect(service.getEditCellLayout()).toEqual(expect.objectContaining({
scaleX: 2,
@@ -74,6 +74,7 @@ export interface ICellEditorState {
documentLayoutObject: IDocumentLayoutObject;
editorUnitId: string;
isInArrayFormulaRange?: Nullable<boolean>;
isPercentFormat?: boolean;
}
export interface ICellEditorLayout {
@@ -423,6 +424,7 @@ export class EditorBridgeService extends Disposable implements IEditorBridgeServ
documentLayoutObject,
editorUnitId: this._editorUnitId,
isInArrayFormulaRange: cell?.isInArrayFormulaRange,
isPercentFormat: cell?.isPercentFormat,
};
}