diff --git a/demo/data.js b/demo/data.js index becad28fbd..c7c70429a9 100644 --- a/demo/data.js +++ b/demo/data.js @@ -1514,12 +1514,22 @@ function computeFormulaCells(cols, rows) { return cells; } -function computeNumberCells(cols, rows) { +function computeNumberCells(cols, rows, type = "numbers") { const cells = {}; for (let col = 0; col < cols; col++) { const letter = _getColumnLetter(col); for (let index = 1; index < rows - 1; index++) { - cells[letter + index] = { content: `${col + index}` }; + switch (type) { + case "numbers": + cells[letter + index] = { content: `${col + index}` }; + break; + case "floats": + cells[letter + index] = { content: `${col + index}.123` }; + break; + case "longFloats": + cells[letter + index] = { content: `${col + index}.123456789123456` }; + break; + } } } return cells; @@ -1552,7 +1562,9 @@ export function makeLargeDataset(cols, rows, sheetsInfo = ["formulas"]) { cells = computeFormulaCells(cols, rows); break; case "numbers": - cells = computeNumberCells(cols, rows); + case "floats": + case "longFloats": + cells = computeNumberCells(cols, rows, sheetsInfo[0]); break; case "strings": cells = computeStringCells(cols, rows); diff --git a/src/components/grid/grid.ts b/src/components/grid/grid.ts index 5fe88896f4..63d6a92cab 100644 --- a/src/components/grid/grid.ts +++ b/src/components/grid/grid.ts @@ -5,7 +5,7 @@ import { HEADER_WIDTH, SCROLLBAR_WIDTH, } from "../../constants"; -import { isInside, range } from "../../helpers/index"; +import { isInside } from "../../helpers/index"; import { interactiveCut } from "../../helpers/ui/cut_interactive"; import { interactivePaste, interactivePasteFromOS } from "../../helpers/ui/paste_interactive"; import { ComposerSelection } from "../../plugins/ui/edition"; @@ -224,12 +224,14 @@ export class Grid extends Component { const col = this.env.model.getters.findVisibleHeader( sheetId, "COL", - range(0, this.env.model.getters.getNumberCols(sheetId)).reverse() + this.env.model.getters.getNumberCols(sheetId) - 1, + 0 )!; const row = this.env.model.getters.findVisibleHeader( sheetId, "ROW", - range(0, this.env.model.getters.getNumberRows(sheetId)).reverse() + this.env.model.getters.getNumberRows(sheetId) - 1, + 0 )!; this.env.model.selection.selectCell(col, row); }, diff --git a/src/helpers/format.ts b/src/helpers/format.ts index a51098a6b2..4a4ed6fa08 100644 --- a/src/helpers/format.ts +++ b/src/helpers/format.ts @@ -188,14 +188,100 @@ const numberRepresentation: Intl.NumberFormat[] = []; * - all digit stored in the decimal part of the number * * The 'maxDecimal' parameter allows to indicate the number of digits to not - * exceed in the decimal part, in which case digits are rounded + * exceed in the decimal part, in which case digits are rounded. * - * Intl.Numberformat is used to properly handle all the roundings. - * e.g. 1234.7 with format ### (<> maxDecimals=0) should become 1235, not 1234 **/ function splitNumber( value: number, maxDecimals: number = MAX_DECIMAL_PLACES +): { integerDigits: string; decimalDigits: string | undefined } { + const asString = value.toString(); + if (asString.includes("e")) return splitNumberIntl(value, maxDecimals); + + if (Number.isInteger(value)) { + return { integerDigits: asString, decimalDigits: undefined }; + } + + const indexOfDot = asString.indexOf("."); + let integerDigits = asString.substring(0, indexOfDot); + let decimalDigits: string | undefined = asString.substring(indexOfDot + 1); + + if (maxDecimals === 0) { + if (Number(decimalDigits[0]) >= 5) { + integerDigits = (Number(integerDigits) + 1).toString(); + } + return { integerDigits, decimalDigits: undefined }; + } + + if (decimalDigits.length > maxDecimals) { + const { integerDigits: roundedIntegerDigits, decimalDigits: roundedDecimalDigits } = + limitDecimalDigits(decimalDigits, maxDecimals); + + decimalDigits = roundedDecimalDigits; + if (roundedIntegerDigits !== "0") { + integerDigits = (Number(integerDigits) + Number(roundedIntegerDigits)).toString(); + } + } + + return { integerDigits, decimalDigits: removeTrailingZeroes(decimalDigits || "") }; +} + +/** + * Return the given string minus the trailing "0" characters. + * + * @param numberString : a string of integers + * @returns the numberString, minus the eventual zeroes at the end + */ +function removeTrailingZeroes(numberString: string): string | undefined { + let i = numberString.length - 1; + while (i >= 0 && numberString[i] === "0") { + i--; + } + return numberString.slice(0, i + 1) || undefined; +} + +/** + * Limit the size of the decimal part of a number to the given number of digits. + */ +function limitDecimalDigits( + decimalDigits: string, + maxDecimals: number +): { + integerDigits: string; + decimalDigits: string | undefined; +} { + let integerDigits = "0"; + let resultDecimalDigits: string | undefined = decimalDigits; + + // Note : we'd want to simply use number.toFixed() to handle the max digits & rounding, + // but it has very strange behaviour. Ex: 12.345.toFixed(2) => "12.35", but 1.345.toFixed(2) => "1.34" + let slicedDecimalDigits = decimalDigits.slice(0, maxDecimals); + const i = maxDecimals; + + if (Number(Number(decimalDigits[i]) < 5)) { + return { integerDigits, decimalDigits: slicedDecimalDigits }; + } + + // round up + const slicedRoundedUp = (Number(slicedDecimalDigits) + 1).toString(); + if (slicedRoundedUp.length > slicedDecimalDigits.length) { + integerDigits = (Number(integerDigits) + 1).toString(); + resultDecimalDigits = undefined; + } else { + resultDecimalDigits = slicedRoundedUp; + } + + return { integerDigits, decimalDigits: resultDecimalDigits }; +} + +/** + * Split numbers into decimal/integer digits using Intl.NumberFormat. + * Supports numbers with a lot of digits that are transformed to scientific notation by + * number.toString(), but is slow. + */ +function splitNumberIntl( + value: number, + maxDecimals: number = MAX_DECIMAL_PLACES ): { integerDigits: string; decimalDigits: string | undefined } { let formatter = numberRepresentation[maxDecimals]; if (!formatter) { diff --git a/src/migrations/data.ts b/src/migrations/data.ts index 6d8e9ea598..29edde107d 100644 --- a/src/migrations/data.ts +++ b/src/migrations/data.ts @@ -4,7 +4,7 @@ import { FORBIDDEN_IN_EXCEL_REGEX, FORMULA_REF_IDENTIFIER, } from "../constants"; -import { getItemId, toXC, toZone, UuidGenerator } from "../helpers/index"; +import { deepCopy, getItemId, toXC, toZone, UuidGenerator } from "../helpers/index"; import { StateUpdateMessage } from "../types/collaborative/transport_service"; import { CoreCommand, @@ -46,7 +46,7 @@ export function load(data?: any, verboseImport?: boolean): WorkbookData { } } } - data = JSON.parse(JSON.stringify(data)); + data = deepCopy(data); // apply migrations, if needed if ("version" in data) { diff --git a/src/model.ts b/src/model.ts index d5913e1ba1..ab4683302d 100644 --- a/src/model.ts +++ b/src/model.ts @@ -143,6 +143,9 @@ export class Model extends EventBus implements CommandDispatcher { uuidGenerator: UuidGenerator; + private readonly handlers: CommandHandler[] = []; + private readonly coreHandlers: CommandHandler[] = []; + constructor( data: any = {}, config: Partial = {}, @@ -194,6 +197,9 @@ export class Model extends EventBus implements CommandDispatcher { // Initiate stream processor this.selection = new SelectionStreamProcessor(this.getters); + this.coreHandlers.push(this.range); + this.handlers.push(this.range); + // registering plugins for (let Plugin of corePluginRegistry.getAll()) { this.setupCorePlugin(Plugin, workbookData); @@ -204,6 +210,8 @@ export class Model extends EventBus implements CommandDispatcher { } this.uuidGenerator.setIsFastStrategy(false); + this.handlers.push(this.history); + // starting plugins this.dispatch("START"); // Model should be the last permanent subscriber in the list since he should render @@ -227,10 +235,6 @@ export class Model extends EventBus implements CommandDispatcher { markRaw(this); } - get handlers(): CommandHandler[] { - return [this.range, ...this.corePlugins, ...this.uiPlugins, this.history]; - } - joinSession() { this.session.join(this.config.client); } @@ -251,6 +255,7 @@ export class Model extends EventBus implements CommandDispatcher { this.getters[name] = plugin[name].bind(plugin); } this.uiPlugins.push(plugin); + this.handlers.push(plugin); const layers = Plugin.layers.map((l) => [plugin, l] as [UIPlugin, LAYERS]); this.renderers.push(...layers); this.renderers.sort((p1, p2) => p1[1] - p2[1]); @@ -282,6 +287,8 @@ export class Model extends EventBus implements CommandDispatcher { } plugin.import(data); this.corePlugins.push(plugin); + this.coreHandlers.push(plugin); + this.handlers.push(plugin); } private onRemoteRevisionReceived({ commands }: { commands: CoreCommand[] }) { @@ -305,7 +312,7 @@ export class Model extends EventBus implements CommandDispatcher { return; } this.isReplayingCommand = true; - this.dispatchToHandlers([this.range, ...this.corePlugins], command); + this.dispatchToHandlers(this.coreHandlers, command); this.isReplayingCommand = false; } ), @@ -437,7 +444,7 @@ export class Model extends EventBus implements CommandDispatcher { const command: Command = { type, ...payload }; const previousStatus = this.status; this.status = Status.RunningCore; - const handlers = this.isReplayingCommand ? [this.range, ...this.corePlugins] : this.handlers; + const handlers = this.isReplayingCommand ? this.coreHandlers : this.handlers; this.dispatchToHandlers(handlers, command); this.status = previousStatus; return DispatchResult.Success; @@ -497,7 +504,7 @@ export class Model extends EventBus implements CommandDispatcher { } } data.revisionId = this.session.getRevisionId() || DEFAULT_REVISION_ID; - data = JSON.parse(JSON.stringify(data)); + data = deepCopy(data); return data; } @@ -526,7 +533,7 @@ export class Model extends EventBus implements CommandDispatcher { handler.exportForExcel(data); } } - data = JSON.parse(JSON.stringify(data)); + data = deepCopy(data); return getXLSX(data); } diff --git a/src/plugins/core/sheet.ts b/src/plugins/core/sheet.ts index 6689b30769..db26239bba 100644 --- a/src/plugins/core/sheet.ts +++ b/src/plugins/core/sheet.ts @@ -1,6 +1,7 @@ import { FORBIDDEN_IN_EXCEL_REGEX } from "../../constants"; import { createDefaultRows, + deepCopy, getUnquotedSheetName, groupConsecutive, isDefined, @@ -686,7 +687,7 @@ export class SheetPlugin extends CorePlugin implements SheetState { private duplicateSheet(fromId: UID, toId: UID) { const sheet = this.getSheet(fromId); const toName = this.getDuplicateSheetName(sheet.name); - const newSheet: Sheet = JSON.parse(JSON.stringify(sheet)); + const newSheet: Sheet = deepCopy(sheet); newSheet.id = toId; newSheet.name = toName; for (let col = 0; col <= newSheet.numberOfCols; col++) { @@ -834,10 +835,10 @@ export class SheetPlugin extends CorePlugin implements SheetState { } private moveCellOnColumnsDeletion(sheet: Sheet, deletedColumn: number) { - for (let [index, row] of Object.entries(sheet.rows)) { - const rowIndex = parseInt(index, 10); + for (let rowIndex = 0; rowIndex < sheet.rows.length; rowIndex++) { + const row = sheet.rows[rowIndex]; for (let i in row.cells) { - const colIndex = parseInt(i, 10); + const colIndex = Number(i); const cellId = row.cells[i]; if (cellId) { if (colIndex === deletedColumn) { @@ -870,11 +871,11 @@ export class SheetPlugin extends CorePlugin implements SheetState { dimension: "rows" | "columns" ) { const commands: UpdateCellPositionCommand[] = []; - for (const [index, row] of Object.entries(sheet.rows)) { - const rowIndex = parseInt(index, 10); + for (let rowIndex = 0; rowIndex < sheet.rows.length; rowIndex++) { + const row = sheet.rows[rowIndex]; if (dimension !== "rows" || rowIndex >= addedElement) { for (let i in row.cells) { - const colIndex = parseInt(i, 10); + const colIndex = Number(i); const cellId = row.cells[i]; if (cellId) { if (dimension === "rows" || colIndex >= addedElement) { @@ -909,11 +910,11 @@ export class SheetPlugin extends CorePlugin implements SheetState { deleteToRow: HeaderIndex ) { const numberRows = deleteToRow - deleteFromRow + 1; - for (let [index, row] of Object.entries(sheet.rows)) { - const rowIndex = parseInt(index, 10); + for (let rowIndex = 0; rowIndex < sheet.rows.length; rowIndex++) { + const row = sheet.rows[rowIndex]; if (rowIndex >= deleteFromRow && rowIndex <= deleteToRow) { for (let i in row.cells) { - const colIndex = parseInt(i, 10); + const colIndex = Number(i); const cellId = row.cells[i]; if (cellId) { this.dispatch("CLEAR_CELL", { @@ -926,7 +927,7 @@ export class SheetPlugin extends CorePlugin implements SheetState { } if (rowIndex > deleteToRow) { for (let i in row.cells) { - const colIndex = parseInt(i, 10); + const colIndex = Number(i); const cellId = row.cells[i]; if (cellId) { this.dispatch("UPDATE_CELL_POSITION", { @@ -945,7 +946,7 @@ export class SheetPlugin extends CorePlugin implements SheetState { const rows: Row[] = []; const cellsQueue = sheet.rows.map((row) => row.cells); for (let i in sheet.rows) { - if (parseInt(i, 10) === index) { + if (Number(i) === index) { continue; } rows.push({ diff --git a/src/plugins/ui/filter_evaluation.ts b/src/plugins/ui/filter_evaluation.ts index 8170204902..eb0f1a5dc2 100644 --- a/src/plugins/ui/filter_evaluation.ts +++ b/src/plugins/ui/filter_evaluation.ts @@ -166,13 +166,11 @@ export class FilterEvaluationPlugin extends UIPlugin { } private intersectZoneWithViewport(sheetId: UID, zone: Zone) { - const colsRange = range(zone.left, zone.right + 1); - const rowsRange = range(zone.top, zone.bottom + 1); return { - left: this.getters.findVisibleHeader(sheetId, "COL", colsRange), - right: this.getters.findVisibleHeader(sheetId, "COL", colsRange.reverse()), - top: this.getters.findVisibleHeader(sheetId, "ROW", rowsRange), - bottom: this.getters.findVisibleHeader(sheetId, "ROW", rowsRange.reverse()), + left: this.getters.findVisibleHeader(sheetId, "COL", zone.left, zone.right), + right: this.getters.findVisibleHeader(sheetId, "COL", zone.right, zone.left), + top: this.getters.findVisibleHeader(sheetId, "ROW", zone.top, zone.bottom), + bottom: this.getters.findVisibleHeader(sheetId, "ROW", zone.bottom, zone.top), }; } diff --git a/src/plugins/ui/header_visibility_ui.ts b/src/plugins/ui/header_visibility_ui.ts index be11e93fa6..4e5967492d 100644 --- a/src/plugins/ui/header_visibility_ui.ts +++ b/src/plugins/ui/header_visibility_ui.ts @@ -1,4 +1,3 @@ -import { range } from "../../helpers"; import { Dimension, ExcelWorkbookData, HeaderIndex, Position, UID } from "../../types"; import { UIPlugin } from "../ui_plugin"; @@ -31,17 +30,44 @@ export class HeaderVisibilityUIPlugin extends UIPlugin { getNextVisibleCellPosition(sheetId: UID, col: number, row: number): Position { return { - col: this.findVisibleHeader(sheetId, "COL", range(col, this.getters.getNumberCols(sheetId)))!, - row: this.findVisibleHeader(sheetId, "ROW", range(row, this.getters.getNumberRows(sheetId)))!, + col: this.findVisibleHeader(sheetId, "COL", col, this.getters.getNumberCols(sheetId) - 1)!, + row: this.findVisibleHeader(sheetId, "ROW", row, this.getters.getNumberRows(sheetId) - 1)!, }; } - findVisibleHeader(sheetId: UID, dimension: Dimension, indexes: number[]): number | undefined { - return indexes.find( - (index) => - this.getters.doesHeaderExist(sheetId, dimension, index) && - !this.isHeaderHidden(sheetId, dimension, index) - ); + /** + * Find the first visible header in the range [`from` => `to`]. + * + * Both `from` and `to` are inclusive. + */ + findVisibleHeader( + sheetId: UID, + dimension: Dimension, + from: number, + to: number + ): number | undefined { + if (from <= to) { + for (let i = from; i <= to; i++) { + if ( + this.getters.doesHeaderExist(sheetId, dimension, i) && + !this.isHeaderHidden(sheetId, dimension, i) + ) { + return i; + } + } + } + if (from > to) { + for (let i = from; i >= to; i--) { + if ( + this.getters.doesHeaderExist(sheetId, dimension, i) && + !this.isHeaderHidden(sheetId, dimension, i) + ) { + return i; + } + } + } + + return undefined; } findLastVisibleColRowIndex( diff --git a/src/selection_stream/selection_stream_processor.ts b/src/selection_stream/selection_stream_processor.ts index c82d6a40a3..b9e715fdc9 100644 --- a/src/selection_stream/selection_stream_processor.ts +++ b/src/selection_stream/selection_stream_processor.ts @@ -421,8 +421,8 @@ export class SelectionStreamProcessor } const { left, right, top, bottom } = zone; const sheetId = this.getters.getActiveSheetId(); - const refCol = this.getters.findVisibleHeader(sheetId, "COL", range(left, right + 1)); - const refRow = this.getters.findVisibleHeader(sheetId, "ROW", range(top, bottom + 1)); + const refCol = this.getters.findVisibleHeader(sheetId, "COL", left, right); + const refRow = this.getters.findVisibleHeader(sheetId, "ROW", top, bottom); if (refRow === undefined || refCol === undefined) { return CommandResult.SelectionOutOfBound; } @@ -530,10 +530,10 @@ export class SelectionStreamProcessor return { col: this.getters.isColHidden(sheetId, anchorCol) - ? this.getters.findVisibleHeader(sheetId, "COL", range(left, right + 1)) || anchorCol + ? this.getters.findVisibleHeader(sheetId, "COL", left, right) || anchorCol : anchorCol, row: this.getters.isRowHidden(sheetId, anchorRow) - ? this.getters.findVisibleHeader(sheetId, "ROW", range(top, bottom + 1)) || anchorRow + ? this.getters.findVisibleHeader(sheetId, "ROW", top, bottom) || anchorRow : anchorRow, }; } diff --git a/tests/__mocks__/transport_service.ts b/tests/__mocks__/transport_service.ts index c835520f63..dfe74b92c5 100644 --- a/tests/__mocks__/transport_service.ts +++ b/tests/__mocks__/transport_service.ts @@ -1,4 +1,5 @@ import { DEFAULT_REVISION_ID } from "../../src/constants"; +import { deepCopy } from "../../src/helpers"; import { UID, WorkbookData } from "../../src/types"; import { CollaborationMessage, @@ -18,7 +19,7 @@ export class MockTransportService implements TransportService { expect(formatValue(0.00000000001)).toBe("0"); expect(formatValue(0.000000000001)).toBe("0"); + expect(formatValue(0.9999999)).toBe("0.9999999"); + expect(formatValue(0.99999999)).toBe("0.99999999"); + expect(formatValue(0.999999999)).toBe("0.999999999"); + expect(formatValue(0.9999999999)).toBe("0.9999999999"); + expect(formatValue(0.99999999999)).toBe("1"); + // @compatibility note: in Google Sheets, the next three tests result in 1234512345 expect(formatValue(1234512345.1)).toBe("1234512345.1"); expect(formatValue(1234512345.12)).toBe("1234512345.12"); diff --git a/tests/xlsx_export.test.ts b/tests/xlsx_export.test.ts index 66a506f15d..fce1385423 100644 --- a/tests/xlsx_export.test.ts +++ b/tests/xlsx_export.test.ts @@ -18,7 +18,7 @@ import { exportPrettifiedXlsx, toRangesData } from "./test_helpers/helpers"; function getExportedExcelData(model: Model): ExcelWorkbookData { model.dispatch("EVALUATE_CELLS"); let data = createEmptyExcelWorkbookData(); - for (let handler of model.handlers) { + for (let handler of model["handlers"]) { if (handler instanceof BasePlugin) { handler.exportForExcel(data); }