snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 05:09:45 +05:00
commit bcf6ef63ad
7118 changed files with 1236854 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
/**
* 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 { numfmt } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
describe('test numfmt date', () => {
it('date parse date', () => {
expect(numfmt.parseDate('asdasdasd')).toBe(null);
expect(numfmt.parseDate('1988/12/12 21:21:21')?.z).toBe('yyyy/mm/dd hh:mm:ss');
expect(numfmt.parseDate('12/12 21:21:21')?.z).toBe('mm/dd hh:mm:ss');
expect(numfmt.parseDate('2012/12 21:21:21')?.z).toBe('yyyy/m hh:mm:ss'); // TODO: is yyy/mm hh:mm:ss ?
expect(numfmt.parseDate('2012/12 21:21')?.z).toBe('yyyy/m hh:mm'); // TODO: is yyy/mm hh:mm ?
expect(numfmt.parseDate('2012/12')?.z).toBe('yyyy/m'); // TODO: is yyy/mm ?
expect(numfmt.parseDate('2012/12/12')?.z).toBe('yyyy/mm/dd');
expect(numfmt.parseDate('21:21')).toBe(null);
});
});

View File

@@ -0,0 +1,304 @@
/**
* 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 { cellToRange, CellValueType, currencySymbols, DEFAULT_NUMBER_FORMAT, DEFAULT_TEXT_FORMAT_EXCEL, isPatternEqualWithoutDecimal, LocaleType } from '@univerjs/core';
import { RemoveNumfmtMutation, SetNumfmtMutation } from '@univerjs/sheets';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getCurrencyFormat, getCurrencySymbolByLocale, getCurrencySymbolIconByLocale } from '../base/const/currency-symbols';
import { getCurrencyType } from '../utils/currency';
import { getDecimalFromPattern, getDecimalString, isPatternHasDecimal, setPatternDecimal } from '../utils/decimal';
import { mergeNumfmtMutations } from '../utils/mutation';
import { getCurrencyFormatOptions, getCurrencyOptions, getDateFormatOptions, getNumberFormatOptions } from '../utils/options';
import { getPatternPreview, getPatternPreviewIgnoreGeneral, getPatternType } from '../utils/pattern';
import { getScientificNotationFormatFromCell, isAllowedPatternForScientificNotationNumber, isScientificNotationNumericCell } from '../utils/scientific-notation';
describe('test numfmt utils function', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('getCurrencyType', () => {
expect(getCurrencyType(`_(${currencySymbols[7]} 123123`)).toBe(currencySymbols[7]);
expect(getCurrencyType('_(# 123123')).toBeUndefined();
expect(getCurrencyType(`_(${currencySymbols[7]} 123123 ${currencySymbols[8]}`)).toBe(currencySymbols[7]);
});
it('getDecimalFromPattern', () => {
expect(getDecimalFromPattern('_(###0.000);--###0.00')).toBe(3);
expect(getDecimalFromPattern('_(###0);-###0.00')).toBe(0); // the function decimal just use positive,negative configuration ignored
expect(getDecimalFromPattern('_(###0.0);--###0.00')).toBe(1);
expect(getDecimalFromPattern('_(###0.);--###0.00')).toBe(0);
});
it('isPatternEqualWithoutDecimal', () => {
expect(isPatternEqualWithoutDecimal('_(###0.000);-###0.00', '[red]_(###0.0000);-###0.00')).toBe(true); // the positive color ignored
expect(isPatternEqualWithoutDecimal('_(###0;-###0.00', '_(###0;[red]-###0.00')).toBe(false);
expect(isPatternEqualWithoutDecimal('_(###00.0;-###0.00', '_(###0;[red]-###0.00')).toBe(false);
expect(isPatternEqualWithoutDecimal('_(###0;-###0.00', '_(###0.00;-###0.00')).toBe(true);
});
it('getDecimalString', () => {
expect(getDecimalString(3)).toBe('000'); // the positive color ignored
expect(getDecimalString(-1)).toBe(''); // the positive color ignored
expect(getDecimalString(0)).toBe(''); // the positive color ignored
});
it('setPatternDecimal', () => {
expect(setPatternDecimal('0.', 4)).toBe('0.0000'); // the positive color ignored
expect(setPatternDecimal('.', 4)).toBe('.0000'); // the positive color ignored
expect(setPatternDecimal('0.0', 4)).toBe('0.0000'); // the positive color ignored
expect(setPatternDecimal('0.0', 0)).toBe('0'); // the positive color ignored
});
it('isPatternHasDecimal', () => {
expect(isPatternHasDecimal('0.00')).toBe(true);
expect(isPatternHasDecimal('#,##0')).toBe(true);
expect(isPatternHasDecimal('General')).toBe(false);
});
it('currency symbol helpers', () => {
expect(getCurrencySymbolByLocale(LocaleType.ZH_CN)).toBe('¥');
expect(getCurrencySymbolByLocale(LocaleType.KO_KR)).toBe('₩');
expect(getCurrencySymbolIconByLocale(LocaleType.FR_FR)).toEqual({
icon: 'EuroIcon',
symbol: '€',
locale: LocaleType.FR_FR,
});
expect(getCurrencySymbolIconByLocale(LocaleType.KO_KR)).toEqual({
icon: 'WonIcon',
symbol: '₩',
locale: LocaleType.KO_KR,
});
expect([
LocaleType.VI_VN,
LocaleType.FA_IR,
LocaleType.AR_SA,
LocaleType.ID_ID,
LocaleType.PL_PL,
].map((locale) => getCurrencySymbolIconByLocale(locale).icon)).toEqual([
'DongIcon',
'RialIcon',
'RialIcon',
'RupiahIcon',
'ZlotyIcon',
]);
expect(getCurrencyFormat(LocaleType.ZH_CN, 3)).toContain('"¥"#,##0.000');
expect(getCurrencyFormat(LocaleType.EN_US, 200)).toContain(`.${'0'.repeat(127)}`);
});
it('option helpers expose formatted labels and values', () => {
expect(getCurrencyOptions()[0]).toEqual({ label: 'Rp', value: 'Rp' });
expect(getCurrencyFormatOptions('€')[0]).toMatchObject({
label: expect.stringContaining('€'),
value: expect.stringContaining('€'),
});
expect(getDateFormatOptions()[0]).toMatchObject({
label: expect.any(String),
value: expect.any(String),
});
expect(getNumberFormatOptions()[0]).toMatchObject({
label: expect.any(String),
value: expect.any(String),
});
});
it('pattern preview helpers return types and gracefully handle general or invalid formats', () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
expect(getPatternType('0%')).toBe('percent');
expect(getPatternPreview('0.00', 12.3)).toEqual({ result: '12.30' });
expect(getPatternPreviewIgnoreGeneral(DEFAULT_NUMBER_FORMAT, 0.1 + 0.2)).toEqual({ result: '0.3' });
expect(getPatternPreview('[invalid', 42)).toEqual({ result: '42' });
});
it('scientific notation helpers detect numeric cells and derive display patterns from mantissa decimals', () => {
expect(isScientificNotationNumericCell({ v: 1e21, t: CellValueType.NUMBER })).toBe(true);
expect(isScientificNotationNumericCell({ v: 1.2345e31, t: CellValueType.NUMBER })).toBe(true);
expect(isScientificNotationNumericCell({ v: '1e21', t: CellValueType.STRING })).toBe(false);
expect(isScientificNotationNumericCell({ v: 123, t: CellValueType.NUMBER })).toBe(false);
expect(isScientificNotationNumericCell(null)).toBe(false);
expect(getScientificNotationFormatFromCell({ v: 1e21, t: CellValueType.NUMBER })).toBe('0.00E+00');
expect(getScientificNotationFormatFromCell({ v: 1.2e21, t: CellValueType.NUMBER })).toBe('0.00E+00');
expect(getScientificNotationFormatFromCell({ v: 1.2345e31, t: CellValueType.NUMBER })).toBe('0.0000E+00');
expect(getScientificNotationFormatFromCell({ v: -1.2345e-31, t: CellValueType.NUMBER })).toBe('0.0000E+00');
});
it('scientific notation format guard only allows general, text, and scientific patterns', () => {
expect(isAllowedPatternForScientificNotationNumber(undefined)).toBe(true);
expect(isAllowedPatternForScientificNotationNumber(DEFAULT_NUMBER_FORMAT)).toBe(true);
expect(isAllowedPatternForScientificNotationNumber(DEFAULT_TEXT_FORMAT_EXCEL)).toBe(true);
expect(isAllowedPatternForScientificNotationNumber('0.00E+00')).toBe(true);
expect(isAllowedPatternForScientificNotationNumber('0.00')).toBe(false);
expect(isAllowedPatternForScientificNotationNumber('0%')).toBe(false);
});
it('mergeNumfmtMutations', () => {
const result = mergeNumfmtMutations([
{
id: SetNumfmtMutation.id,
params: {
values: {
a: { ranges: [cellToRange(2, 3), cellToRange(2, 4), cellToRange(4, 5)] },
},
refMap: { a: { pattern: '', type: '' } },
},
},
{
id: RemoveNumfmtMutation.id,
params: {
ranges: [cellToRange(2, 3), cellToRange(2, 4), cellToRange(4, 5)],
},
},
{
id: SetNumfmtMutation.id,
params: {
values: {
a: { ranges: [cellToRange(4, 6), cellToRange(4, 5)] },
},
refMap: { a: { pattern: '', type: '' } },
},
},
]);
const removeMutation = result[0] as { params: { ranges: unknown } };
const setMutation = result[1] as { params: { values: Record<string, { ranges: unknown }> } };
expect(result[0].id === RemoveNumfmtMutation.id).toBeTruthy();
expect(result[1].id === SetNumfmtMutation.id).toBeTruthy();
expect(removeMutation.params.ranges).toEqual([
{
startRow: 2,
endRow: 2,
startColumn: 3,
endColumn: 4,
},
{
startRow: 4,
endRow: 4,
startColumn: 5,
endColumn: 5,
},
]);
expect(setMutation.params.values['1'].ranges).toEqual([
{
startRow: 2,
endRow: 2,
startColumn: 3,
endColumn: 4,
},
{
startRow: 4,
endRow: 4,
startColumn: 5,
endColumn: 6,
},
]);
});
it('mergeNumfmtMutations multiple data formats of different types', () => {
const result = mergeNumfmtMutations([
{
id: SetNumfmtMutation.id,
params: {
values: {
a: { ranges: [cellToRange(2, 3), cellToRange(2, 4), cellToRange(4, 5)] },
},
refMap: { a: { pattern: 'a', type: '' } },
},
},
{
id: RemoveNumfmtMutation.id,
params: {
ranges: [cellToRange(2, 3), cellToRange(2, 4), cellToRange(4, 5)],
},
},
{
id: SetNumfmtMutation.id,
params: {
values: {
a: { ranges: [cellToRange(4, 6), cellToRange(4, 5)] },
},
refMap: { a: { pattern: 'b', type: '' } },
},
},
]);
expect(result).toEqual([
{
id: 'sheet.mutation.remove.numfmt',
params: {
ranges: [
{
startRow: 2,
endRow: 2,
startColumn: 3,
endColumn: 4,
},
{
startRow: 4,
endRow: 4,
startColumn: 5,
endColumn: 5,
},
],
unitId: undefined,
subUnitId: undefined,
},
},
{
id: 'sheet.mutation.set.numfmt',
params: {
values: {
1: {
ranges: [
{
startRow: 2,
endRow: 2,
startColumn: 3,
endColumn: 4,
},
{
startRow: 4,
endRow: 4,
startColumn: 5,
endColumn: 5,
},
],
},
2: {
ranges: [
{
startRow: 4,
endRow: 4,
startColumn: 5,
endColumn: 6,
},
],
},
},
refMap: {
1: {
pattern: 'a',
type: '',
},
2: {
pattern: 'b',
type: '',
},
},
unitId: undefined,
subUnitId: undefined,
},
},
]);
});
});

View File

@@ -0,0 +1,138 @@
/**
* 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 { LocaleType } from '@univerjs/core';
export const localeCurrencySymbolMap = new Map<LocaleType, string>([
[LocaleType.EN_US, '$'],
[LocaleType.RU_RU, '₽'],
[LocaleType.VI_VN, '₫'],
[LocaleType.ZH_CN, '¥'],
[LocaleType.ZH_TW, 'NT$'],
[LocaleType.ZH_HK, 'HK$'],
[LocaleType.FR_FR, '€'],
[LocaleType.FA_IR, '﷼'],
[LocaleType.KO_KR, '₩'],
[LocaleType.ES_ES, '€'],
[LocaleType.CA_ES, '€'],
[LocaleType.SK_SK, '€'],
[LocaleType.JA_JP, '¥'],
[LocaleType.PT_BR, 'R$'],
[LocaleType.DE_DE, '€'],
[LocaleType.IT_IT, '€'],
[LocaleType.ID_ID, 'Rp'],
[LocaleType.PL_PL, 'zł'],
[LocaleType.AR_SA, '﷼'],
]);
/**
* Get the currency symbol icon based on the locale.
*/
export function getCurrencySymbolIconByLocale(locale: LocaleType) {
switch (locale) {
case LocaleType.CA_ES:
case LocaleType.DE_DE:
case LocaleType.ES_ES:
case LocaleType.FR_FR:
case LocaleType.IT_IT:
case LocaleType.SK_SK:
return {
icon: 'EuroIcon',
symbol: localeCurrencySymbolMap.get(locale) || '€',
locale,
};
case LocaleType.RU_RU:
return {
icon: 'RoubleIcon',
symbol: localeCurrencySymbolMap.get(locale) || '₽',
locale,
};
case LocaleType.JA_JP:
case LocaleType.ZH_CN:
return {
icon: 'RmbIcon',
symbol: localeCurrencySymbolMap.get(locale) || '¥',
locale,
};
case LocaleType.VI_VN:
return {
icon: 'DongIcon',
symbol: localeCurrencySymbolMap.get(locale) || '₫',
locale,
};
case LocaleType.AR_SA:
case LocaleType.FA_IR:
return {
icon: 'RialIcon',
symbol: localeCurrencySymbolMap.get(locale) || '﷼',
locale,
};
case LocaleType.KO_KR:
return {
icon: 'WonIcon',
symbol: localeCurrencySymbolMap.get(locale) || '₩',
locale,
};
case LocaleType.ID_ID:
return {
icon: 'RupiahIcon',
symbol: localeCurrencySymbolMap.get(locale) || 'Rp',
locale,
};
case LocaleType.PL_PL:
return {
icon: 'ZlotyIcon',
symbol: localeCurrencySymbolMap.get(locale) || 'zł',
locale,
};
case LocaleType.EN_US:
case LocaleType.PT_BR:
case LocaleType.ZH_HK:
case LocaleType.ZH_TW:
default:
return {
icon: 'DollarIcon',
symbol: localeCurrencySymbolMap.get(locale) || '$',
locale,
};
}
}
/**
* Get the currency symbol by locale.
*/
export function getCurrencySymbolByLocale(locale: LocaleType): string {
return localeCurrencySymbolMap.get(locale) || '$';
}
/**
* Get the currency format string based on the locale and number of digits.
*/
export function getCurrencyFormat(locale: LocaleType, numberDigits: number = 2): string {
let _numberDigits = numberDigits;
if (numberDigits > 127) {
_numberDigits = 127;
}
let decimal = '';
if (_numberDigits > 0) {
decimal = `.${'0'.repeat(_numberDigits)}`;
}
return `"${getCurrencySymbolByLocale(locale)}"#,##0${decimal}_);[Red]("${getCurrencySymbolByLocale(locale)}"#,##0${decimal})`;
}

View File

@@ -0,0 +1,121 @@
/**
* 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.
*/
export const DATEFMTLISG = [
{
label: '1930-08-05',
suffix: 'yyyy-MM-dd',
},
{
label: '1930/08/05',
suffix: 'yyyy/MM/dd',
},
{
label: '1930年08月05日',
suffix: 'yyyy"年"MM"月"dd"日"',
},
{
label: '08-05',
suffix: 'MM-dd',
},
{
label: '8月5日',
suffix: 'M"月"d"日"',
},
{
label: '13:30:30',
suffix: 'h:mm:ss',
},
{
label: '13:30',
suffix: 'h:mm',
},
{
label: '下午01:30',
suffix: 'A/P hh:mm',
},
{
label: '下午1:30',
suffix: 'A/P h:mm',
},
{
label: '下午1:30:30',
suffix: 'A/P h:mm:ss',
},
{
label: '08-05 下午 01:30',
suffix: 'MM-dd A/P hh:mm',
},
];
export const NUMBERFORMAT = [
{
label: '(1,235)',
suffix: '#,##0_);(#,##0)',
},
{
label: '(1,235) ',
suffix: '#,##0_);[Red](#,##0)',
color: 'red',
},
{
label: '1,234.56',
suffix: '#,##0.00_);#,##0.00',
},
{
label: '1,234.56',
suffix: '#,##0.00_);[Red]#,##0.00',
color: 'red',
},
{
label: '-1,234.56',
suffix: '#,##0.00_);-#,##0.00',
},
{
label: '-1,234.56',
suffix: '#,##0.00_);[Red]-#,##0.00',
color: 'red',
},
];
export const CURRENCYFORMAT = [
{
label: (suffix: string) => `${suffix}1,235`,
suffix: (suffix: string) => `"${suffix}"#,##0.00_);"${suffix}"#,##0.00`,
},
{
label: (suffix: string) => `${suffix}1,235`,
suffix: (suffix: string) => `"${suffix}"#,##0.00_);[Red]"${suffix}"#,##0.00`,
color: 'red',
},
{
label: (suffix: string) => `(${suffix}1,235)`,
suffix: (suffix: string) => `"${suffix}"#,##0.00_);("${suffix}"#,##0.00)`,
},
{
label: (suffix: string) => `(${suffix}1,235)`,
suffix: (suffix: string) => `"${suffix}"#,##0.00_);[Red]("${suffix}"#,##0.00)`,
color: 'red',
},
{
label: (suffix: string) => `-${suffix}1,235`,
suffix: (suffix: string) => `"${suffix}"#,##0.00_);-"${suffix}"#,##0.00`,
},
{
label: (suffix: string) => `-${suffix}1,235`,
suffix: (suffix: string) => `"${suffix}"#,##0.00_);[Red]-"${suffix}"#,##0.00`,
color: 'red',
},
];

View File

@@ -0,0 +1,17 @@
/**
* 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.
*/
export const SHEET_NUMFMT_PLUGIN = 'SHEET_NUMFMT_PLUGIN';

View File

@@ -0,0 +1,370 @@
/**
* 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 { Dependency, IWorkbookData, Injector as RediInjector, Workbook } from '@univerjs/core';
import {
CellValueType,
DEFAULT_TEXT_FORMAT_EXCEL,
ICommandService,
ILogService,
Inject,
Injector,
IUniverInstanceService,
LocaleService,
LocaleType,
LogLevel,
Plugin,
RANGE_TYPE,
RedoCommand,
RegionService,
UndoCommand,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import {
INumfmtService,
NumfmtService,
RemoveNumfmtMutation,
SetNumfmtMutation,
SetRangeValuesMutation,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getCurrencyFormat } from '../../../base/const/currency-symbols';
import { AddDecimalCommand } from '../add-decimal.command';
import { SetCurrencyCommand } from '../set-currency.command';
import { SetNumfmtCommand } from '../set-numfmt.command';
import { SetPercentCommand } from '../set-percent.command';
import { SubtractDecimalCommand } from '../subtract-decimal.command';
function createWorkbookData(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: ['sheet1'],
styles: {
textStyle: {
n: {
pattern: DEFAULT_TEXT_FORMAT_EXCEL,
},
},
twoDecimals: {
n: {
pattern: '0.00',
},
},
},
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
rowCount: 20,
columnCount: 20,
cellData: {
0: {
0: { v: 1.2, t: CellValueType.NUMBER },
1: { v: 123, t: CellValueType.STRING, s: 'textStyle' },
2: { v: 12, t: CellValueType.NUMBER },
3: { v: 0.375, t: CellValueType.NUMBER },
4: { v: 8, t: CellValueType.NUMBER },
5: { v: 1e21, t: CellValueType.NUMBER },
6: { v: 1.2345e31, t: CellValueType.NUMBER },
},
1: {
0: { v: 1.23, t: CellValueType.NUMBER, s: 'twoDecimals' },
},
},
},
},
};
}
function createCommandTestBed(workbookData?: IWorkbookData) {
const univer = new Univer();
const injector = univer.__getInjector();
class TestPlugin extends Plugin {
static override pluginName = 'test-plugin';
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: RediInjector
) {
super();
}
override onStarting(): void {
const dependencies: Dependency[] = [
[SheetsSelectionsService],
[INumfmtService, { useClass: NumfmtService }],
];
dependencies.forEach((dependency) => this._injector.add(dependency));
}
}
univer.registerPlugin(TestPlugin);
const sheet = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, workbookData ?? createWorkbookData());
const get = injector.get.bind(injector);
get(IUniverInstanceService).focusUnit('test');
get(LocaleService).setLocale(LocaleType.ZH_CN);
get(ILogService).setLogLevel(LogLevel.SILENT);
return {
univer,
get,
sheet,
};
}
describe('Sheets numfmt commands', () => {
let univer: Univer;
let get: RediInjector['get'];
let commandService: ICommandService;
let selectionService: SheetsSelectionsService;
let numfmtService: INumfmtService;
let workbook: Workbook;
beforeEach(() => {
const testBed = createCommandTestBed();
univer = testBed.univer;
get = testBed.get;
workbook = testBed.sheet;
commandService = get(ICommandService);
selectionService = get(SheetsSelectionsService);
numfmtService = get(INumfmtService);
[
SetRangeValuesMutation,
SetNumfmtMutation,
RemoveNumfmtMutation,
SetNumfmtCommand,
AddDecimalCommand,
SubtractDecimalCommand,
SetPercentCommand,
SetCurrencyCommand,
].forEach((command) => {
commandService.registerCommand(command);
});
});
afterEach(() => {
univer.dispose();
});
it('sets and removes number formats through SetNumfmtCommand and supports undo/redo', async () => {
const sheet = workbook.getActiveSheet();
await expect(commandService.executeCommand(SetNumfmtCommand.id, {
values: [
{ row: 0, col: 0, pattern: DEFAULT_TEXT_FORMAT_EXCEL },
{ row: 0, col: 1 },
],
})).resolves.toBe(true);
expect(numfmtService.getValue('test', 'sheet1', 0, 0)).toEqual({ pattern: DEFAULT_TEXT_FORMAT_EXCEL });
expect(numfmtService.getValue('test', 'sheet1', 0, 1)).toBeNull();
expect(sheet.getCellRaw(0, 0)?.t).toBe(CellValueType.NUMBER);
expect(sheet.getCellRaw(0, 1)).toMatchObject({ v: 123, t: CellValueType.NUMBER });
await expect(commandService.executeCommand(UndoCommand.id)).resolves.toBe(true);
expect(numfmtService.getValue('test', 'sheet1', 0, 0)).toBeNull();
expect(numfmtService.getValue('test', 'sheet1', 0, 1)).toEqual({ pattern: DEFAULT_TEXT_FORMAT_EXCEL });
expect(sheet.getCellRaw(0, 1)).toMatchObject({ v: '123', t: CellValueType.STRING });
await expect(commandService.executeCommand(RedoCommand.id)).resolves.toBe(true);
expect(numfmtService.getValue('test', 'sheet1', 0, 0)).toEqual({ pattern: DEFAULT_TEXT_FORMAT_EXCEL });
expect(numfmtService.getValue('test', 'sheet1', 0, 1)).toBeNull();
});
it('only allows general, text, and scientific formats for scientific notation numeric values', async () => {
await expect(commandService.executeCommand(SetNumfmtCommand.id, {
values: [{ row: 0, col: 5, pattern: '0.00' }],
})).resolves.toBe(false);
expect(numfmtService.getValue('test', 'sheet1', 0, 5)).toBeNull();
await expect(commandService.executeCommand(SetNumfmtCommand.id, {
values: [{ row: 0, col: 5, pattern: '0.00E+00' }],
})).resolves.toBe(true);
expect(numfmtService.getValue('test', 'sheet1', 0, 5)).toEqual({ pattern: '0.00E+00' });
await expect(commandService.executeCommand(SetNumfmtCommand.id, {
values: [{ row: 0, col: 5, pattern: DEFAULT_TEXT_FORMAT_EXCEL }],
})).resolves.toBe(true);
expect(numfmtService.getValue('test', 'sheet1', 0, 5)).toEqual({ pattern: DEFAULT_TEXT_FORMAT_EXCEL });
await expect(commandService.executeCommand(SetNumfmtCommand.id, {
values: [{ row: 0, col: 5 }],
})).resolves.toBe(true);
expect(numfmtService.getValue('test', 'sheet1', 0, 5)).toBeNull();
});
it('adds decimals to general scientific notation numeric values with a scientific format', async () => {
selectionService.addSelections([
{
range: { startRow: 0, startColumn: 5, endRow: 0, endColumn: 5, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
await expect(commandService.executeCommand(AddDecimalCommand.id)).resolves.toBe(true);
expect(numfmtService.getValue('test', 'sheet1', 0, 5)).toEqual({ pattern: '0.000E+00' });
});
it('adds decimals based on the current mantissa decimals for general scientific notation numeric values', async () => {
selectionService.addSelections([
{
range: { startRow: 0, startColumn: 6, endRow: 0, endColumn: 6, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
await expect(commandService.executeCommand(AddDecimalCommand.id)).resolves.toBe(true);
expect(numfmtService.getValue('test', 'sheet1', 0, 6)).toEqual({ pattern: '0.00000E+00' });
});
it('adds decimals to mixed general numbers without forcing regular numbers into scientific format', async () => {
selectionService.addSelections([
{
range: { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
{
range: { startRow: 0, startColumn: 6, endRow: 0, endColumn: 6, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
await expect(commandService.executeCommand(AddDecimalCommand.id)).resolves.toBe(true);
expect(numfmtService.getValue('test', 'sheet1', 0, 0)).toEqual({ pattern: '0.00000' });
expect(numfmtService.getValue('test', 'sheet1', 0, 6)).toEqual({ pattern: '0.00000E+00' });
});
it('subtracts decimals from general scientific notation numeric values with a scientific format', async () => {
selectionService.addSelections([
{
range: { startRow: 0, startColumn: 5, endRow: 0, endColumn: 5, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
await expect(commandService.executeCommand(SubtractDecimalCommand.id)).resolves.toBe(true);
expect(numfmtService.getValue('test', 'sheet1', 0, 5)).toEqual({ pattern: '0.0E+00' });
});
it('subtracts decimals based on the current mantissa decimals for general scientific notation numeric values', async () => {
selectionService.addSelections([
{
range: { startRow: 0, startColumn: 6, endRow: 0, endColumn: 6, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
await expect(commandService.executeCommand(SubtractDecimalCommand.id)).resolves.toBe(true);
expect(numfmtService.getValue('test', 'sheet1', 0, 6)).toEqual({ pattern: '0.000E+00' });
});
it('adds and subtracts decimals based on selections and existing formats', async () => {
selectionService.addSelections([
{
range: { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
{
range: { startRow: 1, startColumn: 0, endRow: 1, endColumn: 0, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
await expect(commandService.executeCommand(AddDecimalCommand.id)).resolves.toBe(true);
expect(numfmtService.getValue('test', 'sheet1', 0, 0)).toEqual({ pattern: '0.000' });
expect(numfmtService.getValue('test', 'sheet1', 1, 0)).toEqual({ pattern: '0.000' });
await expect(commandService.executeCommand(SubtractDecimalCommand.id)).resolves.toBe(true);
expect(numfmtService.getValue('test', 'sheet1', 0, 0)).toEqual({ pattern: '0.00' });
expect(numfmtService.getValue('test', 'sheet1', 1, 0)).toEqual({ pattern: '0.00' });
});
it('subtracts decimals from raw numeric cells when no number format exists yet', async () => {
selectionService.addSelections([
{
range: { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
await expect(commandService.executeCommand(SubtractDecimalCommand.id)).resolves.toBe(true);
expect(numfmtService.getValue('test', 'sheet1', 0, 0)).toEqual({ pattern: '0' });
});
it('applies percent and currency formats to current selections', async () => {
selectionService.addSelections([
{
range: { startRow: 0, startColumn: 3, endRow: 0, endColumn: 3, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
await expect(commandService.executeCommand(SetPercentCommand.id)).resolves.toBe(true);
expect(numfmtService.getValue('test', 'sheet1', 0, 3)).toEqual({ pattern: '0%' });
selectionService.addSelections([
{
range: { startRow: 0, startColumn: 4, endRow: 0, endColumn: 4, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
get(RegionService).setRegion(LocaleType.FR_FR);
await expect(commandService.executeCommand(SetCurrencyCommand.id)).resolves.toBe(true);
expect(numfmtService.getValue('test', 'sheet1', 0, 4)).toEqual({
pattern: getCurrencyFormat(LocaleType.FR_FR),
});
});
it('returns false for quick commands when there is no current selection', async () => {
await expect(commandService.executeCommand(AddDecimalCommand.id)).resolves.toBe(false);
await expect(commandService.executeCommand(SubtractDecimalCommand.id)).resolves.toBe(false);
await expect(commandService.executeCommand(SetPercentCommand.id)).resolves.toBe(false);
await expect(commandService.executeCommand(SetCurrencyCommand.id)).resolves.toBe(false);
});
});

View File

@@ -0,0 +1,103 @@
/**
* 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 { IAccessor, ICommand } from '@univerjs/core';
import type { INumfmtItemWithCache } from '@univerjs/sheets';
import type { ISetNumfmtCommandParams } from './set-numfmt.command';
import { CellValueType, CommandType, ICommandService, isDefaultFormat, IUniverInstanceService, Range } from '@univerjs/core';
import { getSheetCommandTarget, INumfmtService, SheetsSelectionsService } from '@univerjs/sheets';
import { getDecimalFromPattern, setPatternDecimal } from '../../utils/decimal';
import { getScientificNotationFormatFromCell, isScientificNotationNumericCell } from '../../utils/scientific-notation';
import { SetNumfmtCommand } from './set-numfmt.command';
export const AddDecimalCommand: ICommand = {
id: 'sheet.command.numfmt.add.decimal.command',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor) => {
const commandService = accessor.get(ICommandService);
const selectionManagerService = accessor.get(SheetsSelectionsService);
const numfmtService = accessor.get(INumfmtService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const selections = selectionManagerService.getCurrentSelections();
if (!selections || !selections.length) {
return false;
}
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { unitId, subUnitId } = target;
let maxDecimals = 0;
selections.forEach((selection) => {
Range.foreach(selection.range, (row, col) => {
const numfmtValue = numfmtService.getValue(unitId, subUnitId, row, col);
if (!numfmtValue) {
const cell = target.worksheet.getCellRaw(row, col);
if (isScientificNotationNumericCell(cell)) {
maxDecimals = Math.max(maxDecimals, getDecimalFromPattern(getScientificNotationFormatFromCell(cell)));
return;
}
if (!maxDecimals && cell && cell.t === CellValueType.NUMBER && cell.v) {
const regResult = /\.(\d*)$/.exec(String(cell.v));
if (regResult) {
const length = regResult[1].length;
if (!length) {
return;
}
maxDecimals = Math.max(maxDecimals, length);
}
}
return;
}
const decimals = getDecimalFromPattern(numfmtValue.pattern);
maxDecimals = decimals > maxDecimals ? decimals : maxDecimals;
});
});
const decimals = maxDecimals + 1;
const defaultPattern = setPatternDecimal(`0${decimals > 0 ? '.0' : ''}`, decimals);
const values: ISetNumfmtCommandParams['values'] = [];
selections.forEach((selection) => {
Range.foreach(selection.range, (row, col) => {
const numfmtValue = numfmtService.getValue(unitId, subUnitId, row, col);
if (isDefaultFormat(numfmtValue?.pattern)) {
const cell = target.worksheet.getCellRaw(row, col);
// General scientific notation numbers switch to scientific format before changing decimals.
values.push({
row,
col,
pattern: isScientificNotationNumericCell(cell) ? setPatternDecimal(getScientificNotationFormatFromCell(cell), decimals) : defaultPattern,
});
} else {
const decimals = getDecimalFromPattern((numfmtValue as INumfmtItemWithCache).pattern);
const pattern = setPatternDecimal((numfmtValue as INumfmtItemWithCache).pattern, decimals + 1);
pattern !== (numfmtValue as INumfmtItemWithCache).pattern &&
values.push({
row,
col,
pattern,
});
}
});
});
if (values.length) {
const result = await commandService.executeCommand(SetNumfmtCommand.id, { values });
return result;
}
return false;
},
};

View File

@@ -0,0 +1,54 @@
/**
* 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 { IAccessor, ICommand } from '@univerjs/core';
import type { ISetNumfmtCommandParams } from './set-numfmt.command';
import { CommandType, ICommandService, Range, RegionService } from '@univerjs/core';
import { SheetsSelectionsService } from '@univerjs/sheets';
import { getCurrencyFormat, getCurrencySymbolIconByLocale } from '../../base/const/currency-symbols';
import { SetNumfmtCommand } from './set-numfmt.command';
/**
* This command is triggered by clicking the currency symbol icon in the menu.
* So the currency format is determined by the currency symbol icon.
*/
export const SetCurrencyCommand: ICommand = {
id: 'sheet.command.numfmt.set.currency',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor) => {
const commandService = accessor.get(ICommandService);
const selectionManagerService = accessor.get(SheetsSelectionsService);
const regionService = accessor.get(RegionService);
const selections = selectionManagerService.getCurrentSelections();
if (!selections || !selections.length) {
return false;
}
const values: ISetNumfmtCommandParams['values'] = [];
const currencySymbolIcon = getCurrencySymbolIconByLocale(regionService.getCurrentRegion());
const currencyFormat = getCurrencyFormat(currencySymbolIcon.locale);
selections.forEach((selection) => {
Range.foreach(selection.range, (row, col) => {
values.push({ row, col, pattern: currencyFormat, type: 'currency' });
});
});
const result = await commandService.executeCommand(SetNumfmtCommand.id, { values });
return result;
},
};

View File

@@ -0,0 +1,188 @@
/**
* 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 { IAccessor, ICommand, IMutationInfo, Nullable } from '@univerjs/core';
import type {
FormatType,
IRemoveNumfmtMutationParams,
ISetCellsNumfmt,
ISetNumfmtMutationParams,
ISetRangeValuesMutationParams,
} from '@univerjs/sheets';
import {
CellValueType,
CommandType,
ICommandService,
isTextFormat,
IUndoRedoService,
IUniverInstanceService,
ObjectMatrix,
sequenceExecute,
} from '@univerjs/core';
import {
checkCellValueType,
factoryRemoveNumfmtUndoMutation,
factorySetNumfmtUndoMutation,
getSheetCommandTarget,
rangeMerge,
RemoveNumfmtMutation,
SetNumfmtMutation,
SetRangeValuesMutation,
transformCellsToRange,
} from '@univerjs/sheets';
import { isAllowedPatternForScientificNotationNumber, isScientificNotationNumericCell } from '../../utils/scientific-notation';
export interface ISetNumfmtCommandParams {
unitId?: string;
subUnitId?: string;
values: Array<{ pattern?: string; row: number; col: number; type?: FormatType }>;
}
export const SetNumfmtCommand: ICommand<ISetNumfmtCommandParams> = {
id: 'sheet.command.numfmt.set.numfmt',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler: (accessor: IAccessor, params) => {
if (!params) {
return false;
}
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const undoRedoService = accessor.get(IUndoRedoService);
const target = getSheetCommandTarget(univerInstanceService, params);
if (!target) return false;
const { unitId, subUnitId, worksheet } = target;
// Because of precision issues with scientific notation values, filter out some format settings for them here.
const values = params.values.filter((value) => {
const cell = worksheet.getCellRaw(value.row, value.col);
return !isScientificNotationNumericCell(cell) || isAllowedPatternForScientificNotationNumber(value.pattern);
});
const setCells = values.filter((value) => !!value.pattern) as ISetCellsNumfmt;
const removeCells = values.filter((value) => !value.pattern);
if (!setCells.length && !removeCells.length) {
return false;
}
const setRedos = transformCellsToRange(unitId, subUnitId, setCells);
const removeRedos: IRemoveNumfmtMutationParams = {
unitId,
subUnitId,
ranges: removeCells.map((cell) => ({
startColumn: cell.col,
startRow: cell.row,
endColumn: cell.col,
endRow: cell.row,
})),
};
const redos: Array<IMutationInfo<IRemoveNumfmtMutationParams | ISetNumfmtMutationParams | ISetRangeValuesMutationParams>> = [];
const undos: Array<IMutationInfo<IRemoveNumfmtMutationParams | ISetNumfmtMutationParams | ISetRangeValuesMutationParams>> = [];
if (setCells.length) {
const setCellTypeObj = setCells.reduce((pre, cur) => {
if (isTextFormat(cur.pattern)) {
pre.setValue(cur.row, cur.col, { t: CellValueType.STRING });
}
const cell = worksheet.getCellRaw(cur.row, cur.col);
if (cell) {
const type = checkCellValueType(cell.v);
if (type !== cell.t) {
pre.setValue(cur.row, cur.col, { t: type });
}
}
return pre;
}, new ObjectMatrix<{ t: Nullable<CellValueType> }>()).getMatrix();
const undoSetCellTypeObj = new ObjectMatrix<{ t: Nullable<CellValueType> }>();
new ObjectMatrix<{ t: Nullable<CellValueType> }>(setCellTypeObj).forValue((row, col) => {
const cell = worksheet.getCellRaw(row, col);
if (cell) {
undoSetCellTypeObj.setValue(row, col, { t: cell.t });
} else {
undoSetCellTypeObj.setValue(row, col, { t: undefined });
}
});
Object.keys(setRedos.values).forEach((key) => {
const v = setRedos.values[key];
v.ranges = rangeMerge(v.ranges);
});
redos.push({
id: SetNumfmtMutation.id,
params: setRedos,
});
const undo = factorySetNumfmtUndoMutation(accessor, setRedos);
undos.push(...undo);
}
if (removeCells.length) {
removeRedos.ranges = rangeMerge(removeRedos.ranges);
const setCellTypeObj = removeCells.reduce((pre, cur) => {
const cell = worksheet.getCellRaw(cur.row, cur.col);
if (cell) {
const type = checkCellValueType(cell.v);
if (type !== cell.t) {
pre.setValue(cur.row, cur.col, { t: type });
}
}
return pre;
}, new ObjectMatrix<{ t: Nullable<CellValueType> }>()).getMatrix();
const undoSetCellTypeObj = new ObjectMatrix<{ t: Nullable<CellValueType> }>();
new ObjectMatrix<{ t: Nullable<CellValueType> }>(setCellTypeObj).forValue((row, col) => {
const cell = worksheet.getCellRaw(row, col);
if (cell) {
undoSetCellTypeObj.setValue(row, col, { t: cell.t });
} else {
undoSetCellTypeObj.setValue(row, col, { t: undefined });
}
});
redos.push({
id: RemoveNumfmtMutation.id,
params: removeRedos,
}, {
id: SetRangeValuesMutation.id,
params: {
unitId,
subUnitId,
cellValue: setCellTypeObj,
},
});
const undo = factoryRemoveNumfmtUndoMutation(accessor, removeRedos);
undos.push({
id: SetRangeValuesMutation.id,
params: {
unitId,
subUnitId,
cellValue: undoSetCellTypeObj.getMatrix(),
},
}, ...undo);
}
const result = sequenceExecute(redos, commandService).result;
if (result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations: undos,
redoMutations: redos,
});
}
return result;
},
};

View File

@@ -0,0 +1,46 @@
/**
* 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 { IAccessor, ICommand } from '@univerjs/core';
import type { ISetNumfmtCommandParams } from './set-numfmt.command';
import { CommandType, ICommandService, Range } from '@univerjs/core';
import { SheetsSelectionsService } from '@univerjs/sheets';
import { SetNumfmtCommand } from './set-numfmt.command';
export const SetPercentCommand: ICommand = {
id: 'sheet.command.numfmt.set.percent',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor) => {
const commandService = accessor.get(ICommandService);
const selectionManagerService = accessor.get(SheetsSelectionsService);
const selections = selectionManagerService.getCurrentSelections();
if (!selections || !selections.length) {
return false;
}
const values: ISetNumfmtCommandParams['values'] = [];
const suffix = '0%';
selections.forEach((selection) => {
Range.foreach(selection.range, (row, col) => {
values.push({ row, col, pattern: suffix, type: 'percent' });
});
});
const result = await commandService.executeCommand(SetNumfmtCommand.id, { values });
return result;
},
};

View File

@@ -0,0 +1,101 @@
/**
* 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 { IAccessor, ICommand } from '@univerjs/core';
import type { INumfmtItemWithCache } from '@univerjs/sheets';
import type { ISetNumfmtCommandParams } from './set-numfmt.command';
import { CellValueType, CommandType, ICommandService, isDefaultFormat, IUniverInstanceService, Range } from '@univerjs/core';
import { getSheetCommandTarget, INumfmtService, SheetsSelectionsService } from '@univerjs/sheets';
import { getDecimalFromPattern, setPatternDecimal } from '../../utils/decimal';
import { getScientificNotationFormatFromCell, isScientificNotationNumericCell } from '../../utils/scientific-notation';
import { SetNumfmtCommand } from './set-numfmt.command';
export const SubtractDecimalCommand: ICommand = {
id: 'sheet.command.numfmt.subtract.decimal.command',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor) => {
const commandService = accessor.get(ICommandService);
const selectionManagerService = accessor.get(SheetsSelectionsService);
const numfmtService = accessor.get(INumfmtService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const selections = selectionManagerService.getCurrentSelections();
if (!selections || !selections.length) {
return false;
}
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { unitId, subUnitId } = target;
let maxDecimals = 0;
selections.forEach((selection) => {
Range.foreach(selection.range, (row, col) => {
const numfmtValue = numfmtService.getValue(unitId, subUnitId, row, col);
if (!numfmtValue) {
const cell = target.worksheet.getCellRaw(row, col);
if (isScientificNotationNumericCell(cell)) {
maxDecimals = Math.max(maxDecimals, getDecimalFromPattern(getScientificNotationFormatFromCell(cell)));
return;
}
if (!maxDecimals && cell && cell.t === CellValueType.NUMBER && cell.v) {
const regResult = /\.(\d*)$/.exec(String(cell.v));
if (regResult) {
const length = regResult[1].length;
if (!length) {
return;
}
maxDecimals = Math.max(maxDecimals, length);
}
}
return;
}
const decimals = getDecimalFromPattern(numfmtValue.pattern);
maxDecimals = decimals > maxDecimals ? decimals : maxDecimals;
});
});
const decimals = maxDecimals - 1;
const defaultPattern = setPatternDecimal(`0${decimals > 0 ? '.0' : '.'}`, decimals);
const values: ISetNumfmtCommandParams['values'] = [];
selections.forEach((selection) => {
Range.foreach(selection.range, (row, col) => {
const numfmtValue = numfmtService.getValue(unitId, subUnitId, row, col);
if (isDefaultFormat(numfmtValue?.pattern)) {
const cell = target.worksheet.getCellRaw(row, col);
// General scientific notation numbers switch to scientific format before changing decimals.
values.push({
row,
col,
pattern: isScientificNotationNumericCell(cell) ? setPatternDecimal(getScientificNotationFormatFromCell(cell), decimals) : defaultPattern,
});
} else {
const decimals = getDecimalFromPattern((numfmtValue as INumfmtItemWithCache).pattern);
values.push({
row,
col,
pattern: setPatternDecimal((numfmtValue as INumfmtItemWithCache).pattern, decimals - 1),
});
}
});
});
const result = await commandService.executeCommand(SetNumfmtCommand.id, { values });
return result;
},
};

View File

@@ -0,0 +1,32 @@
/**
* 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.
*/
export const SHEETS_NUMFMT_PLUGIN_CONFIG_KEY = 'sheets-numfmt.config';
export const configSymbol = Symbol(SHEETS_NUMFMT_PLUGIN_CONFIG_KEY);
export interface IUniverSheetsNumfmtConfig {
/**
* Whether to disable the text format alert.
*/
disableTextFormatAlert?: boolean;
/**
* Whether to disable the text format mark.
*/
disableTextFormatMark?: boolean;
}
export const defaultPluginConfig: IUniverSheetsNumfmtConfig = {};

View File

@@ -0,0 +1,364 @@
/**
* 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 { Dependency, IWorkbookData, Workbook, Worksheet } from '@univerjs/core';
import {
cellToRange,
CellValueType,
DateSystem,
ICommandService,
IConfigService,
ILogService,
Inject,
Injector,
InterceptorEffectEnum,
IUniverInstanceService,
LocaleType,
LogLevel,
Plugin,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import {
INTERCEPTOR_POINT,
INumfmtService,
NumfmtService,
SetNumfmtMutation,
SetRangeValuesMutation,
SheetInterceptorService,
} from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SHEETS_NUMFMT_PLUGIN_CONFIG_KEY } from '../../config/config';
import * as patternUtils from '../../utils/pattern';
import { SheetsNumfmtCellContentController } from '../numfmt-cell-content.controller';
function createWorkbookData(
id = 'test',
locale = LocaleType.ZH_CN,
dateSystem = DateSystem.Date1900
): IWorkbookData {
return {
id,
appVersion: '3.0.0-alpha',
locale,
dateSystem,
name: '',
sheetOrder: ['sheet1', 'sheet2'],
styles: {
textStyle: {
n: {
pattern: '@',
},
},
},
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
rowCount: 20,
columnCount: 20,
cellData: {
0: {
0: { v: true, t: CellValueType.BOOLEAN },
1: { v: '001', t: CellValueType.FORCE_STRING },
2: { v: 'text', t: CellValueType.STRING },
},
1: {
0: { v: '123', t: CellValueType.STRING, s: 'textStyle' },
1: { v: '1234.5', t: CellValueType.STRING },
2: { v: -12.3, t: CellValueType.NUMBER },
3: { v: '20%', t: CellValueType.STRING, s: 'textStyle' },
4: { v: 0, t: CellValueType.NUMBER },
},
},
},
sheet2: {
id: 'sheet2',
name: 'Sheet2',
rowCount: 20,
columnCount: 20,
cellData: {
1: {
1: { v: '9.9', t: CellValueType.STRING },
},
},
},
},
};
}
function createControllerTestBed(workbookData?: IWorkbookData) {
const univer = new Univer();
const injector = univer.__getInjector();
class TestPlugin extends Plugin {
static override pluginName = 'test-plugin';
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
const dependencies: Dependency[] = [
[SheetInterceptorService],
[INumfmtService, { useClass: NumfmtService }],
[SheetsNumfmtCellContentController],
];
dependencies.forEach((dependency) => this._injector.add(dependency));
}
}
univer.registerPlugin(TestPlugin);
const sheet = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, workbookData ?? createWorkbookData());
const get = injector.get.bind(injector);
get(IUniverInstanceService).focusUnit('test');
get(ILogService).setLogLevel(LogLevel.SILENT);
return {
univer,
get,
sheet,
};
}
function getInterceptedCell(worksheet: Worksheet, workbook: Workbook, row: number, col: number, get: Injector['get']) {
const interceptorService = get(SheetInterceptorService);
const rawData = worksheet.getCellRaw(row, col);
return interceptorService.fetchThroughInterceptors(INTERCEPTOR_POINT.CELL_CONTENT, InterceptorEffectEnum.Style | InterceptorEffectEnum.Value)(
rawData,
{
unitId: workbook.getUnitId(),
subUnitId: worksheet.getSheetId(),
row,
col,
worksheet,
workbook,
rawData,
}
);
}
describe('SheetsNumfmtCellContentController', () => {
let univer: Univer;
let get: Injector['get'];
let workbook: Workbook;
let commandService: ICommandService;
let numfmtService: INumfmtService;
let controller: SheetsNumfmtCellContentController;
beforeEach(() => {
const testBed = createControllerTestBed();
univer = testBed.univer;
get = testBed.get;
workbook = testBed.sheet;
commandService = get(ICommandService);
numfmtService = get(INumfmtService);
controller = get(SheetsNumfmtCellContentController);
commandService.registerCommand(SetNumfmtMutation);
commandService.registerCommand(SetRangeValuesMutation);
});
afterEach(() => {
vi.restoreAllMocks();
univer.dispose();
});
it('skips unsupported cells and handles text format markers', () => {
const worksheet = workbook.getSheetBySheetId('sheet1')!;
expect(getInterceptedCell(worksheet, workbook, 0, 0, get)).toEqual({ v: true, t: CellValueType.BOOLEAN });
expect(getInterceptedCell(worksheet, workbook, 0, 1, get)).toEqual({ v: '001', t: CellValueType.FORCE_STRING });
expect(getInterceptedCell(worksheet, workbook, 0, 2, get)).toEqual({ v: 'text', t: CellValueType.STRING });
expect(getInterceptedCell(worksheet, workbook, 1, 0, get)).toEqual({
v: '123',
t: CellValueType.STRING,
s: 'textStyle',
markers: {
tl: {
size: 6,
color: '#409f11',
},
},
});
expect(getInterceptedCell(worksheet, workbook, 1, 3, get)).toEqual({
v: '20%',
t: CellValueType.STRING,
s: 'textStyle',
markers: {
tl: {
size: 6,
color: '#409f11',
},
},
});
get(IConfigService).setConfig(SHEETS_NUMFMT_PLUGIN_CONFIG_KEY, { disableTextFormatMark: true });
expect(getInterceptedCell(worksheet, workbook, 1, 0, get)).toEqual({
v: '123',
t: CellValueType.STRING,
s: 'textStyle',
});
expect(getInterceptedCell(worksheet, workbook, 1, 3, get)).toEqual({
v: '20%',
t: CellValueType.STRING,
s: 'textStyle',
});
});
it('formats numeric values and clears render cache on numfmt or value mutations', async () => {
const worksheet = workbook.getSheetBySheetId('sheet1')!;
const previewSpy = vi.spyOn(patternUtils, 'getPatternPreviewIgnoreGeneral');
numfmtService.setValues('test', 'sheet1', [
{ pattern: '0.00', ranges: [cellToRange(1, 1)] },
{ pattern: '[Red]0.00', ranges: [cellToRange(1, 2)] },
]);
const firstFormatted = getInterceptedCell(worksheet, workbook, 1, 1, get);
expect(firstFormatted).toMatchObject({ v: '1234.50', t: CellValueType.NUMBER });
expect(worksheet.getCellRaw(1, 1)).toMatchObject({ v: '1234.5', t: CellValueType.STRING });
const negativeFormatted = getInterceptedCell(worksheet, workbook, 1, 2, get);
expect(negativeFormatted).toMatchObject({
v: '-12.30',
t: CellValueType.NUMBER,
interceptorStyle: {
cl: {
rgb: expect.any(String),
},
},
});
const previewCallsAfterFirstRender = previewSpy.mock.calls.length;
getInterceptedCell(worksheet, workbook, 1, 1, get);
expect(previewSpy).toHaveBeenCalledTimes(previewCallsAfterFirstRender);
await commandService.executeCommand(SetNumfmtMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
refMap: {
1: {
pattern: '0.00',
},
},
values: {
1: {
ranges: [cellToRange(1, 1)],
},
},
});
getInterceptedCell(worksheet, workbook, 1, 1, get);
expect(previewSpy).toHaveBeenCalledTimes(previewCallsAfterFirstRender + 1);
await commandService.executeCommand(SetRangeValuesMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
cellValue: {
1: {
1: {
v: '1234.5',
t: CellValueType.STRING,
},
},
},
});
getInterceptedCell(worksheet, workbook, 1, 1, get);
expect(previewSpy).toHaveBeenCalledTimes(previewCallsAfterFirstRender + 2);
});
it('formats each workbook with its own locale and date system', () => {
const date1900Sheet = workbook.getSheetBySheetId('sheet1')!;
const date1904Workbook = univer.createUnit<IWorkbookData, Workbook>(
UniverInstanceType.UNIVER_SHEET,
createWorkbookData('date1904', LocaleType.FR_FR, DateSystem.Date1904)
);
const date1904Sheet = date1904Workbook.getSheetBySheetId('sheet1')!;
const previewSpy = vi.spyOn(patternUtils, 'getPatternPreviewIgnoreGeneral');
numfmtService.setValues('test', 'sheet1', [{ pattern: 'yyyy-mm-dd', ranges: [cellToRange(1, 4)] }]);
numfmtService.setValues('date1904', 'sheet1', [{ pattern: 'yyyy-mm-dd', ranges: [cellToRange(1, 4)] }]);
expect(getInterceptedCell(date1900Sheet, workbook, 1, 4, get)).toMatchObject({ v: '1900-01-00' });
expect(getInterceptedCell(date1904Sheet, date1904Workbook, 1, 4, get)).toMatchObject({ v: '1904-01-01' });
expect(previewSpy).toHaveBeenLastCalledWith('yyyy-mm-dd', 0, 'fr', DateSystem.Date1904);
});
it('applies and caches a legitimate empty number-format result', () => {
const worksheet = workbook.getSheetBySheetId('sheet1')!;
const previewSpy = vi.spyOn(patternUtils, 'getPatternPreviewIgnoreGeneral');
numfmtService.setValues('test', 'sheet1', [{ pattern: ';;;', ranges: [cellToRange(1, 1)] }]);
expect(getInterceptedCell(worksheet, workbook, 1, 1, get)).toMatchObject({
v: '',
t: CellValueType.NUMBER,
coverable: false,
});
expect(worksheet.getCellRaw(1, 1)).toMatchObject({ v: '1234.5', t: CellValueType.STRING });
const previewCallsAfterFirstRender = previewSpy.mock.calls.length;
getInterceptedCell(worksheet, workbook, 1, 1, get);
expect(previewSpy).toHaveBeenCalledTimes(previewCallsAfterFirstRender);
});
it('resets cached rendering when active sheet changes and supports locale override', () => {
const sheet1 = workbook.getSheetBySheetId('sheet1')!;
const sheet2 = workbook.getSheetBySheetId('sheet2')!;
const previewSpy = vi.spyOn(patternUtils, 'getPatternPreviewIgnoreGeneral');
numfmtService.setValues('test', 'sheet1', [{ pattern: '0.00', ranges: [cellToRange(1, 1)] }]);
expect(getInterceptedCell(sheet1, workbook, 1, 1, get)).toMatchObject({ v: '1234.50', t: CellValueType.NUMBER });
let previewCallCount = previewSpy.mock.calls.length;
getInterceptedCell(sheet1, workbook, 1, 1, get);
expect(previewSpy).toHaveBeenCalledTimes(previewCallCount);
workbook.setActiveSheet(sheet2);
getInterceptedCell(sheet1, workbook, 1, 1, get);
previewCallCount += 1;
expect(previewSpy).toHaveBeenCalledTimes(previewCallCount);
workbook.setActiveSheet(sheet1);
getInterceptedCell(sheet1, workbook, 1, 1, get);
previewCallCount += 1;
expect(previewSpy).toHaveBeenCalledTimes(previewCallCount);
controller.setNumfmtLocal('fr');
expect(controller.locale).toBe('fr');
getInterceptedCell(sheet1, workbook, 1, 1, get);
previewCallCount += 1;
expect(previewSpy).toHaveBeenCalledTimes(previewCallCount);
});
});

View File

@@ -0,0 +1,219 @@
/**
* 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 { ICellData, ICellDataForSheetInterceptor, INumfmtLocaleTag, Workbook } from '@univerjs/core';
import type { ISetNumfmtMutationParams, ISetRangeValuesMutationParams } from '@univerjs/sheets';
import type { IUniverSheetsNumfmtConfig } from '../config/config';
import {
CellValueType,
Disposable,
getNumfmtLocaleTag,
getNumfmtParseValueFilter,
ICommandService,
IConfigService,
Inject,
InterceptorEffectEnum,
isDefaultFormat,
isTextFormat,
IUniverInstanceService,
LocaleService,
ObjectMatrix,
Range,
ThemeService,
UniverInstanceType,
} from '@univerjs/core';
import {
checkCellValueType,
InterceptCellContentPriority,
INTERCEPTOR_POINT,
INumfmtService,
SetNumfmtMutation,
SetRangeValuesMutation,
SheetInterceptorService,
} from '@univerjs/sheets';
import { BehaviorSubject, merge, of, skip, switchMap } from 'rxjs';
import { SHEETS_NUMFMT_PLUGIN_CONFIG_KEY } from '../config/config';
import { getPatternPreviewIgnoreGeneral } from '../utils/pattern';
const TEXT_FORMAT_MARK = {
tl: {
size: 6,
color: '#409f11',
},
};
export class SheetsNumfmtCellContentController extends Disposable {
private _locale$ = new BehaviorSubject<INumfmtLocaleTag | null>(null);
public locale$ = this._locale$.asObservable();
constructor(
@IUniverInstanceService private readonly _instanceService: IUniverInstanceService,
@Inject(SheetInterceptorService) private _sheetInterceptorService: SheetInterceptorService,
@Inject(ThemeService) private _themeService: ThemeService,
@Inject(ICommandService) private _commandService: ICommandService,
@Inject(INumfmtService) private _numfmtService: INumfmtService,
@Inject(LocaleService) private _localeService: LocaleService,
@IConfigService private readonly _configService: IConfigService
) {
super();
this._initInterceptorCellContent();
}
public get locale(): INumfmtLocaleTag {
return this.getLocale();
}
public getLocale(workbook?: Workbook): INumfmtLocaleTag {
return this._locale$.getValue() ?? getNumfmtLocaleTag(workbook?.getSnapshot().locale ?? this._localeService.getCurrentLocale());
}
// eslint-disable-next-line max-lines-per-function
private _initInterceptorCellContent() {
const renderCache = new ObjectMatrix<{ result: ICellData; parameters: string | number }>();
this.disposeWithMe(merge(this._locale$, this._localeService.currentLocale$).subscribe(() => {
renderCache.reset();
}));
this.disposeWithMe(this._sheetInterceptorService.intercept(INTERCEPTOR_POINT.CELL_CONTENT, {
effect: InterceptorEffectEnum.Value | InterceptorEffectEnum.Style,
// eslint-disable-next-line max-lines-per-function, complexity
handler: (cell, location, next) => {
// If the cell is empty, or the cell type is boolean or force_string, do not process it.
// Cell type is number or number string, it will be applied number format.
if (!cell || cell.v === undefined || cell.v === null || cell.t === CellValueType.BOOLEAN || cell.t === CellValueType.FORCE_STRING) {
return next(cell);
}
const unitId = location.unitId;
const sheetId = location.subUnitId;
const locale = this.getLocale(location.workbook);
const dateSystem = location.workbook.getDateSystem();
let numfmtValue;
if (cell?.s) {
const style = location.workbook.getStyles().get(cell.s);
if (style?.n) {
numfmtValue = style.n;
}
}
if (!numfmtValue) {
numfmtValue = this._numfmtService.getValue(unitId, sheetId, location.row, location.col);
}
// If the cell is not formatted, or the format is 'General', do not process it
// e.g. { v: '001', t: 1, s: { n: { pattern: 'General' } } } should display as '001'
if (isDefaultFormat(numfmtValue?.pattern)) {
return next(cell);
}
// If the cell not specified number type, then check the cell value type
if (cell.t !== CellValueType.NUMBER) {
const type = checkCellValueType(cell.v, cell.t);
// just handle number/number string/number string with text format, other type will not be processed
if (
type !== CellValueType.NUMBER &&
!(
isTextFormat(numfmtValue?.pattern) &&
typeof cell.v === 'string' &&
typeof getNumfmtParseValueFilter(cell.v, { locale, dateSystem })?.v === 'number'
)
) {
return next(cell);
}
}
const originCellValue = cell;
if (!cell || cell === location.rawData) {
cell = { ...location.rawData };
}
// Add error marker to text format number
if (isTextFormat(numfmtValue?.pattern)) {
// If the user has disabled the text format mark, do not show it
if (this._configService.getConfig<IUniverSheetsNumfmtConfig>(SHEETS_NUMFMT_PLUGIN_CONFIG_KEY)?.disableTextFormatMark) {
cell.t = CellValueType.STRING;
return next(cell);
}
cell.t = CellValueType.STRING;
cell.markers = { ...cell?.markers, ...TEXT_FORMAT_MARK };
return next(cell);
}
const cacheParameters = `${unitId}_${sheetId}_${originCellValue.v}_${numfmtValue?.pattern}_${locale}_${dateSystem}`;
const cache = renderCache.getValue(location.row, location.col);
if (cache && cache.parameters === cacheParameters) {
return next({ ...cell, ...cache.result });
}
const info = getPatternPreviewIgnoreGeneral(numfmtValue?.pattern as string, Number(originCellValue.v), locale, dateSystem);
const numfmtRes = info.result;
const res: ICellDataForSheetInterceptor = { v: numfmtRes, t: CellValueType.NUMBER };
if (numfmtRes === '') {
res.coverable = false;
}
if (info.color) {
const color = this._themeService.getColorFromTheme(`${info.color}.500`) ?? info.color;
if (color) {
res.interceptorStyle = { cl: { rgb: color } };
}
}
renderCache.setValue(location.row, location.col, {
result: res,
parameters: cacheParameters,
});
Object.assign(cell, res);
return next(cell);
},
priority: InterceptCellContentPriority.NUMFMT,
}));
this.disposeWithMe(this._commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id === SetNumfmtMutation.id) {
const params = commandInfo.params as ISetNumfmtMutationParams;
Object.keys(params.values).forEach((key) => {
const v = params.values[key];
v.ranges.forEach((range) => {
Range.foreach(range, (row, col) => {
renderCache.realDeleteValue(row, col);
});
});
});
} else if (commandInfo.id === SetRangeValuesMutation.id) {
const params = commandInfo.params as ISetRangeValuesMutationParams;
new ObjectMatrix(params.cellValue).forValue((row, col) => {
renderCache.realDeleteValue(row, col);
});
}
}));
this.disposeWithMe(
this._instanceService.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET)
.pipe(
switchMap((workbook) => workbook?.activeSheet$ ?? of(null)),
skip(1)
)
.subscribe(() => renderCache.reset())
);
}
setNumfmtLocal(locale: INumfmtLocaleTag) {
this._locale$.next(locale);
}
}

View File

@@ -0,0 +1,22 @@
/**
* 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 { createIdentifier } from '@univerjs/core';
export const INumfmtController = createIdentifier<INumfmtController>('INumfmtController');
export interface INumfmtController {
openPanel(): void;
}

View File

@@ -0,0 +1,159 @@
/**
* 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 { Dependency, IWorkbookData, UnitModel } from '@univerjs/core';
import {
ILogService,
Inject,
Injector,
IUniverInstanceService,
LocaleService,
LocaleType,
LogLevel,
Plugin,
set,
ThemeService,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { DefinedNamesService, FormulaDataModel, IDefinedNamesService, LexerTreeBuilder } from '@univerjs/engine-formula';
import { INumfmtService, NumfmtService, SheetInterceptorService, SheetsSelectionsService } from '@univerjs/sheets';
import enUS from '@univerjs/sheets/locale/en-US';
import zhCN from '@univerjs/sheets/locale/zh-CN';
import '@univerjs/sheets/facade';
function getTestWorkbookDataDemo(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
name: 'sheet1',
cellData: {
0: {
3: {
f: '=SUM(A1)',
si: '3e4r5t',
},
4: {
v: 123,
t: 2,
},
},
1: {
3: {
f: '=SUM(A2)',
si: 'OSPtzm',
},
},
2: {
3: {
si: 'OSPtzm',
},
},
3: {
3: {
si: 'OSPtzm',
},
},
},
rowCount: 100,
columnCount: 100,
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
}
export interface ITestBed {
univer: Univer;
get: Injector['get'];
sheet: UnitModel<IWorkbookData>;
univerAPI: FUniver;
injector: Injector;
}
export function createFacadeTestBed(workbookData?: IWorkbookData, dependencies?: Dependency[]): ITestBed {
const univer = new Univer();
const injector = univer.__getInjector();
class TestPlugin extends Plugin {
static override pluginName = 'test-plugin';
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
injector.add([SheetsSelectionsService]);
injector.add([SheetInterceptorService]);
injector.add([IDefinedNamesService, { useClass: DefinedNamesService }]);
injector.add([FormulaDataModel]);
injector.add([LexerTreeBuilder]);
injector.add([INumfmtService, { useClass: NumfmtService }]);
dependencies?.forEach((d) => injector.add(d));
this._injector.get(SheetInterceptorService);
}
override onReady(): void {
}
}
// load i18n
injector.get(LocaleService).load({ zhCN, enUS });
// load theme service
const themeService = injector.get(ThemeService);
const theme = themeService.getCurrentTheme();
const newTheme = set(theme, 'gray.1000', '#35322b');
themeService.setTheme(newTheme);
// register builtin plugins
// note that UI plugins are not registered here, because the unit test environment does not have a UI
univer.registerPlugin(TestPlugin);
const sheet = univer.createUnit<IWorkbookData, UnitModel<IWorkbookData>>(UniverInstanceType.UNIVER_SHEET, workbookData || getTestWorkbookDataDemo());
const univerInstanceService = injector.get(IUniverInstanceService);
univerInstanceService.focusUnit('test');
// set log level
const logService = injector.get(ILogService);
logService.setLogLevel(LogLevel.SILENT); // NOTE: change this to `LogLevel.VERBOSE` to debug tests via logs
// init data validation
const univerAPI = FUniver.newAPI(injector);
return {
univer,
get: injector.get.bind(injector),
sheet,
univerAPI,
injector,
};
}

View File

@@ -0,0 +1,78 @@
/**
* 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 { FUniver } from '@univerjs/core/facade';
import { ICommandService } from '@univerjs/core';
import { RemoveNumfmtMutation, SetNumfmtMutation, SetRangeValuesCommand, SetRangeValuesMutation } from '@univerjs/sheets';
import { beforeEach, describe, expect, it } from 'vitest';
import { SetNumfmtCommand, SheetsNumfmtCellContentController } from '../../index';
import { createFacadeTestBed } from './create-test-bed';
import '../index';
describe('Test FRange', () => {
let univerAPI: FUniver;
let testBed: ReturnType<typeof createFacadeTestBed>;
beforeEach(() => {
testBed = createFacadeTestBed(undefined, [[SheetsNumfmtCellContentController]]);
const commandService = testBed.injector.get(ICommandService);
[
SetRangeValuesMutation,
SetRangeValuesCommand,
SetNumfmtMutation,
RemoveNumfmtMutation,
SetNumfmtCommand,
].forEach((command) => commandService.registerCommand(command));
testBed.injector.get(SheetsNumfmtCellContentController);
univerAPI = testBed.univerAPI;
});
it('sets and reads number formats on a single range and a range grid', () => {
const activeSheet = univerAPI.getActiveWorkbook()!.getActiveSheet();
const singleCell = activeSheet.getRange('A1');
expect(singleCell.setNumberFormat('#,##0.00')).toBe(singleCell);
expect(singleCell.getNumberFormat()).toBe('#,##0.00');
const range = activeSheet.getRange('A1:B2');
range.setNumberFormats([
['#,##0.00', '0.00%'],
['yyyy-MM-DD', ''],
]);
expect(range.getNumberFormats()).toEqual([
['#,##0.00', '0.00%'],
['yyyy-MM-DD', ''],
]);
});
it('sets workbook number format locale through the facade API', () => {
const workbook = univerAPI.getActiveWorkbook()!;
expect(workbook.setNumfmtLocal('fr')).toBe(workbook);
});
it('hides a formatted value without changing its raw value', () => {
const activeSheet = univerAPI.getActiveWorkbook()!.getActiveSheet();
const cell = activeSheet.getRange('A1');
cell.setValue(233628);
cell.setNumberFormat(';;;');
expect(cell.getDisplayValue()).toBe('');
expect(cell.getRawValue()).toBe(233628);
expect(cell.getNumberFormat()).toBe(';;;');
});
});

View File

@@ -0,0 +1,159 @@
/**
* 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 { ISetNumfmtCommandParams } from '@univerjs/sheets-numfmt';
import { SetNumfmtCommand } from '@univerjs/sheets-numfmt';
import { FRange } from '@univerjs/sheets/facade';
/**
* @ignore
*/
export interface IFRangeSheetsNumfmtMixin {
// TODO@wzhudev: should separate numfmt package to two
/**
* Set the number format of the range.
* @param {string} pattern - The number format pattern.
* @returns {FRange} The FRange instance for chaining.
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Set the number format of the A1 cell to '#,##0.00'.
* const fRange = fWorksheet.getRange('A1');
* fRange.setValue(1234.567).setNumberFormat('#,##0.00');
* console.log(fRange.getDisplayValue()); // 1,234.57
* ```
*/
setNumberFormat(pattern: string): FRange;
/**
* Sets a rectangular grid of number formats (must match dimensions of this range).
* @param {string[][]} patterns - A two-dimensional array of number formats.
* @returns {FRange} The FRange instance for chaining.
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Set the number formats of the A1:B2 range.
* const fRange = fWorksheet.getRange('A1:B2');
* fRange.setValues([
* [1234.567, 0.1234],
* [45658, 0.9876]
* ]).setNumberFormats([
* ['#,##0.00', '0.00%'],
* ['yyyy-MM-DD', '']
* ]);
* console.log(fRange.getDisplayValues()); // [['1,234.57', '12.34%'], ['2025-01-01', '0.9876']]
* ```
*/
setNumberFormats(patterns: string[][]): FRange;
/**
* Get the number formatting of the top-left cell of the given range. Returns an empty string when no number format is set, even if the cell contains a value.
* @returns {string} The number format of the top-left cell of the range.
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Get the number format of the top-left cell of the A1:B2 range.
* const fRange = fWorksheet.getRange('A1:B2');
* console.log(fRange.getNumberFormat());
* ```
*/
getNumberFormat(): string;
/**
* Returns the number formats for the cells in the range.
* @returns {string[][]} A two-dimensional array of number formats.
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Get the number formats of the A1:B2 range.
* const fRange = fWorksheet.getRange('A1:B2');
* console.log(fRange.getNumberFormats());
* ```
*/
getNumberFormats(): string[][];
}
export class FRangeSheetsNumfmtMixin extends FRange implements IFRangeSheetsNumfmtMixin {
override setNumberFormat(pattern: string): FRange {
// TODO@Gggpound: the API should support other types of parameters
const values: ISetNumfmtCommandParams['values'] = [];
// Add number format info to the `values` array.
const { startColumn, startRow, endColumn, endRow } = this._range;
for (let row = startRow; row <= endRow; row++) {
for (let col = startColumn; col <= endColumn; col++) {
values.push({ row, col, pattern });
}
}
this._commandService.syncExecuteCommand(SetNumfmtCommand.id, {
unitId: this._workbook.getUnitId(),
subUnitId: this._worksheet.getSheetId(),
values,
} as ISetNumfmtCommandParams);
return this;
}
override setNumberFormats(patterns: string[][]): FRange {
const values: ISetNumfmtCommandParams['values'] = [];
// Add number format info to the `values` array.
const { startColumn, startRow, endColumn, endRow } = this._range;
for (let row = startRow; row <= endRow; row++) {
for (let col = startColumn; col <= endColumn; col++) {
const pattern = patterns[row - startRow]?.[col - startColumn];
values.push({ row, col, pattern });
}
}
this._commandService.syncExecuteCommand(SetNumfmtCommand.id, {
unitId: this._workbook.getUnitId(),
subUnitId: this._worksheet.getSheetId(),
values,
} as ISetNumfmtCommandParams);
return this;
}
override getNumberFormat(): string {
const style = this.getCellStyle();
return style?.numberFormat?.pattern ?? '';
}
override getNumberFormats(): string[][] {
const styles = this.getCellStyles();
return styles.map((row) => row.map((cellStyle) => cellStyle?.numberFormat?.pattern ?? ''));
}
}
FRange.extend(FRangeSheetsNumfmtMixin);
declare module '@univerjs/sheets/facade' {
interface FRange extends IFRangeSheetsNumfmtMixin { }
}

View File

@@ -0,0 +1,57 @@
/**
* 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 { INumfmtLocaleTag } from '@univerjs/core';
import { SheetsNumfmtCellContentController } from '@univerjs/sheets-numfmt';
import { FWorkbook } from '@univerjs/sheets/facade';
export interface IFWorkbookSheetsNumfmtMixin {
/**
* Set the locale for number formatting.
* @param {INumfmtLocaleTag} locale zh_CN,zh_TW,zh_HK,ja,ko,th,cs,da,nl,en,en_AU,en_CA,en_GB,en_IE,fi,fr,fr_CA,fr_CH,de,de_CH,el,hu,is,id,it,it_CH,nb,no,pl,pt,pt_BR,ru,sk,es,es_AR,es_BO,es_CL,es_CO,es_EC,es_MX,es_PY,es_UY,es_VE,sv,tr,cy,az,be,bg,ca,fil,gu,he,hr,hy,ka,kk,kn,lt,lv,ml,mn,mr,my,pa,ro,sl,sr,ta,te,uk,vi,ar,bn,hi
* @returns {FWorkbook} The FWorkbook instance for chaining.
* @memberof IFWorkbookNumfmtMixin
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fRange = fWorksheet.getRange('A1');
* fRange.setValue(1234.567).setNumberFormat('#,##0.00');
*
* // Set the locale en_US for number formatting.
* fWorkbook.setNumfmtLocal('en_US');
* console.log(fRange.getDisplayValue()); // 1,234.57
*
* // Set the locale de_DE for number formatting.
* fWorkbook.setNumfmtLocal('de_DE');
* console.log(fRange.getDisplayValue()); // 1.234,57
* ```
*/
setNumfmtLocal(locale: INumfmtLocaleTag): FWorkbook;
}
export class FWorkbookSheetsNumfmtMixin extends FWorkbook implements IFWorkbookSheetsNumfmtMixin {
override setNumfmtLocal(locale: INumfmtLocaleTag): FWorkbook {
const sheetsNumfmtCellContentController = this._injector.get(SheetsNumfmtCellContentController);
sheetsNumfmtCellContentController.setNumfmtLocal(locale);
return this;
}
}
FWorkbook.extend(FWorkbookSheetsNumfmtMixin);
declare module '@univerjs/sheets/facade' {
// eslint-disable-next-line ts/naming-convention
interface FWorkbook extends IFWorkbookSheetsNumfmtMixin { }
}

View File

@@ -0,0 +1,21 @@
/**
* 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 './f-range';
import './f-workbook';
export type * from './f-range';
export type * from './f-workbook';

View File

@@ -0,0 +1,42 @@
/**
* 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.
*/
export {
getCurrencyFormat,
getCurrencySymbolByLocale,
getCurrencySymbolIconByLocale,
localeCurrencySymbolMap,
} from './base/const/currency-symbols';
export { CURRENCYFORMAT, DATEFMTLISG, NUMBERFORMAT } from './base/const/formatdetail';
export { AddDecimalCommand } from './commands/commands/add-decimal.command';
export { SetCurrencyCommand } from './commands/commands/set-currency.command';
export { SetNumfmtCommand } from './commands/commands/set-numfmt.command';
export type { ISetNumfmtCommandParams } from './commands/commands/set-numfmt.command';
export { SetPercentCommand } from './commands/commands/set-percent.command';
export { SubtractDecimalCommand } from './commands/commands/subtract-decimal.command';
export { SHEETS_NUMFMT_PLUGIN_CONFIG_KEY } from './config/config';
export type { IUniverSheetsNumfmtConfig } from './config/config';
export { SheetsNumfmtCellContentController } from './controllers/numfmt-cell-content.controller';
export { UniverSheetsNumfmtPlugin } from './plugin';
export { getCurrencyType } from './utils/currency';
export { getDecimalFromPattern, getDecimalString, isPatternHasDecimal, setPatternDecimal } from './utils/decimal';
export {
getCurrencyFormatOptions,
getCurrencyOptions,
getDateFormatOptions,
getNumberFormatOptions,
} from './utils/options';
export { getPatternPreview, getPatternPreviewIgnoreGeneral, getPatternType } from './utils/pattern';

View File

@@ -0,0 +1,74 @@
/**
* 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 { IUniverSheetsNumfmtConfig } from './config/config';
import { DependentOn, ICommandService, IConfigService, Inject, Injector, merge, Plugin, registerDependencies, touchDependencies, UniverInstanceType } from '@univerjs/core';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import pkg from '../package.json';
import { SHEET_NUMFMT_PLUGIN } from './base/const/plugin-name';
import { AddDecimalCommand } from './commands/commands/add-decimal.command';
import { SetCurrencyCommand } from './commands/commands/set-currency.command';
import { SetNumfmtCommand } from './commands/commands/set-numfmt.command';
import { SetPercentCommand } from './commands/commands/set-percent.command';
import { SubtractDecimalCommand } from './commands/commands/subtract-decimal.command';
import { defaultPluginConfig, SHEETS_NUMFMT_PLUGIN_CONFIG_KEY } from './config/config';
import { SheetsNumfmtCellContentController } from './controllers/numfmt-cell-content.controller';
@DependentOn(UniverSheetsPlugin)
export class UniverSheetsNumfmtPlugin extends Plugin {
static override pluginName = SHEET_NUMFMT_PLUGIN;
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
private readonly _config: Partial<IUniverSheetsNumfmtConfig> = defaultPluginConfig,
@Inject(Injector) override readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
// Manage the plugin configuration.
const { ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
this._configService.setConfig(SHEETS_NUMFMT_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
registerDependencies(this._injector, [
[SheetsNumfmtCellContentController],
]);
touchDependencies(this._injector, [
[SheetsNumfmtCellContentController],
]);
[
AddDecimalCommand,
SubtractDecimalCommand,
SetCurrencyCommand,
SetPercentCommand,
SetNumfmtCommand,
].forEach((config) => {
this.disposeWithMe(this._commandService.registerCommand(config));
});
}
}

View File

@@ -0,0 +1,42 @@
/**
* 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 { DEFAULT_NUMBER_FORMAT } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { getPatternPreview, getPatternType } from '../pattern';
describe('getPatternType', () => {
it('classifies conditional general formats as number formats', () => {
expect(getPatternType(DEFAULT_NUMBER_FORMAT)).toBe('general');
expect(getPatternType('[>0]"A";[<0]"B";"B"')).toBe('number');
});
it('classifies two-section conditional text formats as number formats', () => {
const pattern = '[>0]"未完成";[<=0]"完成"';
expect(getPatternType(pattern)).toBe('number');
expect(getPatternPreview(pattern, 1)).toEqual({ result: '未完成' });
expect(getPatternPreview(pattern, 0)).toEqual({ result: '完成' });
expect(getPatternPreview(pattern, -1)).toEqual({ result: '-完成' });
});
it('classifies three-section conditional text formats as number formats', () => {
const pattern = '[>0]"未完成";[<0]"完成";"完成"';
expect(getPatternType(pattern)).toBe('number');
expect(getPatternPreview(pattern, 1)).toEqual({ result: '未完成' });
expect(getPatternPreview(pattern, 0)).toEqual({ result: '完成' });
expect(getPatternPreview(pattern, -1)).toEqual({ result: '完成' });
});
});

View File

@@ -0,0 +1,22 @@
/**
* 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 { currencySymbols } from '@univerjs/core';
export const getCurrencyType = (pattern: string) => {
const item = currencySymbols.find((code) => pattern.includes(code));
return item;
};

View File

@@ -0,0 +1,54 @@
/**
* 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 { numfmt } from '@univerjs/core';
/**
* the function decimal just use positive,negative configuration ignored
*/
export const getDecimalFromPattern = (pattern: string, defaultValue: number = 0) => {
if (!pattern) {
return defaultValue;
}
const info = numfmt.getFormatInfo(pattern);
return info.maxDecimals ?? defaultValue;
};
export const getDecimalString = (length: number) =>
new Array(Math.min(Math.max(0, Number(length)), 30)).fill(0).join('');
export const setPatternDecimal = (patterns: string, decimalLength: number) => {
const tokens = patterns.split(';').map((pattern) => {
if (/\.0?/.test(pattern)) {
return pattern.replace(
/\.0*/g,
`${decimalLength > 0 ? '.' : ''}${getDecimalString(Number(decimalLength || 0))}`
);
}
if (/0([^0]?)|0$/.test(pattern)) {
return pattern.replace(
/0([^0]+)|0$/,
`0${decimalLength > 0 ? '.' : ''}${getDecimalString(Number(decimalLength || 0))}$1`
);
}
return pattern;
});
return tokens.join(';');
};
export const isPatternHasDecimal = (pattern: string) => /\.0?/.test(pattern) || /0([^0]?)|0$/.test(pattern);

View File

@@ -0,0 +1,91 @@
/**
* 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 { IMutationInfo } from '@univerjs/core';
import type { IRemoveNumfmtMutationParams, ISetNumfmtMutationParams } from '@univerjs/sheets';
import { Tools } from '@univerjs/core';
import { rangeMerge, RemoveNumfmtMutation, SetNumfmtMutation } from '@univerjs/sheets';
export const mergeNumfmtMutations = (list: IMutationInfo[]) => {
const removeMutation = list
.filter((item) => item.id === RemoveNumfmtMutation.id)
.map((item) => item.params) as unknown as IRemoveNumfmtMutationParams[];
const setMutation = list
.filter((item) => item.id === SetNumfmtMutation.id)
.map((item) => item.params) as unknown as ISetNumfmtMutationParams[];
const result: IMutationInfo[] = [];
if (removeMutation[0]) {
const params = removeMutation.reduce(
(res, cur) => {
res.ranges.push(...cur.ranges);
return res;
},
{
ranges: [],
unitId: removeMutation[0].unitId,
subUnitId: removeMutation[0].subUnitId,
} as IRemoveNumfmtMutationParams
);
params.ranges = rangeMerge(params.ranges);
result.push({ id: RemoveNumfmtMutation.id, params });
}
const findKeyFromObj = (obj: Record<string, any>, item: any) => {
for (const key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) {
continue;
}
const value = obj[key];
if (Tools.diffValue(value, item)) {
return key;
}
}
};
if (setMutation[0]) {
let nextIndex = 1;
const params = setMutation.reduce(
(res, cur) => {
Object.keys(cur.values).forEach((key) => {
const curValue = cur.values[key];
const curRef = cur.refMap[key];
const index = findKeyFromObj(res.refMap, curRef);
if (index) {
res.values[index].ranges.push(...curValue.ranges);
} else {
const newIndex = nextIndex++;
res.values[newIndex] = {
ranges: curValue.ranges,
};
res.refMap[newIndex] = curRef;
}
});
return res;
},
{
values: {},
refMap: {},
unitId: setMutation[0].unitId,
subUnitId: setMutation[0].subUnitId,
} as ISetNumfmtMutationParams
);
Object.keys(params.values).forEach((key) => {
const v = params.values[key];
v.ranges = rangeMerge(v.ranges);
});
result.push({ id: SetNumfmtMutation.id, params });
}
return result;
};

View File

@@ -0,0 +1,32 @@
/**
* 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 { currencySymbols } from '@univerjs/core';
import { CURRENCYFORMAT, DATEFMTLISG, NUMBERFORMAT } from '../base/const/formatdetail';
export const getCurrencyOptions = () => currencySymbols.map((item) => ({ label: item, value: item }));
export const getCurrencyFormatOptions = (suffix: string) =>
CURRENCYFORMAT.map((item) => ({
label: item.label(suffix),
value: item.suffix(suffix),
color: item.color,
}));
export const getDateFormatOptions = () => DATEFMTLISG.map((item) => ({ label: item.label, value: item.suffix }));
export const getNumberFormatOptions = () =>
NUMBERFORMAT.map((item) => ({ label: item.label, value: item.suffix, color: item.color }));

View File

@@ -0,0 +1,78 @@
/**
* 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 { DateSystem, INumfmtLocaleTag } from '@univerjs/core';
import type { FormatType } from '@univerjs/sheets';
import { DEFAULT_NUMBER_FORMAT, numfmt } from '@univerjs/core';
import { stripErrorMargin } from '@univerjs/engine-formula';
export const getPatternType = (pattern: string): FormatType => {
const type = numfmt.getFormatInfo(pattern).type || 'unknown';
if (type === 'general' && numfmt.tokenize(pattern).some((token) => token.type === numfmt.tokenTypes.CONDITION)) {
return 'number';
}
return type;
};
interface IPatternPreview {
result: string;
color?: string;
}
export const getPatternPreview = (
pattern: string,
value: number,
locale: INumfmtLocaleTag = 'en',
dateSystem?: DateSystem
): IPatternPreview => {
// in the source code of numfmt, the formatColor function will read the the partitions[3]
try {
const formatColor = numfmt.formatColor(pattern, value);
const color = formatColor ? String(formatColor) : undefined;
const result = numfmt.format(pattern, value, { locale, dateSystem, throws: false });
if (value < 0) {
// pay attention, controllers/ui.controller.ts
// in the pattern, the negative value color may be upper case one , so if we read a color with UpperCase, we should return the color with lower case for our theme system
return {
result,
color,
};
}
return {
result,
};
} catch (e) {
console.warn('getPatternPreview error:', pattern, e);
}
return {
result: String(value),
};
};
export const getPatternPreviewIgnoreGeneral = (
pattern: string,
value: number,
locale?: INumfmtLocaleTag,
dateSystem?: DateSystem
): IPatternPreview => {
if (pattern === DEFAULT_NUMBER_FORMAT) {
return {
result: String(stripErrorMargin(value)), // In Excel, the default General format also needs to handle numeric precision.
};
}
return getPatternPreview(pattern, value, locale, dateSystem);
};

View File

@@ -0,0 +1,40 @@
/**
* 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 { ICellData, Nullable } from '@univerjs/core';
import { CellValueType, isDefaultFormat, isTextFormat } from '@univerjs/core';
import { getPatternType } from './pattern';
export const DEFAULT_SCIENTIFIC_NOTATION_FORMAT = '0.00E+00';
export function isScientificNotationNumericCell(cell: Nullable<ICellData>): boolean {
return cell?.t === CellValueType.NUMBER && cell.v !== undefined && cell.v !== null && /e/i.test(String(cell.v));
}
export function getScientificNotationFormatFromCell(cell: Nullable<ICellData>): string {
const text = String(cell?.v);
const eIndex = text.search(/e/i);
const decimalIndex = text.indexOf('.');
const decimalLength = eIndex > -1 && decimalIndex > -1 ? eIndex - decimalIndex - 1 : 0;
return `0.${'0'.repeat(Math.max(2, decimalLength))}E+00`;
}
// Scientific notation numbers cannot be accurately expanded with JS number precision, so only formats that preserve
// the notation or raw text are allowed.
export function isAllowedPatternForScientificNotationNumber(pattern?: string): boolean {
return isDefaultFormat(pattern) || isTextFormat(pattern) || getPatternType(pattern) === 'scientific';
}